@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/package.json +7 -2
- package/src/api.js +41 -11
- package/src/changelog-data.js +23 -0
- package/src/ci.js +8 -4
- package/src/detect/core.js +17 -6
- package/src/fix.js +132 -46
- package/src/index.js +172 -38
- package/src/integrations.js +18 -3
- package/src/registry/fetch.js +16 -1
- package/src/sources/aws-lambda.js +9 -2
- package/src/sources/aws.js +7 -1
- package/src/sources/filesystem.js +0 -0
- package/src/sources/github-actions.js +9 -2
- package/src/sources/helm.js +9 -3
- package/src/sources/index.js +45 -3
- package/src/sources/k8s.js +6 -2
- package/src/sources/shell.js +126 -14
- package/src/sources/sql.js +6 -2
- package/src/sources/supabase-edge.js +19 -5
- package/src/sources/vercel.js +64 -6
- package/src/telemetry.js +21 -0
- package/src/tui/app.js +55 -13
- package/src/tui/game/launch.js +14 -2
- package/src/tui/signin.js +43 -7
- package/src/tui/views/account.js +19 -2
- package/src/tui/views/alerts.js +24 -9
- package/src/tui/views/whatsnew.js +82 -28
- package/src/updater.js +160 -39
- package/src/upgrade.js +37 -8
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 && !
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
344
|
-
//
|
|
345
|
-
// redaction
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
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
|
|
485
|
-
explicit
|
|
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
|
|
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(
|
|
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", {
|
|
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
|
package/src/integrations.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
package/src/registry/fetch.js
CHANGED
|
@@ -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
|
|
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)
|
|
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);
|
package/src/sources/aws.js
CHANGED
|
@@ -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);
|
package/src/sources/helm.js
CHANGED
|
@@ -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(
|
|
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)
|
|
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);
|