@modelstatus/cli 0.1.86 → 0.1.88

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.
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { hasCmd, run } from "./shell.js";
3
+ import { hasCmd, run, makeCliFailureReporter } from "./shell.js";
4
4
  import { detectInLine } from "../detect/core.js";
5
5
  import { redactValue } from "../redact.js";
6
6
  import { scanConfigEntries, entriesFromKV } from "./configscan.js";
@@ -56,7 +56,10 @@ export function parseGhSecretList(stdout) {
56
56
 
57
57
  /** Line-scan one workflow YAML body → Candidates (model refs in workflow steps).
58
58
  * Pure: takes text + relPath + compiled, returns #L<n>-located candidates with a
59
- * redacted, 160-capped snippet. detectInLine returns a Set, iterated with for…of. */
59
+ * redacted, 160-capped snippet. detectInLine returns a Set, iterated with for…of.
60
+ * Each candidate carries file_path (.github/workflows/<relPath>) — the collectFrom
61
+ * dedupe hint so a workflow the filesystem source ALSO walked isn't double-counted
62
+ * (stripped before candidates leave collectFrom). */
60
63
  export function scanWorkflowText(text, relPath, compiled, env) {
61
64
  const out = [];
62
65
  const seen = new Set();
@@ -74,6 +77,7 @@ export function scanWorkflowText(text, relPath, compiled, env) {
74
77
  source_line: i + 1,
75
78
  environment: env || "unknown",
76
79
  snippet: redactValue(line.trim()).slice(0, 160),
80
+ file_path: path.join(".github", "workflows", relPath),
77
81
  });
78
82
  }
79
83
  });
