@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 +1 -1
- package/src/api.js +6 -0
- package/src/ci.js +2 -2
- package/src/index.js +219 -12
- package/src/integrations.js +121 -0
- package/src/sources/aws-lambda.js +95 -0
- package/src/sources/configscan.js +8 -2
- package/src/sources/filesystem.js +0 -0
- package/src/sources/github-actions.js +156 -0
- package/src/sources/index.js +70 -13
- package/src/sources/scan-process.js +238 -0
- package/src/sources/scan-runner.js +127 -0
- package/src/sources/scan-worker.js +148 -0
- package/src/sources/supabase-edge.js +183 -0
- package/src/sources/supabase.js +5 -0
- package/src/sources/vercel.js +74 -0
- package/src/tui/app.js +45 -2
- package/src/tui/game/DkGame.js +21 -0
- package/src/tui/game/dk-core.js +688 -0
- package/src/tui/game/dk-render.js +160 -0
- package/src/tui/game/input.js +169 -0
- package/src/tui/game/loop.js +337 -0
- package/src/tui/game/term.js +330 -0
- package/src/tui/views/add.js +1 -1
- package/src/tui/views/integrations.js +224 -0
- package/src/tui/views/inventory.js +31 -2
- package/src/tui/views/scan.js +116 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelstatus/cli",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
/**
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|