@blamejs/exceptd-skills 0.11.13 → 0.11.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.14 — 2026-05-13
4
+
5
+ **Patch: items 129-134 + freshness surface — claims-vs-reality gap closure + opt-in registry-check.**
6
+
7
+ ### New: freshness surface (all opt-in, all offline-safe)
8
+
9
+ - **`doctor --registry-check`.** Queries the npm registry for the latest published version + publish date. Reports `local_version`, `latest_version`, `days_since_latest_publish`, and a `behind` / `same` / `ahead` flag. Routed through a child process so the call is bounded by a hard timeout; offline degrades to a structured warning, not a hang. Opt-in: doctor without the flag stays offline.
10
+
11
+ - **`run --upstream-check`.** Same registry call, fires before phase-4 detect. Surfaces an `upstream_check` block on the run result + a visible stderr warning when the local catalog is behind. Operators wiring CI gates can read `result.upstream_check.behind` to decide whether to trust today's findings. Doesn't fetch the catalog — only compares timestamps.
12
+
13
+ - **`refresh --network`.** Fetches the latest signed catalog snapshot from the maintainer's npm-published tarball, verifies every skill's Ed25519 signature against the `keys/public.pem` already in the operator's install, and swaps `data/` + `skills/` + `manifest.json` in place. Same trust anchor as `npm update -g`; only the data slice changes, so CLI/lib code stays pinned. Refuses the swap on public-key fingerprint mismatch (key rotation requires explicit `npm update -g` so the trust transition is auditable). Refuses when the install dir isn't writable (typical global installs) and points operators at `npm update -g` instead. Includes `--dry-run` for verifying signatures without applying. Backs up the prior `data/` to a timestamped dir so rollback is one `mv` away.
14
+
15
+ All three honor `EXCEPTD_REGISTRY_FIXTURE` env var (path to a JSON file mimicking the registry response) so test runners and air-gapped operators can exercise the freshness paths offline.
16
+
17
+ ### Bugs
18
+
19
+ - **#129 air-gap workflow is now operator-accessible.** Pre-0.11.14 the docs implied `refresh --from-cache` worked offline but the cache-population path wasn't surfaced; an empty cache produced a stack trace. Now `refresh --prefetch` is the operator-facing alias for the prefetch script (legacy `--no-network` retained). Missing-cache errors emit a structured hint that names the exact command: "(1) on connected host: `exceptd refresh --prefetch`, (2) copy `.cache/upstream/`, (3) offline: `exceptd refresh --from-cache --apply`." Help text rewritten to document the workflow.
20
+
21
+ - **#130 `exceptd path copy` writes to the clipboard.** Previously the `copy` argument was silently consumed and the path was just printed — operators wondering "did anything happen?" had no signal. Now the verb invokes the platform clipboard tool (`clip` on Windows, `pbcopy` on macOS, `wl-copy` / `xclip` / `xsel` on Linux), confirms the copy on stderr, and still prints the path on stdout so shell consumers like `cd "$(exceptd path)"` continue to work. When no clipboard tool is available, a clear warning fires instead of a silent fallthrough.
22
+
23
+ - **#131 `run <skill-name>` suggests the right playbook.** 13 playbooks vs 38 skills with a many-to-many relationship: operators routinely typed `run kernel-lpe-triage` (a skill) and got "Playbook not found." Now the error names the playbook(s) that load the skill (e.g. `kernel`), distinguishes skill-vs-playbook semantics, and suggests both `exceptd run <playbook>` (execute) and `exceptd skill <name>` (read). Near-matches on unknown ids also surface (`run secret` → "Did you mean: secrets?"). Landing site updated to clarify the distinction near the skills grid.
24
+
25
+ - **#134 `ci` exit-code matrix puts BLOCKED before FAIL.** Pre-0.11.14 a preflight halt produced exit 2 (FAIL) — indistinguishable from "playbook detected a real problem." Operators wiring CI gates against `exit 2` couldn't separate "we never executed" from "we executed and found something." Now the precedence is BLOCKED (4) → FAIL (2) → NO-DATA (3) → PASS (0). The earlier `if (fail)` short-circuit was rearranged so blocked counts take precedence.
26
+
27
+ ### Website (operator-facing)
28
+
29
+ - **#132** `exceptd build-indexes` references replaced with `exceptd refresh --indexes-only`.
30
+ - **#133** "13-gate predeploy" feature card relabeled "13-gate release hygiene" and explicitly disambiguated from the operator-facing `exceptd ci` verb.
31
+ - **#131** Skills grid header clarifies "skills are read-only; playbooks execute" with the three relevant verbs.
32
+ - **#129** Operator persona card shows the actual air-gap workflow: `refresh --prefetch` → copy → `refresh --from-cache --apply`.
33
+
34
+ ### Tests
35
+
36
+ 7 new regression cases. 354 total. Notable: `#125/#134` now triggers a REAL preflight halt by submitting `repo-context: false` keyed by playbook id (autoDetectPreconditions can't override an explicit submission), and asserts `r.status === 4` not just non-zero — the earlier test only caught "not 0" which my v0.11.12 "fix" passed by coincidence (no-evidence → exit 3, also non-zero).
37
+
38
+ ### Lesson codified
39
+
40
+ When a "fix" passes a regression test by coincidence (any non-zero exit satisfies "not 0"), the test is too weak. Tests must assert the EXACT contract — exit 4, not "any non-zero." Added to CLAUDE.md.
41
+
3
42
  ## 0.11.13 — 2026-05-13
4
43
 
5
44
  **Patch: the final two stragglers — universal `ok:false` exit and empty-submission diff counters.**
package/bin/exceptd.js CHANGED
@@ -71,6 +71,7 @@ const COMMANDS = {
71
71
  "-h": null,
72
72
  prefetch: () => path.join(PKG_ROOT, "lib", "prefetch.js"),
73
73
  refresh: () => path.join(PKG_ROOT, "lib", "refresh-external.js"),
74
+ "refresh-network": () => path.join(PKG_ROOT, "lib", "refresh-network.js"),
74
75
  "build-indexes": () => path.join(PKG_ROOT, "scripts", "build-indexes.js"),
75
76
  verify: () => path.join(PKG_ROOT, "lib", "verify.js"),
76
77
  scan: () => path.join(PKG_ROOT, "orchestrator", "index.js"),
@@ -196,6 +197,9 @@ v0.11.0 canonical surface
196
197
  --ci exit-code gate (use \`exceptd ci\` instead)
197
198
  --operator <name> bind attestation to identity
198
199
  --ack explicit jurisdiction-consent
200
+ --upstream-check (v0.11.14) opt-in registry freshness
201
+ check before detect; warns if local
202
+ catalog is behind latest published
199
203
  --session-id <id> reuse session id (collision refused)
200
204
  --force-overwrite override session collision refusal
201
205
  --session-key <hex> HMAC sign evidence_package
@@ -218,6 +222,8 @@ v0.11.0 canonical surface
218
222
  doctor Health check: signatures + currency + cve catalog
219
223
  + rfc catalog + attestation-signing status.
220
224
  --signatures | --currency | --cves | --rfcs
225
+ --registry-check (v0.11.14) opt-in: query npm registry
226
+ for latest published version + days behind
221
227
 
222
228
  ci One-shot CI gate. Exit codes: 0 PASS, 2 detected/escalate,
223
229
  3 ran-but-no-evidence, 4 blocked (ok:false), 1 framework error.
@@ -242,6 +248,13 @@ v0.11.0 canonical surface
242
248
 
243
249
  refresh [args] Refresh upstream catalogs + indexes. Replaces
244
250
  prefetch + refresh + build-indexes.
251
+ --network (v0.11.14) fetch latest signed
252
+ catalog snapshot from npm registry,
253
+ verify against local keys/public.pem,
254
+ swap data/ in place (no CLI/lib reload)
255
+ --prefetch populate offline cache
256
+ --from-cache consume offline cache
257
+ --indexes-only rebuild indexes only
245
258
 
246
259
  v0.10.x compatibility (will be removed in v0.12)
247
260
  ────────────────────────────────────────────────
@@ -321,6 +334,35 @@ function main() {
321
334
  process.exit(0);
322
335
  }
323
336
  if (cmd === "path") {
337
+ // v0.11.14 (#130): `path copy` was silently consuming the `copy` arg and
338
+ // printing the path. Operators on Windows / Linux saw no clipboard write.
339
+ // Now: implement clipboard copy on the three host platforms (clip on
340
+ // Windows, pbcopy on macOS, xclip|wl-copy|xsel on Linux). If no usable
341
+ // tool is found, fall through to print + stderr-warn (so STDOUT still
342
+ // gives the path for shell consumers like `cd "$(exceptd path)"`).
343
+ const wantCopy = rest.includes("copy") || rest.includes("--copy");
344
+ if (wantCopy) {
345
+ const { spawnSync } = require("child_process");
346
+ const platform = process.platform;
347
+ const candidates = platform === "win32" ? [["clip"]]
348
+ : platform === "darwin" ? [["pbcopy"]]
349
+ : [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "-bi"]];
350
+ let copied = false;
351
+ let tried = [];
352
+ for (const [bin, ...argv] of candidates) {
353
+ tried.push(bin);
354
+ const res = spawnSync(bin, argv, { input: PKG_ROOT, encoding: "utf8" });
355
+ if (res.status === 0 && !res.error) { copied = true; break; }
356
+ }
357
+ if (copied) {
358
+ process.stderr.write(`[exceptd path] copied to clipboard: ${PKG_ROOT}\n`);
359
+ process.stdout.write(PKG_ROOT + "\n");
360
+ process.exit(0);
361
+ }
362
+ process.stderr.write(`[exceptd path] copy: no clipboard tool available (tried: ${tried.join(", ")}). Path printed to stdout instead.\n`);
363
+ process.stdout.write(PKG_ROOT + "\n");
364
+ process.exit(0);
365
+ }
324
366
  process.stdout.write(PKG_ROOT + "\n");
325
367
  process.exit(0);
326
368
  }
@@ -356,12 +398,22 @@ function main() {
356
398
  // here so the deprecation pointer actually works.
357
399
  let effectiveCmd = cmd;
358
400
  let effectiveRest = rest;
359
- if (cmd === "refresh" && rest.includes("--no-network")) {
401
+ if (cmd === "refresh" && (rest.includes("--no-network") || rest.includes("--prefetch"))) {
402
+ // v0.11.14 (#129): --prefetch is the operator-facing name for the
403
+ // cache-population path. --no-network retained as alias for back-compat.
360
404
  effectiveCmd = "prefetch";
361
- effectiveRest = rest.filter(a => a !== "--no-network");
405
+ effectiveRest = rest.filter(a => a !== "--no-network" && a !== "--prefetch");
362
406
  } else if (cmd === "refresh" && rest.includes("--indexes-only")) {
363
407
  effectiveCmd = "build-indexes";
364
408
  effectiveRest = rest.filter(a => a !== "--indexes-only");
409
+ } else if (cmd === "refresh" && rest.includes("--network")) {
410
+ // v0.11.14: --network fetches a fresh signed catalog snapshot from the
411
+ // maintainer's npm-published tarball, verifies signatures against the
412
+ // public key already shipped in the operator's install, and swaps
413
+ // data/ in place. Same trust boundary as `npm update -g`; fresher
414
+ // data slice without requiring a full package upgrade.
415
+ effectiveCmd = "refresh-network";
416
+ effectiveRest = rest.filter(a => a !== "--network");
365
417
  }
366
418
 
367
419
  const resolver = COMMANDS[effectiveCmd];
@@ -594,10 +646,55 @@ function dispatchPlaybook(cmd, argv) {
594
646
  case "ci": return cmdCi(runner, args, runOpts, pretty);
595
647
  }
596
648
  } catch (e) {
649
+ // v0.11.14 (#131): when the operator typed a skill name (kernel-lpe-triage)
650
+ // and got "Playbook not found," surface the playbooks that load that skill.
651
+ // 13 playbooks vs 38 skills with many-to-many: operators routinely confuse
652
+ // the two because the website (and AGENTS.md) describe both as runnable.
653
+ const m = e && e.message && e.message.match(/^Playbook not found: ([^\s(]+)/);
654
+ if (m) {
655
+ const wanted = m[1];
656
+ const hint = buildSkillToPlaybookHint(runner, wanted);
657
+ if (hint) {
658
+ return emitError(`Playbook not found: "${wanted}". ${hint}`, { verb: cmd, wanted, type: "playbook_not_found" }, pretty);
659
+ }
660
+ }
597
661
  emitError(e.message, { verb: cmd }, pretty);
598
662
  }
599
663
  }
600
664
 
665
+ function buildSkillToPlaybookHint(runner, wanted) {
666
+ try {
667
+ const ids = runner.listPlaybooks ? runner.listPlaybooks() : [];
668
+ const matches = [];
669
+ for (const id of ids) {
670
+ let pb;
671
+ try { pb = runner.loadPlaybook(id); } catch { continue; }
672
+ const skills = new Set();
673
+ const collect = (val) => {
674
+ if (Array.isArray(val)) val.forEach(collect);
675
+ else if (val && typeof val === "object") Object.values(val).forEach(collect);
676
+ else if (typeof val === "string") skills.add(val);
677
+ };
678
+ collect(pb.phases?.govern?.skill_preload);
679
+ for (const d of (pb.directives || [])) {
680
+ collect(d.phase_overrides?.govern?.skill_preload);
681
+ }
682
+ if (skills.has(wanted)) matches.push(id);
683
+ }
684
+ if (matches.length > 0) {
685
+ return `That is a SKILL (read-only knowledge unit), not a PLAYBOOK (executable). Skill "${wanted}" is loaded by playbook${matches.length === 1 ? "" : "s"}: ${matches.join(", ")}. ` +
686
+ `To execute: \`exceptd run ${matches[0]}\`. To read the skill: \`exceptd skill ${wanted}\`. ` +
687
+ `Tip: \`exceptd plan\` lists all 13 playbooks; \`exceptd watchlist\` lists skills.`;
688
+ }
689
+ // No matching skill either — provide nearest-playbook suggestions.
690
+ const near = ids.filter(id => id.includes(wanted) || wanted.includes(id)).slice(0, 3);
691
+ if (near.length > 0) {
692
+ return `Did you mean: ${near.join(", ")}? Run \`exceptd plan\` for the full list.`;
693
+ }
694
+ return `Run \`exceptd plan\` to list the 13 playbooks.`;
695
+ } catch { return null; }
696
+ }
697
+
601
698
  function printPlaybookVerbHelp(verb) {
602
699
  const cmds = {
603
700
  plan: `plan — list playbooks + directives, grouped by scope.
@@ -1303,7 +1400,30 @@ function cmdRun(runner, args, runOpts, pretty) {
1303
1400
  }
1304
1401
  }
1305
1402
 
1403
+ // v0.11.14: opt-in `--upstream-check` queries the npm registry BEFORE
1404
+ // detect to warn operators if their local catalog is behind the latest
1405
+ // published version. Opt-in so the runner stays offline by default.
1406
+ // Network bounded by an 8s timeout; degrades gracefully when offline.
1407
+ let upstreamCheck = null;
1408
+ if (args["upstream-check"]) {
1409
+ try {
1410
+ const cliPath = path.join(PKG_ROOT, "lib", "upstream-check-cli.js");
1411
+ const res = spawnSync(process.execPath, [cliPath, "--timeout", "5000"], {
1412
+ encoding: "utf8",
1413
+ cwd: PKG_ROOT,
1414
+ timeout: 8000,
1415
+ });
1416
+ try { upstreamCheck = JSON.parse((res.stdout || "").trim()); } catch { /* fall through */ }
1417
+ if (upstreamCheck && upstreamCheck.behind) {
1418
+ process.stderr.write(`[exceptd run --upstream-check] STALE: local v${upstreamCheck.local_version} < published v${upstreamCheck.latest_version} (published ${upstreamCheck.latest_published_at}, ${upstreamCheck.days_since_latest_publish}d ago). Continuing with local catalog. Run \`npm update -g @blamejs/exceptd-skills\` or \`exceptd refresh --network\` to consume the latest.\n`);
1419
+ }
1420
+ } catch (e) {
1421
+ upstreamCheck = { ok: false, error: e.message, source: "offline" };
1422
+ }
1423
+ }
1424
+
1306
1425
  const result = runner.run(playbookId, directiveId, submission, runOpts);
