149 lines
6.0 KiB
TypeScript
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();
|