config-audit/scanners/campaign-export-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

172 lines
7 KiB
JavaScript

#!/usr/bin/env node
/**
* campaign-export-cli — export a tracked repo's action plan into that repo's own `docs/`
* (v5.7 Fase 2, Block 4c).
*
* Block 4b built the cross-repo prioritized backlog; this is the "plan export" half of Block 4c.
* Given a repo tracked in the campaign ledger, it resolves the repo's linked config-audit
* session, reads that session's `action-plan.md`, and assembles (via the pure
* `campaign-export` lib) a `docs/config-audit-plan-<sessionId>.md` document carrying a
* provenance header + the verbatim plan ("planer følger arbeidsstedet").
*
* Read-only by DEFAULT (a dry-run preview that returns the assembled `document` + `targetPath`
* so the command can show the user what will be written). The actual write happens ONLY under
* the opt-in `--write` flag — which the `/config-audit campaign` command invokes solely after
* explicit human approval (Verifiseringsplikt — nothing auto-written). Writing the file
* faithfully (a byte-exact copy of the assembled document) is the CLI's job, not the LLM's, so
* a 200-line plan is never re-typed and cannot drift.
*
* Execution is NOT here: Block 4c reuses the existing `/config-audit implement` (backup +
* apply + verify) + `/config-audit rollback`. This CLI only exports the durable record.
*
* Naming: `-cli` suffix → NOT an orchestrated scanner, so the scanner count is unchanged and
* the snapshot suite stays byte-stable.
*
* Usage:
* node campaign-export-cli.mjs --repo <path> [--write]
* [--ledger-file <p>] [--sessions-dir <p>] [--reference-date <YYYY-MM-DD>] [--output-file <p>]
*
* Exit codes: 0 = exportable (preview ready, or written under --write),
* 1 = advisory: repo tracked but not exportable yet (no linked session / no plan),
* 3 = error (missing --repo, untracked repo, no/corrupt ledger, unreadable plan).
*/
import { resolve, join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import {
loadLedger,
validateLedger,
defaultLedgerPath,
} from './lib/campaign-ledger.mjs';
import { planExportPath, buildPlanExportDocument } from './lib/campaign-export.mjs';
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
/**
* 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);
}
/** Default session store: next to the ledger, OUTSIDE the plugin dir. */
function defaultSessionsDir() {
return join(homedir(), '.claude', 'config-audit', 'sessions');
}
function parseArgs(argv) {
const flags = { repo: null, ledgerFile: null, sessionsDir: null, referenceDate: null, outputFile: null, write: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--repo' && argv[i + 1] !== undefined) flags.repo = argv[++i];
else if (a === '--ledger-file' && argv[i + 1] !== undefined) flags.ledgerFile = argv[++i];
else if (a === '--sessions-dir' && argv[i + 1] !== undefined) flags.sessionsDir = argv[++i];
else if (a === '--reference-date' && argv[i + 1] !== undefined) flags.referenceDate = argv[++i];
else if (a === '--output-file' && argv[i + 1] !== undefined) flags.outputFile = argv[++i];
else if (a === '--write') flags.write = true;
else if (a.startsWith('--')) fail(`unknown flag "${a}"`);
else fail(`unexpected argument "${a}"`);
}
return flags;
}
async function emit(payload, outputFile, exitCode) {
const json = JSON.stringify(payload, null, 2);
if (outputFile) await writeFile(outputFile, json, 'utf-8');
else process.stdout.write(json + '\n');
process.exitCode = exitCode;
}
async function main() {
const flags = parseArgs(process.argv.slice(2));
if (!flags.repo) fail('--repo <path> is required');
if (flags.referenceDate && !DATE_RE.test(flags.referenceDate)) fail('--reference-date must be YYYY-MM-DD');
const ledgerPath = resolve(flags.ledgerFile || defaultLedgerPath());
const sessionsDir = resolve(flags.sessionsDir || defaultSessionsDir());
const repoPath = resolve(flags.repo);
// The clock is read here ONLY — passed to the pure lib as the injected `now`.
const now = flags.referenceDate || new Date().toISOString().slice(0, 10);
let ledger;
try {
ledger = await loadLedger(ledgerPath);
} catch (err) {
fail(`could not read ledger at ${ledgerPath}: ${err.message}`);
}
if (ledger === null) fail(`no campaign ledger at ${ledgerPath} — run "/config-audit campaign init" first`);
const { valid, errors } = validateLedger(ledger);
if (!valid) fail(`ledger at ${ledgerPath} is invalid:\n - ${errors.join('\n - ')}`);
const repo = ledger.repos.find((r) => r.path === repoPath);
if (!repo) fail(`repo "${repoPath}" is not tracked in the campaign — add it first`);
const repoInfo = { path: repo.path, name: repo.name, status: repo.status, sessionId: repo.sessionId ?? null };
// Gate 1: the repo must have a linked session (set via `set-status … --session <id>`).
if (typeof repo.sessionId !== 'string' || repo.sessionId.trim() === '') {
return emit(
{ status: 'ok', action: 'export', repo: repoInfo, exportable: false, problems: ['no-session-linked'],
written: false, targetPath: null, document: null },
flags.outputFile,
1,
);
}
// Gate 2: that session must carry an action-plan.md (i.e. `/config-audit plan` has run).
const sourcePlanPath = join(sessionsDir, repo.sessionId, 'action-plan.md');
let planMarkdown;
try {
planMarkdown = await readFile(sourcePlanPath, 'utf-8');
} catch (err) {
if (err && err.code === 'ENOENT') {
return emit(
{ status: 'ok', action: 'export', repo: repoInfo, sessionId: repo.sessionId, sourcePlanPath,
exportable: false, problems: ['no-action-plan'], written: false, targetPath: null, document: null },
flags.outputFile,
1,
);
}
fail(`could not read action plan at ${sourcePlanPath}: ${err.message}`);
}
const targetPath = planExportPath(repo.path, repo.sessionId);
const document = buildPlanExportDocument({
repoName: repo.name,
repoPath: repo.path,
sessionId: repo.sessionId,
planMarkdown,
now,
});
let written = false;
if (flags.write) {
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, document, 'utf-8');
written = true;
}
return emit(
{ status: 'ok', action: 'export', repo: repoInfo, sessionId: repo.sessionId, sourcePlanPath,
exportable: true, problems: [], written, targetPath, document },
flags.outputFile,
0,
);
}
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;
});
}