@modelstatus/cli 0.1.41 → 0.1.42

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.41",
3
+ "version": "0.1.42",
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/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  getEnvTag, setEnabled, setEnvTag,
11
11
  } from "./integrations.js";
12
12
  import { redactValue } from "./redact.js";
13
- import { assignProjects } from "./upload.js";
13
+ import { assignProjects, buildUsages } from "./upload.js";
14
14
  import { loginViaBrowser } from "./auth.js";
15
15
  import { maybeCheckForUpdate } from "./updater.js";
16
16
  import { track, maybeAnalyticsNotice } from "./telemetry.js";
@@ -109,12 +109,6 @@ function parseArgs(argv) {
109
109
 
110
110
  const uuidish = (s) => /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(s || "");
111
111
 
112
- /** The "owner/name" repo slug for source deep-links. In GitHub Actions
113
- * GITHUB_REPOSITORY is exactly that and the checkout is repo-root-relative (so
114
- * our source_path lines up). Outside CI we return "" (omit it) rather than guess
115
- * from a git remote, since a local scan root may not be the repo root. */
116
- const ghRepoSlug = () => (process.env.GITHUB_REPOSITORY || "").trim();
117
-
118
112
  /** The set of source ids the user named VERBATIM in --sources (empty for the
119
113
  * default / "all"). Naming a live integration here overrides its enabled-gate. */
120
114
  function explicitSources(flags) {
@@ -312,17 +306,12 @@ async function cmdScan(positional, flags) {
312
306
  return true;
313
307
  });
314
308
 
315
- const usages = rows.map((r) => ({
316
- model_id: r.model_id ?? undefined,
317
- // Redact + bound the custom id: a generic-glob hit on an .env line can over-
318
- // capture a secret-ish fragment, and only the snippet was being redacted.
319
- custom_model_name: r.model_id ? undefined : redactValue(r.model_string).slice(0, 120),
320
- environment: r.environment,
321
- location_label: r.location_label,
322
- source_repo: ghRepoSlug() || undefined,
323
- source_path: r.source_path,
324
- source_line: r.source_line ?? undefined,
325
- }));
309
+ // Build the upload payload via the SHARED helper so the command, the TUI Scan
310
+ // tab, and the Here push produce byte-identical usages (model resolution +
311
+ // redaction + the location_label scheme prefix that carries provenance). Parity-
312
+ // only: buildUsages reads the same fields the inline map did and falls back to
313
+ // GITHUB_REPOSITORY for source_repo. No new data is uploaded.
314
+ const usages = await buildUsages(client, rows);
326
315
 
327
316
  // --dry-run: show exactly what WOULD upload (secret-source safety check).
328
317
  if (flags["dry-run"]) {
@@ -2,6 +2,31 @@ import { execFile } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
 
5
+ /** Test-only fixture file name for a command+args, e.g. run("vercel",["env","ls",
6
+ * "production","--scope","team"]) → "vercel.env.ls.production.txt". Per-run scoping
7
+ * flags (--scope/--project-ref/--region) AND the value that follows each are
8
+ * dropped so a fixture is keyed on the stable command shape. Lowercased, path
9
+ * separators → dots. */
10
+ const FIXTURE_SKIP_FLAGS = new Set(["--scope", "--project-ref", "--region"]);
11
+ export function fixtureName(cmd, args = []) {
12
+ const kept = [];
13
+ for (let i = 0; i < args.length; i++) {
14
+ if (FIXTURE_SKIP_FLAGS.has(String(args[i]))) {
15
+ i++; // also skip the flag's value
16
+ continue;
17
+ }
18
+ kept.push(args[i]);
19
+ }
20
+ return (
21
+ [cmd, ...kept]
22
+ .join(".")
23
+ .toLowerCase()
24
+ .replace(/[/\\]/g, ".")
25
+ .replace(/[^a-z0-9._-]/g, "")
26
+ .slice(0, 120) + ".txt"
27
+ );
28
+ }
29
+
5
30
  /** Is `name` an executable on PATH? Pure PATH scan — no shell, no spawn. */
6
31
  export function hasCmd(name) {
7
32
  const PATH = process.env.PATH || "";
@@ -18,8 +43,25 @@ export function hasCmd(name) {
18
43
  }
19
44
 
20
45
  /** Run a command WITHOUT a shell (execFile → no injection). Read-only by
21
- * convention. Never throws; resolves { ok, stdout, stderr, code }. */
46
+ * convention. Never throws; resolves { ok, stdout, stderr, code }.
47
+ *
48
+ * TEST-ONLY: when process.env.MM_SOURCE_FIXTURE is set (a directory of canned
49
+ * vendor outputs), this REPLAYS a fixture file instead of spawning — so the full
50
+ * vercel/supabase collect() flow runs with zero cloud access. It is strictly less
51
+ * capable than the real path (read-only, no spawn / write / network), a pure no-op
52
+ * when the env var is unset (prod default), and a missing fixture degrades exactly
53
+ * like a missing CLI ({ok:false, code:127}). It is the symmetric twin of the
54
+ * LLMSTATUS_INTEGRATIONS_FILE / LLMSTATUS_IGNORE_FILE test redirects. */
22
55
  export function run(cmd, args, { timeout = 25000, input, maxBuffer = 48 * 1024 * 1024 } = {}) {
56
+ const fixtureDir = process.env.MM_SOURCE_FIXTURE;
57
+ if (fixtureDir) {
58
+ try {
59
+ const stdout = fs.readFileSync(path.join(fixtureDir, fixtureName(cmd, args)), "utf8");
60
+ return Promise.resolve({ ok: true, stdout, stderr: "", code: 0 });
61
+ } catch {
62
+ return Promise.resolve({ ok: false, stdout: "", stderr: "no fixture", code: 127 });
63
+ }
64
+ }
23
65
  return new Promise((resolve) => {
24
66
  const child = execFile(cmd, args, { timeout, maxBuffer }, (err, stdout, stderr) => {
25
67
  resolve({ ok: !err, stdout: stdout || "", stderr: stderr || "", code: err?.code ?? 0 });
@@ -114,7 +114,9 @@ export const supabaseEdgeSource = {
114
114
  integration: true,
115
115
  envTag: "unknown",
116
116
  async available() {
117
- return hasCmd("supabase") || !!process.env.SUPABASE_ACCESS_TOKEN;
117
+ // MM_SOURCE_FIXTURE is a test-only OR (no CLI / token needed for the no-cred
118
+ // fixture flow); the real hasCmd PATH check is unchanged.
119
+ return hasCmd("supabase") || !!process.env.SUPABASE_ACCESS_TOKEN || !!process.env.MM_SOURCE_FIXTURE;
118
120
  },
119
121
  /** Read-only identity probe (MAY spawn). Used by the TUI "test" key + verbose
120
122
  * `mm sources`, NOT by the hot collect path. */
@@ -135,8 +137,10 @@ export const supabaseEdgeSource = {
135
137
  const out = [];
136
138
 
137
139
  // (a) Secret NAMES only (never values). Requires the CLI; skipped gracefully
138
- // when only a token is present (no shell to list through).
139
- if (hasCmd("supabase")) {
140
+ // when only a token is present (no shell to list through). Under the test-only
141
+ // MM_SOURCE_FIXTURE, `run` replays a canned table so this branch is exercised
142
+ // end-to-end without the CLI.
143
+ if (hasCmd("supabase") || process.env.MM_SOURCE_FIXTURE) {
140
144
  const refArg = opts?.supabaseProjectRef ? ["--project-ref", opts.supabaseProjectRef] : [];
141
145
  const secrets = await run("supabase", ["secrets", "list", ...refArg]);
142
146
  if (secrets.ok) {
@@ -44,7 +44,9 @@ export const vercelSource = {
44
44
  integration: true,
45
45
  envTag: "unknown",
46
46
  async available() {
47
- return hasCmd("vercel") || !!process.env.VERCEL_TOKEN;
47
+ // MM_SOURCE_FIXTURE is a test-only OR (lights up the spawn surface with no CLI
48
+ // / token so the no-cred fixture flow runs); the real hasCmd PATH check stays.
49
+ return hasCmd("vercel") || !!process.env.VERCEL_TOKEN || !!process.env.MM_SOURCE_FIXTURE;
48
50
  },
49
51
  async authState() {
50
52
  const r = await run("vercel", ["whoami"]);
@@ -0,0 +1,83 @@
1
+ /* Shared, PURE provenance mapping for the CLI (no React, no I/O). It answers one
2
+ * question for any tracked usage: WHERE was this model discovered? — filesystem,
3
+ * an env var, Vercel, Supabase Edge, an AWS Lambda / Bedrock / Secrets / SSM, a
4
+ * GitHub Actions config, Kubernetes, Helm, a SQL/config table, or added by hand.
5
+ *
6
+ * STORAGE: we DERIVE provenance with zero migration. Two non-secret signals that
7
+ * already reach + persist in the DB drive it:
8
+ * (1) the scheme prefix every integration source stamps into `location_label`
9
+ * (vercel://, supabase-edge://, aws-lambda://, aws-bedrock://, aws-secrets://,
10
+ * aws-ssm://, github-actions://, env://, k8s://, helm://, sql://), and
11
+ * (2) `discovered_by` (manual vs cli) for the prefix-less filesystem/manual case.
12
+ * The scheme prefix + the labels/badges below are NON-SECRET — we never surface a
13
+ * secret VALUE, so this keeps the redaction invariant intact.
14
+ *
15
+ * This file MUST stay byte-for-byte equivalent in keys/labels/badges to the web
16
+ * counterpart apps/web/lib/source-meta.ts — CLI↔web parity is the contract. */
17
+
18
+ import { GLYPH, C } from "./ui.js";
19
+
20
+ // scheme (from location_label `^scheme://`) → canonical source_type.
21
+ // NOTE: the AWS Lambda source emits TWO schemes — `aws-lambda://` for an env var
22
+ // found on a Lambda and `aws-bedrock://` for an enabled Bedrock foundation model —
23
+ // so they map to DISTINCT source types. Likewise aws.js emits aws-secrets:// AND
24
+ // aws-ssm://. An unknown/future scheme falls through to "filesystem" (safe default).
25
+ const SCHEME_TO_SOURCE = {
26
+ vercel: "vercel",
27
+ "supabase-edge": "supabase-edge",
28
+ "aws-lambda": "aws-lambda",
29
+ "aws-bedrock": "aws-bedrock",
30
+ "aws-secrets": "aws-secrets",
31
+ "aws-ssm": "aws-ssm",
32
+ "github-actions": "github-actions",
33
+ env: "env",
34
+ k8s: "k8s",
35
+ helm: "helm",
36
+ sql: "sql",
37
+ };
38
+
39
+ /**
40
+ * Derive a usage's source_type from its persisted location_label + discovered_by.
41
+ * - a known `scheme://` prefix → that scheme's source_type
42
+ * - literal "manual" label OR discovered_by === "manual" → manual
43
+ * - otherwise (plain repo path, discovered_by "cli", or empty) → filesystem
44
+ */
45
+ export function sourceOf(locationLabel, discoveredBy) {
46
+ const label = String(locationLabel || "");
47
+ // Scheme allows digits (k8s://). Unknown scheme → falls through to filesystem.
48
+ const m = /^([a-z][a-z0-9-]*):\/\//.exec(label);
49
+ if (m && SCHEME_TO_SOURCE[m[1]]) return SCHEME_TO_SOURCE[m[1]];
50
+ if (label === "manual" || discoveredBy === "manual") return "manual";
51
+ return "filesystem";
52
+ }
53
+
54
+ // source_type → display metadata. `badge` is ≤8 chars (the inventory SOURCE
55
+ // column width). `glyph`/`ascii` are a unicode/ASCII pair so the column degrades
56
+ // on dumb terminals exactly like the rest of the TUI. Colors reuse the SAME hexes
57
+ // as tui/views/integrations.js KIND_COLOR so the Sources tab + Inventory match.
58
+ export const SOURCE_META = {
59
+ filesystem: { label: "Repo file", badge: "repo", glyph: GLYPH.dot, color: C.FG_DIM },
60
+ env: { label: "Env var", badge: "env", glyph: "=", color: C.FG_DIM },
61
+ vercel: { label: "Vercel env", badge: "vercel", glyph: GLYPH.retiring, color: C.FG_STRONG },
62
+ "supabase-edge": { label: "Supabase Edge", badge: "supabase", glyph: "~", color: "#3ecf8e" },
63
+ "aws-lambda": { label: "AWS Lambda", badge: "aws", glyph: "λ", ascii: "L", color: "#ff9900" },
64
+ "aws-bedrock": { label: "AWS Bedrock", badge: "bedrock", glyph: "λ", ascii: "L", color: "#ff9900" },
65
+ "aws-secrets": { label: "AWS Secrets", badge: "secrets", glyph: "λ", ascii: "L", color: "#ff9900" },
66
+ "aws-ssm": { label: "AWS SSM", badge: "ssm", glyph: "λ", ascii: "L", color: "#ff9900" },
67
+ "github-actions": { label: "GitHub Actions", badge: "github", glyph: "⎇", ascii: "gh", color: "#a78bfa" },
68
+ k8s: { label: "Kubernetes", badge: "k8s", glyph: "⎈", ascii: "k8s", color: "#326ce5" },
69
+ helm: { label: "Helm", badge: "helm", glyph: "⎈", ascii: "helm", color: "#0f6fd1" },
70
+ sql: { label: "SQL/config", badge: "sql", glyph: "▦", ascii: "sql", color: C.FG_DIM },
71
+ manual: { label: "Manual", badge: "manual", glyph: GLYPH.repl === "->" ? "+" : "✎", ascii: "+", color: C.FG_FAINT },
72
+ };
73
+
74
+ // Whether the GLYPH table is the ASCII fallback (MM_ASCII=1 / TERM=dumb). When it
75
+ // is, prefer each meta's `ascii` variant for the non-ASCII-safe unicode glyphs
76
+ // (λ/⎇/⎈/▦/✎). GLYPH.repl is "->" only in the ASCII table, so it's a stable probe.
77
+ const ASCII = GLYPH.repl === "->";
78
+
79
+ /** The terminal-safe glyph for a source_type (ASCII fallback when MM_ASCII). */
80
+ export function sourceGlyph(sourceType) {
81
+ const meta = SOURCE_META[sourceType] || SOURCE_META.filesystem;
82
+ return ASCII && meta.ascii ? meta.ascii : meta.glyph;
83
+ }
@@ -10,6 +10,7 @@ import {
10
10
  relativeTime, envTag, cell, cellE, snippetLines, SPINNER, useTick, useAsync, clampCursor,
11
11
  } from "../ui.js";
12
12
  import { readSnippet, findSourceFile } from "../snippet.js";
13
+ import { sourceOf, SOURCE_META, sourceGlyph } from "../source-meta.js";
13
14
 
14
15
  const ENV_ORDER = ["prod", "staging", "dev", "unknown"];
15
16
 
@@ -158,9 +159,13 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
158
159
  // ----- layout: left list pane + right detail drawer -----
159
160
  const drawerW = 34;
160
161
  const leftWidth = Math.max(24, width - drawerW - 2);
161
- // left columns: glyph(2) + slug + sp(1) + env(7) + sp(1) + project; rail is col 1.
162
+ // left columns: glyph(2) + slug + sp(1) + env(7) + sp(1) + SOURCE + sp(1) + project.
163
+ // SOURCE is a fixed badge column on wide layouts, a 1-char glyph when tight, so it
164
+ // shrinks projW (which keeps a floor) rather than overflowing on narrow terminals.
162
165
  const ENV_W = 7;
163
- const FIXED = 2 + 1 + ENV_W + 1; // glyph+sp, sp, env, sp
166
+ const wideSrc = leftWidth >= 70;
167
+ const SOURCE_W = wideSrc ? 8 : 1; // badge (≤8) vs glyph-only
168
+ const FIXED = 2 + 1 + ENV_W + 1 + SOURCE_W + 1; // glyph+sp, sp, env, sp, source, sp
164
169
  const slugW = Math.max(10, Math.floor((leftWidth - 1 - FIXED) * 0.5));
165
170
  const projW = Math.max(8, leftWidth - 1 - FIXED - slugW);
166
171
 
@@ -187,19 +192,24 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
187
192
  const header = h(
188
193
  Text,
189
194
  { color: C.FG_FAINT },
190
- " " + cell("MODEL", 2 + slugW) + cell("ENV", 1 + ENV_W) + "PROJECT",
195
+ " " + cell("MODEL", 2 + slugW) + cell("ENV", 1 + ENV_W) + cell(wideSrc ? "SRC" : "S", SOURCE_W + 1) + "PROJECT",
191
196
  );
192
197
 
193
198
  const rowNodes = view.map((u, i) => {
194
199
  const isCur = start + i === c;
195
200
  const et = envTag(u.environment);
196
201
  const slug = u.model_display || u.custom_model_name || "?";
202
+ const st = sourceOf(u.location_label, u.discovered_by);
203
+ const meta = SOURCE_META[st];
204
+ const srcText = wideSrc ? meta.badge : sourceGlyph(st);
197
205
  const cells = [
198
206
  { text: `${healthGlyph(u.health)} `, color: healthColor(u.health) },
199
207
  { text: cellE(slug, slugW), color: isCur ? C.FG_STRONG : C.FG, bold: isCur },
200
208
  { text: " ", color: C.FG },
201
209
  { text: cell(et.text, ENV_W), color: et.color },
202
210
  { text: " ", color: C.FG },
211
+ { text: cell(srcText, SOURCE_W), color: meta.color },
212
+ { text: " ", color: C.FG },
203
213
  { text: cellE(projName(u.project_id), projW), color: C.FG_DIM },
204
214
  ];
205
215
  return h(ListRow, { key: u.id, active: isCur, cells, width: leftWidth });
@@ -242,6 +252,14 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
242
252
  h(Text, {}, h(Text, { color: C.FG_DIM }, "project "), h(Text, { color: C.FG }, projName(cur.project_id))),
243
253
  cur.source_repo ? h(Text, {}, h(Text, { color: C.FG_DIM }, "repo "), h(Text, { color: C.FG }, cur.source_repo)) : null,
244
254
  h(Text, {}, h(Text, { color: C.FG_DIM }, "where "), h(Text, { color: C.FG_DIM }, where)),
255
+ (() => {
256
+ // Human SOURCE label (derived from the location_label scheme prefix +
257
+ // discovered_by). The raw scheme stays in `where` (it's the locator);
258
+ // this is the friendly name, and `via <discovered_by>` below reads as a
259
+ // sub-detail. Non-secret — never surfaces a value.
260
+ const meta = SOURCE_META[sourceOf(cur.location_label, cur.discovered_by)];
261
+ return h(Text, {}, h(Text, { color: C.FG_DIM }, "source "), h(Text, { color: meta.color }, meta.label));
262
+ })(),
245
263
  rt && rt.text
246
264
  ? h(Text, {}, h(Text, { color: C.FG_DIM }, "retires "), h(Text, { color: rt.color, bold: rt.bold }, rt.text))
247
265
  : null,