@modelstatus/cli 0.1.0
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/LICENSE +21 -0
- package/README.md +124 -0
- package/package.json +55 -0
- package/src/api.js +85 -0
- package/src/auth.js +44 -0
- package/src/config.js +63 -0
- package/src/detect/core.js +58 -0
- package/src/index.js +337 -0
- package/src/openUrl.js +29 -0
- package/src/redact.js +28 -0
- package/src/registry/fetch.js +93 -0
- package/src/registry/local.js +34 -0
- package/src/registry/root-keys.js +12 -0
- package/src/registry/sign.js +17 -0
- package/src/registry/verify.js +67 -0
- package/src/scan.js +15 -0
- package/src/sources/aws.js +63 -0
- package/src/sources/configscan.js +88 -0
- package/src/sources/env.js +21 -0
- package/src/sources/filesystem.js +95 -0
- package/src/sources/helm.js +42 -0
- package/src/sources/index.js +63 -0
- package/src/sources/k8s.js +51 -0
- package/src/sources/shell.js +35 -0
- package/src/sources/sql.js +47 -0
- package/src/tui/app.js +139 -0
- package/src/tui/ui.js +47 -0
- package/src/tui/views/account.js +76 -0
- package/src/tui/views/add.js +84 -0
- package/src/tui/views/alerts.js +160 -0
- package/src/tui/views/inventory.js +102 -0
- package/src/tui/views/scan.js +125 -0
- package/src/tui/views/whatsnew.js +177 -0
- package/src/upgrade.js +29 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveAuth, loadConfig, saveConfig, clearAuth, configFilePath } from "./config.js";
|
|
4
|
+
import { createClient } from "./api.js";
|
|
5
|
+
import { collectFrom, availability, ALL_SOURCE_IDS } from "./sources/index.js";
|
|
6
|
+
import { loginViaBrowser } from "./auth.js";
|
|
7
|
+
|
|
8
|
+
function parseArgs(argv) {
|
|
9
|
+
const flags = {};
|
|
10
|
+
const positional = [];
|
|
11
|
+
const valueFlags = new Set([
|
|
12
|
+
"api", "key", "project", "dir",
|
|
13
|
+
"sources", "region", "namespace", "kube-context", "db", "sql-table", "env",
|
|
14
|
+
]);
|
|
15
|
+
for (let i = 0; i < argv.length; i++) {
|
|
16
|
+
const a = argv[i];
|
|
17
|
+
if (a.startsWith("--")) {
|
|
18
|
+
const name = a.slice(2);
|
|
19
|
+
if (valueFlags.has(name)) flags[name] = argv[++i];
|
|
20
|
+
else flags[name] = true;
|
|
21
|
+
} else positional.push(a);
|
|
22
|
+
}
|
|
23
|
+
if (flags.ci) {
|
|
24
|
+
flags.yes = true;
|
|
25
|
+
flags.json = true;
|
|
26
|
+
}
|
|
27
|
+
return { positional, flags };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const uuidish = (s) => /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(s || "");
|
|
31
|
+
|
|
32
|
+
/** Resolve the requested sources: default filesystem, "all", or a comma list. */
|
|
33
|
+
function parseSources(flags) {
|
|
34
|
+
const raw = (flags.sources || "").trim();
|
|
35
|
+
if (!raw) return ["filesystem"];
|
|
36
|
+
if (raw === "all") return ALL_SOURCE_IDS;
|
|
37
|
+
return raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Per-source options gathered from flags (region/namespace/db/…). */
|
|
41
|
+
function scanOpts(flags, dir) {
|
|
42
|
+
return {
|
|
43
|
+
root: dir,
|
|
44
|
+
region: flags.region,
|
|
45
|
+
namespace: flags.namespace,
|
|
46
|
+
kubeContext: flags["kube-context"],
|
|
47
|
+
db: flags.db,
|
|
48
|
+
sqlTable: flags["sql-table"],
|
|
49
|
+
env: flags.env,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function cmdLogin(positional, flags) {
|
|
54
|
+
const key = positional[1] || flags.key;
|
|
55
|
+
const { apiBase } = resolveAuth(flags);
|
|
56
|
+
if (key) {
|
|
57
|
+
// Paste path: validate + save.
|
|
58
|
+
const client = createClient({ apiBase, apiKey: key });
|
|
59
|
+
const me = await client.me();
|
|
60
|
+
const cfg = loadConfig();
|
|
61
|
+
cfg.apiKey = key;
|
|
62
|
+
cfg.apiBase = apiBase;
|
|
63
|
+
saveConfig(cfg);
|
|
64
|
+
console.log(`✓ Logged in to "${me.account.name}" (${apiBase}).`);
|
|
65
|
+
console.log(` Saved to ${configFilePath}`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Browser path: open + poll.
|
|
69
|
+
await loginViaBrowser({ apiBase });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function cmdSignup(_positional, flags) {
|
|
73
|
+
const { apiBase } = resolveAuth(flags);
|
|
74
|
+
await loginViaBrowser({ apiBase, signup: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function cmdLogout() {
|
|
78
|
+
clearAuth();
|
|
79
|
+
console.log("✓ Signed out (API key removed). Run `mm login` to sign back in.");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function cmdUpgrade(_positional, flags) {
|
|
83
|
+
const { apiBase, apiKey } = resolveAuth(flags);
|
|
84
|
+
if (!apiKey) {
|
|
85
|
+
console.error("No API key. Run `mm login` first.");
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
const client = createClient({ apiBase, apiKey });
|
|
89
|
+
const { upgradeViaBrowser } = await import("./upgrade.js");
|
|
90
|
+
const plan = await upgradeViaBrowser({ client });
|
|
91
|
+
if (!plan) {
|
|
92
|
+
console.error("Upgrade not detected (timed out). Run `mm upgrade` again if you completed checkout.");
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function launchTui(initialView, flags) {
|
|
98
|
+
let { apiBase, apiKey } = resolveAuth(flags);
|
|
99
|
+
if (!apiKey) {
|
|
100
|
+
console.error("Not signed in — starting browser login…");
|
|
101
|
+
({ apiKey } = await loginViaBrowser({ apiBase }));
|
|
102
|
+
}
|
|
103
|
+
const dir = path.resolve(flags.dir || ".");
|
|
104
|
+
const { runApp } = await import("./tui/app.js");
|
|
105
|
+
await runApp({ apiBase, apiKey, dir, initialView });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function cmdScan(positional, flags) {
|
|
109
|
+
const dir = path.resolve(positional[1] || ".");
|
|
110
|
+
const { apiBase, apiKey } = resolveAuth(flags);
|
|
111
|
+
if (!apiKey) {
|
|
112
|
+
console.error("No API key. Run `mm login` or pass --key.");
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
const interactive = !flags.yes && !flags.json && process.stdout.isTTY;
|
|
116
|
+
if (interactive) {
|
|
117
|
+
const { runApp } = await import("./tui/app.js");
|
|
118
|
+
await runApp({ apiBase, apiKey, dir, initialView: "scan" });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Non-interactive (CI / --json / --yes): scan + bulk upload, no TUI.
|
|
123
|
+
const client = createClient({ apiBase, apiKey });
|
|
124
|
+
const sources = parseSources(flags);
|
|
125
|
+
const opts = scanOpts(flags, dir);
|
|
126
|
+
|
|
127
|
+
// Report which sources will actually run (tool/creds/flags present).
|
|
128
|
+
const avail = await availability(sources, opts);
|
|
129
|
+
for (const a of avail) {
|
|
130
|
+
if (!a.known) process.stderr.write(`! unknown source "${a.id}" — skipped\n`);
|
|
131
|
+
else if (!a.available) process.stderr.write(`! ${a.id} unavailable (tool, creds, or flags missing) — skipped\n`);
|
|
132
|
+
}
|
|
133
|
+
const active = avail.filter((a) => a.available).map((a) => a.id);
|
|
134
|
+
process.stderr.write(`Scanning [${active.join(", ") || "none"}] …\n`);
|
|
135
|
+
|
|
136
|
+
const patterns = await client.detectionPatterns();
|
|
137
|
+
const candidates = await collectFrom(sources, opts, patterns);
|
|
138
|
+
if (candidates.length === 0) {
|
|
139
|
+
console.log("No model usage found.");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const uniq = [...new Set(candidates.map((c) => c.model_string))];
|
|
144
|
+
const resolved = uniq.length ? (await client.resolve(uniq)).data : [];
|
|
145
|
+
const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
|
|
146
|
+
const seenRows = new Set();
|
|
147
|
+
const rows = candidates
|
|
148
|
+
.map((c) => {
|
|
149
|
+
const r = byStr.get(c.model_string.toLowerCase());
|
|
150
|
+
return { ...c, model_id: r?.model_id ?? null, display: r?.model_id ? r.display : c.model_string };
|
|
151
|
+
})
|
|
152
|
+
.filter((r) => {
|
|
153
|
+
const k = `${r.model_id ?? "custom:" + r.model_string}|${r.location_label}`;
|
|
154
|
+
if (seenRows.has(k)) return false;
|
|
155
|
+
seenRows.add(k);
|
|
156
|
+
return true;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const usages = rows.map((r) => ({
|
|
160
|
+
model_id: r.model_id ?? undefined,
|
|
161
|
+
custom_model_name: r.model_id ? undefined : r.model_string,
|
|
162
|
+
environment: r.environment,
|
|
163
|
+
location_label: r.location_label,
|
|
164
|
+
source_path: r.source_path,
|
|
165
|
+
source_line: r.source_line ?? undefined,
|
|
166
|
+
}));
|
|
167
|
+
|
|
168
|
+
// --dry-run: show exactly what WOULD upload (secret-source safety check).
|
|
169
|
+
if (flags["dry-run"]) {
|
|
170
|
+
if (flags.json) {
|
|
171
|
+
console.log(JSON.stringify({ scanned: rows.length, would_upload: usages, sources: avail }, null, 2));
|
|
172
|
+
} else {
|
|
173
|
+
const types = [...new Set(rows.map((r) => r.source_type))].join(", ");
|
|
174
|
+
console.log(`Dry run — ${rows.length} usage(s) found across [${types}]. Nothing uploaded:`);
|
|
175
|
+
for (const r of rows.slice(0, 60)) console.log(` ${(r.display || r.model_string).padEnd(28)} ${r.location_label} (${r.environment})`);
|
|
176
|
+
if (rows.length > 60) console.log(` …and ${rows.length - 60} more`);
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let projectId = null;
|
|
182
|
+
const projects = (await client.listProjects()).data;
|
|
183
|
+
if (flags.project) {
|
|
184
|
+
projectId = uuidish(flags.project)
|
|
185
|
+
? flags.project
|
|
186
|
+
: projects.find((p) => p.name === flags.project || p.slug === flags.project)?.id;
|
|
187
|
+
if (!projectId) projectId = (await client.createProject(flags.project)).id;
|
|
188
|
+
} else {
|
|
189
|
+
projectId = projects[0]?.id ?? (await client.createProject(path.basename(dir))).id;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const res = await client.bulkUpload(projectId, usages);
|
|
193
|
+
if (flags.json) {
|
|
194
|
+
console.log(
|
|
195
|
+
JSON.stringify(
|
|
196
|
+
{ scanned: rows.length, uploaded: usages.length, sources: avail.map((a) => ({ id: a.id, available: a.available })), ...res },
|
|
197
|
+
null,
|
|
198
|
+
2,
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
} else {
|
|
202
|
+
console.log(
|
|
203
|
+
`✓ ${rows.length} usage(s) found · uploaded ${usages.length} → ${res.created} created, ${res.updated} updated, ${res.failed} failed.`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** List detection sources and whether each can run right now. */
|
|
209
|
+
async function cmdSources(_positional, flags) {
|
|
210
|
+
const dir = path.resolve(flags.dir || ".");
|
|
211
|
+
const report = await availability(ALL_SOURCE_IDS, scanOpts(flags, dir));
|
|
212
|
+
console.log("Detection sources:");
|
|
213
|
+
for (const a of report) {
|
|
214
|
+
console.log(` ${a.available ? "✓" : "·"} ${a.id.padEnd(13)} ${a.label}${a.available ? "" : " (unavailable)"}`);
|
|
215
|
+
}
|
|
216
|
+
console.log("\nScan with: mm scan --sources env,aws-secrets,k8s,helm,sql (or --sources all)");
|
|
217
|
+
console.log("Options: --region <r> · --namespace <ns> · --kube-context <c> · --db <pg-dsn> --sql-table <t>");
|
|
218
|
+
console.log("Safety: secret VALUES never leave your machine — only model ids upload. Use --dry-run to preview.");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Offline, account-less health check: pull the signed registry snapshot, scan
|
|
222
|
+
* here, resolve + score health entirely on-device. The free tier's core value. */
|
|
223
|
+
async function cmdStatus(positional, flags) {
|
|
224
|
+
const dir = path.resolve(positional[1] || flags.dir || ".");
|
|
225
|
+
const { getRegistry } = await import("./registry/fetch.js");
|
|
226
|
+
const { resolveLocal, computeHealth } = await import("./registry/local.js");
|
|
227
|
+
const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => process.stderr.write(`! ${m}\n`) });
|
|
228
|
+
|
|
229
|
+
const candidates = await collectFrom(parseSources(flags), scanOpts(flags, dir), snapshot.detection);
|
|
230
|
+
const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
|
|
231
|
+
const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
|
|
232
|
+
|
|
233
|
+
// Aggregate locations per known model / per custom string.
|
|
234
|
+
const known = new Map(); // slug -> { model, count }
|
|
235
|
+
const custom = new Map(); // string -> count
|
|
236
|
+
for (const c of candidates) {
|
|
237
|
+
const r = byStr.get(c.model_string.toLowerCase());
|
|
238
|
+
if (r?.model_slug && r.model) {
|
|
239
|
+
const e = known.get(r.model_slug) || { model: r.model, count: 0 };
|
|
240
|
+
e.count++;
|
|
241
|
+
known.set(r.model_slug, e);
|
|
242
|
+
} else custom.set(c.model_string, (custom.get(c.model_string) || 0) + 1);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const today = new Date();
|
|
246
|
+
const ICON = { ok: "🟢", deprecating: "🟡", retiring: "🟠", retired: "🔴", custom: "⚪" };
|
|
247
|
+
const rank = { retired: 0, retiring: 1, deprecating: 2, ok: 3 };
|
|
248
|
+
const rows = [...known.values()]
|
|
249
|
+
.map(({ model, count }) => ({ model, count, health: computeHealth(model, 90, today) }))
|
|
250
|
+
.sort((a, b) => rank[a.health] - rank[b.health] || String(a.model.retires_date || "9999").localeCompare(String(b.model.retires_date || "9999")));
|
|
251
|
+
const attention = rows.filter((r) => r.health !== "ok");
|
|
252
|
+
|
|
253
|
+
if (flags.json) {
|
|
254
|
+
console.log(JSON.stringify({
|
|
255
|
+
registry: { version: snapshot.version, generated_at: snapshot.generated_at, models: snapshot.models.length },
|
|
256
|
+
scanned: dir,
|
|
257
|
+
references: candidates.length,
|
|
258
|
+
models: rows.map((r) => ({ slug: r.model.slug, display: r.model.display, health: r.health, retires_date: r.model.retires_date, replacement_slug: r.model.replacement_slug, places: r.count })),
|
|
259
|
+
custom: [...custom.entries()].map(([name, places]) => ({ name, places })),
|
|
260
|
+
needs_attention: attention.length,
|
|
261
|
+
}, null, 2));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const ageDays = Math.round((today - new Date(snapshot.generated_at)) / 86_400_000);
|
|
266
|
+
console.log(`LLM Status — registry ${snapshot.version} (${ageDays <= 0 ? "today" : ageDays + "d old"}), ${snapshot.models.length} models`);
|
|
267
|
+
console.log(`Scanned ${dir}: ${candidates.length} reference(s) → ${known.size} model(s), ${custom.size} custom\n`);
|
|
268
|
+
if (!rows.length && !custom.size) {
|
|
269
|
+
console.log("No model usage found here. Try `mm status <dir>`, or `mm scan` to map a whole project.");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (rows.length) {
|
|
273
|
+
console.log("Models in use:");
|
|
274
|
+
for (const r of rows) {
|
|
275
|
+
const tail = r.model.retires_date ? `retires ${r.model.retires_date}` : "";
|
|
276
|
+
const repl = r.health !== "ok" && r.model.replacement_slug ? ` → ${r.model.replacement_slug}` : "";
|
|
277
|
+
console.log(` ${ICON[r.health]} ${r.health.padEnd(11)} ${r.model.slug.padEnd(32)} ${tail.padEnd(20)}${repl} (${r.count})`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (custom.size) {
|
|
281
|
+
console.log("\nCustom / unrecognized:");
|
|
282
|
+
for (const [name, places] of custom) console.log(` ⚪ ${name} (${places})`);
|
|
283
|
+
}
|
|
284
|
+
if (attention.length) {
|
|
285
|
+
console.log(`\n⚠ ${attention.length} model(s) need attention before they retire.`);
|
|
286
|
+
console.log(" Get alerted automatically (email/Slack/SMS): `mm login` then `mm upgrade` — scanning is free, alerting is the paid feature.");
|
|
287
|
+
} else if (rows.length) {
|
|
288
|
+
console.log("\n✓ Everything you use is current.");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const HELP = `LLM Status CLI
|
|
293
|
+
|
|
294
|
+
Usage:
|
|
295
|
+
mm Launch the TUI (inventory, scan, what's-new, alerts, account)
|
|
296
|
+
mm status [dir] Offline model-health check for a dir — no account needed
|
|
297
|
+
mm login [api_key] Browser sign-in with polling (or paste a key)
|
|
298
|
+
mm signup Create an account in the browser, then poll
|
|
299
|
+
mm logout Forget the saved API key
|
|
300
|
+
mm scan [dir] Scan for model usage; interactive TUI, or --ci/--json for pipelines
|
|
301
|
+
mm sources List detection sources and whether each can run here
|
|
302
|
+
mm upgrade Open Stripe checkout and poll until Pro is active
|
|
303
|
+
mm tui Same as bare \`mm\`
|
|
304
|
+
|
|
305
|
+
Scan sources (--sources, default filesystem; "all" for everything):
|
|
306
|
+
filesystem repo files aws-secrets AWS Secrets Manager + SSM
|
|
307
|
+
env live process env k8s kubectl secrets + configmaps
|
|
308
|
+
sql psql --db <dsn> helm helm release values
|
|
309
|
+
Secret sources shell out to your already-authenticated CLIs, run read-only,
|
|
310
|
+
and only ever upload model ids — secret VALUES never leave your machine.
|
|
311
|
+
|
|
312
|
+
Flags: --api <url> · --key <key> · --project <id|name> · --yes · --json · --ci · --dry-run
|
|
313
|
+
--sources <list> · --region <r> · --namespace <ns> · --kube-context <c> · --db <dsn> · --sql-table <t>
|
|
314
|
+
|
|
315
|
+
Get started: \`mm login\` (opens your browser).`;
|
|
316
|
+
|
|
317
|
+
async function main() {
|
|
318
|
+
const { positional, flags } = parseArgs(process.argv.slice(2));
|
|
319
|
+
const cmd = positional[0];
|
|
320
|
+
try {
|
|
321
|
+
if (cmd === "login") await cmdLogin(positional, flags);
|
|
322
|
+
else if (cmd === "signup") await cmdSignup(positional, flags);
|
|
323
|
+
else if (cmd === "logout") cmdLogout();
|
|
324
|
+
else if (cmd === "scan") await cmdScan(positional, flags);
|
|
325
|
+
else if (cmd === "status") await cmdStatus(positional, flags);
|
|
326
|
+
else if (cmd === "sources") await cmdSources(positional, flags);
|
|
327
|
+
else if (cmd === "upgrade") await cmdUpgrade(positional, flags);
|
|
328
|
+
else if (cmd === "tui" || !cmd) await launchTui(positional[1] || "inventory", flags);
|
|
329
|
+
else if (cmd === "help" || flags.help) console.log(HELP);
|
|
330
|
+
else console.log(HELP);
|
|
331
|
+
} catch (e) {
|
|
332
|
+
console.error(`Error: ${e?.message ?? e}`);
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
main();
|
package/src/openUrl.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/** Open a URL in the user's default browser, cross-platform. Best-effort: if it
|
|
4
|
+
* fails we still printed the URL, so the user can open it manually. */
|
|
5
|
+
export function openUrl(url) {
|
|
6
|
+
// Skip actually launching a browser in tests / headless / CI / SSH sessions —
|
|
7
|
+
// the URL is always printed, so the user can open it manually.
|
|
8
|
+
if (process.env.LLMSTATUS_NO_OPEN) return false;
|
|
9
|
+
const platform = process.platform;
|
|
10
|
+
let cmd, args;
|
|
11
|
+
if (platform === "darwin") {
|
|
12
|
+
cmd = "open";
|
|
13
|
+
args = [url];
|
|
14
|
+
} else if (platform === "win32") {
|
|
15
|
+
cmd = "cmd";
|
|
16
|
+
args = ["/c", "start", "", url];
|
|
17
|
+
} else {
|
|
18
|
+
cmd = "xdg-open";
|
|
19
|
+
args = [url];
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
23
|
+
child.on("error", () => {});
|
|
24
|
+
child.unref();
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/redact.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Mask anything that looks like a secret value before it can be displayed or
|
|
2
|
+
* uploaded. The product only needs the model id / provider-key NAME / location —
|
|
3
|
+
* never the secret value. Used by every Candidate emitter; essential for the
|
|
4
|
+
* secrets/config sources added in PR2. */
|
|
5
|
+
const SECRETY = [
|
|
6
|
+
/\bsk-[A-Za-z0-9_\-]{12,}/g, // OpenAI-style keys
|
|
7
|
+
/\b(mm|sk|pk|rk|key|tok|ghp|gho|xox[baprs])[-_][A-Za-z0-9_\-]{12,}/gi,
|
|
8
|
+
/\bAKIA[0-9A-Z]{16}\b/g, // AWS access key id
|
|
9
|
+
/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, // long base64-ish blobs
|
|
10
|
+
/\b[0-9a-f]{32,}\b/gi, // long hex blobs
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/** Redact secret-looking substrings, keeping the line readable for context. */
|
|
14
|
+
export function redactValue(s) {
|
|
15
|
+
if (!s) return s;
|
|
16
|
+
let out = s;
|
|
17
|
+
for (const re of SECRETY) out = out.replace(re, "«redacted»");
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Redact the value half of `KEY=value` / `KEY: value` config lines while
|
|
22
|
+
* keeping the key (which is what we actually detect on). */
|
|
23
|
+
export function redactConfigLine(line) {
|
|
24
|
+
return redactValue(line).replace(
|
|
25
|
+
/(["']?[A-Za-z0-9_.\-]+["']?\s*[:=]\s*)("?)([^"'\s#]{8,})\2/g,
|
|
26
|
+
(m, k) => `${k}«redacted»`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/* Fetch + verify the registry snapshot, with cache + offline fallback + anti-
|
|
2
|
+
* rollback. Trust chain: pinned root -> keys.json manifest -> latest.json pointer
|
|
3
|
+
* -> snapshot blob (sha256). Never moves to an older version than we've trusted. */
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { ROOT_PUBLIC_KEYS } from "./root-keys.js";
|
|
9
|
+
import { verifyManifest, verifyPointer, verifyBlob, isNewer } from "./verify.js";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_BASE = process.env.LLMSTATUS_REGISTRY_URL || "https://cdn.llmstatus.ai";
|
|
12
|
+
const DEFAULT_CACHE = path.join(os.homedir(), ".config", "llmstatus", "registry-cache.json");
|
|
13
|
+
const STALE_DAYS = 30;
|
|
14
|
+
|
|
15
|
+
async function readUrl(u) {
|
|
16
|
+
if (u.startsWith("file://")) return fs.readFileSync(fileURLToPath(u));
|
|
17
|
+
const res = await fetch(u, { redirect: "follow" });
|
|
18
|
+
if (!res.ok) throw new Error(`GET ${u} -> ${res.status}`);
|
|
19
|
+
return Buffer.from(await res.arrayBuffer());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readCache(file) {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeCache(file, obj) {
|
|
31
|
+
try {
|
|
32
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
33
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
34
|
+
fs.writeFileSync(tmp, JSON.stringify(obj));
|
|
35
|
+
fs.renameSync(tmp, file); // atomic vs concurrent runs
|
|
36
|
+
} catch {
|
|
37
|
+
/* cache is best-effort */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function warnIfStale(cache, now, log) {
|
|
42
|
+
const gen = cache?.snapshot?.generated_at;
|
|
43
|
+
if (!gen) return;
|
|
44
|
+
const days = (now.getTime() - new Date(gen).getTime()) / 86_400_000;
|
|
45
|
+
if (days > STALE_DAYS) log(`registry is ${Math.round(days)} days old — run online to refresh.`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Returns a verified snapshot object. Order: try network (verify + cache),
|
|
49
|
+
* else fall back to the cached snapshot; only ever moves forward in version. */
|
|
50
|
+
export async function getRegistry(opts = {}) {
|
|
51
|
+
const base = (opts.base || DEFAULT_BASE).replace(/\/$/, "");
|
|
52
|
+
const cacheFile = opts.cacheFile || DEFAULT_CACHE;
|
|
53
|
+
const roots = opts.roots || ROOT_PUBLIC_KEYS;
|
|
54
|
+
const now = opts.now || new Date();
|
|
55
|
+
const log = opts.log || (() => {});
|
|
56
|
+
const cache = readCache(cacheFile);
|
|
57
|
+
|
|
58
|
+
if (opts.offline) {
|
|
59
|
+
if (!cache?.snapshot) throw new Error("offline and no cached registry — run once online first.");
|
|
60
|
+
warnIfStale(cache, now, log);
|
|
61
|
+
return cache.snapshot;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const manifest = JSON.parse((await readUrl(`${base}/registry/keys.json`)).toString());
|
|
66
|
+
const m = verifyManifest(manifest, roots, now);
|
|
67
|
+
// Anti-rollback: never accept an older manifest than the one we last trusted.
|
|
68
|
+
if (cache?.manifestVersion && isNewer(cache.manifestVersion, m.version)) throw new Error("manifest rollback");
|
|
69
|
+
|
|
70
|
+
const pointer = JSON.parse((await readUrl(`${base}/registry/latest.json`)).toString());
|
|
71
|
+
const signed = verifyPointer(pointer, m.keys);
|
|
72
|
+
if (cache?.pointerVersion && isNewer(cache.pointerVersion, signed.version)) throw new Error("pointer rollback");
|
|
73
|
+
|
|
74
|
+
// No change since cache → reuse the cached blob, skip re-download.
|
|
75
|
+
if (cache?.snapshot && !isNewer(signed.version, cache.pointerVersion)) {
|
|
76
|
+
writeCache(cacheFile, { ...cache, manifestVersion: m.version, fetched_at: now.toISOString() });
|
|
77
|
+
return cache.snapshot;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const blob = await readUrl(signed.url);
|
|
81
|
+
verifyBlob(blob, signed.sha256);
|
|
82
|
+
const snapshot = JSON.parse(blob.toString());
|
|
83
|
+
writeCache(cacheFile, { manifestVersion: m.version, pointerVersion: signed.version, fetched_at: now.toISOString(), snapshot });
|
|
84
|
+
return snapshot;
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (cache?.snapshot) {
|
|
87
|
+
log(`registry refresh failed (${e.message}); using cached copy.`);
|
|
88
|
+
warnIfStale(cache, now, log);
|
|
89
|
+
return cache.snapshot;
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`couldn't fetch the model registry and no cached copy is available: ${e.message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/* Offline resolution + health from a verified snapshot — mirrors the server's
|
|
2
|
+
* computeHealth (apps/web/lib/serialize.ts) so an offline run matches online. */
|
|
3
|
+
|
|
4
|
+
/** Resolve model strings against the snapshot's detection table (exact match). */
|
|
5
|
+
export function resolveLocal(snapshot, strings) {
|
|
6
|
+
const exact = new Map((snapshot.detection?.model_strings || []).map((s) => [s.match.toLowerCase(), s.model_slug]));
|
|
7
|
+
const bySlug = new Map((snapshot.models || []).map((m) => [m.slug, m]));
|
|
8
|
+
return strings.map((s) => {
|
|
9
|
+
const slug = exact.get(String(s).toLowerCase()) || null;
|
|
10
|
+
const model = slug ? bySlug.get(slug) || null : null;
|
|
11
|
+
return { input: s, model_slug: slug, display: model?.display ?? s, model };
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Mirror of the server's computeHealth for a snapshot (slug-space) model. */
|
|
16
|
+
export function computeHealth(model, retiringWindowDays = 90, today = new Date()) {
|
|
17
|
+
if (!model) return "custom";
|
|
18
|
+
const ret = model.retires_date ? new Date(model.retires_date) : null;
|
|
19
|
+
if (model.status === "retired" || (ret && ret <= today)) return "retired";
|
|
20
|
+
if (ret) {
|
|
21
|
+
const days = Math.round((ret.getTime() - today.getTime()) / 86_400_000);
|
|
22
|
+
if (days <= retiringWindowDays) return "retiring";
|
|
23
|
+
}
|
|
24
|
+
if (model.status === "deprecating" || model.deprecation_date) return "deprecating";
|
|
25
|
+
return "ok";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Models needing attention (deprecating/retiring/retired), soonest retirement first. */
|
|
29
|
+
export function needsAttention(snapshot, retiringWindowDays = 90, today = new Date()) {
|
|
30
|
+
return (snapshot.models || [])
|
|
31
|
+
.map((m) => ({ ...m, health: computeHealth(m, retiringWindowDays, today) }))
|
|
32
|
+
.filter((m) => m.health !== "ok")
|
|
33
|
+
.sort((a, b) => String(a.retires_date || "9999-99-99").localeCompare(String(b.retires_date || "9999-99-99")));
|
|
34
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Pinned registry ROOT public key (mini-TUF trust anchor). The CLI trusts ONLY
|
|
2
|
+
// this to verify the root-signed keys.json manifest, which names the current
|
|
3
|
+
// snapshot SIGNING key — so the signing key rotates with NO CLI update. Replacing
|
|
4
|
+
// the ROOT key is the rare case that needs a CLI release. Private root key is COLD
|
|
5
|
+
// in 1Password (LLMStore: "Registry Signing Keys"); never deployed. Generated 2026-05-28.
|
|
6
|
+
export const ROOT_KEY_ID = "root-2026-05-28";
|
|
7
|
+
export const ROOT_PUBLIC_KEYS = {
|
|
8
|
+
[ROOT_KEY_ID]: `-----BEGIN PUBLIC KEY-----
|
|
9
|
+
MCowBQYDK2VwAyEAwkgXnIdHzIcuf4F/E/aP5exSaV0Q+nn05dVOHK79i7I=
|
|
10
|
+
-----END PUBLIC KEY-----
|
|
11
|
+
`,
|
|
12
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/* Ed25519 signing over the canonical encoding (mirror of verify.js). Used by the
|
|
2
|
+
* offline rotate-signing-key script and tests. The PRODUCTION snapshot signer
|
|
3
|
+
* lives server-side (apps/web) with the signing key from Vercel env; this stays
|
|
4
|
+
* dependency-free so the cold-root rotation script can run anywhere. */
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import { canonicalize } from "./verify.js";
|
|
7
|
+
|
|
8
|
+
/** Sign `signed` with an Ed25519 PEM private key; returns base64. */
|
|
9
|
+
export function signEd25519(signed, privateKeyPem) {
|
|
10
|
+
const key = crypto.createPrivateKey(privateKeyPem);
|
|
11
|
+
return crypto.sign(null, Buffer.from(canonicalize(signed)), key).toString("base64");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Wrap a payload as a signed envelope: { signed, signature, alg }. */
|
|
15
|
+
export function makeSigned(signed, privateKeyPem) {
|
|
16
|
+
return { signed, signature: signEd25519(signed, privateKeyPem), alg: "ed25519" };
|
|
17
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* Registry trust chain (mini-TUF). The CLI trusts a pinned ROOT public key only.
|
|
2
|
+
* Root signs a keys.json manifest -> manifest names the current SIGNING key(s) ->
|
|
3
|
+
* signing key signs latest.json (the snapshot pointer) -> pointer pins the blob's
|
|
4
|
+
* sha256. So the signing key rotates with no CLI update; only a root change needs
|
|
5
|
+
* a release. All signatures are Ed25519 over a canonical JSON encoding. */
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
|
|
8
|
+
/** Deterministic JSON: recursively sorted keys, no insignificant whitespace.
|
|
9
|
+
* Signer and verifier MUST encode the same object the same way, so we never
|
|
10
|
+
* sign/verify raw bytes that could differ by key order or spacing. */
|
|
11
|
+
export function canonicalize(value) {
|
|
12
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
13
|
+
if (Array.isArray(value)) return "[" + value.map(canonicalize).join(",") + "]";
|
|
14
|
+
return "{" + Object.keys(value).sort().map((k) => JSON.stringify(k) + ":" + canonicalize(value[k])).join(",") + "}";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Verify an Ed25519 signature (base64) over canonicalize(signed) with a PEM key. */
|
|
18
|
+
export function verifyEd25519(signed, signatureB64, publicKeyPem) {
|
|
19
|
+
try {
|
|
20
|
+
const key = crypto.createPublicKey(publicKeyPem);
|
|
21
|
+
return crypto.verify(null, Buffer.from(canonicalize(signed)), key, Buffer.from(signatureB64, "base64"));
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Verify keys.json with a pinned root key map; return { version, keys } where
|
|
28
|
+
* keys maps trusted signing key_id -> PEM (expired ones dropped). Throws on any
|
|
29
|
+
* failure so callers can fall back to a cached, previously-trusted manifest. */
|
|
30
|
+
export function verifyManifest(manifest, rootPublicKeys, now = new Date()) {
|
|
31
|
+
const signed = manifest?.signed;
|
|
32
|
+
if (!signed || !Array.isArray(signed.keys)) throw new Error("malformed manifest");
|
|
33
|
+
const rootPem = rootPublicKeys[signed.root_key_id];
|
|
34
|
+
if (!rootPem) throw new Error(`manifest signed by untrusted root '${signed.root_key_id}'`);
|
|
35
|
+
if (!verifyEd25519(signed, manifest.signature, rootPem)) throw new Error("manifest signature invalid");
|
|
36
|
+
const keys = {};
|
|
37
|
+
for (const k of signed.keys) {
|
|
38
|
+
if (k.not_after && new Date(k.not_after) < now) continue; // expired signing key
|
|
39
|
+
if (k.alg && k.alg !== "ed25519") continue;
|
|
40
|
+
keys[k.key_id] = k.public_key;
|
|
41
|
+
}
|
|
42
|
+
return { version: signed.version, keys };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Verify latest.json (the snapshot pointer) against the manifest's trusted keys.
|
|
46
|
+
* Returns the verified `signed` payload ({ version, url, sha256, key_id, … }). */
|
|
47
|
+
export function verifyPointer(pointer, trustedKeys) {
|
|
48
|
+
const signed = pointer?.signed;
|
|
49
|
+
if (!signed) throw new Error("malformed pointer");
|
|
50
|
+
const pem = trustedKeys[signed.key_id];
|
|
51
|
+
if (!pem) throw new Error(`pointer signed by unknown/expired key '${signed.key_id}'`);
|
|
52
|
+
if (!verifyEd25519(signed, pointer.signature, pem)) throw new Error("pointer signature invalid");
|
|
53
|
+
return signed;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The signed pointer pins the blob's sha256; confirm the download matches. */
|
|
57
|
+
export function verifyBlob(bytes, sha256Hex) {
|
|
58
|
+
const got = crypto.createHash("sha256").update(bytes).digest("hex");
|
|
59
|
+
if (got !== sha256Hex) throw new Error("snapshot sha256 mismatch");
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Anti-rollback: only accept a version strictly newer than what we've trusted
|
|
64
|
+
* before. Versions are sortable strings (UTC stamps / monotonic ids). */
|
|
65
|
+
export function isNewer(candidate, trusted) {
|
|
66
|
+
return !trusted || String(candidate) > String(trusted);
|
|
67
|
+
}
|
package/src/scan.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Backwards-compatible scan facade. The real work now lives in detect/core.js
|
|
2
|
+
* and sources/*; this keeps the `scan()` / `guessEnv()` shape the CLI used. */
|
|
3
|
+
import { compilePatterns } from "./detect/core.js";
|
|
4
|
+
import { filesystemSource, guessEnv } from "./sources/filesystem.js";
|
|
5
|
+
|
|
6
|
+
/** Scan a directory for model strings. Returns rows shaped like the old API:
|
|
7
|
+
* { model_string, file, line, snippet }. Callers that want the richer Candidate
|
|
8
|
+
* shape (location_label/environment/source_path) should use sources/index.js. */
|
|
9
|
+
export async function scan(root, patterns) {
|
|
10
|
+
const compiled = compilePatterns(patterns);
|
|
11
|
+
const cands = await filesystemSource.collect({ root }, compiled);
|
|
12
|
+
return cands.map((c) => ({ model_string: c.model_string, file: c.file, line: c.line, snippet: c.snippet }));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export { guessEnv };
|