From ee0c762151ec4f33db2cc4ec31a3a166d91423cf Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Mon, 22 Jun 2026 17:25:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(campaign):=20/config-audit=20campaign=20?= =?UTF-8?q?=E2=80=94=20human-approved=20write-CLI=20+=20command=20(v5.7=20?= =?UTF-8?q?Fase=202=20Block=203c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the THIN machine-wide campaign surface (ledger + roll-up + status). The write half of the campaign motor, mirroring how knowledge-refresh gates register writes: - scanners/campaign-write-cli.mjs (-cli → NOT a scanner; 11 tests): init/add/ set-status, each a thin wrapper over the invariant-enforcing lib transforms (createLedger/addRepo/setRepoStatus) + saveLedger — path-normalization/dedup, idempotent add, status-lifecycle guard and updatedDate bump never hand-rolled. init refuses to clobber an existing/corrupt ledger (exit 1, file untouched); add auto-inits + reports added vs skipped; set-status takes --findings/--session. Deterministic: --reference-date is the only clock read, injected as `now`. Exit 0=write, 1=advisory no-op, 3=error. - commands/campaign.md (opus, no Web): thin orchestrator — always reports first (read-only campaign-cli), then proposes init/add/set-status and invokes ONE write-CLI subcommand only on explicit human approval (Verifiseringsplikt; never hand-edits the JSON). add --discover finds git repos under a root to pick from. Not byte-stable (own command, outside snapshot suite) like /config-audit optimize + knowledge-refresh. Both CLIs are -cli → scanner count stays 15, snapshot suite untouched. commands 20→21, suite 1127→1138 (+11), test files 64→65. Docs: CLAUDE.md §campaign-write-cli + command table + Testing badge; README commands badge 20→21 + table row; help.md + router wiring. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 34 +++- README.md | 3 +- commands/campaign.md | 177 +++++++++++++++++ commands/config-audit.md | 3 +- commands/help.md | 1 + scanners/campaign-write-cli.mjs | 196 +++++++++++++++++++ tests/scanners/campaign-write-cli.test.mjs | 211 +++++++++++++++++++++ 7 files changed, 622 insertions(+), 3 deletions(-) create mode 100644 commands/campaign.md create mode 100644 scanners/campaign-write-cli.mjs create mode 100644 tests/scanners/campaign-write-cli.test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 5e40915..0a60a44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ Analyzes and optimizes Claude Code configuration across three pillars: | `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence | | `/config-audit whats-active` | Read-only inventory of plugins, skills, MCP, hooks, CLAUDE.md active for a repo (with token estimates) | | `/config-audit knowledge-refresh` | Keep the best-practices register fresh — deterministic stale check (sources older than ~90d) + web candidate poll (CC changelog + Anthropic blog); **human-approved writes only** (Verifiseringsplikt). The "living" half of the knowledge base. Web/judgment-driven, **not byte-stable** | +| `/config-audit campaign` | Machine-wide audit campaign — durable ledger ABOVE sessions: per-repo lifecycle (pending→audited→planned→implemented) + machine-wide roll-up by severity, resumable across sessions. Read-only report (campaign-cli) + **human-approved** writes via a deterministic write-CLI (init/add/set-status). THIN: tracks state, doesn't run audits/fixes. Judgment-driven, **not byte-stable** | | `/config-audit discover` | Run discovery phase only | | `/config-audit analyze` | Run analysis phase only | | `/config-audit interview` | Gather user preferences (opt-in) | @@ -112,7 +113,7 @@ Default: auto-detects scope from git context. Override with `/config-audit full| node --test 'tests/**/*.test.mjs' ``` -1127 tests across 64 test files (21 lib + 33 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`. +1138 tests across 65 test files (21 lib + 34 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`. ### active-config-reader — load-pattern model + rule/agent/output-style enumeration (v5.6 Foundation) @@ -447,6 +448,37 @@ writes, Verifiseringsplikt). `--ledger-file` overrides the default path (determi `--output-file` mirrors the sibling. Exit codes: **0** = initialized & valid, **1** = not initialized yet (advisory), **3** = error (parse/corrupt/invalid). suite 1119→1127. +### campaign-write-cli + `/config-audit campaign` — the WRITE half (v5.7 Fase 2, Block 3c, commands 20→21) + +The human-approved mutation half of the campaign motor, completing the THIN campaign surface +(ledger + roll-up + status). Two pieces: + +- **`scanners/campaign-write-cli.mjs`** (`-cli` → NOT an orchestrated scanner → scanner count stays + 15, suite byte-stable; 11 tests): the sibling of `campaign-cli` that *mutates*. Subcommands + `init` / `add ...` / `set-status `, each a thin wrapper over the + invariant-enforcing lib transforms (`createLedger`/`addRepo`/`setRepoStatus`) + `saveLedger` — so + path-normalization/dedup, idempotent add, the status-lifecycle guard, and the `updatedDate` bump + are **never re-implemented by hand**. `init` refuses to clobber an existing (or corrupt) ledger + (**exit 1** advisory, file untouched); `add` **auto-inits** when no ledger exists and reports + `added` vs `skipped`; `set-status` accepts `--findings ''` + `--session `. Determinism + mirrors the lib + `knowledge-refresh-cli`: `--reference-date` is the **only** place the clock is + read (defaults to today), passed to the transforms as the injected `now`. Exit: **0** = write + performed, **1** = advisory no-op (init-clobber), **3** = error (unknown subcommand, bad args, + invalid status, untracked repo, no/corrupt ledger). +- **`commands/campaign.md`** (opus, `allowed-tools: Read/Write/Edit/Bash/Glob` — **no Web**, + judgment-free): a thin orchestrator. It always **reports** first (read-only `campaign-cli`), then + for `init`/`add`/`set-status` it proposes the change and, **only on explicit human approval**, + invokes one write-CLI subcommand (Verifiseringsplikt — it never hand-edits the ledger JSON). + `add --discover ` finds git repos under a root and lets the user pick. When marking a repo + `audited` it attaches findings-by-severity from the repo's session (or user-provided counts) — + **never invented**. + +**Not a new scanner, not byte-stable.** Both CLIs carry the `-cli` suffix (out of the +scan-orchestrator → scanner count stays **15**, snapshot suite untouched); the command's +orchestration is judgment-driven and deliberately outside the snapshot suite, exactly like +`/config-audit optimize` + `knowledge-refresh`. **No new agent** (web/judgment-free, runs in the +command's own context). suite 1127→1138. + ## Gotchas - Session directories accumulate — use `/config-audit cleanup` to manage diff --git a/README.md b/README.md index ba0bd9f..e138712 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ![Version](https://img.shields.io/badge/version-5.7.0-blue) ![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple) ![Scanners](https://img.shields.io/badge/scanners-15-cyan) -![Commands](https://img.shields.io/badge/commands-20-green) +![Commands](https://img.shields.io/badge/commands-21-green) ![Agents](https://img.shields.io/badge/agents-7-orange) ![Hooks](https://img.shields.io/badge/hooks-4-red) ![Tests](https://img.shields.io/badge/tests-1091+-brightgreen) @@ -293,6 +293,7 @@ Your team configuration changes over time. Track it: | `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence | | `/config-audit whats-active` | Read-only inventory of plugins, skills, MCP, hooks, CLAUDE.md active for a repo (with token estimates) | | `/config-audit knowledge-refresh` | Keep the best-practices register fresh — flag stale entries (sources older than ~90d) + poll for new/changed Claude Code practices; **human-approved writes only** (Verifiseringsplikt). Deterministic stale core + web candidate poll | +| `/config-audit campaign` | Machine-wide audit campaign — durable ledger above sessions tracking each repo's lifecycle (pending → audited → planned → implemented) + a machine-wide roll-up by severity, resumable across sessions; **human-approved writes only** (read-only report + deterministic write-CLI) | | `/config-audit discover` | Run discovery phase only | | `/config-audit analyze` | Run analysis phase only | | `/config-audit interview` | Set preferences for action plan _(optional)_ | diff --git a/commands/campaign.md b/commands/campaign.md new file mode 100644 index 0000000..d3428c5 --- /dev/null +++ b/commands/campaign.md @@ -0,0 +1,177 @@ +--- +name: config-audit:campaign +description: Machine-wide audit campaign — track which repos are pending/audited/planned/implemented across sessions, with a machine-wide roll-up. Human-approved writes only. +argument-hint: "[init | add ... | add --discover | set-status ]" +allowed-tools: Read, Write, Edit, Bash, Glob +model: opus +--- + +# Config-Audit: Campaign + +A single config-audit session audits **one** scope. A **campaign** sits above sessions: a +durable ledger of every repo you mean to bring up to standard, each repo's lifecycle status +(**pending → audited → planned → implemented**), and a machine-wide roll-up of findings by +severity. It persists to `~/.claude/config-audit/campaign-ledger.json` — **outside** the +plugin dir, next to `sessions/` — so it survives plugin uninstall/reinstall/upgrade and +resumes across sessions. + +**The Iron rule (Verifiseringsplikt): nothing is ever auto-written.** Reporting is read-only. +Every mutation — creating the ledger, adding a repo, changing a status — is proposed first +and applied **only on explicit approval**, by invoking one deterministic write-CLI subcommand. +The command never hand-edits the ledger JSON. + +This is the **THIN** campaign surface (ledger + roll-up + status). Cross-repo backlog +prioritization and execution are a later block — this command does not run audits or apply +fixes itself; it tracks where each repo stands. + +## Two CLIs back this command + +- **Read (report):** `scanners/campaign-cli.mjs` — loads + validates the ledger, emits the + repo list + roll-up. Never writes. +- **Write (mutate):** `scanners/campaign-write-cli.mjs` — `init` / `add` / `set-status`, each a + thin wrapper over the invariant-enforcing lib transforms + save. Invoked **only** after the + user approves a specific action. + +Both take `--ledger-file ` (defaults to the durable path) and `--output-file `; +the write-CLI also takes `--reference-date ` (the audit date stamp). + +## Implementation + +### Step 1: Parse arguments + +From `$ARGUMENTS`, pick the mode: + +- *(empty)* or `report` → **report** (read-only). Default. +- `init` → initialize the ledger. +- `add ...` → add one or more repo paths. +- `add --discover ` → find git repos under `` and let the user pick which to add. +- `set-status ` → transition a tracked repo (`status` ∈ pending/audited/planned/implemented). +- `help` → show this surface and stop. + +Set a shared date stamp for any write: `TODAY=$(date +%F)`. + +### Step 2: Always report current state first + +Whatever the mode, start by showing where the campaign stands (read-only): + +```bash +node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-cli.mjs \ + --output-file ~/.claude/config-audit/sessions/campaign-report.json 2>/dev/null; echo $? +``` + +Exit **0** = a campaign exists, **1** = not initialized yet (advisory — normal first run), +**3** = real error → "The campaign ledger couldn't be read — it may be corrupt." (Stop; do not +attempt a write over a corrupt ledger.) + +Read `~/.claude/config-audit/sessions/campaign-report.json` with the Read tool (per the UX +rules — never show the raw JSON). It has `initialized`, `repos[]` (each: `path, name, status, +sessionId, findingsBySeverity, updatedDate`), and `rollUp {totalRepos, byStatus, bySeverity, +reposWithFindings}`. + +Present it as two short tables: + +**Campaign roll-up** + +| Status | Repos | +|--------|-------| +| pending / audited / planned / implemented | … | + +…plus a one-line severity total across audited repos (e.g. "Findings so far: 3 critical, +8 high, 5 medium, 12 low across 4 audited repos"). + +**Repos** + +| Repo | Status | Findings (C/H/M/L) | Last updated | +|------|--------|--------------------|--------------| + +If `initialized` is false, say so plainly: "No campaign yet. Run `/config-audit campaign init` +to start one." Then — if the mode was `init` or `add` — continue to that step (those bootstrap +a campaign); for `report`/`set-status` on an uninitialized ledger, stop after this message. + +### Step 3 (mode `init`): Initialize + +If already initialized, say so and stop (no clobber). Otherwise tell the user what will happen, +then create it: + +```bash +node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs init \ + --reference-date "$TODAY" \ + --output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $? +``` + +Exit **0** = created, **1** = already initialized (advisory). Confirm: "Campaign ledger created +at `~/.claude/config-audit/campaign-ledger.json`." Then suggest `add`. + +### Step 4 (mode `add`): Add repos — propose, approve, write + +**Gather candidates.** +- Explicit paths: use the paths given after `add`. +- `--discover `: find git repos (depth-limited), e.g. + ```bash + find "" -maxdepth 3 -type d -name .git 2>/dev/null | sed 's:/\.git$::' + ``` + Present the discovered repos as a numbered list and ask **which** to add (and confirm any + that are already tracked will be skipped). Use Glob as a fallback if `find` is unavailable. + +**Confirm, then write.** Show the final list and ask for explicit approval. On approval, add +them in one call (idempotent — already-tracked repos are skipped, not reset): + +```bash +node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs add ... \ + --reference-date "$TODAY" \ + --output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $? +``` + +(For a single repo with a custom display name, add `--name ""`.) Read the result file and +report what was `added` vs `skipped`, then re-show the repo table. + +### Step 5 (mode `set-status`): Transition a repo — propose, approve, write + +Confirm the repo is tracked (from Step 2's report) and that `status` is one of +pending/audited/planned/implemented. State the transition ("``: pending → audited") and +ask for approval. + +When marking a repo **audited**, optionally attach its findings-by-severity so the machine-wide +roll-up stays meaningful. Two honest sources, in order of preference: +1. If the repo was audited in a config-audit session, read that session's finding counts and + build `{"critical":C,"high":H,"medium":M,"low":L}` — pass `--session ` too. +2. Otherwise, use counts the user provides. **Never invent counts** (Verifiseringsplikt) — if + none are available, transition the status without `--findings`. + +On approval: + +```bash +node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs set-status \ + --reference-date "$TODAY" \ + [--findings '{"critical":0,"high":0,"medium":0,"low":0}'] [--session ] \ + --output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $? +``` + +Exit **3** = invalid status, untracked repo, or no ledger → report the message plainly and do +not retry blindly. On success, read the result and re-show the updated roll-up + repo row. + +### Step 6: Next steps + +Tailor to where the campaign stands: + +- **Just initialized / few repos:** "`/config-audit campaign add --discover ~/repos` to enroll + your repos." +- **Pending repos exist:** "Run `/config-audit` in a pending repo to audit it, then + `/config-audit campaign set-status audited` to record the result here." +- **Audited but not planned:** "`/config-audit plan` in that repo, then mark it `planned`." +- Always: the campaign survives this session — re-run `/config-audit campaign` anytime to see + the machine-wide picture. + +## Notes + +- **Read-only report, human-approved writes.** `campaign-cli` never writes; every mutation goes + through `campaign-write-cli` and only after explicit approval, exactly mirroring how + `/config-audit knowledge-refresh` gates register writes. +- **Deterministic core, not byte-stable command.** The lib transforms + both CLIs are + unit-tested and deterministic (`--reference-date` injected); this command's orchestration is + judgment-driven and deliberately **not** in the snapshot suite (like `/config-audit optimize` + and `knowledge-refresh`). +- The `-cli` suffix keeps both CLIs out of the scan-orchestrator, so the scanner count and the + byte-stable snapshot suite are unaffected. +- **THIN scope:** ledger + roll-up + status only. Cross-repo backlog prioritization and execution + are a later block — this command tracks state, it does not run audits or apply fixes. diff --git a/commands/config-audit.md b/commands/config-audit.md index bbec033..b699f04 100644 --- a/commands/config-audit.md +++ b/commands/config-audit.md @@ -1,7 +1,7 @@ --- name: config-audit description: Claude Code Configuration Intelligence - audit, analyze, and optimize your configuration -argument-hint: "[posture|tokens|manifest|feature-gap|optimize|fix|rollback|plan|implement|help|discover|analyze|interview|drift|plugin-health|whats-active|status|cleanup]" +argument-hint: "[posture|tokens|manifest|feature-gap|optimize|fix|rollback|plan|implement|help|discover|analyze|interview|drift|plugin-health|whats-active|campaign|status|cleanup]" allowed-tools: Read, Write, Glob, Grep, Bash, Agent, AskUserQuestion model: opus --- @@ -29,6 +29,7 @@ If a subcommand is provided, route to it: - `drift` → `/config-audit:drift` - `plugin-health` → `/config-audit:plugin-health` - `whats-active` → `/config-audit:whats-active` +- `campaign` → `/config-audit:campaign` - `status` → `/config-audit:status` - `cleanup` → `/config-audit:cleanup` diff --git a/commands/help.md b/commands/help.md index 7f0f7e8..5e88745 100644 --- a/commands/help.md +++ b/commands/help.md @@ -54,6 +54,7 @@ if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi | `/config-audit drift` | Compare current config against a saved baseline | | `/config-audit plugin-health` | Audit plugin structure and the metadata block at the top of each command/agent file | | `/config-audit whats-active` | Show active plugins/skills/MCP/hooks/CLAUDE.md with token estimates | +| `/config-audit campaign` | Track a machine-wide audit campaign across repos — per-repo status + roll-up, resumable across sessions (human-approved writes) | ### Utility diff --git a/scanners/campaign-write-cli.mjs b/scanners/campaign-write-cli.mjs new file mode 100644 index 0000000..5438112 --- /dev/null +++ b/scanners/campaign-write-cli.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node + +/** + * campaign-write-cli — the human-approved WRITE half of the durable campaign ledger + * (v5.7 Fase 2, Block 3c). + * + * Sibling of the read-only `campaign-cli`: where that one only reports, this one mutates. + * Every mutation is routed through the pure, invariant-enforcing lib transforms + * (`createLedger`/`addRepo`/`setRepoStatus`) + `saveLedger` — so path normalization/dedup, + * idempotent add, the status-lifecycle guard, and the `updatedDate` bump are never + * re-implemented by hand. The `/config-audit campaign` command is a thin opus orchestrator: + * it reports (via campaign-cli), proposes a change, and only on explicit human approval + * invokes a single subcommand here (Verifiseringsplikt). It NEVER auto-writes. + * + * Determinism mirrors the lib + the knowledge-refresh CLI: `--reference-date` is the only + * place the clock is read (defaulting to today), and it is passed to the transforms as the + * injected `now`, so the persisted stamps are fully testable. + * + * Naming: `-cli` suffix → NOT an orchestrated scanner (the scan-orchestrator only loads + * scanner modules), so the scanner count is unchanged and the snapshot suite stays + * byte-stable. + * + * Usage: + * node campaign-write-cli.mjs init [--ledger-file

] [--reference-date ] + * node campaign-write-cli.mjs add ... [--name ] [--ledger-file

] [--reference-date ] + * node campaign-write-cli.mjs set-status + * [--findings ''] [--session ] + * [--ledger-file

] [--reference-date ] + * (all accept [--output-file

] to write the result payload to a file instead of stdout) + * + * Exit codes: 0 = write performed, 1 = advisory no-op (init when already initialized), + * 3 = error (unknown subcommand, bad args, invalid status, untracked repo, no/corrupt ledger). + */ + +import { resolve } from 'node:path'; +import { writeFile } from 'node:fs/promises'; +import { + createLedger, + addRepo, + setRepoStatus, + rollUp, + loadLedger, + saveLedger, + defaultLedgerPath, +} from './lib/campaign-ledger.mjs'; + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +function fail(message) { + process.stderr.write(`Error: ${message}\n`); + process.exit(3); +} + +/** Parse argv into a subcommand, positional args, and the flag map. */ +function parseArgs(argv) { + const positionals = []; + const flags = { ledgerFile: null, referenceDate: null, outputFile: null, name: null, findings: null, session: null }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--ledger-file' && argv[i + 1] !== undefined) flags.ledgerFile = argv[++i]; + else if (a === '--reference-date' && argv[i + 1] !== undefined) flags.referenceDate = argv[++i]; + else if (a === '--output-file' && argv[i + 1] !== undefined) flags.outputFile = argv[++i]; + else if (a === '--name' && argv[i + 1] !== undefined) flags.name = argv[++i]; + else if (a === '--findings' && argv[i + 1] !== undefined) flags.findings = argv[++i]; + else if (a === '--session' && argv[i + 1] !== undefined) flags.session = argv[++i]; + else if (a.startsWith('--')) fail(`unknown flag "${a}"`); + else positionals.push(a); + } + return { subcommand: positionals[0], rest: positionals.slice(1), flags }; +} + +/** Load an existing ledger, treating a parse error as a hard failure (never clobber corrupt data). */ +async function loadOrFail(path) { + try { + return await loadLedger(path); // null on ENOENT (no ledger yet) + } catch (err) { + fail(`could not read ledger at ${path}: ${err.message}`); + } +} + +async function emit(payload, outputFile, exitCode) { + const json = JSON.stringify(payload, null, 2); + if (outputFile) await writeFile(outputFile, json, 'utf-8'); + else process.stdout.write(json + '\n'); + process.exit(exitCode); +} + +async function main() { + const { subcommand, rest, flags } = parseArgs(process.argv.slice(2)); + + if (!subcommand) fail('a subcommand is required: init | add | set-status'); + + const ledgerPath = resolve(flags.ledgerFile || defaultLedgerPath()); + if (flags.referenceDate && !DATE_RE.test(flags.referenceDate)) fail('--reference-date must be YYYY-MM-DD'); + // The clock is read here ONLY — the transforms take this injected `now` and stay pure. + const now = flags.referenceDate || new Date().toISOString().slice(0, 10); + + if (subcommand === 'init') { + const existing = await loadOrFail(ledgerPath); + if (existing !== null) { + // Advisory no-op: never wipe an existing campaign. + return emit( + { status: 'ok', action: 'init', written: false, alreadyInitialized: true, ledgerPath }, + flags.outputFile, + 1, + ); + } + const ledger = createLedger({ now }); + await saveLedger(ledgerPath, ledger); + return emit( + { + status: 'ok', action: 'init', written: true, alreadyInitialized: false, ledgerPath, + schemaVersion: ledger.schemaVersion, createdDate: ledger.createdDate, updatedDate: ledger.updatedDate, + repos: ledger.repos, rollUp: rollUp(ledger), + }, + flags.outputFile, + 0, + ); + } + + if (subcommand === 'add') { + const paths = rest; + if (paths.length === 0) fail('add requires at least one repo path'); + const loaded = await loadOrFail(ledgerPath); + const autoInitialized = loaded === null; + let ledger = loaded === null ? createLedger({ now }) : loaded; + + const added = []; + const skipped = []; + for (const p of paths) { + const resolved = resolve(p); + const present = ledger.repos.some((r) => r.path === resolved); + // --name applies only to a lone path; multi-add lets the lib derive each basename. + const name = paths.length === 1 ? flags.name || undefined : undefined; + ledger = addRepo(ledger, { path: p, name }, { now }); + (present ? skipped : added).push(resolved); + } + await saveLedger(ledgerPath, ledger); + return emit( + { + status: 'ok', action: 'add', written: true, autoInitialized, ledgerPath, + added, skipped, repos: ledger.repos, rollUp: rollUp(ledger), + }, + flags.outputFile, + 0, + ); + } + + if (subcommand === 'set-status') { + const [path, status] = rest; + if (!path || !status) fail('set-status requires '); + const ledger = await loadOrFail(ledgerPath); + if (ledger === null) fail(`no ledger at ${ledgerPath} — run "init" or "add" first`); + + let findingsBySeverity; + if (flags.findings !== null) { + try { + findingsBySeverity = JSON.parse(flags.findings); + } catch (err) { + fail(`--findings must be valid JSON: ${err.message}`); + } + } + const opts = { now }; + if (findingsBySeverity !== undefined) opts.findingsBySeverity = findingsBySeverity; + if (flags.session !== null) opts.sessionId = flags.session; + + let next; + try { + next = setRepoStatus(ledger, path, status, opts); + } catch (err) { + // RangeError (bad status) or Error (untracked repo) → caller error. + fail(err.message); + } + await saveLedger(ledgerPath, next); + const resolved = resolve(path); + return emit( + { + status: 'ok', action: 'set-status', written: true, ledgerPath, + repo: next.repos.find((r) => r.path === resolved), repos: next.repos, rollUp: rollUp(next), + }, + flags.outputFile, + 0, + ); + } + + fail(`unknown subcommand "${subcommand}" — expected init | add | set-status`); +} + +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); + }); +} diff --git a/tests/scanners/campaign-write-cli.test.mjs b/tests/scanners/campaign-write-cli.test.mjs new file mode 100644 index 0000000..ddf42b5 --- /dev/null +++ b/tests/scanners/campaign-write-cli.test.mjs @@ -0,0 +1,211 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, mkdtempSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { + loadLedger, + validateLedger, + rollUp, +} from '../../scanners/lib/campaign-ledger.mjs'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const CLI = resolve(__dirname, '../../scanners/campaign-write-cli.mjs'); + +const NOW = '2026-06-22'; + +// Every test passes an explicit --ledger-file in a temp dir + an injected --reference-date, +// so the write-CLI never touches the real default path under HOME and never reads the clock. +// The suite is hermetic + deterministic by construction. +function runWrite(args) { + try { + const stdout = execFileSync('node', [CLI, ...args], { encoding: 'utf-8', timeout: 15000 }); + return { status: 0, stdout }; + } catch (err) { + return { status: err.status, stdout: err.stdout || '', stderr: err.stderr || '' }; + } +} + +function newDir() { + return mkdtempSync(join(tmpdir(), 'camp-write-')); +} + +// Read the persisted ledger back through the real lib so a test proves the on-disk file +// is exactly what the command layer would later load. +async function readLedger(file) { + const ledger = await loadLedger(file); + return { ledger, validation: validateLedger(ledger) }; +} + +describe('campaign-write-cli — init', () => { + it('creates a valid, empty, versioned ledger (exit 0)', async () => { + const dir = newDir(); + const file = join(dir, 'ledger.json'); + const { status, stdout } = runWrite(['init', '--ledger-file', file, '--reference-date', NOW]); + assert.equal(status, 0); + const out = JSON.parse(stdout); + assert.equal(out.action, 'init'); + assert.equal(out.written, true); + assert.equal(out.alreadyInitialized, false); + assert.equal(out.ledgerPath, resolve(file)); + + const { ledger, validation } = await readLedger(file); + assert.ok(validation.valid, validation.errors.join('; ')); + assert.equal(ledger.schemaVersion, 1); + assert.equal(ledger.createdDate, NOW); + assert.equal(ledger.updatedDate, NOW); + assert.deepEqual(ledger.repos, []); + }); + + it('refuses to clobber an existing ledger (exit 1, file untouched)', async () => { + const dir = newDir(); + const file = join(dir, 'ledger.json'); + // First init, then add a repo so we can prove a second init does NOT wipe it. + runWrite(['init', '--ledger-file', file, '--reference-date', NOW]); + runWrite(['add', '/r/a', '--ledger-file', file, '--reference-date', NOW]); + + const { status, stdout } = runWrite(['init', '--ledger-file', file, '--reference-date', NOW]); + assert.equal(status, 1, 'advisory: already initialized'); + const out = JSON.parse(stdout); + assert.equal(out.written, false); + assert.equal(out.alreadyInitialized, true); + + const { ledger } = await readLedger(file); + assert.equal(ledger.repos.length, 1, 'existing repo preserved — not clobbered'); + }); +}); + +describe('campaign-write-cli — add', () => { + it('auto-initializes when no ledger exists, then adds repos (exit 0)', async () => { + const dir = newDir(); + const file = join(dir, 'ledger.json'); + assert.ok(!existsSync(file)); + const { status, stdout } = runWrite([ + 'add', '/r/a', '/r/b', '--ledger-file', file, '--reference-date', NOW, + ]); + assert.equal(status, 0); + const out = JSON.parse(stdout); + assert.equal(out.action, 'add'); + assert.equal(out.autoInitialized, true); + assert.deepEqual(out.added.sort(), [resolve('/r/a'), resolve('/r/b')].sort()); + + const { ledger, validation } = await readLedger(file); + assert.ok(validation.valid, validation.errors.join('; ')); + assert.equal(ledger.repos.length, 2); + assert.ok(ledger.repos.every((r) => r.status === 'pending')); + }); + + it('appends to an existing ledger and is idempotent on re-add (added vs skipped)', async () => { + const dir = newDir(); + const file = join(dir, 'ledger.json'); + runWrite(['add', '/r/a', '/r/b', '--ledger-file', file, '--reference-date', NOW]); + + const { status, stdout } = runWrite([ + 'add', '/r/a', '/r/c', '--ledger-file', file, '--reference-date', NOW, + ]); + assert.equal(status, 0); + const out = JSON.parse(stdout); + assert.equal(out.autoInitialized, false); + assert.deepEqual(out.added, [resolve('/r/c')]); + assert.deepEqual(out.skipped, [resolve('/r/a')]); + + const { ledger } = await readLedger(file); + assert.equal(ledger.repos.length, 3); + }); + + it('uses --name for a single path; derives basenames for multiple', async () => { + const dir = newDir(); + const solo = join(dir, 'solo.json'); + runWrite(['add', '/r/x', '--name', 'custom', '--ledger-file', solo, '--reference-date', NOW]); + const { ledger: l1 } = await readLedger(solo); + assert.equal(l1.repos[0].name, 'custom'); + + const multi = join(dir, 'multi.json'); + runWrite(['add', '/r/alpha', '/r/beta', '--ledger-file', multi, '--reference-date', NOW]); + const { ledger: l2 } = await readLedger(multi); + assert.deepEqual(l2.repos.map((r) => r.name).sort(), ['alpha', 'beta']); + }); +}); + +describe('campaign-write-cli — set-status', () => { + async function seeded() { + const dir = newDir(); + const file = join(dir, 'ledger.json'); + runWrite(['add', '/r/a', '/r/b', '--ledger-file', file, '--reference-date', NOW]); + return file; + } + + it('transitions a tracked repo and attaches findings + session (exit 0)', async () => { + const file = await seeded(); + const findings = { critical: 1, high: 2, medium: 0, low: 4 }; + const { status, stdout } = runWrite([ + 'set-status', '/r/a', 'audited', + '--findings', JSON.stringify(findings), + '--session', '20260622_120000', + '--ledger-file', file, '--reference-date', NOW, + ]); + assert.equal(status, 0); + const out = JSON.parse(stdout); + assert.equal(out.action, 'set-status'); + assert.equal(out.repo.status, 'audited'); + assert.deepEqual(out.repo.findingsBySeverity, findings); + assert.equal(out.repo.sessionId, '20260622_120000'); + + const { ledger } = await readLedger(file); + assert.deepEqual(rollUp(ledger).bySeverity, findings); + assert.deepEqual(out.rollUp, rollUp(ledger)); + }); + + it('exits 3 on an invalid status', async () => { + const file = await seeded(); + const { status } = runWrite([ + 'set-status', '/r/a', 'nonsense', '--ledger-file', file, '--reference-date', NOW, + ]); + assert.equal(status, 3); + }); + + it('exits 3 when the repo is not tracked', async () => { + const file = await seeded(); + const { status } = runWrite([ + 'set-status', '/r/ghost', 'audited', '--ledger-file', file, '--reference-date', NOW, + ]); + assert.equal(status, 3); + }); + + it('exits 3 when no ledger exists yet', () => { + const dir = newDir(); + const { status } = runWrite([ + 'set-status', '/r/a', 'audited', + '--ledger-file', join(dir, 'nope.json'), '--reference-date', NOW, + ]); + assert.equal(status, 3); + }); +}); + +describe('campaign-write-cli — determinism + --output-file', () => { + it('stamps updatedDate from --reference-date and writes the payload to --output-file', async () => { + const dir = newDir(); + const file = join(dir, 'ledger.json'); + runWrite(['init', '--ledger-file', file, '--reference-date', '2026-01-01']); + const report = join(dir, 'report.json'); + const { stdout } = runWrite([ + 'add', '/r/a', '--ledger-file', file, '--reference-date', '2026-03-15', + '--output-file', report, + ]); + assert.equal(stdout.trim(), '', 'stdout is silent when --output-file is given'); + const written = JSON.parse(readFileSync(report, 'utf-8')); + assert.equal(written.action, 'add'); + + const { ledger } = await readLedger(file); + assert.equal(ledger.createdDate, '2026-01-01', 'created stamp preserved'); + assert.equal(ledger.updatedDate, '2026-03-15', 'updated stamp from the later --reference-date'); + }); + + it('exits 3 on an unknown subcommand', () => { + const dir = newDir(); + const { status } = runWrite(['frobnicate', '--ledger-file', join(dir, 'x.json')]); + assert.equal(status, 3); + }); +});