feat: add runMigrateCli wrapper at sub-path './cli'

Extracts the union of authd's three CLI scripts (db-migrate.ts,
db-status.ts, db-stamp.ts) into one wrapper. Consumers wire a ~5-line shim
that injects their own openDb / migrationsDir / config-loading logic. The
wrapper returns an exit code rather than calling process.exit, which keeps
it testable via PassThrough stream capture.
This commit is contained in:
2026-05-12 02:16:21 -07:00
parent d2dfa5f61b
commit a4c994ca9a

107
src/cli.ts Normal file
View File

@@ -0,0 +1,107 @@
import type Database from 'better-sqlite3';
import {
applyMigrations,
listMigrations,
readAppliedRows,
stampMigration,
} from './migrate.js';
export type DbCommand = 'migrate' | 'status' | 'stamp';
export interface RunCliOptions {
/** Caller-controlled DB open. Wrapper closes it before returning. */
openDb: () => Database.Database;
migrationsDir: string;
command: DbCommand;
/** Required for `stamp`. */
version?: string;
/** Default false. Forwarded to applyMigrations. */
stampGenesis?: boolean;
/** Default 'users'. Forwarded to applyMigrations. */
genesisProbeTable?: string;
/** Default process.stdout / process.stderr. Wired for tests. */
stdout?: NodeJS.WritableStream;
stderr?: NodeJS.WritableStream;
}
export function runMigrateCli(opts: RunCliOptions): number {
const stdout = opts.stdout ?? process.stdout;
const stderr = opts.stderr ?? process.stderr;
if (opts.command === 'stamp') {
if (!opts.version) {
stderr.write('error: version required\n');
return 1;
}
const db = opts.openDb();
try {
const file = stampMigration(db, opts.migrationsDir, opts.version);
stdout.write(`stamped:${file.version}\n`);
db.close();
return 0;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
stderr.write(`error: ${msg}\n`);
db.close();
return 1;
}
}
if (opts.command === 'status') {
const db = opts.openDb();
const files = listMigrations(opts.migrationsDir);
const appliedByVersion = new Map(
readAppliedRows(db).map((r) => [r.version, r]),
);
let exitCode = 0;
const pending: string[] = [];
const mismatched: string[] = [];
for (const file of files) {
const applied = appliedByVersion.get(file.version);
if (!applied) {
pending.push(file.version);
stdout.write(`pending:${file.version}\n`);
continue;
}
if (applied.checksum !== file.checksum) {
mismatched.push(file.version);
stdout.write(`checksum-mismatch:${file.version}\n`);
exitCode = 2;
continue;
}
stdout.write(`applied:${file.version}\n`);
}
if (pending.length === 0 && mismatched.length === 0) {
stdout.write(`pending:none\n`);
}
db.close();
return exitCode;
}
// migrate
const db = opts.openDb();
const summary = applyMigrations(db, opts.migrationsDir, {
stampGenesis: opts.stampGenesis,
genesisProbeTable: opts.genesisProbeTable,
logger: (msg) => stdout.write(`${msg}\n`),
});
if (summary.stamped.length > 0) {
stdout.write(`stamped:${summary.stamped.join(',')}\n`);
}
if (summary.applied === 0 && summary.stamped.length === 0) {
stdout.write(`pending:none\n`);
} else if (summary.applied > 0) {
stdout.write(`applied:${summary.applied}\n`);
}
stdout.write(
`migrations: ${summary.applied + summary.alreadyApplied} applied, ${summary.pending} pending\n`,
);
db.close();
return 0;
}