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
254 lines
9.4 KiB
JavaScript
254 lines
9.4 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Config-Audit Fix CLI
|
|
* Standalone entry point for running fixes without the command.
|
|
* Usage: node fix-cli.mjs <path> [--apply] [--global] [--json]
|
|
* Dry-run by default — must pass --apply to write changes.
|
|
* Zero external dependencies.
|
|
*/
|
|
|
|
import { resolve } from 'node:path';
|
|
import { writeFile } from 'node:fs/promises';
|
|
import { runAllScanners } from './scan-orchestrator.mjs';
|
|
import { planFixes, applyFixes, verifyFixes } from './fix-engine.mjs';
|
|
import { createBackup } from './lib/backup.mjs';
|
|
import { humanizeFinding } from './lib/humanizer.mjs';
|
|
|
|
// `--dry-run` is a no-op alias: dry-run is already the default. It exists because
|
|
// commands/fix.md documents it in argument-hint, and a documented flag that the
|
|
// CLI silently drops is the same fail-silent class as the unknown-flag sink below.
|
|
const BOOL_FLAGS = ['--apply', '--dry-run', '--json', '--raw', '--global'];
|
|
const VALUE_FLAGS = ['--output-file'];
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
let targetPath = '.';
|
|
let apply = false;
|
|
let jsonMode = false;
|
|
let rawMode = false;
|
|
let includeGlobal = false;
|
|
let outputFile = null;
|
|
|
|
// Same defect class as M-BUG-21 in drift-cli: this loop used to end in
|
|
// `else if (!args[i].startsWith('-')) targetPath = args[i]` with no
|
|
// unknown-flag branch, so an unrecognised flag was dropped silently and its
|
|
// VALUE became the scan target. Here that is worse than in drift: combined
|
|
// with --apply it silently moves the WRITE target to another tree.
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
|
|
if (BOOL_FLAGS.includes(arg)) {
|
|
if (arg === '--apply') apply = true;
|
|
else if (arg === '--json') jsonMode = true;
|
|
else if (arg === '--raw') rawMode = true;
|
|
else if (arg === '--global') includeGlobal = true;
|
|
// --dry-run: default behaviour, accepted so it is not silently dropped.
|
|
} else if (VALUE_FLAGS.includes(arg)) {
|
|
const value = args[i + 1];
|
|
if (value === undefined || value.startsWith('-')) {
|
|
throw new Error(`Option ${arg} requires a value.`);
|
|
}
|
|
outputFile = value;
|
|
i++;
|
|
} else if (arg.startsWith('-')) {
|
|
throw new Error(
|
|
`Unknown option: ${arg}\n` +
|
|
`Valid options: ${[...BOOL_FLAGS, ...VALUE_FLAGS].join(' ')}`
|
|
);
|
|
} else {
|
|
targetPath = arg;
|
|
}
|
|
}
|
|
|
|
// Whether to suppress prose stderr (true for both --json and --raw machine paths).
|
|
const machineMode = jsonMode || rawMode;
|
|
|
|
const resolvedPath = resolve(targetPath);
|
|
|
|
if (!machineMode) {
|
|
process.stderr.write(`Config-Audit Fix CLI v2.1.0\n`);
|
|
process.stderr.write(`Target: ${resolvedPath}\n`);
|
|
process.stderr.write(`Mode: ${apply ? 'APPLY' : 'DRY-RUN'}\n\n`);
|
|
process.stderr.write(`Scanning...\n`);
|
|
}
|
|
|
|
// 1. Run all scanners
|
|
const envelope = await runAllScanners(targetPath, {
|
|
includeGlobal,
|
|
humanizedProgress: !machineMode,
|
|
});
|
|
|
|
// 2. Plan fixes
|
|
const { fixes, skipped, manual } = planFixes(envelope);
|
|
|
|
if (!machineMode) {
|
|
process.stderr.write(`\n`);
|
|
process.stderr.write(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
|
|
process.stderr.write(` Config-Audit Fix Plan\n`);
|
|
process.stderr.write(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n`);
|
|
|
|
if (fixes.length > 0) {
|
|
process.stderr.write(` Auto-fixable (${fixes.length}):\n`);
|
|
for (let i = 0; i < fixes.length; i++) {
|
|
process.stderr.write(` ${i + 1}. [${fixes[i].findingId}] ${fixes[i].description}\n`);
|
|
}
|
|
} else {
|
|
process.stderr.write(` No auto-fixable issues found.\n`);
|
|
}
|
|
|
|
if (manual.length > 0) {
|
|
// Default mode humanizes the manual-finding titles for the prose render.
|
|
// The JSON `manual` array (later in this function) keeps v5.0.0 verbatim.
|
|
process.stderr.write(`\n Manual (${manual.length}):\n`);
|
|
for (let i = 0; i < manual.length; i++) {
|
|
const m = manual[i];
|
|
const title = humanizeFinding({
|
|
id: m.findingId,
|
|
scanner: typeof m.findingId === 'string' ? m.findingId.split('-')[1] || '' : '',
|
|
severity: m.severity || 'info',
|
|
title: m.title,
|
|
description: m.description || '',
|
|
recommendation: m.recommendation || '',
|
|
}).title;
|
|
process.stderr.write(` ${fixes.length + i + 1}. [${m.findingId}] ${title}\n`);
|
|
}
|
|
}
|
|
|
|
if (skipped.length > 0) {
|
|
process.stderr.write(`\n Skipped (${skipped.length}): could not generate fix plan\n`);
|
|
}
|
|
|
|
process.stderr.write(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
|
|
}
|
|
|
|
// 3. Apply or dry-run
|
|
let applied = [];
|
|
let failed = [];
|
|
let verified = [];
|
|
let regressions = [];
|
|
let backupId = null;
|
|
|
|
if (fixes.length === 0) {
|
|
const output = { planned: [], applied: [], failed: [], verified: [], regressions: [], manual, backupId: null };
|
|
if (machineMode) {
|
|
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
}
|
|
if (outputFile) await writeFile(outputFile, JSON.stringify(output, null, 2) + '\n', 'utf-8');
|
|
process.exit(0);
|
|
}
|
|
|
|
if (apply) {
|
|
// Create backup first. file-rename used to be excluded here, so a rule file
|
|
// whose only defect was its extension was renamed with NO backup entry —
|
|
// while commands/fix.md promised "every fix creates a backup first" and
|
|
// handed the user a backupId that could not restore it. The source file is
|
|
// backed up like any other; rollback recreates it at its original path.
|
|
const filesToBackup = [...new Set(fixes.map(f => f.file))];
|
|
const backup = createBackup(filesToBackup);
|
|
backupId = backup.backupId;
|
|
|
|
if (!machineMode) {
|
|
process.stderr.write(`\n Backup created: ${backup.backupPath}\n`);
|
|
process.stderr.write(` Applying ${fixes.length} fixes...\n\n`);
|
|
}
|
|
|
|
const result = await applyFixes(fixes, { dryRun: false, backupDir: backup.backupPath });
|
|
applied = result.applied;
|
|
failed = result.failed;
|
|
|
|
if (!machineMode) {
|
|
process.stderr.write(` Results: ${applied.length} applied, ${failed.length} failed\n`);
|
|
if (failed.length > 0) {
|
|
for (const f of failed) {
|
|
process.stderr.write(` FAILED: [${f.findingId}] ${f.error}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Verify
|
|
if (applied.length > 0) {
|
|
if (!machineMode) {
|
|
process.stderr.write(`\n Verifying...\n`);
|
|
}
|
|
|
|
// Verification must re-scan the scope the fix run used. It hardcoded
|
|
// includeGlobal:false, so with --global every untouched global-scope
|
|
// finding fell out of the re-scan and was reported as verified.
|
|
const verification = await verifyFixes(envelope, applied, { includeGlobal });
|
|
verified = verification.verified;
|
|
regressions = verification.regressions;
|
|
|
|
if (!machineMode) {
|
|
process.stderr.write(` Verified: ${verified.length}/${applied.length}\n`);
|
|
if (regressions.length > 0) {
|
|
process.stderr.write(` Regressions: ${regressions.join(', ')}\n`);
|
|
}
|
|
// There is no rollback-cli.mjs — the restore path is the command, which
|
|
// drives rollback-engine.mjs. Pointing at a nonexistent script in the
|
|
// one message a user reaches for after a bad fix is the worst place for
|
|
// a dead reference.
|
|
process.stderr.write(`\n Rollback: /config-audit rollback ${backupId}\n`);
|
|
}
|
|
}
|
|
} else {
|
|
// Dry-run mode
|
|
const result = await applyFixes(fixes, { dryRun: true });
|
|
applied = result.applied;
|
|
|
|
if (!machineMode) {
|
|
process.stderr.write(`\n Dry-run complete. Pass --apply to execute.\n`);
|
|
}
|
|
}
|
|
|
|
// JSON output (both --json and --raw write byte-equal v5.0.0-shape stdout)
|
|
{
|
|
const output = {
|
|
planned: fixes.map(f => ({
|
|
findingId: f.findingId,
|
|
file: f.file,
|
|
type: f.type,
|
|
description: f.description,
|
|
})),
|
|
applied: applied.map(a => ({
|
|
findingId: a.findingId,
|
|
file: a.file,
|
|
status: a.status,
|
|
})),
|
|
failed: failed.map(f => ({
|
|
findingId: f.findingId,
|
|
file: f.file,
|
|
status: f.status,
|
|
error: f.error,
|
|
})),
|
|
verified,
|
|
regressions,
|
|
manual: manual.map(m => ({
|
|
findingId: m.findingId,
|
|
title: m.title,
|
|
recommendation: m.recommendation,
|
|
})),
|
|
backupId,
|
|
};
|
|
const serialized = JSON.stringify(output, null, 2) + '\n';
|
|
if (machineMode) process.stdout.write(serialized);
|
|
// --output-file carries the same payload to disk. ux-rules rule 2 requires
|
|
// it: commands run scanners with `2>/dev/null`, so anything the command has
|
|
// to act on must ride in a file, not in stdout or stderr.
|
|
if (outputFile) await writeFile(outputFile, serialized, 'utf-8');
|
|
|
|
// Exit code follows the convention the other scanners use: 0 PASS,
|
|
// 2 FAIL, 3 tool error. A failed fix used to exit 0, so a caller could not
|
|
// tell a clean run from one that silently lost a fix.
|
|
if (failed.length > 0) process.exitCode = 2;
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
});
|
|
}
|