Files
homeclaw/scripts/migrate-vault-to-users.ts
T
2026-05-05 00:51:37 +09:00

149 lines
6.0 KiB
TypeScript

/**
* Copy non-auth secrets from the global vault (./.smallclaw/vault)
* to per-user vaults (./.smallclaw/users/<u>/workspace/.smallclaw/vault).
*
* Skips gateway.auth.* keys — those are gateway-scope, not user-scope.
* Idempotent: re-running overwrites existing entries with the same plaintext.
*
* Usage:
* npx tsx scripts/migrate-vault-to-users.ts # all users, all non-auth keys
* npx tsx scripts/migrate-vault-to-users.ts --dry-run # preview only
* npx tsx scripts/migrate-vault-to-users.ts --user papa # one user
* npx tsx scripts/migrate-vault-to-users.ts --user papa,jasmine # multiple
* npx tsx scripts/migrate-vault-to-users.ts --keys email.* # glob filter (multiple --keys allowed)
* npx tsx scripts/migrate-vault-to-users.ts --user jasmine --delete-keys email.*
* # remove from one user
*/
import path from 'path';
import fs from 'fs';
import { getVault } from '../src/security/vault';
const REPO_ROOT = path.resolve(__dirname, '..');
const GLOBAL_DIR = path.join(REPO_ROOT, '.smallclaw');
const USERS_DIR = path.join(GLOBAL_DIR, 'users');
const SKIP_PREFIXES = ['gateway.auth.'];
// ── arg parsing ──────────────────────────────────────────────────────────────
interface Args {
dryRun: boolean;
users: string[] | null; // null = all users on disk
keyPatterns: string[]; // empty = all non-skip keys
deletePatterns: string[]; // if non-empty: delete instead of copy
}
function parseArgs(argv: string[]): Args {
const args: Args = { dryRun: false, users: null, keyPatterns: [], deletePatterns: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--dry-run') args.dryRun = true;
else if (a === '--user' || a === '--users') {
args.users = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean);
}
else if (a === '--keys') args.keyPatterns.push(argv[++i] || '');
else if (a === '--delete-keys') args.deletePatterns.push(argv[++i] || '');
else if (a.startsWith('--')) throw new Error(`Unknown flag: ${a}`);
}
return args;
}
function globToRegex(glob: string): RegExp {
const esc = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
return new RegExp(`^${esc}$`);
}
function matchAny(key: string, patterns: string[]): boolean {
if (patterns.length === 0) return true; // no filter = match all
return patterns.some((p) => globToRegex(p).test(key));
}
// ── helpers ──────────────────────────────────────────────────────────────────
function listUsersOnDisk(): string[] {
if (!fs.existsSync(USERS_DIR)) return [];
return fs.readdirSync(USERS_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
}
function userVaultDir(user: string): string {
return path.join(USERS_DIR, user, 'workspace', '.smallclaw');
}
// ── main ─────────────────────────────────────────────────────────────────────
function main() {
const args = parseArgs(process.argv.slice(2));
const globalVault = getVault(GLOBAL_DIR);
const allUsers = listUsersOnDisk();
const users = args.users ?? allUsers;
for (const u of users) {
if (!allUsers.includes(u)) console.warn(`! warning: user "${u}" has no directory under ${USERS_DIR}`);
}
const isDelete = args.deletePatterns.length > 0;
const mode = args.dryRun ? 'DRY RUN' : 'APPLY';
console.log(`Global vault : ${GLOBAL_DIR}/vault`);
console.log(`Users : ${users.join(', ') || '(none)'}`);
console.log(`Mode : ${mode}`);
if (isDelete) {
console.log(`Action : DELETE keys matching: ${args.deletePatterns.join(', ')}`);
} else {
const allKeys = globalVault.keys();
const transferKeys = allKeys
.filter((k) => !SKIP_PREFIXES.some((p) => k.startsWith(p)))
.filter((k) => matchAny(k, args.keyPatterns));
console.log(`Keys to copy : ${transferKeys.length}${args.keyPatterns.length ? ` (filter: ${args.keyPatterns.join(', ')})` : ''}`);
if (transferKeys.length) console.log(` ${transferKeys.join(', ')}`);
runCopy(globalVault, users, transferKeys, args.dryRun);
return;
}
console.log('');
runDelete(users, args.deletePatterns, args.dryRun);
}
function runCopy(globalVault: ReturnType<typeof getVault>, users: string[], keys: string[], dryRun: boolean) {
console.log('');
for (const user of users) {
const dir = userVaultDir(user);
console.log(`→ ${user} (${dir}/vault)`);
if (dryRun) { console.log(` [dry-run] would copy ${keys.length} keys`); continue; }
fs.mkdirSync(dir, { recursive: true });
const userVault = getVault(dir);
let copied = 0, missed = 0;
for (const k of keys) {
const sec = globalVault.get(k, 'migrate:user-seed');
if (!sec) { missed++; continue; }
userVault.set(k, sec.expose(), 'migrate:user-seed');
copied++;
}
console.log(` copied=${copied} missed=${missed}`);
}
console.log('\nDone.');
}
function runDelete(users: string[], patterns: string[], dryRun: boolean) {
for (const user of users) {
const dir = userVaultDir(user);
if (!fs.existsSync(path.join(dir, 'vault'))) {
console.log(`→ ${user} (no vault — skipping)`);
continue;
}
const userVault = getVault(dir);
const matching = userVault.keys().filter((k) => matchAny(k, patterns));
console.log(`→ ${user} (${dir}/vault) — ${matching.length} match`);
for (const k of matching) {
console.log(` ${dryRun ? '[dry-run] would delete' : 'delete'}: ${k}`);
if (!dryRun) userVault.delete(k, 'migrate:user-prune');
}
}
console.log('\nDone.');
}
main();