#!/usr/bin/env node /** * Config-Audit Rollback CLI — the runnable entry to the backup/restore engine. * * `rollback-engine.mjs` has verified checksums before AND after each write, * resolved the pre-v2.2.0 backup root and reported `createdNotRemoved` since * M-BUG-22/M-BUG-25. None of it was reachable: measured at the head of this * chunk, 16 files under `scanners/` carried a `process.argv` entry and the * engine was not one of them. `commands/rollback.md` drove the restore as model * prose — an ESM `import` block a template cannot execute, with ad-hoc `cp` * offered underneath as the runnable alternative and "(checksum verified)" * pre-rendered in the success output. `cp` establishes no checksum, so the * verification was a property of the template rather than of the run (R1). * * `--create` lives here for the same reason `--restore` does. The implement * pipeline used to build its backup by hand — `mkdir`, `cp`, a `date`-derived * id and a manifest typed out in the template — while `parseManifest` knew one * frozen sample of that format, pinned by a hand-written fixture rather than by * the template's own text. One side of that contract was maintained by editing * prose (R2). Now both sides are `lib/backup.mjs`. * * Usage: * node rollback-cli.mjs [--list] * node rollback-cli.mjs --restore [--dry-run] [--approve-scope] * node rollback-cli.mjs --delete * node rollback-cli.mjs --create --target [--target ...] * [--created ...] [--backup-id ] * ... plus [--repo ] [--output-file ] [--json] * * Exit codes: * 0 done, nothing owed * 1 the run completed but something is outstanding — a restore the scope * gate will not perform without `--approve-scope` (nothing was written), * or a backup that covered fewer targets than it was given * 2 at least one file failed to restore (checksum mismatch or write error) * 3 the CLI could not do its job — malformed argv, or a backup id that * resolves in neither root * * A gated restore is exit 1, not 3: "this write leaves your project" is a * verdict about a write that WAS examined, and it rides in the payload where a * command can act on it. Anything that only reaches stderr is invisible to a * command running under `2>/dev/null` (F3's class). * * Zero external dependencies. */ import { resolve } from 'node:path'; import { writeOutputFile } from './lib/write-output.mjs'; import { requireValidArgs } from './lib/cli-args.mjs'; import { createBackup, getBackupDir, getLegacyBackupDir } from './lib/backup.mjs'; import { listBackups, restoreBackup, deleteBackup } from './rollback-engine.mjs'; /** Flag surface. Anything else is exit 3. */ const ARG_SPEC = { boolean: ['--list', '--create', '--dry-run', '--approve-scope', '--json'], value: ['--restore', '--delete', '--target', '--created', '--backup-id', '--repo', '--output-file'], }; /** The mode flags, in the order a diagnostic should name them. */ const MODES = ['--list', '--create', '--restore', '--delete']; function fail(message) { process.stderr.write(`Error: ${message}\n`); process.exitCode = 3; } async function main() { const args = process.argv.slice(2); if (!requireValidArgs(args, ARG_SPEC)) return; const targets = []; const created = []; let mode = null; let backupId = null; let dryRun = false; let approveScope = false; let jsonMode = false; let outputFile = null; let overrideId = null; // The session's root, never a path derived from the backup (#63). A restore // writes to the ABSOLUTE originals recorded at backup time, so reading the // root off those paths would call a machine-wide write "in-repo" and silence // the strongest gate exactly where it matters. let repoRoot = process.cwd(); const setMode = (flag) => { if (mode !== null && mode !== flag) return false; mode = flag; return true; }; for (let i = 0; i < args.length; i++) { const a = args[i]; if (MODES.includes(a)) { if (!setMode(a)) { // Silently letting the last one win is how a `--delete` rides along // behind a `--list` the caller thought it was running. fail(`only one mode may be given (${MODES.join(', ')}); saw "${mode}" and "${a}"`); return; } if (a === '--restore' || a === '--delete') backupId = args[++i]; } else if (a === '--target') targets.push(args[++i]); else if (a === '--created') created.push(args[++i]); else if (a === '--backup-id') overrideId = args[++i]; else if (a === '--repo') repoRoot = args[++i]; else if (a === '--output-file') outputFile = args[++i]; else if (a === '--dry-run') dryRun = true; else if (a === '--approve-scope') approveScope = true; else if (a === '--json') jsonMode = true; } if (mode === null) mode = '--list'; const meta = { mode: mode.slice(2), backupRoot: getBackupDir(), legacyBackupRoot: getLegacyBackupDir(), repo: resolve(repoRoot), }; let payload; let lines = []; if (mode === '--list') { const { backups } = await listBackups(); payload = { meta, count: backups.length, backups }; lines = backups.map((b) => `${b.id}\t${b.files.length}\t${b.legacy ? 'legacy' : 'current'}`); } else if (mode === '--create') { if (targets.length === 0) { fail('--create needs at least one --target'); return; } const result = createBackup(targets, { ...(overrideId ? { backupId: overrideId } : {}), created, }); payload = { meta, backupId: result.backupId, backupPath: result.backupPath, files: result.manifest.files, created: result.manifest.created, skipped: result.skipped, }; lines = [`${result.backupId}\t${result.manifest.files.length}\t${result.skipped.length}`]; // A backup that covers fewer files than it was asked for is the state the // caller must not mistake for a clean one: it is about to edit a file it // cannot roll back. if (result.skipped.length > 0) process.exitCode = 1; } else if (mode === '--delete') { const result = await deleteBackup(backupId); if (!result.deleted) { fail(result.error); return; } payload = { meta, backupId, deleted: true, error: null }; lines = [`${backupId}\tdeleted`]; } else { let result; try { result = await restoreBackup(backupId, { dryRun, approveScope, repoRoot }); } catch (err) { // "Backup not found" and "unreadable manifest" are both the CLI failing to // do its job, never a verdict about a restore that happened. fail(err.message); return; } payload = { meta, backupId, dryRun, gate: result.gate, requiresApproval: result.requiresApproval, disclosures: result.disclosures, restored: result.restored, failed: result.failed, refused: result.refused, createdNotRemoved: result.createdNotRemoved ?? [], legacy: result.legacy ?? false, }; lines = [ ...result.restored.map((r) => `${r.status}\t${r.originalPath}`), ...result.failed.map((f) => `${f.status}\t${f.originalPath}`), ...result.refused.map((r) => `${r.status}\t${r.originalPath}`), ]; if (result.failed.length > 0) process.exitCode = 2; else if (payload.requiresApproval && !approveScope && !dryRun) process.exitCode = 1; } const json = `${JSON.stringify(payload, null, 2)}\n`; if (outputFile) { await writeOutputFile(outputFile, json); // Nothing on stdout when writing to a file — a command rendering this would // otherwise show the user the raw payload (ux-rules rule 1). } else if (jsonMode) { process.stdout.write(json); } else { for (const line of lines) process.stdout.write(`${line}\n`); } } try { await main(); } catch (err) { process.stderr.write(`Error: ${err.message}\n`); process.exitCode = 3; }