@modelstatus/cli 0.1.34 → 0.1.36

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstatus/cli",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "description": "Track which AI models you use, where, and never get surprised by a retirement. Free offline model-health for any repo (mm status), browser sign-in for cloud inventory + alerts.",
5
5
  "keywords": [
6
6
  "llm",
package/src/api.js CHANGED
@@ -80,6 +80,12 @@ export function createClient({ apiBase, apiKey }) {
80
80
  createChannel: (body) => req("POST", "/channels", { action: "connect", ...body }),
81
81
  testChannel: (body) => req("POST", "/channels", { action: "test", ...body }),
82
82
 
83
+ // integrations — the cross-device mirror of the local integrations.json toggle.
84
+ // The LOCAL file is authoritative for what `mm scan` runs; these sync the web
85
+ // card + carry plan-gating (402 on free). Best-effort from the TUI.
86
+ listIntegrations: () => req("GET", "/integrations"),
87
+ setIntegration: (body) => req("PATCH", "/integrations", body),
88
+
83
89
  // notifications feed
84
90
  listNotifications: (params) => req("GET", `/notifications${qs(params)}`),
85
91
  readNotification: (id) => req("POST", `/notifications/${id}/read`),
package/src/ci.js CHANGED
@@ -16,7 +16,7 @@ const BADGE = { ok: "🟢", deprecating: "🟡", retiring: "🟠", retired: "
16
16
 
17
17
  /** Evaluate `dir` and return { findings, failing, threshold, failOn, counts, snapshot }.
18
18
  * `findings` are per-(model, location) entries with health worse than ok. */
19
- export async function evaluateCi({ dir, sources = ["filesystem"], scanOpts = {}, failOn = "retired", offline = false, log = () => {} }) {
19
+ export async function evaluateCi({ dir, sources = ["filesystem"], explicit = new Set(), scanOpts = {}, failOn = "retired", offline = false, log = () => {} }) {
20
20
  // Online by default so CI checks against the LATEST registry; --offline (or the
21
21
  // env) falls back to the cached snapshot. cacheFile env keeps tests hermetic.
22
22
  const snapshot = await getRegistry({
@@ -24,7 +24,7 @@ export async function evaluateCi({ dir, sources = ["filesystem"], scanOpts = {},
24
24
  cacheFile: process.env.LLMSTATUS_REGISTRY_CACHE || undefined,
25
25
  log: (m) => log(`${m}\n`),
26
26
  });
27
- const candidates = await collectFrom(sources, { root: dir, ...scanOpts }, snapshot.detection);
27
+ const candidates = await collectFrom(sources, { root: dir, ...scanOpts }, snapshot.detection, explicit);
28
28
  const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
29
29
  const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
30
30
  const today = new Date();
package/src/index.js CHANGED
@@ -1,9 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { resolveAuth, loadConfig, saveConfig, clearAuth, configFilePath } from "./config.js";
5
6
  import { createClient } from "./api.js";
6
- import { collectFrom, availability, ALL_SOURCE_IDS } from "./sources/index.js";
7
+ import { collectFrom, availability, ALL_SOURCE_IDS, getSource } from "./sources/index.js";
8
+ import {
9
+ INTEGRATION_IDS, INTEGRATION_META, readIntegrations, enabledIds,
10
+ getEnvTag, setEnabled, setEnvTag,
11
+ } from "./integrations.js";
7
12
  import { redactValue } from "./redact.js";
8
13
  import { assignProjects } from "./upload.js";
9
14
  import { loginViaBrowser } from "./auth.js";
@@ -11,12 +16,81 @@ import { maybeCheckForUpdate } from "./updater.js";
11
16
  import { track, maybeAnalyticsNotice } from "./telemetry.js";
12
17
  import { BUILD_VERSION } from "./version.js";
13
18
 
19
+ // TRUE BACKGROUND SCAN — hidden worker dispatch. MUST be the first executable
20
+ // statement, BEFORE main()/maybeAnalyticsNotice/track/maybeCheckForUpdate: those
21
+ // write to stdout (the analytics notice) and trigger the self-updater, but the
22
+ // worker's stdout MUST be PURE NDJSON. The dynamic import pulls in ONLY
23
+ // filesystem.js + detect/core + registry (no ink, upload, auth, telemetry).
24
+ //
25
+ // argv is env-agnostic: under node argv = [node, /abs/src/index.js, sentinel, …];
26
+ // under the bun-compiled binary argv = [/abs/binary, /$bunfs/…, sentinel, …]. In
27
+ // BOTH the sentinel is at index 2 and worker args at index 3+. (See
28
+ // src/sources/scan-process.js buildSpawnArgs for the matching parent spawn.)
29
+ if (process.argv[2] === "__mm_scan_worker") {
30
+ const { runWorker } = await import("./sources/scan-worker.js");
31
+ await runWorker(process.argv.slice(3)); // never returns — calls process.exit
32
+ }
33
+
34
+ // HIDDEN bench seam (NOT a public command; absent from help). Runs the REAL
35
+ // game loop with a per-frame timestamp tap → stdout NDJSON, AND starts the REAL
36
+ // background scan subprocess (the production self-re-exec) so the SHIPPED binary
37
+ // is measured doing BOTH jobs at once — the exact concurrent path `mm play`
38
+ // drives. Used by test/game-jitter-compiled.test.mjs to gate the bun-compiled
39
+ // artifact (where the runtime, and thus the self-re-exec branch, differs from
40
+ // node). Headless: drives the loop via its _inject IO seams (no TTY needed).
41
+ // mm __bench_frames --seconds=N --with-scan=DIR [--ndjson]
42
+ if (process.argv[2] === "__bench_frames") {
43
+ const args = process.argv.slice(3);
44
+ const getv = (k, d) => { const a = args.find((x) => x.startsWith(`--${k}=`)); return a ? a.slice(k.length + 3) : d; };
45
+ const seconds = Number(getv("seconds", "4")) || 4;
46
+ const scanDir = getv("with-scan", null);
47
+ const now = () => Number(process.hrtime.bigint() / 1000000n);
48
+ const emit = (o) => process.stdout.write(JSON.stringify(o) + "\n");
49
+
50
+ const { runGame } = await import("./tui/game/loop.js");
51
+ let scan = null;
52
+ if (scanDir) {
53
+ const { startScanProcess, isBunRuntime } = await import("./sources/scan-process.js");
54
+ scan = startScanProcess({ root: scanDir }, {});
55
+ // Echo the self-re-exec proof: the worker's runtime + spawn argv0, so the
56
+ // test KNOWS the subprocess path didn't silently fall back to in-process.
57
+ emit({ t: "reexec", bun: isBunRuntime(), parentExec: process.execPath, childPid: scan.child.pid, childSpawnFile: process.execPath });
58
+ }
59
+
60
+ // Fake IO so the loop runs without a TTY; wrap schedule to timestamp each tick.
61
+ const writes = [];
62
+ const out = { columns: 80, rows: 24, isTTY: false, write: (s) => { if (s) writes.push(s.length); return true; }, setRawMode() { return this; }, on() {}, removeListener() {}, resume() {}, pause() {} };
63
+ const input = { isTTY: false, isRaw: false, setRawMode(v) { this.isRaw = v; return this; }, on() {}, removeListener() {}, resume() {}, pause() {}, write() { return true; } };
64
+ const seqs = ["\x1b[C", "\x1b[D", " ", "\x1b[A"]; let ki = 0;
65
+ const drive = setInterval(() => { (input._d || []).forEach((f) => f(Buffer.from(seqs[ki++ % seqs.length]))); }, 45);
66
+ input.on = (ev, fn) => { if (ev === "data") (input._d ||= []).push(fn); };
67
+ input.removeListener = (ev, fn) => { if (ev === "data") input._d = (input._d || []).filter((f) => f !== fn); };
68
+
69
+ let prev = null;
70
+ const p = runGame({
71
+ width: 80, height: 24, scanStore: scan,
72
+ _inject: {
73
+ out, inp: input, proc: process, now,
74
+ schedule: (fn, ms) => setTimeout(() => { const t = now(); if (prev != null) emit({ t: "frame", dtMs: t - prev }); prev = t; fn(); }, ms),
75
+ cancel: (h) => clearTimeout(h),
76
+ },
77
+ });
78
+ setTimeout(() => { (input._d || []).forEach((f) => f(Buffer.from("q"))); }, seconds * 1000);
79
+ await p;
80
+ clearInterval(drive);
81
+ emit({ t: "done", filesScanned: scan ? scan.stats.filesScanned : 0, candidates: scan ? scan.stats.candidateCount : 0, phase: scan ? scan.stats.phase : "none" });
82
+ try { scan && scan.abort(); } catch {}
83
+ process.exit(0);
84
+ }
85
+
14
86
  function parseArgs(argv) {
15
87
  const flags = {};
16
88
  const positional = [];
17
89
  const valueFlags = new Set([
18
90
  "api", "key", "project", "dir", "fail-on", "diff", "json-out",
19
91
  "sources", "region", "namespace", "kube-context", "db", "sql-table", "env",
92
+ // Per-integration scope flags (non-secret): consumed by the 4 live sources.
93
+ "vercel-project", "vercel-team", "gh-repo", "supabase-ref",
20
94
  ]);
21
95
  for (let i = 0; i < argv.length; i++) {
22
96
  const a = argv[i];
@@ -41,15 +115,33 @@ const uuidish = (s) => /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(s || "");
41
115
  * from a git remote, since a local scan root may not be the repo root. */
42
116
  const ghRepoSlug = () => (process.env.GITHUB_REPOSITORY || "").trim();
43
117
 
44
- /** Resolve the requested sources: default filesystem, "all", or a comma list. */
118
+ /** The set of source ids the user named VERBATIM in --sources (empty for the
119
+ * default / "all"). Naming a live integration here overrides its enabled-gate. */
120
+ function explicitSources(flags) {
121
+ const raw = (flags.sources || "").trim();
122
+ if (!raw || raw === "all") return new Set();
123
+ return new Set(raw.split(",").map((s) => s.trim()).filter(Boolean));
124
+ }
125
+
126
+ /** Resolve the requested sources.
127
+ * - empty → filesystem + every ENABLED live integration (toggled-on integrations
128
+ * scan by default; the rest are NOT in the default set, so no surprise
129
+ * network calls).
130
+ * - "all" → ALL_SOURCE_IDS, but a live integration is included only when enabled
131
+ * (so `all` never silently fires a not-authorized integration); the
132
+ * existing non-integration sources stay in `all` unconditionally.
133
+ * - list → honored verbatim (an explicit id overrides the enabled-gate). */
45
134
  function parseSources(flags) {
46
135
  const raw = (flags.sources || "").trim();
47
- if (!raw) return ["filesystem"];
48
- if (raw === "all") return ALL_SOURCE_IDS;
136
+ if (!raw) return ["filesystem", ...enabledIds()];
137
+ if (raw === "all") {
138
+ const enabled = enabledIds();
139
+ return ALL_SOURCE_IDS.filter((id) => !getSource(id)?.integration || enabled.has(id));
140
+ }
49
141
  return raw.split(",").map((s) => s.trim()).filter(Boolean);
50
142
  }
51
143
 
52
- /** Per-source options gathered from flags (region/namespace/db/…). */
144
+ /** Per-source options gathered from flags (region/namespace/db/integration scope). */
53
145
  function scanOpts(flags, dir) {
54
146
  return {
55
147
  root: dir,
@@ -59,6 +151,10 @@ function scanOpts(flags, dir) {
59
151
  db: flags.db,
60
152
  sqlTable: flags["sql-table"],
61
153
  env: flags.env,
154
+ vercelProject: flags["vercel-project"],
155
+ vercelTeam: flags["vercel-team"],
156
+ ghRepo: flags["gh-repo"],
157
+ supabaseProjectRef: flags["supabase-ref"],
62
158
  };
63
159
  }
64
160
 
@@ -106,6 +202,49 @@ async function cmdUpgrade(_positional, flags) {
106
202
  }
107
203
  }
108
204
 
205
+ /** `mm play [dir]` — the standalone game: NO Ink, ever. Runs the direct-ANSI
206
+ * 60Hz loop while a background scan subprocess walks `dir` (its progress feeds
207
+ * the in-game HUD). Pure terminal, restores cleanly on every exit path. */
208
+ async function cmdPlay(positional, flags) {
209
+ if (!process.stdout.isTTY) {
210
+ console.error("`mm play` needs an interactive terminal (a TTY).");
211
+ process.exit(1);
212
+ }
213
+ const dir = path.resolve(positional[1] || flags.dir || ".");
214
+ const { runGame } = await import("./tui/game/loop.js");
215
+
216
+ // Best-effort background scan of `dir` so the HUD shows live progress. We
217
+ // pre-fetch the signed registry once and hand the worker a cache file so it
218
+ // skips the network; on any failure the game still plays (scanStore stays in
219
+ // its error/empty state, which the HUD renders as "scan unavailable").
220
+ let scanHandle = null;
221
+ let cacheFile = null;
222
+ try {
223
+ const { startScanProcess } = await import("./sources/scan-process.js");
224
+ try {
225
+ const { getRegistry } = await import("./registry/fetch.js");
226
+ const snapshot = await getRegistry({ offline: !!flags.offline }).catch(() => null);
227
+ if (snapshot) {
228
+ cacheFile = path.join(os.tmpdir(), `mm-reg-${process.pid}.json`);
229
+ fs.writeFileSync(cacheFile, JSON.stringify(snapshot));
230
+ }
231
+ } catch { /* no cache — worker fetches itself */ }
232
+ scanHandle = startScanProcess({ root: dir, registryCachePath: cacheFile || undefined }, {});
233
+ } catch { scanHandle = null; }
234
+
235
+ try {
236
+ await runGame({
237
+ width: process.stdout.columns || 80,
238
+ height: process.stdout.rows || 24,
239
+ level: 1,
240
+ scanStore: scanHandle,
241
+ });
242
+ } finally {
243
+ try { scanHandle && scanHandle.abort(); } catch { /* ignore */ }
244
+ try { if (cacheFile) fs.unlinkSync(cacheFile); } catch { /* ignore */ }
245
+ }
246
+ }
247
+
109
248
  async function launchTui(initialView, flags) {
110
249
  // Pass apiKey straight through (may be null). The TUI's Bootstrap wrapper
111
250
  // renders an interactive SignIn screen when there's no key, then swaps to
@@ -136,10 +275,13 @@ async function cmdScan(positional, flags) {
136
275
  // Non-interactive (CI / --json / --yes): scan + bulk upload, no TUI.
137
276
  const client = createClient({ apiBase, apiKey });
138
277
  const sources = parseSources(flags);
278
+ const explicit = explicitSources(flags);
139
279
  const opts = scanOpts(flags, dir);
140
280
 
141
- // Report which sources will actually run (tool/creds/flags present).
142
- const avail = await availability(sources, opts);
281
+ // Report which sources will actually run (tool/creds/flags present). A live
282
+ // integration named explicitly in --sources counts as available without being
283
+ // toggled on (explicit intent overrides the enabled-gate).
284
+ const avail = await availability(sources, opts, explicit);
143
285
  for (const a of avail) {
144
286
  if (!a.known) process.stderr.write(`! unknown source "${a.id}" — skipped\n`);
145
287
  else if (!a.available) process.stderr.write(`! ${a.id} unavailable (tool, creds, or flags missing) — skipped\n`);
@@ -148,7 +290,7 @@ async function cmdScan(positional, flags) {
148
290
  process.stderr.write(`Scanning [${active.join(", ") || "none"}] …\n`);
149
291
 
150
292
  const patterns = await client.detectionPatterns();
151
- const candidates = await collectFrom(sources, opts, patterns);
293
+ const candidates = await collectFrom(sources, opts, patterns, explicit);
152
294
  if (candidates.length === 0) {
153
295
  console.log("No model usage found.");
154
296
  return;
@@ -305,6 +447,7 @@ async function cmdCi(positional, flags) {
305
447
  const res = await evaluateCi({
306
448
  dir,
307
449
  sources: parseSources(flags),
450
+ explicit: explicitSources(flags),
308
451
  scanOpts: scanOpts(flags, dir),
309
452
  failOn,
310
453
  offline: !!flags.offline,
@@ -397,19 +540,75 @@ async function cmdClear(_positional, flags) {
397
540
  console.log(`✓ Cleared ${res.usages ?? 0} usage(s)${res.projects ? ` + ${res.projects} project(s)` : ""}. Your inventory is clean — rescan to repopulate.`);
398
541
  }
399
542
 
400
- /** List detection sources and whether each can run right now. */
543
+ /** List detection sources and whether each can run right now. Live integrations
544
+ * also show their on/off toggle (the `int` column) so toggled state is visible
545
+ * here too. */
401
546
  async function cmdSources(_positional, flags) {
402
547
  const dir = path.resolve(flags.dir || ".");
403
548
  const report = await availability(ALL_SOURCE_IDS, scanOpts(flags, dir));
404
549
  console.log("Detection sources:");
405
550
  for (const a of report) {
406
- console.log(` ${a.available ? "✓" : "·"} ${a.id.padEnd(13)} ${a.label}${a.available ? "" : " (unavailable)"}`);
551
+ // For an integration, the toggle column shows on/off; "·" elsewhere is N/A.
552
+ const toggle = a.integration ? (a.enabled ? "on " : "off") : "— ";
553
+ console.log(` ${a.available ? "✓" : "·"} ${a.id.padEnd(15)} ${toggle} ${a.label}${a.available ? "" : " (unavailable)"}`);
407
554
  }
408
555
  console.log("\nScan with: mm scan --sources env,aws-secrets,k8s,helm,sql (or --sources all)");
556
+ console.log("Integrations (toggle with `mm integrations enable <id>`): " + INTEGRATION_IDS.join(", "));
409
557
  console.log("Options: --region <r> · --namespace <ns> · --kube-context <c> · --db <pg-dsn> --sql-table <t>");
558
+ console.log(" --vercel-project <p> · --vercel-team <t> · --gh-repo <owner/name> · --supabase-ref <ref>");
410
559
  console.log("Safety: secret VALUES never leave your machine — only model ids upload. Use --dry-run to preview.");
411
560
  }
412
561
 
562
+ /** `mm integrations [list|enable <id>|disable <id>|env <id> <tag>]` — manage the
563
+ * local enabled-state of the live integrations (the authoritative toggle for what
564
+ * `mm scan` runs by default). Non-secret only; authorization is your own CLI. */
565
+ function cmdIntegrations(positional, flags) {
566
+ const sub = positional[1];
567
+ const id = positional[2];
568
+ const known = (x) => INTEGRATION_IDS.includes(x);
569
+
570
+ if (sub === "enable" || sub === "disable") {
571
+ if (!known(id)) {
572
+ console.error(`Unknown integration "${id}". One of: ${INTEGRATION_IDS.join(", ")}`);
573
+ process.exit(1);
574
+ }
575
+ setEnabled(id, sub === "enable");
576
+ console.log(`✓ ${id} ${sub === "enable" ? "enabled" : "disabled"}. It ${sub === "enable" ? "now runs" : "no longer runs"} in the default scan.`);
577
+ return;
578
+ }
579
+ if (sub === "env") {
580
+ const tag = positional[3];
581
+ if (!known(id)) {
582
+ console.error(`Unknown integration "${id}". One of: ${INTEGRATION_IDS.join(", ")}`);
583
+ process.exit(1);
584
+ }
585
+ try {
586
+ setEnvTag(id, tag);
587
+ } catch (e) {
588
+ console.error(e.message);
589
+ process.exit(1);
590
+ }
591
+ console.log(`✓ ${id} env set to "${tag}".${tag === "unknown" ? " (override cleared — env is now guessed)" : ""}`);
592
+ return;
593
+ }
594
+
595
+ // Default / "list": show current state.
596
+ const cur = readIntegrations();
597
+ if (flags.json) {
598
+ console.log(JSON.stringify({ data: INTEGRATION_IDS.map((iid) => ({ id: iid, ...INTEGRATION_META[iid], enabled: !!cur[iid]?.enabled, env_tag: getEnvTag(iid) || "unknown", ref: cur[iid]?.ref })) }, null, 2));
599
+ return;
600
+ }
601
+ console.log("Live integrations:");
602
+ for (const iid of INTEGRATION_IDS) {
603
+ const meta = INTEGRATION_META[iid];
604
+ const on = cur[iid]?.enabled ? "on " : "off";
605
+ const tag = getEnvTag(iid) || "unknown";
606
+ console.log(` ${on} ${iid.padEnd(15)} env=${tag.padEnd(8)} (${meta.requiresCmd}) ${meta.label}`);
607
+ }
608
+ console.log("\nToggle: mm integrations enable|disable <id> · Env: mm integrations env <id> prod|staging|dev|unknown");
609
+ console.log("Authorization is your already-authenticated CLI (aws/gh/vercel/supabase) — we never store a token.");
610
+ }
611
+
413
612
  /** Offline, account-less health check: pull the signed registry snapshot, scan
414
613
  * here, resolve + score health entirely on-device. The free tier's core value. */
415
614
  async function cmdStatus(positional, flags) {
@@ -418,7 +617,7 @@ async function cmdStatus(positional, flags) {
418
617
  const { resolveLocal, computeHealth } = await import("./registry/local.js");
419
618
  const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => process.stderr.write(`! ${m}\n`) });
420
619
 
421
- const candidates = await collectFrom(parseSources(flags), scanOpts(flags, dir), snapshot.detection);
620
+ const candidates = await collectFrom(parseSources(flags), scanOpts(flags, dir), snapshot.detection, explicitSources(flags));
422
621
  const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
423
622
  const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
424
623
 
@@ -493,19 +692,25 @@ Usage:
493
692
  mm ci [dir] CI gate: fail the build on deprecated/retiring models (GitHub annotations)
494
693
  (--diff <base> limits findings to files changed vs base; auto on PRs via GITHUB_BASE_REF)
495
694
  mm sources List detection sources and whether each can run here
695
+ mm integrations Manage live integrations (list | enable <id> | disable <id> | env <id> <tag>)
496
696
  mm clear Delete all tracked usages from your inventory (--all also wipes projects/rules; --yes to skip the prompt)
497
697
  mm upgrade Open Stripe checkout and poll until Pro is active
698
+ mm play [dir] Play Donkey Kong while a background scan walks the dir (just for fun)
498
699
  mm tui Force-launch the TUI (logs you in first if needed)
499
700
 
500
- Scan sources (--sources, default filesystem; "all" for everything):
701
+ Scan sources (--sources; default = filesystem + enabled integrations; "all" for everything):
501
702
  filesystem repo files aws-secrets AWS Secrets Manager + SSM
502
703
  env live process env k8s kubectl secrets + configmaps
503
704
  sql psql --db <dsn> helm helm release values
705
+ Live integrations (toggle on with \`mm integrations enable <id>\`, or name in --sources):
706
+ aws-lambda Lambda env + Bedrock vercel project env (names) supabase-edge edge fns + secrets
707
+ github-actions workflow YAML + secret names
504
708
  Secret sources shell out to your already-authenticated CLIs, run read-only,
505
709
  and only ever upload model ids — secret VALUES never leave your machine.
506
710
 
507
711
  Flags: --api <url> · --key <key> · --project <id|name> · --yes · --json · --ci · --dry-run
508
712
  --sources <list> · --region <r> · --namespace <ns> · --kube-context <c> · --db <dsn> · --sql-table <t>
713
+ --vercel-project <p> · --vercel-team <t> · --gh-repo <owner/name> · --supabase-ref <ref>
509
714
 
510
715
  Get started: \`mm login\` (opens your browser).`;
511
716
 
@@ -553,7 +758,9 @@ async function main() {
553
758
  else if (cmd === "ci") await cmdCi(positional, flags);
554
759
  else if (cmd === "status") await cmdStatus(positional, flags);
555
760
  else if (cmd === "sources") await cmdSources(positional, flags);
761
+ else if (cmd === "integrations") cmdIntegrations(positional, flags);
556
762
  else if (cmd === "clear") await cmdClear(positional, flags);
763
+ else if (cmd === "play") await cmdPlay(positional, flags);
557
764
  else if (cmd === "upgrade") await cmdUpgrade(positional, flags);
558
765
  else if (cmd === "tui" || !cmd) await launchTui(positional[1], flags);
559
766
  else if (cmd === "help" || flags.help) console.log(HELP);
@@ -0,0 +1,121 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ /**
6
+ * On-disk toggle state for the LIVE integration sources (aws-lambda, vercel,
7
+ * supabase-edge, github-actions). This is the SINGLE source of truth read by
8
+ * parseSources (index.js), availability()/collectFrom() (sources/index.js),
9
+ * `mm integrations`, and the TUI Integrations view.
10
+ *
11
+ * It lives in its OWN file (NOT config.json) so the API-key file's blast radius
12
+ * stays small, and is written 0600/0700 with the exact recipe config.js uses.
13
+ *
14
+ * SECURITY: this file is NON-SECRET scope only. `enabled` is a boolean, `envTag`
15
+ * is a declared env label, and `ref` holds a non-secret scope hint (an AWS
16
+ * account id, a vercel team/project slug, a gh repo slug). It NEVER stores a
17
+ * provider key/token — authorization for a live source is always the user's
18
+ * already-authenticated local CLI (aws/gh/vercel/supabase), never our file.
19
+ *
20
+ * On-disk shape:
21
+ * {
22
+ * "aws-lambda": { "enabled": true, "envTag": "prod", "ref": "123456789012" },
23
+ * "vercel": { "enabled": false },
24
+ * "supabase-edge": { "enabled": false },
25
+ * "github-actions": { "enabled": true }
26
+ * }
27
+ */
28
+
29
+ const dir = path.join(os.homedir(), ".config", "llmstatus");
30
+
31
+ /** The integrations file path. LLMSTATUS_INTEGRATIONS_FILE overrides it (tests
32
+ * point this at a temp file so they never touch the dev's real toggles — mirrors
33
+ * filesystem.js's LLMSTATUS_IGNORE_FILE). */
34
+ export function integrationsFilePath() {
35
+ return process.env.LLMSTATUS_INTEGRATIONS_FILE || path.join(dir, "integrations.json");
36
+ }
37
+
38
+ /** The 4 known live-integration ids. enabledIds() intersects with this so a
39
+ * stale/hand-edited key can never inject an unknown source into a scan. */
40
+ export const INTEGRATION_IDS = ["aws-lambda", "vercel", "supabase-edge", "github-actions"];
41
+
42
+ /** Display + capability metadata. `requiresCmd` is the vendor CLI each one shells
43
+ * out to (the `available()` PATH check). Kept here so the command, the TUI view,
44
+ * and cmdSources all read one table. */
45
+ export const INTEGRATION_META = {
46
+ "aws-lambda": { label: "AWS Lambda env/config + Bedrock", requiresCmd: "aws" },
47
+ vercel: { label: "Vercel project env", requiresCmd: "vercel" },
48
+ "supabase-edge": { label: "Supabase Edge Functions + secrets", requiresCmd: "supabase" },
49
+ "github-actions": { label: "GitHub Actions secrets + workflows", requiresCmd: "gh" },
50
+ };
51
+
52
+ const ENV_TAGS = new Set(["prod", "staging", "dev", "unknown"]);
53
+
54
+ /** Parsed integrations.json, or {} (missing/corrupt file → {}, like loadConfig). */
55
+ export function readIntegrations() {
56
+ try {
57
+ return JSON.parse(fs.readFileSync(integrationsFilePath(), "utf8"));
58
+ } catch {
59
+ return {};
60
+ }
61
+ }
62
+
63
+ /** Write the whole map. Owner-only (0600), mirroring config.js's saveConfig
64
+ * exactly — chmod after write also tightens a pre-existing loose-perm file
65
+ * (writeFileSync's mode only applies on create). Returns the file path. */
66
+ export function writeIntegrations(next) {
67
+ const file = integrationsFilePath();
68
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
69
+ fs.writeFileSync(file, JSON.stringify(next, null, 2), { mode: 0o600 });
70
+ try {
71
+ fs.chmodSync(file, 0o600);
72
+ } catch {
73
+ /* best-effort (e.g. Windows) */
74
+ }
75
+ return file;
76
+ }
77
+
78
+ /** Is this integration toggled on? */
79
+ export function getEnabled(id) {
80
+ return !!readIntegrations()[id]?.enabled;
81
+ }
82
+
83
+ /** The declared env override for an integration, or undefined if none is set
84
+ * (so the source falls back to guessEnvFrom). "unknown" is never returned — it's
85
+ * the "clear the override" sentinel (see setEnvTag). */
86
+ export function getEnvTag(id) {
87
+ const t = readIntegrations()[id]?.envTag;
88
+ return t && t !== "unknown" ? t : undefined;
89
+ }
90
+
91
+ /** The enabled live-integration ids, intersected with INTEGRATION_IDS so a stale
92
+ * key can't inject an unknown source. Returns a Set for cheap `.has()` in the gate. */
93
+ export function enabledIds() {
94
+ const cur = readIntegrations();
95
+ return new Set(INTEGRATION_IDS.filter((id) => !!cur[id]?.enabled));
96
+ }
97
+
98
+ /** Toggle an integration on/off (merges, preserving envTag/ref). Returns the path. */
99
+ export function setEnabled(id, enabled) {
100
+ const cur = readIntegrations();
101
+ return writeIntegrations({ ...cur, [id]: { ...cur[id], enabled: !!enabled } });
102
+ }
103
+
104
+ /** Set the declared env override. Validates the union; "unknown" CLEARS the
105
+ * override (so guessEnvFrom resumes) rather than force-stamping every finding as
106
+ * "unknown" and defeating positive-marker detection. Returns the path. */
107
+ export function setEnvTag(id, envTag) {
108
+ if (!ENV_TAGS.has(envTag)) throw new Error(`envTag must be one of ${[...ENV_TAGS].join(", ")}`);
109
+ const cur = readIntegrations();
110
+ const entry = { ...cur[id] };
111
+ if (envTag === "unknown") delete entry.envTag;
112
+ else entry.envTag = envTag;
113
+ return writeIntegrations({ ...cur, [id]: entry });
114
+ }
115
+
116
+ /** Set a non-secret scope hint (AWS account id / vercel slug / gh repo). Returns
117
+ * the path. NEVER pass a secret here — this is for display + locator context only. */
118
+ export function setRef(id, ref) {
119
+ const cur = readIntegrations();
120
+ return writeIntegrations({ ...cur, [id]: { ...cur[id], ref: ref || undefined } });
121
+ }
@@ -0,0 +1,95 @@
1
+ import { hasCmd, run } from "./shell.js";
2
+ import { scanConfigEntries, entriesFromKV } from "./configscan.js";
3
+
4
+ /* Pure parsers (unit-tested) — keep all JSON shape knowledge here, like aws.js.
5
+ * Each try/catch-es so malformed CLI output degrades to [] / {} rather than throws. */
6
+
7
+ /** `aws lambda list-functions` → [FunctionName]. */
8
+ export function parseFunctionList(stdout) {
9
+ try {
10
+ return (JSON.parse(stdout).Functions || []).map((f) => f.FunctionName).filter(Boolean);
11
+ } catch {
12
+ return [];
13
+ }
14
+ }
15
+
16
+ /** `aws lambda get-function-configuration` → the Environment.Variables map {K:V}. */
17
+ export function parseFunctionEnv(stdout) {
18
+ try {
19
+ return JSON.parse(stdout)?.Environment?.Variables || {};
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ /** `aws bedrock list-foundation-models` → [modelId] from .modelSummaries[].modelId.
26
+ * These are KNOWN-enabled foundation-model ids (not secrets) but still routed
27
+ * through the funnel for redaction + dedup parity. */
28
+ export function parseBedrockModels(stdout) {
29
+ try {
30
+ return (JSON.parse(stdout).modelSummaries || []).map((m) => m.modelId).filter(Boolean);
31
+ } catch {
32
+ return [];
33
+ }
34
+ }
35
+
36
+ /** AWS Lambda env/config + Bedrock model ids. LIVE integration: gated on the
37
+ * enabled toggle (integrations.json) AND the `aws` CLI being present. Shells out
38
+ * to your already-authenticated `aws` CLI, read-only. Lambda env VALUES are
39
+ * scanned locally for model ids and never uploaded — only model ids leave.
40
+ * opts: { region, awsBedrock? }. */
41
+ export const awsLambdaSource = {
42
+ id: "aws-lambda",
43
+ label: "AWS Lambda env/config + Bedrock",
44
+ kind: "cli",
45
+ integration: true,
46
+ envTag: "unknown",
47
+ async available() {
48
+ return hasCmd("aws");
49
+ },
50
+ /** Richer read-only identity probe (beyond the cheap PATH check). */
51
+ async authState(opts) {
52
+ const region = opts?.region ? ["--region", opts.region] : [];
53
+ const r = await run("aws", ["sts", "get-caller-identity", "--output", "json", ...region]);
54
+ if (!r.ok) return { connected: false, mode: "sts", reason: (r.stderr || "not authenticated").split("\n")[0] };
55
+ let account;
56
+ try {
57
+ account = JSON.parse(r.stdout).Account;
58
+ } catch {
59
+ /* ignore */
60
+ }
61
+ return { connected: true, mode: "sts", account };
62
+ },
63
+ async collect(opts, compiled) {
64
+ const region = opts?.region ? ["--region", opts.region] : [];
65
+ const tag = opts?.region || "default";
66
+ const out = [];
67
+
68
+ // (a) Lambda functions → per-function env vars (Bedrock model ids show up here).
69
+ const list = await run("aws", ["lambda", "list-functions", "--output", "json", ...region]);
70
+ if (list.ok) {
71
+ for (const fn of parseFunctionList(list.stdout)) {
72
+ const cfg = await run("aws", ["lambda", "get-function-configuration", "--function-name", fn, "--output", "json", ...region]);
73
+ if (!cfg.ok) continue;
74
+ for (const [k, v] of Object.entries(parseFunctionEnv(cfg.stdout))) {
75
+ const entries = entriesFromKV(k, v, `aws-lambda://${tag}/${fn}#${k}`, fn);
76
+ out.push(...scanConfigEntries(entries, compiled, { sourceType: "aws-lambda", env: opts?.env }));
77
+ }
78
+ }
79
+ }
80
+
81
+ // (b) Bedrock enabled foundation models — emitted DIRECTLY but still through
82
+ // the funnel (the model id is the value) for redaction + dedup parity. Default
83
+ // on; opts.awsBedrock === false skips the extra call.
84
+ if (opts?.awsBedrock !== false) {
85
+ const bm = await run("aws", ["bedrock", "list-foundation-models", "--output", "json", ...region]);
86
+ if (bm.ok) {
87
+ for (const modelId of parseBedrockModels(bm.stdout)) {
88
+ const entries = entriesFromKV("bedrock-model", modelId, `aws-bedrock://${tag}/foundation-models#${modelId}`, opts?.region);
89
+ out.push(...scanConfigEntries(entries, compiled, { sourceType: "aws-bedrock", env: opts?.env }));
90
+ }
91
+ }
92
+ }
93
+ return out;
94
+ },
95
+ };
@@ -10,12 +10,18 @@ import { redactValue } from "../redact.js";
10
10
 
11
11
  const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
12
12
 
13
- /** Guess an environment label from any contextual hints (namespace, key, path). */
13
+ /** Guess an environment label from any contextual hints (namespace, key, path).
14
+ * We claim an env only on a POSITIVE marker — including an explicit prod/live one
15
+ * — and never fabricate "prod" from the mere absence of a hint (a k8s "default"
16
+ * namespace, an unlabelled secret). No marker → "unknown", so the user can tag it
17
+ * rather than trust a coin-flip. An explicit env passed to scanConfigEntries still
18
+ * overrides this entirely. */
14
19
  export function guessEnvFrom(...hints) {
15
20
  const s = hints.filter(Boolean).join(" ").toLowerCase();
16
21
  if (/(^|[^a-z])(dev|development|sandbox|local)([^a-z]|$)/.test(s)) return "dev";
17
22
  if (/(^|[^a-z])(stag|staging|stg|qa|uat|test)([^a-z]|$)/.test(s)) return "staging";
18
- return "prod";
23
+ if (/(^|[^a-z])(prod|production|prd|live)([^a-z]|$)/.test(s)) return "prod";
24
+ return "unknown";
19
25
  }
20
26
 
21
27
  /** Flatten a nested object/array into leaf entries with dotted locator paths. */
Binary file