#!/usr/bin/env node /** * campaign-cli — read-only reporter for the durable machine-wide campaign ledger * (v5.7 Fase 2, Block 3b). * * Mirrors the knowledge-refresh-cli precedent: it is the DETERMINISTIC, READ-ONLY half * of the hybrid motor. It loads the campaign ledger (the durable file that sits ABOVE * individual config-audit sessions), validates it, and emits the repo list + a * machine-wide roll-up as JSON. It NEVER writes the ledger — initialization and every * status transition belong to the command layer (Block 3c `/config-audit campaign`), * which calls the lib's pure transforms + saveLedger only on explicit, human-approved * action. A missing ledger file is reported gracefully (initialized:false), NEVER created. * * Naming: `-cli` suffix → NOT an orchestrated scanner (the scan-orchestrator only loads * scanner modules), so the scanner count is unchanged and the snapshot suite stays * byte-stable. * * Usage: * node campaign-cli.mjs [--ledger-file ] [--output-file ] * * Exit codes: 0 = initialized & valid, 1 = not initialized yet (advisory), 3 = error. */ import { resolve } from 'node:path'; import { writeOutputFile } from './lib/write-output.mjs'; import { loadLedger, validateLedger, rollUp, buildBacklog, defaultLedgerPath, } from './lib/campaign-ledger.mjs'; /** * Usage error. Throws rather than calling process.exit(): exit() discards * unflushed stdout when stdout is a pipe. The top-level catch prints the same * `Error: ` text and sets the same exit code 3, so callers see no difference. */ class CliUsageError extends Error {} function fail(message) { throw new CliUsageError(message); } async function main() { const args = process.argv.slice(2); let ledgerFile = null; let outputFile = null; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === '--ledger-file' && args[i + 1]) ledgerFile = args[++i]; else if (a === '--output-file' && args[i + 1]) outputFile = args[++i]; // A flag we do not understand must fail loudly. Silently dropping it lets a // caller-side mistake — a typo'd `--ledger-file`, or a shell that did not // word-split "--flag value" into two argv entries — produce a confident // report about the wrong ledger. else if (a.startsWith('--')) fail(`unknown flag "${a}"`); } const ledgerPath = resolve(ledgerFile || defaultLedgerPath()); let ledger; try { // loadLedger returns null on ENOENT (graceful first run) and throws on parse error. ledger = await loadLedger(ledgerPath); } catch (err) { fail(`could not read ledger at ${ledgerPath}: ${err.message}`); } let payload; let exitCode; if (ledger === null) { // Graceful first run — the ledger does not exist yet. We DO NOT create it; that is // the command layer's job (Block 3c), on explicit human-approved action. payload = { status: 'ok', initialized: false, ledgerPath, schemaVersion: null, createdDate: null, updatedDate: null, repos: [], rollUp: rollUp({ repos: [] }), backlog: buildBacklog({ repos: [] }), }; exitCode = 1; // advisory: there is no campaign to report yet } else { const { valid, errors } = validateLedger(ledger); if (!valid) { fail(`ledger at ${ledgerPath} is invalid:\n - ${errors.join('\n - ')}`); } payload = { status: 'ok', initialized: true, ledgerPath, schemaVersion: ledger.schemaVersion, createdDate: ledger.createdDate, updatedDate: ledger.updatedDate, repos: ledger.repos, rollUp: rollUp(ledger), backlog: buildBacklog(ledger), }; exitCode = 0; } const json = JSON.stringify(payload, null, 2); if (outputFile) await writeOutputFile(outputFile, json, 'utf-8'); else process.stdout.write(json + '\n'); process.exitCode = exitCode; } const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname); if (isDirectRun) { main().catch((err) => { const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal'; process.stderr.write(`${prefix}: ${err.message}\n`); process.exitCode = 3; }); }