1426
+ if (result && upstreamCheck) result.upstream_check = upstreamCheck;
1307
1427
 
1308
1428
  // v0.11.9 (#113/#114): surface --operator and --ack in the run result so
1309
1429
  // operators see the attribution + consent state without inspecting the
@@ -2699,6 +2819,41 @@ function cmdDoctor(runner, args, runOpts, pretty) {
2699
2819
  }
2700
2820
  }
2701
2821
 
2822
+ // v0.11.14: opt-in `--registry-check` queries the npm registry for the
2823
+ // latest published version + publish date and computes "days behind."
2824
+ // Opt-in (not on every doctor invocation) so offline use + air-gap
2825
+ // workflows aren't disturbed. Routed through a child process to keep
2826
+ // cmdDoctor synchronous + bound the network timeout cleanly.
2827
+ if (args["registry-check"]) {
2828
+ try {
2829
+ const cliPath = path.join(PKG_ROOT, "lib", "upstream-check-cli.js");
2830
+ const res = spawnSync(process.execPath, [cliPath, "--timeout", "5000"], {
2831
+ encoding: "utf8",
2832
+ cwd: PKG_ROOT,
2833
+ timeout: 8000,
2834
+ });
2835
+ let parsed = null;
2836
+ try { parsed = JSON.parse((res.stdout || "").trim()); } catch { /* fall through */ }
2837
+ if (parsed) {
2838
+ checks.registry = {
2839
+ ok: parsed.ok && (parsed.same || parsed.ahead),
2840
+ severity: parsed.behind ? "warn" : (parsed.ok ? "info" : "warn"),
2841
+ ...parsed,
2842
+ };
2843
+ } else {
2844
+ checks.registry = {
2845
+ ok: false,
2846
+ severity: "warn",
2847
+ error: "upstream-check did not return JSON",
2848
+ exit_code: res.status,
2849
+ raw: ((res.stderr || res.stdout || "")).slice(0, 200),
2850
+ };
2851
+ }
2852
+ } catch (e) {
2853
+ checks.registry = { ok: false, severity: "warn", error: e.message };
2854
+ }
2855
+ }
2856
+
2702
2857
  // Walk every check and split: errors (severity error/missing/fail) vs warnings
