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
208 lines
8.8 KiB
JavaScript
208 lines
8.8 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Config-Audit Drift CLI
|
|
* Compare current configuration against a saved baseline.
|
|
* Usage:
|
|
* node drift-cli.mjs <path> --save [--name my-baseline]
|
|
* node drift-cli.mjs <path> [--baseline my-baseline] [--json] [--output-file path]
|
|
* node drift-cli.mjs --list
|
|
* Unknown options and value-less --name/--baseline/--output-file exit 3.
|
|
* Zero external dependencies.
|
|
*/
|
|
|
|
import { resolve } from 'node:path';
|
|
import { writeFile } from 'node:fs/promises';
|
|
import { runAllScanners } from './scan-orchestrator.mjs';
|
|
import { diffEnvelopes, formatDiffReport } from './lib/diff-engine.mjs';
|
|
import { saveBaseline, loadBaseline, listBaselines } from './lib/baseline.mjs';
|
|
import { humanizeFindings } from './lib/humanizer.mjs';
|
|
|
|
const BOOL_FLAGS = ['--save', '--list', '--json', '--raw', '--global'];
|
|
const VALUE_FLAGS = ['--name', '--baseline', '--output-file'];
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
let targetPath = '.';
|
|
let baselineName = 'default';
|
|
let save = false;
|
|
let list = false;
|
|
let jsonMode = false;
|
|
let rawMode = false;
|
|
let includeGlobal = false;
|
|
let outputFile = null;
|
|
|
|
// M-BUG-21: this loop used to end in `else if (!arg.startsWith('-')) targetPath = arg`,
|
|
// with no unknown-flag branch. An unrecognised flag was dropped silently and its
|
|
// VALUE fell through to targetPath — so `--output-file /tmp/x.json` scanned
|
|
// /tmp/x.json, a path that does not exist, yielding a near-empty scan and
|
|
// therefore permanent phantom drift. A missing value for --name was equally
|
|
// silent and destructive: it left baselineName at 'default' and OVERWROTE the
|
|
// default baseline. Both now fail loudly (exit 3) instead.
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
|
|
if (BOOL_FLAGS.includes(arg)) {
|
|
if (arg === '--save') save = true;
|
|
else if (arg === '--list') list = true;
|
|
else if (arg === '--json') jsonMode = true;
|
|
else if (arg === '--raw') rawMode = true;
|
|
else if (arg === '--global') includeGlobal = true;
|
|
} else if (VALUE_FLAGS.includes(arg)) {
|
|
const value = args[i + 1];
|
|
if (value === undefined || value.startsWith('-')) {
|
|
throw new Error(`Option ${arg} requires a value.`);
|
|
}
|
|
if (arg === '--name' || arg === '--baseline') baselineName = value;
|
|
else outputFile = value;
|
|
i++;
|
|
} else if (arg.startsWith('-')) {
|
|
throw new Error(
|
|
`Unknown option: ${arg}\n` +
|
|
`Valid options: ${[...BOOL_FLAGS, ...VALUE_FLAGS].join(' ')}`
|
|
);
|
|
} else {
|
|
targetPath = arg;
|
|
}
|
|
}
|
|
|
|
// --- List mode ---
|
|
if (list) {
|
|
const result = await listBaselines();
|
|
// commands/drift.md runs this with `2>/dev/null` (ux-rules rule 2). The
|
|
// human listing below goes to stderr, so without --output-file the command
|
|
// received 0 bytes and could render nothing. The flag was already accepted
|
|
// by the arg parser; only list mode ignored it.
|
|
if (outputFile) await writeFile(outputFile, JSON.stringify(result, null, 2) + '\n', 'utf-8');
|
|
if (jsonMode || rawMode) {
|
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
} else {
|
|
if (result.baselines.length === 0) {
|
|
process.stderr.write('No baselines saved.\n');
|
|
process.stderr.write('Save one with: node drift-cli.mjs <path> --save\n');
|
|
} else {
|
|
process.stderr.write('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
process.stderr.write(' Saved Baselines\n');
|
|
process.stderr.write('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n');
|
|
for (const b of result.baselines) {
|
|
process.stderr.write(` ${b.name.padEnd(20)} ${b.findingCount} findings ${b.savedAt}\n`);
|
|
}
|
|
process.stderr.write('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- Save mode ---
|
|
if (save) {
|
|
if (!jsonMode && !rawMode) {
|
|
process.stderr.write(`Config-Audit Drift CLI v2.1.0\n`);
|
|
process.stderr.write(`Saving baseline "${baselineName}" for ${resolve(targetPath)}\n\n`);
|
|
}
|
|
|
|
const envelope = await runAllScanners(targetPath, { includeGlobal, humanizedProgress: !jsonMode && !rawMode });
|
|
const result = await saveBaseline(envelope, baselineName);
|
|
|
|
if (jsonMode || rawMode) {
|
|
process.stdout.write(JSON.stringify({ saved: true, name: result.name, path: result.path }, null, 2) + '\n');
|
|
} else {
|
|
process.stderr.write(`\nBaseline "${result.name}" saved to ${result.path}\n`);
|
|
process.stderr.write(`Findings: ${envelope.aggregate.total_findings}\n`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- Drift mode (default) ---
|
|
if (!jsonMode && !rawMode) {
|
|
process.stderr.write(`Config-Audit Drift CLI v2.1.0\n`);
|
|
process.stderr.write(`Target: ${resolve(targetPath)}\n`);
|
|
process.stderr.write(`Baseline: ${baselineName}\n\n`);
|
|
}
|
|
|
|
// Load baseline
|
|
const baseline = await loadBaseline(baselineName);
|
|
if (!baseline) {
|
|
if (jsonMode || rawMode) {
|
|
process.stdout.write(JSON.stringify({ error: `Baseline "${baselineName}" not found. Save one with --save.` }, null, 2) + '\n');
|
|
} else {
|
|
process.stderr.write(`Baseline "${baselineName}" not found.\n`);
|
|
process.stderr.write(`Save one first: node drift-cli.mjs <path> --save\n`);
|
|
}
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// M-BUG-27: a baseline carries the path it was saved from, but nothing ever
|
|
// compared it against the current target. Diffing a repo against a baseline
|
|
// anchored elsewhere produced 100% phantom drift — every baseline finding
|
|
// "resolved", every current finding "new" — and reported it as trend
|
|
// "improving": a reassuring and entirely false signal, on the DEFAULT
|
|
// baseline. The warning goes to stderr in every mode; stdout stays
|
|
// byte-identical to the frozen v5.0.0 shape.
|
|
const baselineTarget = baseline._baseline?.target_path || '';
|
|
const currentTarget = resolve(targetPath);
|
|
const anchorMatches = !baselineTarget || baselineTarget === currentTarget;
|
|
if (baselineTarget && baselineTarget !== currentTarget) {
|
|
process.stderr.write(
|
|
`\nWarning: baseline "${baselineName}" was saved from a different target path.\n` +
|
|
` baseline: ${baselineTarget}\n` +
|
|
` current: ${currentTarget}\n` +
|
|
` The two scans cover different trees, so this diff is not a drift signal.\n` +
|
|
` Re-anchor with: drift-cli.mjs ${currentTarget} --save --name ${baselineName}\n\n`
|
|
);
|
|
}
|
|
|
|
// Run current scan
|
|
const current = await runAllScanners(targetPath, {
|
|
includeGlobal,
|
|
humanizedProgress: !jsonMode && !rawMode,
|
|
});
|
|
|
|
// Diff
|
|
const diff = diffEnvelopes(baseline, current);
|
|
|
|
// Default mode: humanize finding-bearing diff fields before report rendering.
|
|
// `_baselineAnchor` rides here and NOT in the raw shape: commands/drift.md runs
|
|
// the CLI under `2>/dev/null`, so the stderr warning above never reaches the
|
|
// caller that has to act on it. --json/--raw stay v5.0.0-shaped.
|
|
const humanizedDiff = {
|
|
...diff,
|
|
_baselineAnchor: { matches: anchorMatches, baselineTarget, currentTarget },
|
|
newFindings: humanizeFindings(diff.newFindings || []),
|
|
resolvedFindings: humanizeFindings(diff.resolvedFindings || []),
|
|
unchangedFindings: humanizeFindings(diff.unchangedFindings || []),
|
|
movedFindings: humanizeFindings(diff.movedFindings || []),
|
|
};
|
|
|
|
if (jsonMode || rawMode) {
|
|
// --json and --raw both write the raw v5.0.0-shape diff (byte-identical).
|
|
process.stdout.write(JSON.stringify(diff, null, 2) + '\n');
|
|
} else {
|
|
const report = formatDiffReport(humanizedDiff);
|
|
process.stderr.write('\n' + report + '\n');
|
|
}
|
|
|
|
// ux-rules rule 2: every scanner Bash call uses `--output-file <path>` and the
|
|
// command reads the file with the Read tool. drift-cli had no such flag, and
|
|
// its default-mode report goes to stderr — which commands/drift.md discarded
|
|
// via `2>/dev/null` while instructing the agent to "read stdout". The command
|
|
// captured nothing. Matches posture.mjs: raw diff in --json/--raw, humanized
|
|
// otherwise; stdout is unaffected.
|
|
if (outputFile) {
|
|
const fileDiff = (jsonMode || rawMode) ? diff : humanizedDiff;
|
|
await writeFile(outputFile, JSON.stringify(fileDiff, null, 2), 'utf-8');
|
|
process.stderr.write(`\nResults written to ${outputFile}\n`);
|
|
}
|
|
|
|
// Exit code: 0=stable/improving, 1=degrading
|
|
process.exitCode = diff.summary.trend === 'degrading' ? 1 : 0;
|
|
}
|
|
|
|
// Only run CLI if invoked directly
|
|
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
|
if (isDirectRun) {
|
|
main().catch(err => {
|
|
process.stderr.write(`Fatal: ${err.message}\n`);
|
|
process.exitCode = 3;
|
|
});
|
|
}
|