#!/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 { 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`); } process.stderr.write(`\n Rollback: node scanners/rollback-cli.mjs ${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); }); }