@modelstatus/cli 0.1.85 → 0.1.87

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/src/index.js CHANGED
@@ -6,6 +6,7 @@ import { spawnSync } from "node:child_process";
6
6
  import { resolveAuth, loadConfig, saveConfig, clearAuth, configFilePath } from "./config.js";
7
7
  import { createClient } from "./api.js";
8
8
  import { collectFrom, availability, ALL_SOURCE_IDS, getSource } from "./sources/index.js";
9
+ import * as SourcesMod from "./sources/index.js";
9
10
  import {
10
11
  INTEGRATION_IDS, INTEGRATION_META, readIntegrations, enabledIds,
11
12
  getEnvTag, setEnabled, setEnvTag,
@@ -14,7 +15,7 @@ import { redactValue } from "./redact.js";
14
15
  import { assignProjects, buildUsages } from "./upload.js";
15
16
  import { loginViaBrowser } from "./auth.js";
16
17
  import { maybeCheckForUpdate, forceUpdate } from "./updater.js";
17
- import { track, analyticsState, maybeFirstRun } from "./telemetry.js";
18
+ import { track, analyticsState, maybeFirstRun, telemetryCommand } from "./telemetry.js";
18
19
  import { startProgress } from "./spinner.js";
19
20
  import { BUILD_VERSION } from "./version.js";
20
21
 
@@ -85,21 +86,55 @@ if (process.argv[2] === "__bench_frames") {
85
86
  process.exit(0);
86
87
  }
87
88
 
89
+ // The ONE table of every flag the CLI accepts (keep HELP/COMMAND_HELP in sync).
90
+ // VALUE_FLAGS take an argument (space or GNU `--flag=value` form); BOOL_FLAGS
91
+ // don't. Anything else is an invocation error — a typo'd flag must fail loudly,
92
+ // never silently change behavior (`--fail-on=none` used to parse as an unknown
93
+ // boolean and silently run the CI gate at the default threshold).
94
+ const VALUE_FLAGS = new Set([
95
+ "api", "key", "project", "dir", "fail-on", "diff", "json-out", "model",
96
+ "sources", "region", "namespace", "kube-context", "db", "sql-table", "env",
97
+ // Per-integration scope flags (non-secret): consumed by the 4 live sources.
98
+ "vercel-project", "vercel-team", "gh-repo", "supabase-ref",
99
+ ]);
100
+ const BOOL_FLAGS = new Set([
101
+ "help", "h", "version", "v", "ci", "yes", "json", "dry-run", "update",
102
+ "offline", "all", "force", "report", "lifetime", "scan", "rescan", "fresh",
103
+ ]);
104
+
105
+ function argError(msg) {
106
+ console.error(msg);
107
+ process.exit(1);
108
+ }
109
+
88
110
  function parseArgs(argv) {
89
111
  const flags = {};
90
112
  const positional = [];
91
- const valueFlags = new Set([
92
- "api", "key", "project", "dir", "fail-on", "diff", "json-out", "model",
93
- "sources", "region", "namespace", "kube-context", "db", "sql-table", "env",
94
- // Per-integration scope flags (non-secret): consumed by the 4 live sources.
95
- "vercel-project", "vercel-team", "gh-repo", "supabase-ref",
96
- ]);
97
113
  for (let i = 0; i < argv.length; i++) {
98
114
  const a = argv[i];
99
115
  if (a.startsWith("--")) {
100
- const name = a.slice(2);
101
- if (valueFlags.has(name)) flags[name] = argv[++i];
102
- else flags[name] = true;
116
+ let name = a.slice(2);
117
+ let inline = null; // GNU `--name=value` form: split on the FIRST '='
118
+ const eq = name.indexOf("=");
119
+ if (eq !== -1) {
120
+ inline = name.slice(eq + 1);
121
+ name = name.slice(0, eq);
122
+ }
123
+ if (VALUE_FLAGS.has(name)) {
124
+ const v = inline !== null ? inline : argv[++i];
125
+ // A missing value — or the NEXT flag where the value should be — must
126
+ // error, not swallow the flag (`mm status --dir --json` used to scan a
127
+ // dir literally named "--json" and lose --json).
128
+ if (v === undefined || v === "" || (inline === null && v.startsWith("--"))) {
129
+ argError(`--${name} requires a value`);
130
+ }
131
+ flags[name] = v;
132
+ } else if (BOOL_FLAGS.has(name)) {
133
+ if (inline !== null) argError(`--${name} does not take a value`);
134
+ flags[name] = true;
135
+ } else {
136
+ argError(`unknown flag: --${name} (run \`mm --help\` for the list)`);
137
+ }
103
138
  } else if (a === "-h") flags.help = true;
104
139
  else if (a === "-v") flags.version = true;
105
140
  else positional.push(a);
@@ -156,6 +191,34 @@ function scanOpts(flags, dir) {
156
191
  };
157
192
  }
158
193
 
194
+ /** Loud path guard for scan-shaped commands: a missing path (or a file where a
195
+ * directory is expected) is an INVOCATION error, exit 1 — never a clean
196
+ * "0 references" scan. A typo'd dir in a CI workflow used to gate green forever. */
197
+ function requireDirectory(dir) {
198
+ let st = null;
199
+ try { st = fs.statSync(dir); } catch { /* missing */ }
200
+ if (!st) {
201
+ console.error(`path does not exist: ${dir}`);
202
+ process.exit(1);
203
+ }
204
+ if (!st.isDirectory()) {
205
+ console.error(`expected a directory: ${dir}`);
206
+ process.exit(1);
207
+ }
208
+ }
209
+
210
+ /** Unknown --sources ids are an invocation error (exit 1), never a silent
211
+ * empty scan (`--sources filesysten` used to report a clean result). Prefers
212
+ * sources/index.js's unknownSourceIds; falls back to a getSource filter. */
213
+ function assertKnownSources(ids) {
214
+ const unknownOf = SourcesMod.unknownSourceIds ?? ((xs) => xs.filter((id) => !getSource(id)));
215
+ const unknown = unknownOf(ids);
216
+ if (unknown.length) {
217
+ console.error(`unknown source: ${unknown.join(", ")} (known: ${ALL_SOURCE_IDS.join(", ")})`);
218
+ process.exit(1);
219
+ }
220
+ }
221
+
159
222
  async function cmdLogin(positional, flags) {
160
223
  const key = positional[1] || flags.key;
161
224
  const { apiBase } = resolveAuth(flags);
@@ -203,7 +266,8 @@ async function cmdUpgrade(_positional, flags) {
203
266
  const checkoutPlan = flags.lifetime ? "lifetime" : undefined;
204
267
  const plan = await upgradeViaBrowser({ client, plan: checkoutPlan });
205
268
  if (!plan) {
206
- console.error("Upgrade not detected (timed out). Run `mm upgrade` again if you completed checkout.");
269
+ // Never claim success here the target plan was not confirmed.
270
+ console.error("Purchase not confirmed yet — check your plan at llmstatus.ai/app, or re-run `mm upgrade` after completing checkout.");
207
271
  process.exit(1);
208
272
  }
209
273
  }
@@ -274,23 +338,28 @@ async function launchTui(initialView, flags) {
274
338
 
275
339
  async function cmdScan(positional, flags) {
276
340
  const dir = path.resolve(positional[1] || flags.dir || ".");
341
+ requireDirectory(dir);
342
+ const dryRun = !!flags["dry-run"];
277
343
  const { apiBase, apiKey } = resolveAuth(flags);
278
- if (!apiKey) {
344
+ // --dry-run is a fully LOCAL preview — no account, ZERO calls to the API.
345
+ // (The signed registry CDN fetch is its only network, same as `mm status`.)
346
+ if (!apiKey && !dryRun) {
279
347
  console.error("No API key. Run `mm login` or pass --key.");
280
348
  process.exit(1);
281
349
  }
282
350
  // --dry-run must NOT launch the interactive TUI (which can upload) — it's a
283
351
  // no-upload preview, so force the non-interactive path even on a TTY.
284
- const interactive = !flags.yes && !flags.json && !flags["dry-run"] && process.stdout.isTTY;
352
+ const interactive = !flags.yes && !flags.json && !dryRun && process.stdout.isTTY;
285
353
  if (interactive) {
286
354
  const { runApp } = await import("./tui/app.js");
287
355
  await runApp({ apiBase, apiKey, dir, initialView: "scan" });
288
356
  return;
289
357
  }
290
358
 
291
- // Non-interactive (CI / --json / --yes): scan + bulk upload, no TUI.
292
- const client = createClient({ apiBase, apiKey });
359
+ // Non-interactive (CI / --json / --yes): scan (+ bulk upload unless --dry-run).
360
+ const client = dryRun ? null : createClient({ apiBase, apiKey });
293
361
  const sources = parseSources(flags);
362
+ assertKnownSources(sources);
294
363
  const explicit = explicitSources(flags);
295
364
  const opts = scanOpts(flags, dir);
296
365
 
@@ -305,7 +374,22 @@ async function cmdScan(positional, flags) {
305
374
  const active = avail.filter((a) => a.available).map((a) => a.id);
306
375
  process.stderr.write(`Scanning [${active.join(", ") || "none"}] …\n`);
307
376
 
308
- const patterns = await client.detectionPatterns();
377
+ // Detection patterns: the upload path asks the API (needs the key anyway); the
378
+ // dry-run path uses the SIGNED PUBLIC SNAPSHOT so the preview never touches
379
+ // the API — it works logged-out and offline (--offline / cached registry).
380
+ let snapshot = null;
381
+ let patterns;
382
+ if (dryRun) {
383
+ const { getRegistry } = await import("./registry/fetch.js");
384
+ snapshot = await getRegistry({
385
+ offline: !!flags.offline || process.env.LLMSTATUS_REGISTRY_OFFLINE === "1",
386
+ cacheFile: process.env.LLMSTATUS_REGISTRY_CACHE || undefined,
387
+ log: (m) => process.stderr.write(`! ${m}\n`),
388
+ });
389
+ patterns = snapshot.detection;
390
+ } else {
391
+ patterns = await client.detectionPatterns();
392
+ }
309
393
  const candidates = await collectFrom(sources, opts, patterns, explicit);
310
394
  if (candidates.length === 0) {
311
395
  // Respect --json/--ci even on the empty path, so `mm scan --ci | jq` never
@@ -313,7 +397,7 @@ async function cmdScan(positional, flags) {
313
397
  if (flags.json) {
314
398
  const srcRows = avail.map((a) => ({ id: a.id, available: a.available }));
315
399
  console.log(JSON.stringify(
316
- flags["dry-run"]
400
+ dryRun
317
401
  ? { scanned: 0, would_upload: [], sources: avail }
318
402
  : { scanned: 0, uploaded: 0, sources: srcRows, created: 0, updated: 0, failed: 0 },
319
403
  null, 2,
@@ -324,31 +408,45 @@ async function cmdScan(positional, flags) {
324
408
  return;
325
409
  }
326
410
 
411
+ // Resolution: dry-run resolves against the local snapshot (no POST of the
412
+ // detected model inventory); the real upload path resolves server-side.
327
413
  const uniq = [...new Set(candidates.map((c) => c.model_string))];
328
- const resolved = uniq.length ? (await client.resolve(uniq)).data : [];
329
- const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
414
+ let byStr;
415
+ if (dryRun) {
416
+ const { resolveLocal } = await import("./registry/local.js");
417
+ byStr = new Map(resolveLocal(snapshot, uniq).map((r) => [r.input.toLowerCase(), r]));
418
+ } else {
419
+ const resolved = uniq.length ? (await client.resolve(uniq)).data : [];
420
+ byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
421
+ }
330
422
  const seenRows = new Set();
331
423
  const rows = candidates
332
424
  .map((c) => {
333
425
  const r = byStr.get(c.model_string.toLowerCase());
334
- return { ...c, model_id: r?.model_id ?? null, display: r?.model_id ? r.display : c.model_string };
426
+ const hit = r?.model_id ?? r?.model_slug ?? null;
427
+ return { ...c, model_id: r?.model_id ?? null, model_slug: r?.model_slug ?? null, display: hit ? r.display : c.model_string };
335
428
  })
336
429
  .filter((r) => {
337
- const k = `${r.model_id ?? "custom:" + r.model_string}|${r.location_label}`;
430
+ const k = `${r.model_id ?? r.model_slug ?? "custom:" + r.model_string}|${r.location_label}`;
338
431
  if (seenRows.has(k)) return false;
339
432
  seenRows.add(k);
340
433
  return true;
341
434
  });
342
435
 
343
- // Build the upload payload via the SHARED helper so the command, the TUI Scan
344
- // tab, and the Here push produce byte-identical usages (model resolution +
345
- // redaction + the location_label scheme prefix that carries provenance). Parity-
346
- // only: buildUsages reads the same fields the inline map did and falls back to
347
- // GITHUB_REPOSITORY for source_repo. No new data is uploaded.
348
- const usages = await buildUsages(client, rows);
349
-
350
- // --dry-run: show exactly what WOULD upload (secret-source safety check).
351
- if (flags["dry-run"]) {
436
+ // --dry-run: show exactly what WOULD upload (secret-source safety check),
437
+ // built entirely locally model_slug instead of the server's model_id, the
438
+ // same redaction as the real payload, and nothing sent anywhere.
439
+ if (dryRun) {
440
+ const ghRepo = (process.env.GITHUB_REPOSITORY || "").trim();
441
+ const usages = rows.map((r) => ({
442
+ model_slug: r.model_slug ?? undefined,
443
+ custom_model_name: r.model_slug ? undefined : redactValue(r.model_string).slice(0, 120),
444
+ environment: r.environment,
445
+ location_label: r.location_label,
446
+ source_repo: r.source_repo || ghRepo || undefined,
447
+ source_path: r.source_path,
448
+ source_line: r.source_line ?? undefined,
449
+ }));
352
450
  if (flags.json) {
353
451
  console.log(JSON.stringify({ scanned: rows.length, would_upload: usages, sources: avail }, null, 2));
354
452
  } else {
@@ -356,10 +454,20 @@ async function cmdScan(positional, flags) {
356
454
  console.log(`Dry run — ${rows.length} usage(s) found across [${types}]. Nothing uploaded:`);
357
455
  for (const r of rows.slice(0, 60)) console.log(` ${(r.display || r.model_string).padEnd(28)} ${r.location_label} (${r.environment})`);
358
456
  if (rows.length > 60) console.log(` …and ${rows.length - 60} more`);
457
+ console.log(apiKey
458
+ ? "\n(dry run — nothing uploaded. Re-run without --dry-run to upload.)"
459
+ : "\n(dry run — nothing uploaded. Uploading needs an account: run `mm login` first.)");
359
460
  }
360
461
  return;
361
462
  }
362
463
 
464
+ // Build the upload payload via the SHARED helper so the command, the TUI Scan
465
+ // tab, and the Here push produce byte-identical usages (model resolution +
466
+ // redaction + the location_label scheme prefix that carries provenance). Parity-
467
+ // only: buildUsages reads the same fields the inline map did and falls back to
468
+ // GITHUB_REPOSITORY for source_repo. No new data is uploaded.
469
+ const usages = await buildUsages(client, rows);
470
+
363
471
  // --project routes everything to one project; otherwise assignProjects derives a
364
472
  // real per-file project (git-root README › repo › path chunk). Shared with the
365
473
  // TUI so the command + tabs assign identically.
@@ -471,18 +579,28 @@ async function cmdCi(positional, flags) {
471
579
  // workspaces) the --diff path math mismatches and ALL findings are dropped →
472
580
  // a silent GREEN check while retired models in changed files slip through.
473
581
  let dir = path.resolve(positional[1] || flags.dir || ".");
474
- try { dir = fs.realpathSync(dir); } catch { /* keep resolved path if it doesn't exist */ }
582
+ try { dir = fs.realpathSync(dir); } catch { /* keep resolved path; requireDirectory errors next */ }
583
+ // A CI gate must NEVER read a bad invocation as a clean pass: a missing/renamed
584
+ // dir or a typo'd source id is exit 1, not a green "no models found".
585
+ requireDirectory(dir);
586
+ const sources = parseSources(flags);
587
+ assertKnownSources(sources);
588
+ const explicit = explicitSources(flags);
475
589
  const VALID_FAIL_ON = new Set(["none", "deprecating", "retiring", "retired"]);
476
590
  const failOn = String(flags["fail-on"] || "retired").toLowerCase();
477
591
  if (flags["fail-on"] !== undefined && !VALID_FAIL_ON.has(failOn)) {
478
592
  console.error(`Invalid --fail-on "${flags["fail-on"]}". One of: ${[...VALID_FAIL_ON].join(", ")}.`);
479
593
  process.exit(1);
480
594
  }
595
+ // Surface skipped-but-requested sources on stderr (annotations stay on stdout).
596
+ for (const a of await availability(sources, scanOpts(flags, dir), explicit)) {
597
+ if (!a.available) process.stderr.write(`! ${a.id} unavailable (tool, creds, or flags missing) — skipped\n`);
598
+ }
481
599
  const { evaluateCi, annotationLines, summaryMarkdown, filterToChangedFiles, getChangedFiles, HEALTH_RANK } = await import("./ci.js");
482
600
  const res = await evaluateCi({
483
601
  dir,
484
- sources: parseSources(flags),
485
- explicit: explicitSources(flags),
602
+ sources,
603
+ explicit,
486
604
  scanOpts: scanOpts(flags, dir),
487
605
  failOn,
488
606
  offline: !!flags.offline,
@@ -501,7 +619,7 @@ async function cmdCi(positional, flags) {
501
619
  if (changed) {
502
620
  findings = filterToChangedFiles(findings, changed);
503
621
  failing = findings.filter((f) => HEALTH_RANK[f.health] >= threshold);
504
- counts = { ok: 0, deprecating: 0, retiring: 0, retired: 0 };
622
+ counts = { ok: 0, deprecating: 0, retiring: 0, retired: 0, withdrawn: 0 };
505
623
  for (const f of findings) counts[f.health]++;
506
624
  process.stderr.write(`! --diff ${diffBase}: ${findings.length} finding(s) in ${changed.size} changed file(s).\n`);
507
625
  }
@@ -530,7 +648,7 @@ async function cmdCi(positional, flags) {
530
648
  const ICON = { ok: "🟢", deprecating: "🟡", retiring: "🟠", retired: "🔴", withdrawn: "⛔" };
531
649
  console.log(`LLM Status CI — scanned ${dir} (fail-on: ${failOn})`);
532
650
  if (!findings.length) {
533
- console.log("✓ No deprecated, retiring, or retired AI models found.");
651
+ console.log("✓ No deprecated, retiring, retired, or withdrawn AI models found.");
534
652
  } else {
535
653
  for (const f of findings) {
536
654
  console.log(` ${ICON[f.health]} ${f.health.padEnd(11)} ${f.slug.padEnd(30)} ${String(f.location || "").padEnd(28)}${f.retires ? ` retires ${f.retires}` : ""}${f.replacement ? ` → ${f.replacement}` : ""}`);
@@ -586,6 +704,7 @@ async function cmdClear(_positional, flags) {
586
704
  * rewritable (vercel:// etc. are skipped). */
587
705
  async function cmdFix(positional, flags) {
588
706
  const dir = path.resolve(positional[1] || flags.dir || ".");
707
+ requireDirectory(dir);
589
708
  const { getRegistry } = await import("./registry/fetch.js");
590
709
  const { resolveLocal, computeHealth } = await import("./registry/local.js");
591
710
  const { planFixes, applyFixes, terminalReplacement, recordFixes } = await import("./fix.js");
@@ -737,6 +856,15 @@ function cmdIntegrations(positional, flags) {
737
856
  * here, resolve + score health entirely on-device. The free tier's core value. */
738
857
  async function cmdStatus(positional, flags) {
739
858
  const dir = path.resolve(positional[1] || flags.dir || ".");
859
+ // Loud invocation errors BEFORE scanning: a missing path, a file argument, or
860
+ // a typo'd --sources id must never read as a clean "0 references" result.
861
+ requireDirectory(dir);
862
+ const sources = parseSources(flags);
863
+ assertKnownSources(sources);
864
+ const explicit = explicitSources(flags);
865
+ for (const a of await availability(sources, scanOpts(flags, dir), explicit)) {
866
+ if (!a.available) process.stderr.write(`! ${a.id} unavailable (tool, creds, or flags missing) — skipped\n`);
867
+ }
740
868
  const { getRegistry } = await import("./registry/fetch.js");
741
869
  const { resolveLocal, computeHealth, dropResolvedFragments } = await import("./registry/local.js");
742
870
  // A cold `mm status` downloads the ~225 kB registry snapshot then walks the
@@ -750,7 +878,7 @@ async function cmdStatus(positional, flags) {
750
878
 
751
879
  prog.update("scanning for model references…");
752
880
  const onProgress = ({ filesScanned, candidates: c }) => prog.update(`scanning… ${filesScanned} files, ${c} reference(s)`);
753
- let candidates = await collectFrom(parseSources(flags), scanOpts(flags, dir), snapshot.detection, explicitSources(flags), onProgress);
881
+ let candidates = await collectFrom(sources, scanOpts(flags, dir), snapshot.detection, explicit, onProgress);
754
882
  prog.stop();
755
883
  const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
756
884
  const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
@@ -930,7 +1058,7 @@ const COMMAND_HELP = {
930
1058
  On a TTY with no flags it opens the interactive Scan tab. --ci/--json/--yes run
931
1059
  non-interactively.
932
1060
 
933
- --dry-run show exactly what WOULD upload, upload nothing
1061
+ --dry-run show exactly what WOULD upload, upload nothing (fully local — no login needed)
934
1062
  --json / --ci machine output (--ci implies --yes + --json)
935
1063
  --yes upload without the interactive TUI
936
1064
  --project <id|name> route everything to one project (created if the name is new)
@@ -1028,8 +1156,14 @@ async function main() {
1028
1156
 
1029
1157
  // Anonymous, opt-out usage analytics (one-time disclosure, then a single
1030
1158
  // event per invocation). No-op without a baked key / when opted out.
1159
+ // telemetryCommand sends the vetted command WORD only — `mm ~/some/repo`
1160
+ // reports "dir", a typo reports "unknown"; a path never leaves the machine.
1031
1161
  maybeFirstRun(); // one-time cli_first_run per install (same opt-out as below)
1032
- track("cli_command", { command: cmd || "tui" });
1162
+ track("cli_command", {
1163
+ command: telemetryCommand(cmd, (p) => {
1164
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
1165
+ }),
1166
+ });
1033
1167
 
1034
1168
  // Explicit self-update: `mm update` (command) or `--update` (flag on any
1035
1169
  // command). Apply the update in place and RE-EXEC the fresh binary so this one
@@ -51,13 +51,28 @@ export const INTEGRATION_META = {
51
51
 
52
52
  const ENV_TAGS = new Set(["prod", "staging", "dev", "unknown"]);
53
53
 
54
- /** Parsed integrations.json, or {} (missing/corrupt file → {}, like loadConfig). */
54
+ /** Parsed integrations.json, or {} (missing file → {}, like loadConfig). A file
55
+ * that parses to a NON-OBJECT (e.g. the literal `null`, an array, a number) or
56
+ * doesn't parse at all also yields {} — every downstream reader does
57
+ * `cur[id]?.enabled`, so returning null once crashed EVERY command with an
58
+ * opaque TypeError. Invalid content warns ONCE per process, naming the file. */
59
+ let warnedInvalidIntegrations = false;
55
60
  export function readIntegrations() {
61
+ const file = integrationsFilePath();
62
+ let raw;
56
63
  try {
57
- return JSON.parse(fs.readFileSync(integrationsFilePath(), "utf8"));
64
+ raw = fs.readFileSync(file, "utf8");
58
65
  } catch {
59
- return {};
66
+ return {}; // missing file is the normal first-run state — stay quiet
67
+ }
68
+ let parsed = null;
69
+ try { parsed = JSON.parse(raw); } catch { /* falls through to the warning */ }
70
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
71
+ if (!warnedInvalidIntegrations) {
72
+ warnedInvalidIntegrations = true;
73
+ process.stderr.write(`! ignoring invalid integrations file (${file}) — expected a JSON object. Fix or delete it; using defaults.\n`);
60
74
  }
75
+ return {};
61
76
  }
62
77
 
63
78
  /** Write the whole map. Owner-only (0600), mirroring config.js's saveConfig
@@ -12,9 +12,24 @@ const DEFAULT_BASE = process.env.LLMSTATUS_REGISTRY_URL || "https://cdn.llmstatu
12
12
  const DEFAULT_CACHE = path.join(os.homedir(), ".config", "llmstatus", "registry-cache.json");
13
13
  const STALE_DAYS = 30;
14
14
 
15
+ // Cap each snapshot fetch. Without it, a CDN edge that accepts the connection
16
+ // but never responds holds every registry-touching command (status/fix/ci/TUI)
17
+ // for undici's ~5-minute default before the cache fallback can kick in — a
18
+ // timeout throws into getRegistry's existing catch, which falls back to the
19
+ // cached copy with one stderr note. Overridable for tests / hostile networks.
20
+ const fetchTimeoutMs = () => Number(process.env.MM_REGISTRY_TIMEOUT_MS) || 10_000;
21
+ const isTimeout = (e) => e?.name === "TimeoutError" || e?.name === "AbortError" || e?.code === "ABORT_ERR" || e?.cause?.name === "TimeoutError";
22
+
15
23
  async function readUrl(u) {
16
24
  if (u.startsWith("file://")) return fs.readFileSync(fileURLToPath(u));
17
- const res = await fetch(u, { redirect: "follow" });
25
+ const ms = fetchTimeoutMs();
26
+ let res;
27
+ try {
28
+ res = await fetch(u, { redirect: "follow", signal: AbortSignal.timeout(ms) });
29
+ } catch (e) {
30
+ if (isTimeout(e)) throw new Error(`GET ${u} timed out after ${Math.max(1, Math.round(ms / 1000))}s`);
31
+ throw e;
32
+ }
18
33
  if (!res.ok) throw new Error(`GET ${u} -> ${res.status}`);
19
34
  return Buffer.from(await res.arrayBuffer());
20
35
  }
@@ -1,4 +1,4 @@
1
- import { hasCmd, run } from "./shell.js";
1
+ import { hasCmd, run, makeCliFailureReporter } from "./shell.js";
2
2
  import { scanConfigEntries, entriesFromKV } from "./configscan.js";
3
3
 
4
4
  /* Pure parsers (unit-tested) — keep all JSON shape knowledge here, like aws.js.
@@ -63,14 +63,20 @@ export const awsLambdaSource = {
63
63
  async collect(opts, compiled) {
64
64
  const region = opts?.region ? ["--region", opts.region] : [];
65
65
  const tag = opts?.region || "default";
66
+ // Expired SSO / dead creds must be LOUD, never a silent "no functions".
67
+ const fail = makeCliFailureReporter("aws-lambda", "aws", opts);
66
68
  const out = [];
67
69
 
68
70
  // (a) Lambda functions → per-function env vars (Bedrock model ids show up here).
69
71
  const list = await run("aws", ["lambda", "list-functions", "--output", "json", ...region]);
72
+ if (!list.ok) fail(list);
70
73
  if (list.ok) {
71
74
  for (const fn of parseFunctionList(list.stdout)) {
72
75
  const cfg = await run("aws", ["lambda", "get-function-configuration", "--function-name", fn, "--output", "json", ...region]);
73
- if (!cfg.ok) continue;
76
+ if (!cfg.ok) {
77
+ fail(cfg); // per-item failures collapse to one line per failure mode
78
+ continue;
79
+ }
74
80
  for (const [k, v] of Object.entries(parseFunctionEnv(cfg.stdout))) {
75
81
  const entries = entriesFromKV(k, v, `aws-lambda://${tag}/${fn}#${k}`, fn);
76
82
  out.push(...scanConfigEntries(entries, compiled, { sourceType: "aws-lambda", env: opts?.env }));
@@ -83,6 +89,7 @@ export const awsLambdaSource = {
83
89
  // on; opts.awsBedrock === false skips the extra call.
84
90
  if (opts?.awsBedrock !== false) {
85
91
  const bm = await run("aws", ["bedrock", "list-foundation-models", "--output", "json", ...region]);
92
+ if (!bm.ok) fail(bm);
86
93
  if (bm.ok) {
87
94
  for (const modelId of parseBedrockModels(bm.stdout)) {
88
95
  const entries = entriesFromKV("bedrock-model", modelId, `aws-bedrock://${tag}/foundation-models#${modelId}`, opts?.region);
@@ -1,4 +1,4 @@
1
- import { hasCmd, run } from "./shell.js";
1
+ import { hasCmd, run, makeCliFailureReporter } from "./shell.js";
2
2
  import { scanConfigEntries, entriesFromKV } from "./configscan.js";
3
3
 
4
4
  /* Pure parsers (unit-tested) — keep all JSON shape knowledge here. */
@@ -36,12 +36,17 @@ export const awsSecretsSource = {
36
36
  async collect(opts, compiled) {
37
37
  const region = opts?.region ? ["--region", opts.region] : [];
38
38
  const tag = opts?.region || "default";
39
+ // Expired SSO / missing creds / a timeout kill must be LOUD — a failed aws
40
+ // call reading as "no secrets" is a false-clean for a deprecation scanner.
41
+ const fail = makeCliFailureReporter("aws-secrets", "aws", opts);
39
42
  const out = [];
40
43
 
41
44
  const list = await run("aws", ["secretsmanager", "list-secrets", "--output", "json", ...region]);
45
+ if (!list.ok) fail(list);
42
46
  if (list.ok) {
43
47
  for (const name of parseSecretList(list.stdout)) {
44
48
  const v = await run("aws", ["secretsmanager", "get-secret-value", "--secret-id", name, "--output", "json", ...region]);
49
+ if (!v.ok) fail(v); // per-item failures collapse to one line per failure mode
45
50
  const val = v.ok ? parseSecretValue(v.stdout) : null;
46
51
  if (val == null) continue;
47
52
  const entries = entriesFromKV(name.split("/").pop(), val, `aws-secrets://${tag}/${name}`, name);
@@ -52,6 +57,7 @@ export const awsSecretsSource = {
52
57
  const ssm = await run("aws", [
53
58
  "ssm", "get-parameters-by-path", "--path", "/", "--recursive", "--with-decryption", "--output", "json", ...region,
54
59
  ]);
60
+ if (!ssm.ok) fail(ssm);
55
61
  if (ssm.ok) {
56
62
  for (const p of parseSsm(ssm.stdout)) {
57
63
  const entries = entriesFromKV(p.name.split("/").pop(), p.value, `aws-ssm://${tag}${p.name}`, p.name);
Binary file
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { hasCmd, run } from "./shell.js";
3
+ import { hasCmd, run, makeCliFailureReporter } from "./shell.js";
4
4
  import { detectInLine } from "../detect/core.js";
5
5
  import { redactValue } from "../redact.js";
6
6
  import { scanConfigEntries, entriesFromKV } from "./configscan.js";
@@ -56,7 +56,10 @@ export function parseGhSecretList(stdout) {
56
56
 
57
57
  /** Line-scan one workflow YAML body → Candidates (model refs in workflow steps).
58
58
  * Pure: takes text + relPath + compiled, returns #L<n>-located candidates with a
59
- * redacted, 160-capped snippet. detectInLine returns a Set, iterated with for…of. */
59
+ * redacted, 160-capped snippet. detectInLine returns a Set, iterated with for…of.
60
+ * Each candidate carries file_path (.github/workflows/<relPath>) — the collectFrom
61
+ * dedupe hint so a workflow the filesystem source ALSO walked isn't double-counted
62
+ * (stripped before candidates leave collectFrom). */
60
63
  export function scanWorkflowText(text, relPath, compiled, env) {
61
64
  const out = [];
62
65
  const seen = new Set();
@@ -74,6 +77,7 @@ export function scanWorkflowText(text, relPath, compiled, env) {
74
77
  source_line: i + 1,
75
78
  environment: env || "unknown",
76
79
  snippet: redactValue(line.trim()).slice(0, 160),
80
+ file_path: path.join(".github", "workflows", relPath),
77
81
  });
78
82
  }
79
83
  });
@@ -112,12 +116,14 @@ export const githubActionsSource = {
112
116
  // the explicit env (overriding guessEnvFrom). Else fall back to the folded opts.env.
113
117
  const ghEnv = opts?.ghEnvironment || "";
114
118
  const envArg = ghEnv ? ["--env", ghEnv] : [];
119
+ const fail = makeCliFailureReporter("github-actions", "gh", opts);
115
120
  const out = [];
116
121
 
117
122
  // (a) VARIABLES — non-secret VALUES, scanned through the redaction funnel. We
118
123
  // ask for JSON so the value column is unambiguous; a model id in a variable
119
124
  // value (e.g. OPENAI_MODEL=gpt-4o) is exactly what we want to catch.
120
125
  const vars = await run("gh", ["variable", "list", ...repoArg, ...envArg, "--json", "name,value"]);
126
+ if (!vars.ok) fail(vars); // logged-out gh etc. must not read as "no variables"
121
127
  if (vars.ok) {
122
128
  for (const { name, value } of parseVariableList(vars.stdout)) {
123
129
  const entries = entriesFromKV(name, value, `github-actions://${repoTag}/variables#${name}`, ghEnv || repoTag);
@@ -127,6 +133,7 @@ export const githubActionsSource = {
127
133
 
128
134
  // (b) Secret NAMES only (never a value — there is no value API anyway).
129
135
  const secrets = await run("gh", ["secret", "list", ...repoArg, ...envArg]);
136
+ if (!secrets.ok) fail(secrets);
130
137
  if (secrets.ok) {
131
138
  for (const name of parseGhSecretList(secrets.stdout)) {
132
139
  const entries = entriesFromKV(name, "", `github-actions://${repoTag}/secrets#${name}`, ghEnv || repoTag);
@@ -1,4 +1,4 @@
1
- import { hasCmd, run } from "./shell.js";
1
+ import { hasCmd, run, makeCliFailureReporter } from "./shell.js";
2
2
  import { scanConfigEntries, flattenConfig } from "./configscan.js";
3
3
 
4
4
  /** Pure parser: `helm list -A -o json` → [{ name, namespace }]. */
@@ -21,12 +21,18 @@ export const helmSource = {
21
21
  async available() {
22
22
  return hasCmd("helm");
23
23
  },
24
- async collect(_opts, compiled) {
24
+ async collect(opts, compiled) {
25
+ // A dead cluster / broken helm must be LOUD, never a silent "no releases".
26
+ const fail = makeCliFailureReporter("helm", "helm", opts);
25
27
  const out = [];
26
28
  const list = await run("helm", ["list", "-A", "-o", "json"]);
29
+ if (!list.ok) fail(list);
27
30
  for (const r of parseHelmList(list.ok ? list.stdout : "[]")) {
28
31
  const v = await run("helm", ["get", "values", r.name, "-n", r.namespace, "-o", "json"]);
29
- if (!v.ok) continue;
32
+ if (!v.ok) {
33
+ fail(v); // per-release failures collapse to one line per failure mode
34
+ continue;
35
+ }
30
36
  let vals;
31
37
  try {
32
38
  vals = JSON.parse(v.stdout);