R1+R2 as one chunk — both KRITISK rows of the Q3 severity table sit on the restore path, and neither closes alone. R1: rollback-engine.mjs verified every checksum before AND after each write, resolved the legacy backup root and reported createdNotRemoved — and none of it was reachable. Measured: 16 files under scanners/ carry a process.argv entry; the engine was not one of them. commands/rollback.md drove the restore as model prose: an ESM import block a template cannot execute, ad-hoc `cp` offered underneath as the runnable path, and "(checksum verified)" pre-rendered three times in the success output. `cp` establishes no checksum, so the verification was a property of the template rather than of the run — on the one surface that runs when the user is already in trouble. R2: implement.md Step 3 hand-built its backup (mkdir, cp, a date-derived id, a manifest typed out in the template) while parseManifest knew one frozen sample of that format, pinned by a HAND-WRITTEN fixture instead of by the template's own text. Rename a key and parseManifest returns zero files while rollback reports success. Fixing only R1 leaves the new CLI parsing a prose format; fixing only R2 leaves a clean format with no runnable entry. - scanners/rollback-cli.mjs — --list / --create / --restore / --delete over the existing engine, on the shared requireValidArgs gate. Exit 0 done, 1 outstanding (gate refusal with nothing written, or a backup that covered fewer targets than given), 2 a file failed, 3 could not do the job. A gated restore is 1, not 3: "this write leaves your project" is a verdict about a write that WAS examined, and it rides in the payload where a command under 2>/dev/null can act on it. - createBackup gains `created` (recorded, never copied — no backup can hold a file that does not exist) and `skipped`, so a backup covering fewer files than asked is no longer indistinguishable from a clean one. - implement.md Step 3 and rollback.md now call the CLI. parseManifest's implement-format branch stays: nothing writes that shape now, but every backup made before this chunk is on disk in it. - backup-restore-contract.test.mjs checks every field rollback.md renders against a payload produced by RUNNING the CLI. That is what replaced "(checksum verified)". 20 guards seen red against the original state before any production code, then each against its own defect. Two holes that surfaced there were mine: the implement assertion matched `--create` as a substring of `--created` and stayed green when the call was removed; and mutating the argv gate showed requireValidArgs sets exit 3 by itself, so a CLI can report that it could not parse its arguments and still run the restore underneath — that case is now asserted on the bytes. Suite 1752 -> 1777, 0 fail. Frozen tests/snapshots/v5.0.0 untouched. Dogfooded through the templates' own command lines against a sandboxed HOME, including the machine-wide arm: refused with the file unchanged, then restored under --approve-scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Logq8GGWKhtyDem63FTEnG
310 lines
12 KiB
JavaScript
310 lines
12 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 { 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`);
|
|
}
|
|
// The user-facing entry, not the CLI: `/config-audit rollback` renders
|
|
// the scope disclosures and asks before a restore that leaves the repo.
|
|
// `scanners/rollback-cli.mjs` exists now (R1) and is what the command
|
|
// runs, but naming it here would hand the user the ungated half.
|
|
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;
|
|
});
|
|
}
|