@modelstatus/cli 0.1.86 → 0.1.87
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 +7 -2
- package/src/api.js +41 -11
- package/src/changelog-data.js +14 -0
- package/src/ci.js +8 -4
- package/src/detect/core.js +17 -6
- package/src/fix.js +132 -46
- package/src/index.js +172 -38
- package/src/integrations.js +18 -3
- package/src/registry/fetch.js +16 -1
- package/src/sources/aws-lambda.js +9 -2
- package/src/sources/aws.js +7 -1
- package/src/sources/filesystem.js +0 -0
- package/src/sources/github-actions.js +9 -2
- package/src/sources/helm.js +9 -3
- package/src/sources/index.js +45 -3
- package/src/sources/k8s.js +6 -2
- package/src/sources/shell.js +126 -14
- package/src/sources/sql.js +6 -2
- package/src/sources/supabase-edge.js +19 -5
- package/src/sources/vercel.js +64 -6
- package/src/telemetry.js +21 -0
- package/src/tui/app.js +55 -13
- package/src/tui/game/launch.js +14 -2
- package/src/tui/signin.js +43 -7
- package/src/tui/views/account.js +19 -2
- package/src/tui/views/alerts.js +24 -9
- package/src/updater.js +160 -39
- package/src/upgrade.js +37 -8
package/src/sources/index.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
}
|
package/src/sources/k8s.js
CHANGED
|
@@ -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)
|
|
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
|
};
|
package/src/sources/shell.js
CHANGED
|
@@ -27,23 +27,101 @@ export function fixtureName(cmd, args = []) {
|
|
|
27
27
|
);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
return
|
|
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
|
|
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(
|
|
67
|
-
resolve({
|
|
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 {
|
package/src/sources/sql.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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(
|
|
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;
|
package/src/sources/vercel.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import
|
|
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
|
|
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)
|
|
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
|
-
|
|
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", "ci",
|
|
79
|
+
"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;
|
package/src/tui/app.js
CHANGED
|
@@ -371,9 +371,20 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
|
|
|
371
371
|
}
|
|
372
372
|
|
|
373
373
|
/** Top-level TUI entry — always renders App; auth unlocks tabs in-place. */
|
|
374
|
-
function Bootstrap(props) {
|
|
374
|
+
export function Bootstrap(props) {
|
|
375
375
|
const [apiKey, setApiKey] = React.useState(props.apiKey);
|
|
376
|
-
return h(App, {
|
|
376
|
+
return h(App, {
|
|
377
|
+
...props,
|
|
378
|
+
apiKey,
|
|
379
|
+
onSignedIn: (k) => {
|
|
380
|
+
track("signed_in");
|
|
381
|
+
// Keep the module-level controller in sync: a game round-trip remounts
|
|
382
|
+
// from _opts (captured at runApp time), and a stale null apiKey there
|
|
383
|
+
// would silently sign the session back out after the game.
|
|
384
|
+
if (appController._opts) appController._opts = { ...appController._opts, apiKey: k };
|
|
385
|
+
setApiKey(k);
|
|
386
|
+
},
|
|
387
|
+
});
|
|
377
388
|
}
|
|
378
389
|
|
|
379
390
|
// Module-level controller so a view (the Scan tab) can UNMOUNT the whole Ink
|
|
@@ -383,6 +394,22 @@ function Bootstrap(props) {
|
|
|
383
394
|
export const appController = {
|
|
384
395
|
_instance: null,
|
|
385
396
|
_opts: null,
|
|
397
|
+
// A game handoff is in flight: the coming unmount is NOT a real quit. Set by
|
|
398
|
+
// launch.js BEFORE unmount(); cleared by remount() (or endHandoff() when the
|
|
399
|
+
// game fails to come back). runApp's exit waiter checks it to decide whether
|
|
400
|
+
// to keep the alt screen + promise alive or treat the unmount as a quit.
|
|
401
|
+
_handoff: false,
|
|
402
|
+
_handoffWaiter: null, // runApp's deferred quit-or-rearm check (see runApp)
|
|
403
|
+
/** Mark the next unmount as a game handoff (a remount will follow). */
|
|
404
|
+
beginHandoff() { this._handoff = true; },
|
|
405
|
+
/** End a handoff — normally via remount(); called directly (without a
|
|
406
|
+
* remount) when the game errored, so runApp treats the unmount as a quit. */
|
|
407
|
+
endHandoff() {
|
|
408
|
+
this._handoff = false;
|
|
409
|
+
const w = this._handoffWaiter;
|
|
410
|
+
this._handoffWaiter = null;
|
|
411
|
+
if (w) w();
|
|
412
|
+
},
|
|
386
413
|
/** Tear down the current Ink tree (releases raw mode + stdin listeners). */
|
|
387
414
|
unmount() {
|
|
388
415
|
try { this._instance && this._instance.unmount(); } catch { /* already gone */ }
|
|
@@ -398,6 +425,10 @@ export const appController = {
|
|
|
398
425
|
// right before this, so by now the terminal reports its stable full height.
|
|
399
426
|
try { process.stdout.write("\x1b[2J\x1b[H"); } catch { /* ignore */ }
|
|
400
427
|
this._instance = render(h(Bootstrap, opts));
|
|
428
|
+
// launch.js re-entered the alt screen right before remounting — record it so
|
|
429
|
+
// the final quit's leaveAlt() actually restores the host screen + scrollback.
|
|
430
|
+
this._inAlt = true;
|
|
431
|
+
this.endHandoff(); // flush runApp's waiter → it re-arms on the new instance
|
|
401
432
|
return this._instance;
|
|
402
433
|
},
|
|
403
434
|
};
|
|
@@ -427,20 +458,31 @@ export function runApp(opts) {
|
|
|
427
458
|
appController._opts = opts;
|
|
428
459
|
const app = render(h(Bootstrap, opts));
|
|
429
460
|
appController._instance = app;
|
|
430
|
-
// waitUntilExit resolves when the CURRENT instance unmounts
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
461
|
+
// waitUntilExit resolves when the CURRENT instance unmounts — including the
|
|
462
|
+
// deliberate unmount at the start of a game handoff (launch.js), which nulls
|
|
463
|
+
// _instance and resolves this promise BEFORE the remount happens. So a game
|
|
464
|
+
// launch must not read as a quit: launch.js sets appController._handoff before
|
|
465
|
+
// unmounting, and while it's set we park the decision in _handoffWaiter.
|
|
466
|
+
// remount()/endHandoff() flush the waiter — a remount re-arms on the new
|
|
467
|
+
// instance; a failed handoff (no remount) falls through to the real-quit path.
|
|
434
468
|
return new Promise((resolve) => {
|
|
435
469
|
const arm = (inst) => {
|
|
436
470
|
inst.waitUntilExit().then(() => {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
471
|
+
const settle = () => {
|
|
472
|
+
if (appController._handoff) {
|
|
473
|
+
// Game round-trip in flight — decide after remount()/endHandoff().
|
|
474
|
+
appController._handoffWaiter = settle;
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
// If a remount happened (a new instance is live and differs), keep waiting.
|
|
478
|
+
if (appController._instance && appController._instance !== inst) {
|
|
479
|
+
arm(appController._instance);
|
|
480
|
+
} else {
|
|
481
|
+
leaveAlt(); // real quit → restore the host screen + scrollback
|
|
482
|
+
resolve();
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
settle();
|
|
444
486
|
});
|
|
445
487
|
};
|
|
446
488
|
arm(app);
|
package/src/tui/game/launch.js
CHANGED
|
@@ -26,13 +26,15 @@ export async function playGameInTui({ dir, width, height, initialView = "scan",
|
|
|
26
26
|
if (launching) return;
|
|
27
27
|
launching = true;
|
|
28
28
|
track("game_opened", { game: "donkey_kong", scan_phase: scanPhase });
|
|
29
|
+
let appController = null; // hoisted so the catch below can end a dangling handoff
|
|
29
30
|
try {
|
|
30
|
-
const [{ runGame }, { startScanProcess },
|
|
31
|
+
const [{ runGame }, { startScanProcess }, app, { writeDiskScan, loadRegistry }] = await Promise.all([
|
|
31
32
|
import("./loop.js"),
|
|
32
33
|
import("../../sources/scan-process.js"),
|
|
33
34
|
import("../app.js"),
|
|
34
35
|
import("../scan-stream.js"),
|
|
35
36
|
]);
|
|
37
|
+
appController = app.appController;
|
|
36
38
|
|
|
37
39
|
// (1) Start a TRUE background scan SUBPROCESS over `dir`. It survives the Ink
|
|
38
40
|
// unmount (separate OS process). Pre-fetch + cache the registry so the worker
|
|
@@ -56,6 +58,11 @@ export async function playGameInTui({ dir, width, height, initialView = "scan",
|
|
|
56
58
|
} catch { handle = null; }
|
|
57
59
|
|
|
58
60
|
// (2) Unmount the Ink tree (releases raw mode + stdin). Let teardown settle.
|
|
61
|
+
// beginHandoff FIRST: unmount() resolves ink's exit promise synchronously,
|
|
62
|
+
// and without the flag runApp's exit waiter would read this as a real quit —
|
|
63
|
+
// dropping the alt screen mid-game and resolving runApp early (main() then
|
|
64
|
+
// races the game for stdout). See appController/runApp in ../app.js.
|
|
65
|
+
appController.beginHandoff();
|
|
59
66
|
appController.unmount();
|
|
60
67
|
await new Promise((r) => setImmediate(r));
|
|
61
68
|
|
|
@@ -90,8 +97,13 @@ export async function playGameInTui({ dir, width, height, initialView = "scan",
|
|
|
90
97
|
// useTermDims — we no longer poll for it).
|
|
91
98
|
try { process.stdout.write("\x1b[?1049l\x1b[?1049h\x1b[2J\x1b[H"); } catch { /* ignore */ }
|
|
92
99
|
await new Promise((r) => setImmediate(r));
|
|
93
|
-
appController.remount({ initialView, fresh: false });
|
|
100
|
+
appController.remount({ initialView, fresh: false }); // remount ends the handoff
|
|
94
101
|
} catch (e) {
|
|
102
|
+
// The handoff died without a remount (e.g. the game threw). End it so
|
|
103
|
+
// runApp's parked waiter treats the earlier unmount as a real quit and
|
|
104
|
+
// restores the host screen instead of waiting forever. No-op when the
|
|
105
|
+
// error happened before beginHandoff (tree still mounted → toast shows).
|
|
106
|
+
if (appController) appController.endHandoff();
|
|
95
107
|
if (onError) onError(e); else throw e;
|
|
96
108
|
} finally {
|
|
97
109
|
launching = false;
|