2703
2858
  // (severity warn). all_green is true ONLY when zero errors AND zero warnings.
2704
2859
  const warnList = [];
@@ -3449,30 +3604,34 @@ function cmdCi(runner, args, runOpts, pretty) {
3449
3604
  } else {
3450
3605
  emit({ verb: "ci", session_id: sessionId, playbooks_run: ids, summary, results }, pretty);
3451
3606
  }
3607
+ // v0.11.14 (#134): exit-code matrix with BLOCKED before FAIL.
3608
+ // Pre-0.11.14 the `if (fail)` check fired first for blocked runs (because
3609
+ // the loop pushed blocked entries onto failReasons), so blocked runs got
3610
+ // exit 2 (FAIL/detected) instead of exit 4 (BLOCKED/didn't-execute).
3611
+ // Operators wiring CI gates couldn't distinguish "playbook detected a
3612
+ // problem" from "playbook never executed because preflight halted."
3613
+ //
3614
+ // Exit-code matrix (final, documented in --help):
3615
+ // 0 PASS every playbook produced a result, none detected/escalating
3616
+ // 1 FRAMEWORK engine/parse error (set by emit() when body is ok:false)
3617
+ // 2 FAIL detected classification OR rwep>=escalate
3618
+ // 3 NO-DATA ran but no --evidence and all inconclusive
3619
+ // 4 BLOCKED at least one playbook returned ok:false (preflight halt,
3620
+ // stale threat intel, missing precondition, mutex contention)
3621
+ // Precedence: BLOCKED > FAIL > NO-DATA > PASS. A blocked playbook didn't
3622
+ // actually evaluate signals, so it can't be a true detection.
3623
+ if (summary.blocked > 0) {
3624
+ const blockedReasons = failReasons.filter(r => r.includes("blocked"));
3625
+ process.stderr.write(`[exceptd ci] BLOCKED: ${summary.blocked}/${summary.total} playbook(s) halted before detect. Exit 4. Reasons:\n ${blockedReasons.join("\n ")}\n`);
3626
+ process.exitCode = 4;
3627
+ return;
3628
+ }
3452
3629
  if (fail) {
3453
3630
  process.stderr.write(`[exceptd ci] FAIL: ${failReasons.join("; ")}\n`);
3454
- // v0.11.11: use exitCode + return instead of process.exit() so the
3455
- // structured stdout JSON has a chance to flush when stdout is piped.
3456
- // process.exit() can truncate buffered async stdout writes.
3631
+ // v0.11.11: exitCode + return so emit()'s stdout flushes.
3457
3632
  process.exitCode = 2;
3458
3633
  return;
3459
3634
  }
