#!/usr/bin/env tsx import { backfillLegacyApiKeyHashes, DEFAULT_LEGACY_API_KEY_HASH_BACKFILL_BATCH_SIZE } from "@toolctrl/db/api-keys.server"; import type { LegacyApiKeyHashBackfillResult } from "@toolctrl/db/api-keys.server"; export type ApiKeyMigrationCliEnv = NodeJS.ProcessEnv & { API_KEY_HASH_SECRET?: string; JWT_SECRET?: string; }; export type ApiKeyMigrationCliDependencies = { backfillLegacyApiKeyHashes?: typeof backfillLegacyApiKeyHashes; stdout?: Pick; stderr?: Pick; }; type ApiKeyMigrationCliOptions = { dryRun: boolean; batchSize: number; }; function parseApiKeyMigrationArgs(argv: string[]): ApiKeyMigrationCliOptions { let dryRun = true; let batchSize = DEFAULT_LEGACY_API_KEY_HASH_BACKFILL_BATCH_SIZE; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--dry-run") { dryRun = true; continue; } if (arg === "--execute") { dryRun = false; continue; } if (arg === "--batch-size") { const value = argv[index + 1]; if (!value || value.startsWith("--")) { throw new Error("Invalid --batch-size: expected a positive integer."); } batchSize = parseBatchSize(value); index += 1; continue; } if (arg.startsWith("--batch-size=")) { batchSize = parseBatchSize(arg.slice("--batch-size=".length)); continue; } throw new Error(`Unknown argument: ${arg}`); } return { dryRun, batchSize }; } function parseBatchSize(value: string): number { if (!/^\d+$/.test(value)) { throw new Error("Invalid --batch-size: expected a positive integer."); } const batchSize = Number(value); if (!Number.isSafeInteger(batchSize) || batchSize < 1) { throw new Error("Invalid --batch-size: expected a positive integer."); } return batchSize; } function resolveHashSecret(env: ApiKeyMigrationCliEnv): string | undefined { return env.API_KEY_HASH_SECRET?.trim() || env.JWT_SECRET?.trim() || undefined; } function writeLine(stream: Pick, value: string): void { stream.write(`${value}\n`); } function summarizeResult(result: LegacyApiKeyHashBackfillResult): Omit & { failureIds: string[] } { return { scanned: result.scanned, updated: result.updated, skipped: result.skipped, failed: result.failed, failureIds: result.failures.map((failure) => failure.id), }; } function redactCliError(error: unknown): string { if (error instanceof Error) { return error.name || "Error"; } return "UnknownError"; } export async function runApiKeyMigrationCli( argv: string[] = process.argv.slice(2), env: ApiKeyMigrationCliEnv = process.env, dependencies: ApiKeyMigrationCliDependencies = {}, ): Promise { const stdout = dependencies.stdout ?? process.stdout; const stderr = dependencies.stderr ?? process.stderr; const migrate = dependencies.backfillLegacyApiKeyHashes ?? backfillLegacyApiKeyHashes; let options: ApiKeyMigrationCliOptions; try { options = parseApiKeyMigrationArgs(argv); } catch (error) { writeLine(stderr, error instanceof Error ? error.message : "Invalid arguments."); return 1; } if (options.dryRun) { writeLine( stdout, JSON.stringify({ mode: "dry-run", batchSize: options.batchSize, scanned: 0, updated: 0, skipped: 0, failed: 0, message: "Dry run only; no database writes performed. Use --execute to apply the backfill.", }), ); return 0; } try { const result = await migrate({ batchSize: options.batchSize, hashSecret: resolveHashSecret(env) }); writeLine(stdout, JSON.stringify({ mode: "execute", batchSize: options.batchSize, ...summarizeResult(result) })); return result.failed > 0 ? 1 : 0; } catch (error) { writeLine( stderr, JSON.stringify({ mode: "execute", batchSize: options.batchSize, scanned: 0, updated: 0, skipped: 0, failed: 1, error: redactCliError(error) }), ); return 1; } } if (import.meta.url === `file://${process.argv[1]}`) { runApiKeyMigrationCli() .then((exitCode) => { process.exitCode = exitCode; }) .catch(() => { process.exitCode = 1; }); }