@@ -112,12 +116,14 @@ export const githubActionsSource = {
112
116
  // the explicit env (overriding guessEnvFrom). Else fall back to the folded opts.env.
113
117
  const ghEnv = opts?.ghEnvironment || "";
114
118
  const envArg = ghEnv ? ["--env", ghEnv] : [];
119
+ const fail = makeCliFailureReporter("github-actions", "gh", opts);
115
120
  const out = [];
116
121
 
117
122
  // (a) VARIABLES — non-secret VALUES, scanned through the redaction funnel. We
118
123
  // ask for JSON so the value column is unambiguous; a model id in a variable
119
124
  // value (e.g. OPENAI_MODEL=gpt-4o) is exactly what we want to catch.
120
125
  const vars = await run("gh", ["variable", "list", ...repoArg, ...envArg, "--json", "name,value"]);
126
+ if (!vars.ok) fail(vars); // logged-out gh etc. must not read as "no variables"
121
127
  if (vars.ok) {
122
128
  for (const { name, value } of parseVariableList(vars.stdout)) {
123
129
  const entries = entriesFromKV(name, value, `github-actions://${repoTag}/variables#${name}`, ghEnv || repoTag);
@@ -127,6 +133,7 @@ export const githubActionsSource = {
127
133
 
128
134
  // (b) Secret NAMES only (never a value — there is no value API anyway).
129
135
  const secrets = await run("gh", ["secret", "list", ...repoArg, ...envArg]);
136
+ if (!secrets.ok) fail(secrets);
130
137
  if (secrets.ok) {
131
138
  for (const name of parseGhSecretList(secrets.stdout)) {
132
139
  const entries = entriesFromKV(name, "", `github-actions://${repoTag}/secrets#${name}`, ghEnv || repoTag);
@@ -1,4 +1,4 @@
1
- import { hasCmd, run } from "./shell.js";
1
+ import { hasCmd, run, makeCliFailureReporter } from "./shell.js";
2
2
  import { scanConfigEntries, flattenConfig } from "./configscan.js";
3
3
 
4
4
  /** Pure parser: `helm list -A -o json` → [{ name, namespace }]. */
@@ -21,12 +21,18 @@ export const helmSource = {
21
21
  async available() {
22
22
  return hasCmd("helm");
23
23
  },
24
- async collect(_opts, compiled) {
24
+ async collect(opts, compiled) {
25
+ // A dead cluster / broken helm must be LOUD, never a silent "no releases".
26
+ const fail = makeCliFailureReporter("helm", "helm", opts);
25
27
  const out = [];
26
28
  const list = await run("helm", ["list", "-A", "-o", "json"]);
29
+ if (!list.ok) fail(list);
27
30
  for (const r of parseHelmList(list.ok ? list.stdout : "[]")) {
28
31
  const v = await run("helm", ["get", "values", r.name, "-n", r.namespace, "-o", "json"]);
29
- if (!v.ok) continue;
32
+ if (!v.ok) {
33
+ fail(v); // per-release failures collapse to one line per failure mode
34
+ continue;
35
+ }
30
36
  let vals;
31
37
  try {
32
38
  vals = JSON.parse(v.stdout);
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import { compilePatterns } from "../detect/core.js";
2
3
  import { filesystemSource } from "./filesystem.js";
3
4
  import { envSource } from "./env.js";
@@ -53,6 +54,13 @@ export function getSource(id) {
53
54
  return SOURCES.find((s) => s.id === id) ?? null;
54
55
  }
55
56
 
57
+ /** The requested ids that match NO registered source (typos). Callers treat
58
+ * these as an invocation error — `mm ci --sources filesysten` must fail loudly,
59
+ * never scan nothing and report a green build. */
60
+ export function unknownSourceIds(ids = []) {
61
+ return (ids || []).filter((id) => !getSource(id));
62
+ }
63
+
56
64
  /** A live integration runs only when enabled in integrations.json OR explicitly
57
65
  * named (so a one-off `--sources vercel` works without toggling it on first).
58
66
  * The 6 original sources omit `integration` → this never gates them. */
@@ -93,15 +101,31 @@ export async function availability(sourceIds, opts = {}, explicit = new Set()) {
93
101
  /** Run a set of sources, returning a flat, de-duplicated Candidate[]. Stays on
94
102
  * the cheap path: uses available() (PATH check), never authState() (spawn).
95
103
  * `explicit` is the set of ids the caller named verbatim — naming a live
96
- * integration there overrides the enabled-gate. */
104
+ * integration there overrides the enabled-gate.
105
+ *
106
+ * The returned array also carries a non-enumerable `failures` property:
107
+ * [{ source, cmd, code, signal, message }] — one entry per vendor-CLI run that
108
+ * failed during collection (expired creds, unreachable cluster, timeout kill).
109
+ * Non-enumerable so every existing Candidate[] consumer (map/filter/JSON) is
110
+ * byte-identical; callers that care (scan/status/ci) read `result.failures`
111
+ * so an auth failure can never masquerade as a clean scan. */
97
112
  export async function collectFrom(sourceIds, opts, patterns, explicit = new Set(), onProgress = null) {
98
113
  const compiled = compilePatterns(patterns);
99
114
  const ids = sourceIds && sourceIds.length ? sourceIds : ["filesystem"];
100
115
  const seen = new Set();
116
+ const seenFileRefs = new Set();
101
117
  const out = [];
118
+ const failures = [];
119
+ const root = path.resolve(opts?.root || ".");
102
120
  for (const id of ids) {
103
121
  const src = getSource(id);
104
- if (!src) continue;
122
+ if (!src) {
123
+ // Belt-and-braces: callers validate ids up front via unknownSourceIds(),
124
+ // but a typo that still reaches the collect layer must never be a silent
125
+ // no-op scan (the old behavior turned `--sources filesysten` green).
126
+ process.stderr.write(`! unknown source "${id}" — skipped\n`);
127
+ continue;
128
+ }
105
129
  // Live-integration gate: skip a disabled integration unless explicitly named.
106
130
  if (!integrationAllowed(src, id, explicit)) continue;
107
131
  if (!(await src.available(opts))) continue;
@@ -110,13 +134,31 @@ export async function collectFrom(sourceIds, opts, patterns, explicit = new Set(
110
134
  // and Vercel's authoritative deploy target still wins inside its own collect.
111
135
  // onProgress (optional) lets a caller render a live file counter — only the
112
136
  // filesystem source emits progress; the rest ignore the extra opt.
113
- const srcOpts = src.integration ? { ...opts, env: opts.env || getEnvTag(id), onProgress } : { ...opts, onProgress };
137
+ // onSourceFailure is the per-source status channel each CLI-backed source
138
+ // reports failed vendor runs through (see makeCliFailureReporter in shell.js).
139
+ const srcOpts = {
140
+ ...(src.integration ? { ...opts, env: opts.env || getEnvTag(id) } : opts),
141
+ onProgress,
142
+ onSourceFailure: (f) => failures.push(f),
143
+ };
114
144
  for (const c of await src.collect(srcOpts, compiled)) {
115
145
  const key = `${c.model_string}|${c.location_label}`;
116
146
  if (seen.has(key)) continue;
117
147
  seen.add(key);
148
+ // Two sources can cover the SAME file line under different locator schemes
149
+ // (filesystem "supabase/functions/x.ts:1" vs supabase-edge
150
+ // "supabase-edge://…/x.ts#L1"; likewise .github/workflows). Dedupe by
151
+ // (model, resolved file, line) — the first source to emit the ref wins.
152
+ const file = c.file_path || (c.source_type === "file" ? c.source_path : null);
153
+ if (file && c.source_line != null) {
154
+ const fkey = `${c.model_string}|${path.resolve(root, file)}:${c.source_line}`;
155
+ if (seenFileRefs.has(fkey)) continue;
156
+ seenFileRefs.add(fkey);
157
+ }
158
+ delete c.file_path; // internal dedupe hint only — not part of the Candidate shape
118
159
  out.push(c);
119
160
  }
120
161
  }
162
+ Object.defineProperty(out, "failures", { value: failures, enumerable: false });
121
163
  return out;
122
164
  }
@@ -1,4 +1,4 @@
1
- import { hasCmd, run } from "./shell.js";
1
+ import { hasCmd, run, makeCliFailureReporter } from "./shell.js";
2
2
  import { scanConfigEntries, entriesFromKV } from "./configscan.js";
3
3
 
4
4
  const b64decode = (s) => {
@@ -45,7 +45,11 @@ export const k8sSource = {
45
45
  const ctx = opts?.kubeContext ? ["--context", opts.kubeContext] : [];
46
46
  const nsArgs = opts?.namespace ? ["-n", opts.namespace] : ["-A"];
47
47
  const res = await run("kubectl", ["get", "secrets,configmaps", ...nsArgs, "-o", "json", ...ctx]);
48
- if (!res.ok) return [];
48
+ if (!res.ok) {
49
+ // Unreachable cluster / expired context must be LOUD, never "no configmaps".
50
+ makeCliFailureReporter("k8s", "kubectl", opts)(res);
51
+ return [];
52
+ }
49
53
  return scanConfigEntries(extractK8sEntries(res.stdout), compiled, { sourceType: "k8s" });
50
54
  },
51
55
  };
@@ -27,23 +27,101 @@ export function fixtureName(cmd, args = []) {
27
27
  );
28
28
  }
29
29
 
30
- /** Is `name` an executable on PATH? Pure PATH scan no shell, no spawn. */
31
- export function hasCmd(name) {
32
- const PATH = process.env.PATH || "";
33
- for (const dir of PATH.split(path.delimiter)) {
30
+ /** Windows executable extensions when PATHEXT is unset (matches cmd.exe's own default core). */
31
+ const WIN_DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
32
+
33
+ /** Resolve `name` to its full path on PATH, or null. POSIX: the original
34
+ * accessSync(X_OK) probe, unchanged. win32: where.exe semantics — executables
35
+ * carry extensions (aws.exe, vercel.cmd), so probe name+ext for each PATHEXT
36
+ * extension. The BARE extensionless name is deliberately NOT probed on win32:
37
+ * Windows can't spawn it (the npm sh-shim named "vercel" must not shadow
38
+ * "vercel.cmd"), and X_OK degrades to F_OK there anyway — plain existence is
39
+ * the probe. `platform` / `env` / `probe` are injectable so the win32 logic is
40
+ * unit-testable off-Windows. */
41
+ export function whichCmd(name, { platform = process.platform, env = process.env, probe } = {}) {
42
+ const win = platform === "win32";
43
+ const joiner = win ? path.win32 : path;
44
+ const canUse =
45
+ probe ||
46
+ ((p) => {
47
+ try {
48
+ fs.accessSync(p, win ? fs.constants.F_OK : fs.constants.X_OK);
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ });
54
+ let candidates = [name];
55
+ if (win) {
56
+ const exts = (env.PATHEXT || WIN_DEFAULT_PATHEXT)
57
+ .split(";")
58
+ .map((e) => e.trim())
59
+ .filter((e) => e.startsWith("."));
60
+ const lower = name.toLowerCase();
61
+ const alreadyHasExt = exts.some((e) => lower.endsWith(e.toLowerCase()));
62
+ candidates = alreadyHasExt ? [name] : exts.map((e) => name + e);
63
+ }
64
+ for (const dir of String(env.PATH || "").split(win ? ";" : path.delimiter)) {
34
65
  if (!dir) continue;
35
- try {
36
- fs.accessSync(path.join(dir, name), fs.constants.X_OK);
37
- return true;
38
- } catch {
39
- /* keep looking */
66
+ for (const cand of candidates) {
67
+ const full = joiner.join(dir, cand);
68
+ if (canUse(full)) return full;
40
69
  }
41
70
  }
42
- return false;
71
+ return null;
72
+ }
73
+
74
+ /** Is `name` an executable on PATH? Pure PATH scan — no shell, no spawn. */
75
+ export function hasCmd(name) {
76
+ return whichCmd(name) != null;
77
+ }
78
+
79
+ /** First meaningful line of a failed run's output (stderr → stdout → spawn error). */
80
+ function firstFailLine(res) {
81
+ for (const blob of [res?.stderr, res?.stdout, res?.errMessage]) {
82
+ const line = String(blob || "").split(/\r?\n/).find((l) => l.trim());
83
+ if (line) return line.trim().slice(0, 200);
84
+ }
85
+ return "no output";
86
+ }
87
+
88
+ /** How the run died, for humans: "exit 1", "signal SIGTERM" (timeout kill), or a
89
+ * spawn-error code like "ENOENT". */
90
+ function exitLabel(res) {
91
+ if (res?.signal) return `signal ${res.signal}`;
92
+ if (typeof res?.code === "number") return `exit ${res.code}`;
93
+ return String(res?.code || "spawn error");
94
+ }
95
+
96
+ /** Per-source vendor-CLI failure reporter: a failed spawn/exit (expired creds,
97
+ * unreachable cluster, unlinked project, timeout/maxBuffer kill) must NEVER read
98
+ * as a clean scan. Writes ONE terse stderr line per distinct failure mode —
99
+ * "! vercel source: vercel CLI failed (exit 1): <first stderr line>" — so a
100
+ * per-item loop (one aws call per secret) can't flood, and forwards EVERY
101
+ * failure to opts.onSourceFailure (collectFrom's per-source status channel).
102
+ * A missing test fixture (run() under MM_SOURCE_FIXTURE) stays quiet — it
103
+ * deliberately degrades like a missing CLI, which available() would have gated
104
+ * out before any spawn. */
105
+ export function makeCliFailureReporter(sourceId, bin, opts) {
106
+ const seen = new Set();
107
+ return (res) => {
108
+ if (process.env.MM_SOURCE_FIXTURE && res?.stderr === "no fixture") return;
109
+ const label = exitLabel(res);
110
+ const message = firstFailLine(res);
111
+ opts?.onSourceFailure?.({ source: sourceId, cmd: bin, code: res?.code ?? null, signal: res?.signal ?? null, message });
112
+ if (seen.has(label)) return;
113
+ seen.add(label);
114
+ process.stderr.write(`! ${sourceId} source: ${bin} CLI failed (${label}): ${message}\n`);
115
+ };
43
116
  }
44
117
 
45
118
  /** Run a command WITHOUT a shell (execFile → no injection). Read-only by
46
- * convention. Never throws; resolves { ok, stdout, stderr, code }.
119
+ * convention. Never throws; resolves { ok, stdout, stderr, code, signal?,
120
+ * errMessage? } — code is the exit code (a string like "ENOENT" for spawn
121
+ * errors, null when killed by signal), signal/errMessage carry the how/why of
122
+ * a non-exit death so failures can be reported precisely. `cwd` (optional)
123
+ * runs the tool in a specific directory (vercel resolves its project link
124
+ * from the cwd, so the scan root — not our process cwd — must be it).
47
125
  *
48
126
  * TEST-ONLY: when process.env.MM_SOURCE_FIXTURE is set (a directory of canned
49
127
  * vendor outputs), this REPLAYS a fixture file instead of spawning — so the full
@@ -52,7 +130,7 @@ export function hasCmd(name) {
52
130
  * when the env var is unset (prod default), and a missing fixture degrades exactly
53
131
  * like a missing CLI ({ok:false, code:127}). It is the symmetric twin of the
54
132
  * LLMSTATUS_INTEGRATIONS_FILE / LLMSTATUS_IGNORE_FILE test redirects. */
55
- export function run(cmd, args, { timeout = 25000, input, maxBuffer = 48 * 1024 * 1024 } = {}) {
133
+ export function run(cmd, args, { timeout = 25000, input, maxBuffer = 48 * 1024 * 1024, cwd } = {}) {
56
134
  const fixtureDir = process.env.MM_SOURCE_FIXTURE;
57
135
  if (fixtureDir) {
58
136
  try {
@@ -62,9 +140,43 @@ export function run(cmd, args, { timeout = 25000, input, maxBuffer = 48 * 1024 *
62
140
  return Promise.resolve({ ok: false, stdout: "", stderr: "no fixture", code: 127 });
63
141
  }
64
142
  }
143
+ // win32: execFile can spawn .exe/.com directly but NOT .cmd/.bat shims (EINVAL
144
+ // since the CVE-2024-27980 hardening). Resolve the PATH match up front: real
145
+ // executables get their absolute path; .cmd/.bat shims (npm-installed vercel/
146
+ // supabase) go through cmd.exe via shell:true with the path pre-quoted. Args
147
+ // here are programmatic (subcommands, flags, slugs) — anything shell-unsafe is
148
+ // refused loudly rather than handed to cmd.exe unescaped.
149
+ let file = cmd;
150
+ let shell = false;
151
+ if (process.platform === "win32" && !/[\\/]/.test(cmd)) {
152
+ const resolved = whichCmd(cmd);
153
+ if (resolved) {
154
+ const ext = path.win32.extname(resolved).toLowerCase();
155
+ if (ext === ".cmd" || ext === ".bat") {
156
+ const unsafe = (args || []).find((a) => !/^[A-Za-z0-9_\-./:=@,+]*$/.test(String(a)));
157
+ if (unsafe !== undefined) {
158
+ return Promise.resolve({
159
+ ok: false, stdout: "", code: "EINVAL", signal: null,
160
+ stderr: `refusing to pass a shell-unsafe argument to a .cmd shim: ${String(unsafe).slice(0, 40)}`,
161
+ });
162
+ }
163
+ file = `"${resolved}"`;
164
+ shell = true;
165
+ } else {
166
+ file = resolved;
167
+ }
168
+ }
169
+ }
65
170
  return new Promise((resolve) => {
66
- const child = execFile(cmd, args, { timeout, maxBuffer }, (err, stdout, stderr) => {
67
- resolve({ ok: !err, stdout: stdout || "", stderr: stderr || "", code: err?.code ?? 0 });
171
+ const child = execFile(file, args, { timeout, maxBuffer, cwd, shell }, (err, stdout, stderr) => {
172
+ resolve({
173
+ ok: !err,
174
+ stdout: stdout || "",
175
+ stderr: stderr || "",
176
+ code: err ? (err.code ?? null) : 0,
177
+ signal: err?.signal ?? null,
178
+ errMessage: err ? String(err.message || "").split("\n")[0] : undefined,
179
+ });
68
180
  });
69
181
  if (input != null) {
70
182
  try {
@@ -1,4 +1,4 @@
1
- import { hasCmd, run } from "./shell.js";
1
+ import { hasCmd, run, makeCliFailureReporter } from "./shell.js";
2
2
  import { detectInLine } from "../detect/core.js";
3
3
  import { redactValue } from "../redact.js";
4
4
 
@@ -21,7 +21,11 @@ export const sqlSource = {
21
21
  // Force read-only so a bad table/DSN can never mutate anything.
22
22
  const sql = `SET default_transaction_read_only=on; SELECT * FROM ${table};`;
23
23
  const res = await run("psql", [opts.db, "-A", "-t", "-c", sql]);
24
- if (!res.ok) return [];
24
+ if (!res.ok) {
25
+ // A bad DSN / unreachable DB must be LOUD, never a silent "no rows".
26
+ makeCliFailureReporter("sql", "psql", opts)(res);
27
+ return [];
28
+ }
25
29
 
26
30
  const out = [];
27
31
  const seen = new Set();
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { hasCmd, run } from "./shell.js";
3
+ import { hasCmd, run, makeCliFailureReporter } from "./shell.js";
4
4
  import { detectInLine } from "../detect/core.js";
5
5
  import { redactValue } from "../redact.js";
6
6
  import { scanConfigEntries, entriesFromKV } from "./configscan.js";
@@ -68,8 +68,11 @@ export function parseFunctionList(stdout) {
68
68
  * locatorBase + compiled (+ optional env override), returns candidates with a
69
69
  * #L<n> locator and a redacted, 160-capped snippet. detectInLine returns a Set,
70
70
  * iterated with for…of (not .length). The snippet is redactValue'd FIRST then
71
- * sliced so a secret straddling the 160-char boundary can't leak a half-token. */
72
- export function scanFunctionBody(text, locatorBase, compiled, env) {
71
+ * sliced so a secret straddling the 160-char boundary can't leak a half-token.
72
+ * `filePath` (optional, root-relative) stamps file_path on each candidate — the
73
+ * collectFrom dedupe hint so a body the filesystem source ALSO walked isn't
74
+ * double-counted (stripped before candidates leave collectFrom). */
75
+ export function scanFunctionBody(text, locatorBase, compiled, env, filePath) {
73
76
  const out = [];
74
77
  const seen = new Set();
75
78
  String(text || "")
@@ -88,6 +91,7 @@ export function scanFunctionBody(text, locatorBase, compiled, env) {
88
91
  source_line: i + 1,
89
92
  environment: env || "unknown",
90
93
  snippet: redactValue(line.trim()).slice(0, 160),
94
+ ...(filePath ? { file_path: filePath } : {}),
91
95
  });
92
96
  }
93
97
  });
@@ -134,6 +138,7 @@ export const supabaseEdgeSource = {
134
138
  },
135
139
  async collect(opts, compiled) {
136
140
  const ref = opts?.supabaseProjectRef || "local";
141
+ const fail = makeCliFailureReporter("supabase-edge", "supabase", opts);
137
142
  const out = [];
138
143
 
139
144
  // (a) Secret NAMES only (never values). Requires the CLI; skipped gracefully
@@ -143,7 +148,8 @@ export const supabaseEdgeSource = {
143
148
  if (hasCmd("supabase") || process.env.MM_SOURCE_FIXTURE) {
144
149
  const refArg = opts?.supabaseProjectRef ? ["--project-ref", opts.supabaseProjectRef] : [];
145
150
  const secrets = await run("supabase", ["secrets", "list", ...refArg]);
146
- if (secrets.ok) {
151
+ if (!secrets.ok) fail(secrets); // logged-out CLI etc. must not read as "no secrets"
152
+ else {
147
153
  for (const name of parseSupabaseSecrets(secrets.stdout)) {
148
154
  // NAME as the entry key, EMPTY value → detectInLine runs on the name
149
155
  // only; there is no value to leak.
@@ -179,7 +185,15 @@ export const supabaseEdgeSource = {
179
185
  } catch {
180
186
  continue;
181
187
  }
182
- out.push(...scanFunctionBody(text, `supabase-edge://${ref}/${slug}/${f}`, compiled, opts?.env));
188
+ out.push(
189
+ ...scanFunctionBody(
190
+ text,
191
+ `supabase-edge://${ref}/${slug}/${f}`,
192
+ compiled,
193
+ opts?.env,
194
+ path.join("supabase", "functions", slug, f), // collectFrom dedupe hint vs the filesystem walk
195
+ ),
196
+ );
183
197
  }
184
198
  }
185
199
  return out;
@@ -1,4 +1,6 @@
1
- import { hasCmd, run } from "./shell.js";
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { hasCmd, run, makeCliFailureReporter } from "./shell.js";
2
4
  import { scanConfigEntries, entriesFromKV } from "./configscan.js";
3
5
 
4
6
  /** Deployment TARGET → environment. The target is AUTHORITATIVE (Vercel tells us
@@ -32,11 +34,35 @@ export function parseVercelEnvLs(stdout) {
32
34
  return rows;
33
35
  }
34
36
 
37
+ /** The project this directory is LINKED to. `vercel link` writes
38
+ * .vercel/project.json; newer CLIs record projectName there. null when
39
+ * unlinked or the name isn't recorded. */
40
+ export function readLinkedProject(root) {
41
+ try {
42
+ const j = JSON.parse(fs.readFileSync(path.join(root || ".", ".vercel", "project.json"), "utf8"));
43
+ return j?.projectName || null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /** The "> Environment Variables found for <team>/<project> [123ms]" banner names
50
+ * the project the CLI ACTUALLY queried — parse it so provenance is real, never
51
+ * whatever name a flag claimed. */
52
+ export function parseProjectFromBanner(stdout) {
53
+ const m = /environment variables found for\s+(\S+)/i.exec(String(stdout || ""));
54
+ if (!m) return null;
55
+ const who = m[1].replace(/[.,;]+$/, "");
56
+ return (who.includes("/") ? who.split("/").pop() : who) || null;
57
+ }
58
+
35
59
  /** Vercel project env. LIVE integration: gated on the enabled toggle AND the
36
60
  * `vercel` CLI being present (or a VERCEL_TOKEN in the env — the CLI auto-picks
37
61
  * it up for non-interactive auth). Lists env-var NAMES only via `vercel env ls`
38
62
  * (NEVER `vercel env pull` — that would write plaintext secrets to disk).
39
- * opts: { vercelProject, vercelTeam }. */
63
+ * opts: { root, vercelProject, vercelTeam }. The CLI is run in the scan root
64
+ * and scans the project LINKED there; --vercel-project asserts (not selects)
65
+ * that project — a mismatch errors instead of mislabeling provenance. */
40
66
  export const vercelSource = {
41
67
  id: "vercel",
42
68
  label: "Vercel project env",
@@ -55,20 +81,52 @@ export const vercelSource = {
55
81
  },
56
82
  async collect(opts, compiled) {
57
83
  const scope = opts?.vercelTeam ? ["--scope", opts.vercelTeam] : [];
58
- const project = opts?.vercelProject || "default";
84
+ const requested = opts?.vercelProject || null;
85
+ const fail = makeCliFailureReporter("vercel", "vercel", opts);
86
+ // `vercel env ls` has NO project flag: the CLI queries whatever project the
87
+ // cwd is LINKED to. So we run it in the scan root and derive the REAL
88
+ // project from the link file / output banner. --vercel-project is honored
89
+ // only when it matches that real project — a mismatch (or an unverifiable
90
+ // project) is an ERROR, never a silently mislabeled result.
91
+ let project = readLinkedProject(opts?.root);
92
+ const assertRequestedMatches = () => {
93
+ if (requested && project && project !== requested) {
94
+ throw new Error(
95
+ `--vercel-project "${requested}" doesn't match the project this directory is linked to ("${project}") — ` +
96
+ `the vercel CLI only scans the linked project. run \`vercel link --project ${requested}\` in the scan root first`,
97
+ );
98
+ }
99
+ };
100
+ assertRequestedMatches();
59
101
  const out = [];
102
+ let listed = false;
60
103
  // One pull per target so the env is authoritative + per-row correct. We ask
61
104
  // for NAMES only; `vercel env ls <target>` prints the table without values.
62
105
  for (const target of ["production", "preview", "development"]) {
63
- const r = await run("vercel", ["env", "ls", target, ...scope]);
64
- if (!r.ok) continue;
106
+ const r = await run("vercel", ["env", "ls", target, ...scope], { cwd: opts?.root });
107
+ if (!r.ok) {
108
+ fail(r);
109
+ continue;
110
+ }
111
+ listed = true;
112
+ project = project || parseProjectFromBanner(r.stdout);
113
+ assertRequestedMatches();
65
114
  for (const { name } of parseVercelEnvLs(r.stdout)) {
66
115
  // NAME-only entry: empty value → detectInLine runs on the name only, no
67
116
  // value to leak. The TARGET is the authoritative env (overrides everything).
68
- const entries = entriesFromKV(name, "", `vercel://${project}/${target}#${name}`, target);
117
+ // Label fallback: `requested` may appear here before the banner confirms
118
+ // it — safe, because the post-loop check below throws (discarding these
119
+ // rows) whenever a requested project was never verified.
120
+ const entries = entriesFromKV(name, "", `vercel://${project || requested || "default"}/${target}#${name}`, target);
69
121
  out.push(...scanConfigEntries(entries, compiled, { sourceType: "vercel", env: TARGET_ENV[target] }));
70
122
  }
71
123
  }
124
+ if (requested && listed && !project) {
125
+ throw new Error(
126
+ `--vercel-project "${requested}" can't be honored — couldn't confirm which project the vercel CLI queried ` +
127
+ `(no .vercel/project.json link, no project in the CLI output). run \`vercel link --project ${requested}\` in the scan root first`,
128
+ );
129
+ }
72
130
  return out;
73
131
  },
74
132
  };
package/src/telemetry.js CHANGED
@@ -69,6 +69,27 @@ export function analyticsState() {
69
69
  return { on: !!POSTHOG_KEY, reason: POSTHOG_KEY ? "" : "no key baked in" };
70
70
  }
71
71
 
72
+ /** The vetted command words the `cli_command` event may ever carry. Anything
73
+ * else in argv[0] — a directory path (`mm ~/client-repo`), a typo, a file —
74
+ * must NEVER reach PostHog: the privacy promise ("never code, model names, or
75
+ * paths") includes the path the user points mm at. Keep in sync with the
76
+ * dispatch in index.js main(). */
77
+ const KNOWN_COMMANDS = new Set([
78
+ "login", "signup", "logout", "config", "analytics", "scan", "fix", "prompt",
79
+ "ci", "status", "sources", "integrations", "clear", "play", "upgrade", "tui",
80
+ "update", "version", "help",
81
+ ]);
82
+
83
+ /** Sanitize the raw first positional into a telemetry-safe command word:
84
+ * empty → "tui" (bare `mm`), a known command → itself, an existing directory
85
+ * (`mm <dir>` launches the TUI on it) → "dir", anything else → "unknown".
86
+ * `isDir` is injected so this stays pure/testable. */
87
+ export function telemetryCommand(cmd, isDir = () => false) {
88
+ if (!cmd) return "tui";
89
+ if (KNOWN_COMMANDS.has(cmd)) return cmd;
90
+ try { return isDir(cmd) ? "dir" : "unknown"; } catch { return "unknown"; }
91
+ }
92
+
72
93
  /** Fire-and-forget capture. Never throws, never blocks the CLI meaningfully. */
73
94
  export function track(event, properties = {}) {
74
95
  if (!enabled()) return;