/** * Copy non-auth secrets from the global vault (./.smallclaw/vault) * to per-user vaults (./.smallclaw/users//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, 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();