DEL B chunk `interview` (+ discover/status/cleanup/help). Fasit written before the run predicted 8 defects and refuted 4 candidates; all 8 confirmed, all 4 refutations held, and three predictions turned out too narrow. - M-BUG-36: `drift --list` reached the command as 0 bytes. drift-cli accepted --output-file but list mode ignored it, and the listing goes to stderr, which the command discards per ux-rules rule 2. Fixing the caller alone would not have helped. - M-BUG-37: feature-gap's "Create backup" step ran fix-cli without --apply. Dry-run is the default, so no backup existed (backupId: null) while the command went on to edit config believing it could roll back. - M-BUG-38: fix-cli told users to recover with scanners/rollback-cli.mjs, which does not exist. Dead reference in the one message read after a bad fix. - M-BUG-21 fourth arm: five templates carried literal [--global]/[--full-machine] inside executable bash blocks. A bracketed placeholder does not start with a dash, so every scanner's arg loop takes it as the scan target. - interview and analyze never said which session they act on; interview could rewind a finished session; cleanup interpolated an unvalidated id into rm -rf (an empty id deletes every session); status advertised a `resume` command that does not exist and documented an `all` argument it never parsed. TDD: 9 red tests first, including a machine sweep for dead /config-audit references and for bracketed flags in bash blocks. Suite 1432 -> 1441/0. Frozen v5.0.0 snapshots untouched; --raw/--json contracts unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGvA1uUQn2hPBPMaCKK6x3
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');
|
|
}
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
// --- 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`);
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
// --- 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.exit(1);
|
|
}
|
|
|
|
// 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
|
|
if (diff.summary.trend === 'degrading') process.exit(1);
|
|
process.exit(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.exit(3);
|
|
});
|
|
}
|