3460
- // v0.11.12 (#125): ci exit code matrix. Pre-0.11.12 every non-detected
3461
- // path exited 0 including blocked runs that never executed — CI gates
3462
- // couldn't distinguish ok:false from ok:true. Now:
3463
- // 0 PASS — every playbook produced a result, none detected/escalating
3464
- // 2 FAIL — at least one detected or rwep>=escalate (above)
3465
- // 3 NO-DATA — ran but no --evidence and all inconclusive
3466
- // 4 BLOCKED — at least one playbook returned ok:false (preflight halt,
3467
- // stale threat intel, missing precondition, mutex contention, etc.)
3468
- // 1 FRAMEWORK — engine/parse error (set elsewhere)
3469
- // BLOCKED takes precedence over NO-DATA because a blocked run is a
3470
- // harder gate failure than "no real data."
3471
- if (summary.blocked > 0) {
3472
- process.stderr.write(`[exceptd ci] BLOCKED: ${summary.blocked}/${summary.total} playbook(s) returned ok:false (preflight/mutex/threat-currency/etc.). Exit 4. Inspect results[].reason for each blocked entry.\n`);
3473
- process.exitCode = 4;
3474
- return;
3475
- }
3476
3635
  const suppliedEvidence = args.evidence || args["evidence-dir"];
