#!/usr/bin/env node /** * Config-Audit Fix CLI * Standalone entry point for running fixes without the command. * Usage: node fix-cli.mjs [--apply] [--global] [--json] * Dry-run by default — must pass --apply to write changes. * Zero external dependencies. */ import { resolve } from 'node:path'; import { writeOutputFile } from './lib/write-output.mjs'; import { requireTargetDir } from './lib/require-target-dir.mjs'; 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', '--approve-scope']; const VALUE_FLAGS = ['--output-file', '--repo']; 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; let approveScope = false; // The session's root, never the scan target (#63). `--global` fixes files // under `~/.claude` while the session still stands somewhere else, so reading // the root off the target would classify a machine-wide write as "in-repo" // and silence the strongest gate exactly where it matters. let repoRoot = process.cwd(); // 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; else if (arg === '--approve-scope') approveScope = 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.`); } if (arg === '--repo') repoRoot = 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; } } // Whether to suppress prose stderr (true for both --json and --raw machine paths). const machineMode = jsonMode || rawMode; const resolvedPath = resolve(targetPath); if (!(await requireTargetDir(resolvedPath))) { process.exitCode = 3; return; } 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; // The engine's scope verdict, carried out to the payload. A `disclose` class // writes without withholding anything, so the ONLY place the command can // learn that a write left the project is here — stderr is discarded by // `2>/dev/null` (ux-rules rule 2), which is F3's defect class. let scopeGate = null; let scopeDisclosures = []; 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 writeOutputFile(outputFile, JSON.stringify(output, null, 2) + '\n', 'utf-8'); return; } 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, repoRoot, approveScope, }); applied = result.applied; failed = result.failed; scopeGate = result.gate ?? null; scopeDisclosures = result.disclosures ?? []; // A refused set is a verdict about a config that WAS examined, not a tool // failure — so it rides in the payload and keeps the normal exit contract // (#62). Anything a command must act on has to reach it through // `--output-file`; stderr alone is invisible to the command layer (F3). if (result.requiresApproval && result.refused.length > 0) { const payload = { status: 'refused', reason: 'scope-gate', gate: result.gate, requiresApproval: true, disclosures: result.disclosures, refused: result.refused, backupId, }; const json = JSON.stringify(payload, null, 2) + '\n'; if (machineMode) process.stdout.write(json); if (outputFile) await writeOutputFile(outputFile, json, 'utf-8'); if (!machineMode) { for (const line of result.disclosures) process.stderr.write(`\n ${line}\n`); process.stderr.write( `\n Refused ${result.refused.length} fix(es) pending your go-ahead.` + ' Re-run with --approve-scope to apply them.\n', ); } return; } 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, gate: scopeGate, disclosures: scopeDisclosures, }; 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 writeOutputFile(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.exitCode = 3; }); }