config-audit/scanners/campaign-cli.mjs
Kjell Tore Guttormsen de8a7b5d51 fix(scanners): stop discarding our own stdout when it is a pipe
process.exit() terminates immediately, but Node writes stdout asynchronously
when stdout is a pipe — everything still buffered is dropped. scan-orchestrator
measured 246 854 bytes to a file against 65 536 to a pipe (131 072 on another
run; the cut point is a flush race), so every machine consumer that pipes the
envelope got truncated, unparseable JSON. The failure reads like a corrupt file,
not like a cut-off, which is what made it survive this long. Reported by
org-ops, whose census pipes our output.

Closes the class rather than the one CLI where it was visible. campaign-cli,
campaign-export-cli, campaign-write-cli, knowledge-refresh-cli, drift-cli and
fix-cli all exited the same way on their success paths and were green only
because their payloads fit the pipe buffer today; size is not correctness. All
38 sites across 14 files now set process.exitCode and return, which is the
pattern self-audit.mjs already used.

Two contracts needed care rather than substitution: fail() is a never-returns
guard at ~25 call sites, so it throws a CliUsageError the top-level catch
renders with the identical "Error: " prefix and exit code 3; the path guards
needed an explicit return so main() stops instead of running on. Exit codes and
stderr text are unchanged, and the frozen v5.0.0 snapshots are untouched.

The class sweep is landed as a test, not as fourteen edits — it caught one site
this commit had missed. Suite 1443/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8sS1DuDV6bUJcyumLwbvj
2026-07-31 21:40:15 +02:00

119 lines
3.8 KiB
JavaScript

#!/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 <path>] [--output-file <path>]
*
* Exit codes: 0 = initialized & valid, 1 = not initialized yet (advisory), 3 = error.
*/
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
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];
}
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 writeFile(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;
});
}