ktg-plugin-marketplace/plugins/config-audit/scanners/fix-cli.mjs
Kjell Tore Guttormsen 5eecb968d8 feat(humanizer): wire humanizer into 6 remaining CLIs with --raw
Adds --raw flag to all 6 remaining CLIs and wires humanization into the
default rendering path. --json and --raw both bypass humanization for
v5.0.0 byte-equal output; default mode humanizes findings/diff/prose.

  token-hotspots-cli: humanizes payload.findings before stdout JSON write.
  plugin-health-scanner: humanizes finding titles in stderr brief summary;
    --json/--raw write byte-identical v5.0.0-shape result to stdout.
  drift-cli: humanizes diff.{newFindings,resolvedFindings,unchangedFindings,
    movedFindings} before formatDiffReport; --raw applies to save and list
    modes too. Baselines remain raw v5.0.0 on disk.
  fix-cli: humanizes manual-finding titles in stderr fix-plan prose; both
    --json and --raw produce identical machine-readable JSON to stdout.
  manifest, whats-active: --raw is a no-op (no findings, inventory only)
    but parsed for CLI surface consistency.

Decision on missing --output-file flag for drift-cli/fix-cli/plugin-health:
deferred. SC-6/SC-7 tests in Wave 4 will use stdout-redirect (the simpler
Alt B path) since these CLIs already write JSON to stdout in machine modes.

Test cli-humanizer.test.mjs covers all 6 CLIs. Three CLIs that read
environment state (plugin-health, manifest, whats-active) verify
mode-equivalence (--json == --raw) instead of frozen-snapshot byte-equal,
because their output reflects current marketplace state which drifts as
plugins are added since the Wave 0 capture.

Wave 3 / Step 7 of v5.1.0 humanizer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 17:47:09 +02:00

204 lines
6.6 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 { 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';
async function main() {
const args = process.argv.slice(2);
let targetPath = '.';
let apply = false;
let jsonMode = false;
let rawMode = false;
let includeGlobal = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--apply') {
apply = true;
} else if (args[i] === '--json') {
jsonMode = true;
} else if (args[i] === '--raw') {
rawMode = true;
} else if (args[i] === '--global') {
includeGlobal = true;
} else if (!args[i].startsWith('-')) {
targetPath = args[i];
}
}
// 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 });
// 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) {
if (machineMode) {
const output = { planned: [], applied: [], failed: [], verified: [], regressions: [], manual, backupId: null };
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
}
process.exit(0);
}
if (apply) {
// Create backup first
const filesToBackup = [...new Set(fixes.filter(f => f.type !== 'file-rename').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`);
}
const verification = await verifyFixes(envelope, applied);
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)
if (machineMode) {
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,
};
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
}
}
// 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);
});
}