#!/usr/bin/env node /** * Config-Audit Drift CLI * Compare current configuration against a saved baseline. * Usage: * node drift-cli.mjs --save [--name my-baseline] * node drift-cli.mjs [--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 --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 --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 ` 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; }); }