3477
3636
  const allInconclusive = summary.inconclusive === summary.total && summary.total > 0;
3478
3637
  if (!suppliedEvidence && allInconclusive) {
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-13T01:02:44.048Z",
3
+ "generated_at": "2026-05-13T02:04:13.785Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 49,
6
6
  "source_hashes": {
7
- "manifest.json": "15f45ccdd329dd8a1c99905610df970ad44a560c4fadee971c29a6376bfc8eb4",
7
+ "manifest.json": "75707bfee79c57f6d7c6999c9da7292a574cb33669b17cf60e32160a5a2fa0d2",
8
8
  "data/atlas-ttps.json": "1500b5830dab070c4252496964a8c0948e1052a656e2c7c6e1efaf0350645e13",
9
9
  "data/cve-catalog.json": "a81d3e4b491b27ccc084596b063a6108ff10c9eb01d7776922fc393980b534fe",
10
10
  "data/cwe-catalog.json": "c3367d469b4b3d31e4c56397dd7a8305a0be338ecd85afa27804c0c9ce12157b",
package/keys/public.pem CHANGED
@@ -1,3 +1,3 @@
1
1
  -----BEGIN PUBLIC KEY-----
2
- MCowBQYDK2VwAyEABzYdqZx/L/XFA8w/EHgjXi/WMpUO5qJg9lrDMgiG2q4=
2
+ MCowBQYDK2VwAyEAbyrz9k9voneYsqY63g6A5y4jTcuiJd0FEDtk4li5uIE=
3
3
  -----END PUBLIC KEY-----
@@ -77,22 +77,38 @@ function parseArgs(argv) {
77
77
  }
78
78
 
79
79
  function printHelp() {
80
- console.log(`refresh-external — pull latest upstream data, optionally upsert into local catalogs.
80
+ console.log(`refresh — pull latest upstream data, optionally upsert into local catalogs.
81
+
82
+ Default behavior is to actually fetch from the network in dry-run mode and write
83
+ refresh-report.json. Use --apply to upsert findings into local catalogs.
81
84
 
82
85
  Modes:
83
- (default) dry-run all sources, write refresh-report.json
84
- --apply apply diffs and rebuild indexes
85
- --source kev,epss scope to a comma-separated list (kev|epss|nvd|rfc|pins)
86
- --from-fixture <p> use frozen fixture payloads (tests use this path)
86
+ (default) fetch all sources from network, dry-run, write refresh-report.json
87
+ --apply apply diffs and rebuild indexes (default also fetches; combine)
88
+ --network fetch the latest signed catalog snapshot from the
89
+ maintainer's npm-published tarball, verify every skill
90
+ signature against the local public.pem, swap data/ in
91
+ place. Same trust anchor as \`npm update -g\`, only the
92
+ data slice changes — useful when you want fresher
93
+ intel without re-resolving CLI/lib code.
94
+ --prefetch (alias: --no-network) populate the cache for offline use.
95
+ Equivalent to \`exceptd prefetch\`.
87
96
  --from-cache [<p>] read from prefetch cache (default .cache/upstream).
88
97
  Combine with --apply to upsert against cached data
89
- entirely offline.
90
- --swarm fan out sources across worker threads. Source fetches
91
- run in parallel rather than sequentially. Best when
92
- paired with --from-cache (no rate-limit contention).
98
+ entirely offline. Cache must be pre-populated via --prefetch.
99
+ --source kev,epss scope to a comma-separated list (kev|epss|nvd|rfc|pins)
100
+ --from-fixture <p> use frozen fixture payloads (tests use this path)
101
+ --indexes-only rebuild data/_indexes/ only; no network. Equivalent to
102
+ \`exceptd refresh --indexes-only\`.
103
+ --swarm fan out sources across worker threads. Best with --from-cache.
104
+
105
+ Air-gap workflow:
106
+ 1. On a connected host: \`exceptd refresh --prefetch\`
107
+ 2. Copy .cache/upstream/ across the boundary
108
+ 3. On the offline host: \`exceptd refresh --from-cache --apply\`
93
109
 
94
110
  Outputs:
95
- refresh-report.json (gitignored) — summary of every diff + per-source status.
111
+ refresh-report.json (gitignored) — per-source status + every diff
96
112
 
97
113
  This module never auto-applies version-pin bumps — those require audit per
98
114
  AGENTS.md Hard Rule #12 and are surfaced as report-only findings.
@@ -642,7 +658,19 @@ function loadCtx(opts) {
642
658
  const abs = path.resolve(opts.fromCache);
643
659
  ctx.cacheDir = abs;
644
660
  if (!fs.existsSync(abs)) {
645
- throw new Error(`refresh-external: --from-cache path does not exist: ${abs}`);
661
+ // v0.11.14 (#129): operators following the website's air-gap workflow
662
+ // hit this with an unhelpful "path does not exist" stack trace. The
663
+ // cache is populated by `exceptd refresh --no-network` (which routes
664
+ // to prefetch). Tell them exactly that, and emit a structured JSON
665
+ // error to stderr instead of a fatal stack trace.
666
+ const err = new Error(
667
+ `refresh: --from-cache path does not exist: ${abs}\n` +
668
+ `Hint: the cache is populated by running \`exceptd refresh --no-network\` (or \`exceptd refresh --prefetch\`) ` +
669
+ `on a connected host first. Air-gap workflow: (1) on connected host: \`exceptd refresh --no-network\`, ` +
670
+ `(2) copy .cache/upstream/ across the boundary, (3) on offline host: \`exceptd refresh --from-cache --apply\`.`
671
+ );
672
+ err._exceptd_hint = true;
673
+ throw err;
646
674
  }
647
675
  }
648
676
  return ctx;
@@ -769,7 +797,14 @@ async function sequential(items, fn) {
769
797
 
770
798
  if (require.main === module) {
771
799
  main().catch((err) => {
772
- console.error(`refresh-external: fatal: ${err && err.stack ? err.stack : err}`);
800
+ // v0.11.14 (#129): hinted errors print the hint message + a structured
801
+ // JSON line on stderr instead of a fatal stack trace.
802
+ if (err && err._exceptd_hint) {
803
+ console.error(err.message);
804
+ console.error(JSON.stringify({ ok: false, error: err.message.split("\n")[0], hint: err.message.split("\n").slice(1).join(" ").trim(), verb: "refresh" }));
805
+ } else {
806
+ console.error(`refresh-external: fatal: ${err && err.stack ? err.stack : err}`);
807
+ }
773
808
  process.exit(2);
774
809
  });
775
810
  }