@modelstatus/cli 0.1.33 → 0.1.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api.js +6 -0
- package/src/ci.js +2 -2
- package/src/index.js +106 -12
- package/src/integrations.js +121 -0
- package/src/sources/aws-lambda.js +95 -0
- package/src/sources/configscan.js +8 -2
- package/src/sources/filesystem.js +0 -0
- package/src/sources/github-actions.js +156 -0
- package/src/sources/index.js +70 -13
- package/src/sources/scan-runner.js +127 -0
- package/src/sources/supabase-edge.js +183 -0
- package/src/sources/supabase.js +5 -0
- package/src/sources/vercel.js +74 -0
- package/src/tui/app.js +5 -1
- package/src/tui/game/DkGame.js +187 -0
- package/src/tui/game/dk-core.js +413 -0
- package/src/tui/game/dk-render.js +114 -0
- package/src/tui/views/add.js +1 -1
- package/src/tui/views/integrations.js +224 -0
- package/src/tui/views/inventory.js +36 -3
- package/src/tui/views/scan.js +103 -7
package/src/sources/index.js
CHANGED
|
@@ -5,22 +5,43 @@ import { awsSecretsSource } from "./aws.js";
|
|
|
5
5
|
import { k8sSource } from "./k8s.js";
|
|
6
6
|
import { helmSource } from "./helm.js";
|
|
7
7
|
import { sqlSource } from "./sql.js";
|
|
8
|
+
import { awsLambdaSource } from "./aws-lambda.js";
|
|
9
|
+
import { vercelSource } from "./vercel.js";
|
|
10
|
+
import { supabaseEdgeSource } from "./supabase.js";
|
|
11
|
+
import { githubActionsSource } from "./github-actions.js";
|
|
12
|
+
import { enabledIds, getEnvTag } from "../integrations.js";
|
|
8
13
|
|
|
9
14
|
/**
|
|
10
15
|
* A Source discovers AI-model usage from one place and emits normalized Candidates:
|
|
11
16
|
* { model_string, source_type, location_label, source_path, source_line?, environment, snippet }
|
|
12
17
|
*
|
|
13
|
-
* Interface
|
|
18
|
+
* Interface (only id/label/available/collect are REQUIRED — the rest are OPTIONAL
|
|
19
|
+
* and read via ?? / ?., so the 6 original sources keep working untouched):
|
|
14
20
|
* id: string
|
|
15
21
|
* label: string
|
|
16
|
-
* available(opts): Promise<boolean> // is the backing tool/creds present?
|
|
22
|
+
* available(opts): Promise<boolean> // is the backing tool/creds present? (cheap PATH check — no spawn)
|
|
17
23
|
* collect(opts, compiled): Promise<Candidate[]>
|
|
24
|
+
* kind?: "local" | "cli" | "mcp" | "api" // descriptive grouping only; NEVER gates execution (default "local")
|
|
25
|
+
* authState?(opts): Promise<{ connected, mode, account?, reason? }>
|
|
26
|
+
* // OPTIONAL richer read-only identity probe (MAY spawn); used by the
|
|
27
|
+
* // TUI "test" key + verbose `mm sources`, NOT by the hot collect path
|
|
28
|
+
* envTag?: "prod"|"staging"|"dev"|"unknown" // a declared default env fallback (the authoritative per-source
|
|
29
|
+
* // envTag comes from integrations.json and is folded into opts.env)
|
|
30
|
+
* integration?: true // marks a LIVE integration subject to the enabled-gate (the 6
|
|
31
|
+
* // original sources omit it → unaffected)
|
|
18
32
|
*
|
|
19
|
-
* The secrets/config sources (env, aws-secrets, k8s, helm, sql)
|
|
20
|
-
* an ALREADY-AUTHENTICATED CLI, run read-only, scan
|
|
21
|
-
* snippet — only non-sensitive model ids ever leave the
|
|
33
|
+
* The secrets/config sources (env, aws-secrets, k8s, helm, sql) and the 4 live
|
|
34
|
+
* integrations each shell out to an ALREADY-AUTHENTICATED CLI, run read-only, scan
|
|
35
|
+
* locally, and REDACT every snippet — only non-sensitive model ids ever leave the
|
|
36
|
+
* machine, never secrets. Secret-NAME-only surfaces (gh/supabase secret lists,
|
|
37
|
+
* vercel env names) pass the NAME with an EMPTY value so no value can leak.
|
|
22
38
|
*/
|
|
23
|
-
const SOURCES = [
|
|
39
|
+
const SOURCES = [
|
|
40
|
+
filesystemSource, envSource, awsSecretsSource, k8sSource, helmSource, sqlSource,
|
|
41
|
+
// Live integrations (integration:true) — only run when toggled on in
|
|
42
|
+
// integrations.json OR named explicitly in --sources (see the gate below).
|
|
43
|
+
awsLambdaSource, vercelSource, supabaseEdgeSource, githubActionsSource,
|
|
44
|
+
];
|
|
24
45
|
|
|
25
46
|
export const ALL_SOURCE_IDS = SOURCES.map((s) => s.id);
|
|
26
47
|
|
|
@@ -32,27 +53,63 @@ export function getSource(id) {
|
|
|
32
53
|
return SOURCES.find((s) => s.id === id) ?? null;
|
|
33
54
|
}
|
|
34
55
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
56
|
+
/** A live integration runs only when enabled in integrations.json OR explicitly
|
|
57
|
+
* named (so a one-off `--sources vercel` works without toggling it on first).
|
|
58
|
+
* The 6 original sources omit `integration` → this never gates them. */
|
|
59
|
+
function integrationAllowed(src, id, explicit) {
|
|
60
|
+
if (!src?.integration) return true; // not an integration → always allowed
|
|
61
|
+
return enabledIds().has(id) || explicit.has(id);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Which of the requested sources are usable right now (tool/creds present).
|
|
65
|
+
* For a live integration, `available` is reported as `enabled && hasCmd` UNLESS
|
|
66
|
+
* it was explicitly requested by name (then just hasCmd). Each row also carries
|
|
67
|
+
* `enabled` + `integration` so cmdSources / the TUI can render the toggle.
|
|
68
|
+
* `explicit` is the set of ids the caller named verbatim. */
|
|
69
|
+
export async function availability(sourceIds, opts = {}, explicit = new Set()) {
|
|
37
70
|
const ids = sourceIds && sourceIds.length ? sourceIds : ["filesystem"];
|
|
71
|
+
const enabled = enabledIds();
|
|
38
72
|
const report = [];
|
|
39
73
|
for (const id of ids) {
|
|
40
74
|
const src = getSource(id);
|
|
41
|
-
|
|
75
|
+
const hasTool = src ? await src.available(opts) : false;
|
|
76
|
+
const isIntegration = !!src?.integration;
|
|
77
|
+
const isEnabled = enabled.has(id);
|
|
78
|
+
// Integrations only count as "available" when enabled (unless explicitly asked).
|
|
79
|
+
const available = isIntegration && !explicit.has(id) ? hasTool && isEnabled : hasTool;
|
|
80
|
+
report.push({
|
|
81
|
+
id,
|
|
82
|
+
label: src?.label ?? id,
|
|
83
|
+
kind: src?.kind ?? "local",
|
|
84
|
+
available,
|
|
85
|
+
known: !!src,
|
|
86
|
+
integration: isIntegration,
|
|
87
|
+
enabled: isEnabled,
|
|
88
|
+
});
|
|
42
89
|
}
|
|
43
90
|
return report;
|
|
44
91
|
}
|
|
45
92
|
|
|
46
|
-
/** Run a set of sources, returning a flat, de-duplicated Candidate[].
|
|
47
|
-
|
|
93
|
+
/** Run a set of sources, returning a flat, de-duplicated Candidate[]. Stays on
|
|
94
|
+
* the cheap path: uses available() (PATH check), never authState() (spawn).
|
|
95
|
+
* `explicit` is the set of ids the caller named verbatim — naming a live
|
|
96
|
+
* integration there overrides the enabled-gate. */
|
|
97
|
+
export async function collectFrom(sourceIds, opts, patterns, explicit = new Set()) {
|
|
48
98
|
const compiled = compilePatterns(patterns);
|
|
49
99
|
const ids = sourceIds && sourceIds.length ? sourceIds : ["filesystem"];
|
|
50
100
|
const seen = new Set();
|
|
51
101
|
const out = [];
|
|
52
102
|
for (const id of ids) {
|
|
53
103
|
const src = getSource(id);
|
|
54
|
-
if (!src
|
|
55
|
-
|
|
104
|
+
if (!src) continue;
|
|
105
|
+
// Live-integration gate: skip a disabled integration unless explicitly named.
|
|
106
|
+
if (!integrationAllowed(src, id, explicit)) continue;
|
|
107
|
+
if (!(await src.available(opts))) continue;
|
|
108
|
+
// Fold the per-source declared envTag into opts.env so the integration's env
|
|
109
|
+
// overrides guessEnvFrom — but an explicit --env flag (opts.env) still wins,
|
|
110
|
+
// and Vercel's authoritative deploy target still wins inside its own collect.
|
|
111
|
+
const srcOpts = src.integration ? { ...opts, env: opts.env || getEnvTag(id) } : opts;
|
|
112
|
+
for (const c of await src.collect(srcOpts, compiled)) {
|
|
56
113
|
const key = `${c.model_string}|${c.location_label}`;
|
|
57
114
|
if (seen.has(key)) continue;
|
|
58
115
|
seen.add(key);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/* Background-scan RUNNER: a thin, React-free seam over the cooperative
|
|
2
|
+
* streaming filesystem scan. It owns the AbortController + paused flag, runs
|
|
3
|
+
* scanFilesystemStreaming on the main event loop (the walk yields via
|
|
4
|
+
* setImmediate on a time + count budget, so a foreground frame loop and the
|
|
5
|
+
* input handler keep ticking in the gaps), and re-emits the engine's raw
|
|
6
|
+
* onEvent protocol onto a NAMED handlers object so callers never touch
|
|
7
|
+
* event-type strings.
|
|
8
|
+
*
|
|
9
|
+
* Why no worker_threads: the shipped artifact is a single Bun-compiled binary
|
|
10
|
+
* built from ONE entry (`bun build src/index.js --compile`). A `new Worker(new
|
|
11
|
+
* URL('./scan-worker.js', …))` would resolve to a loose .js path that doesn't
|
|
12
|
+
* exist inside the self-contained binary — it'd work under `node src/index.js`
|
|
13
|
+
* (npm) but break on the CDN binary. The scan is already background-capable on
|
|
14
|
+
* the single thread, so a worker buys nothing here. The returned handle shape
|
|
15
|
+
* ({ abort, pause, resume, paused }) is DELIBERATELY identical to what a
|
|
16
|
+
* worker-backed impl would expose, so a future worker swap (with build-script
|
|
17
|
+
* support + an in-process fallback) is mechanical, not a rewrite. */
|
|
18
|
+
import { scanFilesystemStreaming } from "./filesystem.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Start a background filesystem scan.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} opts
|
|
24
|
+
* @param {string} opts.root dir to walk (required)
|
|
25
|
+
* @param {object} opts.compiled compiled detection patterns (compilePatterns(snapshot.detection))
|
|
26
|
+
* @param {string[]} [opts.exclude] extra ignore patterns (forwarded verbatim)
|
|
27
|
+
* @param {object} [opts.env] env override (MM_MAX_PER_FILE etc.), forwarded verbatim
|
|
28
|
+
* @param {number} [opts.yieldBudgetMs=8] elapsed-time yield budget (the core responsiveness enhancement)
|
|
29
|
+
* @param {number} [opts.maxFilesPerSlice=40] file-count yield ceiling (legacy YIELD_EVERY backstop)
|
|
30
|
+
*
|
|
31
|
+
* @param {object} [handlers]
|
|
32
|
+
* @param {(candidate:object)=>void} [handlers.onCandidate] per detected usage — the firehose, NOT batched here
|
|
33
|
+
* @param {(p:object)=>void} [handlers.onProgress] { filesScanned, dirsSeen, catalogsSkipped, currentDir }
|
|
34
|
+
* @param {(d:object)=>void} [handlers.onDir] { path, dirsSeen }
|
|
35
|
+
* @param {(s:object)=>void} [handlers.onSkip] { path, distinct, catalogsSkipped } (catalog files)
|
|
36
|
+
* @param {(r:object)=>void} [handlers.onDone] { candidates, filesScanned, dirsSeen, catalogsSkipped, scannedAt } — suppressed after abort()
|
|
37
|
+
* @param {(err:Error)=>void} [handlers.onError] walk threw
|
|
38
|
+
*
|
|
39
|
+
* @returns {{ abort:()=>void, pause:()=>void, resume:()=>void, paused:boolean }}
|
|
40
|
+
* abort() idempotent; aborts the walk + suppresses onDone (so a torn-down
|
|
41
|
+
* consumer never setState-after-unmount). resume from partial is N/A.
|
|
42
|
+
* pause() idempotent; engine stops reading files but the async fn stays alive
|
|
43
|
+
* (event loop free → a foreground loop keeps ticking).
|
|
44
|
+
* resume() idempotent.
|
|
45
|
+
* paused getter mirroring the internal flag (for UI).
|
|
46
|
+
*
|
|
47
|
+
* Coalescing (the 120ms render flush) stays in the CONSUMER — the runner is a
|
|
48
|
+
* synchronous re-emitter so its semantics are identical whether backed by
|
|
49
|
+
* cooperative async (direct calls, today) or a future worker (message-port
|
|
50
|
+
* events). It does NOT touch React, registry fetch, disk cache, or upload.
|
|
51
|
+
*/
|
|
52
|
+
export function startScan(opts, handlers = {}) {
|
|
53
|
+
const { root, compiled, exclude, env, yieldBudgetMs = 8, maxFilesPerSlice = 40 } = opts || {};
|
|
54
|
+
const {
|
|
55
|
+
onCandidate = () => {},
|
|
56
|
+
onProgress = () => {},
|
|
57
|
+
onDir = () => {},
|
|
58
|
+
onSkip = () => {},
|
|
59
|
+
onDone = () => {},
|
|
60
|
+
onError = () => {},
|
|
61
|
+
} = handlers;
|
|
62
|
+
|
|
63
|
+
const ac = new AbortController();
|
|
64
|
+
let paused = false;
|
|
65
|
+
let aborted = false;
|
|
66
|
+
// Track the latest progress counters so onDone can report a complete summary
|
|
67
|
+
// even when the final event before completion was a `candidate` (no counts).
|
|
68
|
+
let filesScanned = 0;
|
|
69
|
+
let dirsSeen = 0;
|
|
70
|
+
let catalogsSkipped = 0;
|
|
71
|
+
|
|
72
|
+
scanFilesystemStreaming(
|
|
73
|
+
{ root, signal: ac.signal, exclude, env, isPaused: () => paused, yieldBudgetMs, maxFilesPerSlice },
|
|
74
|
+
compiled,
|
|
75
|
+
(ev) => {
|
|
76
|
+
// Re-dispatch the raw engine event onto named handlers. No batching here.
|
|
77
|
+
switch (ev.type) {
|
|
78
|
+
case "dir":
|
|
79
|
+
dirsSeen = ev.dirsSeen;
|
|
80
|
+
onDir({ path: ev.path, dirsSeen: ev.dirsSeen });
|
|
81
|
+
break;
|
|
82
|
+
case "candidate":
|
|
83
|
+
onCandidate(ev.candidate);
|
|
84
|
+
break;
|
|
85
|
+
case "skip":
|
|
86
|
+
catalogsSkipped = ev.catalogsSkipped;
|
|
87
|
+
onSkip({ path: ev.path, distinct: ev.distinct, catalogsSkipped: ev.catalogsSkipped });
|
|
88
|
+
break;
|
|
89
|
+
case "progress":
|
|
90
|
+
filesScanned = ev.filesScanned;
|
|
91
|
+
dirsSeen = ev.dirsSeen;
|
|
92
|
+
catalogsSkipped = ev.catalogsSkipped;
|
|
93
|
+
onProgress({ filesScanned: ev.filesScanned, dirsSeen: ev.dirsSeen, catalogsSkipped: ev.catalogsSkipped, currentDir: ev.currentDir });
|
|
94
|
+
break;
|
|
95
|
+
default:
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
).then(
|
|
100
|
+
(candidates) => {
|
|
101
|
+
// Suppress onDone after abort: the engine returns its partial set on a
|
|
102
|
+
// detected abort, but a torn-down caller must not be re-notified.
|
|
103
|
+
if (aborted) return;
|
|
104
|
+
onDone({ candidates, filesScanned, dirsSeen, catalogsSkipped, scannedAt: Date.now() });
|
|
105
|
+
},
|
|
106
|
+
(err) => {
|
|
107
|
+
if (aborted) return;
|
|
108
|
+
onError(err instanceof Error ? err : new Error(String(err)));
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
abort() {
|
|
114
|
+
aborted = true;
|
|
115
|
+
ac.abort();
|
|
116
|
+
},
|
|
117
|
+
pause() {
|
|
118
|
+
paused = true;
|
|
119
|
+
},
|
|
120
|
+
resume() {
|
|
121
|
+
paused = false;
|
|
122
|
+
},
|
|
123
|
+
get paused() {
|
|
124
|
+
return paused;
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { hasCmd, run } from "./shell.js";
|
|
4
|
+
import { detectInLine } from "../detect/core.js";
|
|
5
|
+
import { redactValue } from "../redact.js";
|
|
6
|
+
import { scanConfigEntries, entriesFromKV } from "./configscan.js";
|
|
7
|
+
|
|
8
|
+
/* Pure parsers (unit-tested). All command-output shape knowledge lives here so
|
|
9
|
+
* the source body stays a thin orchestration layer that's easy to reason about. */
|
|
10
|
+
|
|
11
|
+
/** `supabase secrets list` → secret NAMES only. Handles both the table output
|
|
12
|
+
* (NAME | DIGEST) and `--output json` ([{name}]). We NEVER fetch secret values:
|
|
13
|
+
* a secret VALUE could never be a useful model id, and pulling it would be a
|
|
14
|
+
* needless exfiltration risk. NAMES alone let `detectInLine` flag a name like
|
|
15
|
+
* OPENAI_MODEL_OVERRIDE without ever touching a value. */
|
|
16
|
+
export function parseSupabaseSecrets(stdout) {
|
|
17
|
+
const s = String(stdout || "").trim();
|
|
18
|
+
// JSON form first (`--output json`).
|
|
19
|
+
try {
|
|
20
|
+
const j = JSON.parse(s);
|
|
21
|
+
if (Array.isArray(j)) return j.map((x) => x.name || x.NAME).filter(Boolean);
|
|
22
|
+
} catch {
|
|
23
|
+
/* fall through to table parse */
|
|
24
|
+
}
|
|
25
|
+
const names = [];
|
|
26
|
+
for (const raw of s.split(/\r?\n/)) {
|
|
27
|
+
const line = raw.trim();
|
|
28
|
+
if (!line) continue;
|
|
29
|
+
if (/^name\b/i.test(line)) continue; // header row
|
|
30
|
+
if (/^[-\s|]+$/.test(line)) continue; // separator rule
|
|
31
|
+
const name = line
|
|
32
|
+
.split(/\s{2,}|\t|\s\|\s|\|/)
|
|
33
|
+
.map((c) => c.trim())
|
|
34
|
+
.filter(Boolean)[0];
|
|
35
|
+
if (name && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) names.push(name);
|
|
36
|
+
}
|
|
37
|
+
return names;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `supabase functions list` → edge-function slugs. Handles `--output json`
|
|
41
|
+
* ([{ name|slug }]) and the table output (header SLUG/NAME, then one per row). */
|
|
42
|
+
export function parseFunctionList(stdout) {
|
|
43
|
+
const s = String(stdout || "").trim();
|
|
44
|
+
if (!s) return [];
|
|
45
|
+
try {
|
|
46
|
+
const j = JSON.parse(s);
|
|
47
|
+
if (Array.isArray(j)) return j.map((x) => x.slug || x.name || x.SLUG || x.NAME).filter(Boolean);
|
|
48
|
+
} catch {
|
|
49
|
+
/* fall through to table parse */
|
|
50
|
+
}
|
|
51
|
+
const slugs = [];
|
|
52
|
+
for (const raw of s.split(/\r?\n/)) {
|
|
53
|
+
const line = raw.trim();
|
|
54
|
+
if (!line) continue;
|
|
55
|
+
if (/^(slug|name|id)\b/i.test(line)) continue; // header row
|
|
56
|
+
if (/^[-\s|]+$/.test(line)) continue; // separator rule
|
|
57
|
+
const first = line
|
|
58
|
+
.split(/\s{2,}|\t|\s\|\s|\|/)
|
|
59
|
+
.map((c) => c.trim())
|
|
60
|
+
.filter(Boolean)[0];
|
|
61
|
+
// Edge-function slugs are url-safe: letters, digits, hyphens, underscores.
|
|
62
|
+
if (first && /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(first)) slugs.push(first);
|
|
63
|
+
}
|
|
64
|
+
return slugs;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Line-scan one edge-function source body → Candidates. Pure: takes text +
|
|
68
|
+
* locatorBase + compiled (+ optional env override), returns candidates with a
|
|
69
|
+
* #L<n> locator and a redacted, 160-capped snippet. detectInLine returns a Set,
|
|
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) {
|
|
73
|
+
const out = [];
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
String(text || "")
|
|
76
|
+
.split(/\r?\n/)
|
|
77
|
+
.forEach((line, i) => {
|
|
78
|
+
for (const model_string of detectInLine(line, compiled)) {
|
|
79
|
+
const locator = `${locatorBase}#L${i + 1}`;
|
|
80
|
+
const key = `${model_string}|${locator}`;
|
|
81
|
+
if (seen.has(key)) continue;
|
|
82
|
+
seen.add(key);
|
|
83
|
+
out.push({
|
|
84
|
+
model_string,
|
|
85
|
+
source_type: "supabase-edge",
|
|
86
|
+
location_label: locator,
|
|
87
|
+
source_path: locator,
|
|
88
|
+
source_line: i + 1,
|
|
89
|
+
environment: env || "unknown",
|
|
90
|
+
snippet: redactValue(line.trim()).slice(0, 160),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Supabase Edge Functions + secrets. LIVE integration: gated on the enabled
|
|
98
|
+
* toggle AND the `supabase` CLI (see sources/index.js's gate). Two surfaces, both
|
|
99
|
+
* through the redaction funnel:
|
|
100
|
+
* (a) `supabase secrets list` → secret NAME-only entries (empty value, so no
|
|
101
|
+
* value can ever leak) → scanConfigEntries.
|
|
102
|
+
* (b) LOCAL edge-function source under <root>/supabase/functions, line-scanned
|
|
103
|
+
* via detectInLine. We read the LOCAL repo (the same model as the
|
|
104
|
+
* filesystem source) — we never `supabase functions download`, so no fetched
|
|
105
|
+
* code is written to disk and the scan stays fully testable.
|
|
106
|
+
*
|
|
107
|
+
* available() is satisfied by the `supabase` CLI on PATH OR a SUPABASE_ACCESS_TOKEN
|
|
108
|
+
* in the environment (the token authenticates the CLI / Management API; the local
|
|
109
|
+
* function-body scan needs neither). opts: { root, supabaseProjectRef, env }. */
|
|
110
|
+
export const supabaseEdgeSource = {
|
|
111
|
+
id: "supabase-edge",
|
|
112
|
+
label: "Supabase Edge Functions + secrets",
|
|
113
|
+
kind: "cli",
|
|
114
|
+
integration: true,
|
|
115
|
+
envTag: "unknown",
|
|
116
|
+
async available() {
|
|
117
|
+
return hasCmd("supabase") || !!process.env.SUPABASE_ACCESS_TOKEN;
|
|
118
|
+
},
|
|
119
|
+
/** Read-only identity probe (MAY spawn). Used by the TUI "test" key + verbose
|
|
120
|
+
* `mm sources`, NOT by the hot collect path. */
|
|
121
|
+
async authState() {
|
|
122
|
+
if (!hasCmd("supabase")) {
|
|
123
|
+
return {
|
|
124
|
+
connected: !!process.env.SUPABASE_ACCESS_TOKEN,
|
|
125
|
+
mode: "token",
|
|
126
|
+
reason: process.env.SUPABASE_ACCESS_TOKEN ? undefined : "supabase CLI not installed",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const r = await run("supabase", ["projects", "list"]);
|
|
130
|
+
if (!r.ok) return { connected: false, mode: "projects-list", reason: (r.stderr || "not authenticated").split("\n")[0] };
|
|
131
|
+
return { connected: true, mode: "projects-list" };
|
|
132
|
+
},
|
|
133
|
+
async collect(opts, compiled) {
|
|
134
|
+
const ref = opts?.supabaseProjectRef || "local";
|
|
135
|
+
const out = [];
|
|
136
|
+
|
|
137
|
+
// (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
|
+
const refArg = opts?.supabaseProjectRef ? ["--project-ref", opts.supabaseProjectRef] : [];
|
|
141
|
+
const secrets = await run("supabase", ["secrets", "list", ...refArg]);
|
|
142
|
+
if (secrets.ok) {
|
|
143
|
+
for (const name of parseSupabaseSecrets(secrets.stdout)) {
|
|
144
|
+
// NAME as the entry key, EMPTY value → detectInLine runs on the name
|
|
145
|
+
// only; there is no value to leak.
|
|
146
|
+
const entries = entriesFromKV(name, "", `supabase-edge://${ref}/secrets#${name}`, ref);
|
|
147
|
+
out.push(...scanConfigEntries(entries, compiled, { sourceType: "supabase-edge", env: opts?.env }));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// (b) LOCAL edge-function source bodies (no network download → testable, no
|
|
153
|
+
// disk-write of fetched code). Walk <root>/supabase/functions one level deep.
|
|
154
|
+
const fnDir = path.join(opts?.root || ".", "supabase", "functions");
|
|
155
|
+
let slugs = [];
|
|
156
|
+
try {
|
|
157
|
+
slugs = fs
|
|
158
|
+
.readdirSync(fnDir, { withFileTypes: true })
|
|
159
|
+
.filter((d) => d.isDirectory())
|
|
160
|
+
.map((d) => d.name);
|
|
161
|
+
} catch {
|
|
162
|
+
/* no local functions dir — secrets-only is fine */
|
|
163
|
+
}
|
|
164
|
+
for (const slug of slugs) {
|
|
165
|
+
let files = [];
|
|
166
|
+
try {
|
|
167
|
+
files = fs.readdirSync(path.join(fnDir, slug)).filter((f) => /\.(ts|js|tsx|jsx|mjs)$/.test(f));
|
|
168
|
+
} catch {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
for (const f of files) {
|
|
172
|
+
let text;
|
|
173
|
+
try {
|
|
174
|
+
text = fs.readFileSync(path.join(fnDir, slug, f), "utf8");
|
|
175
|
+
} catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
out.push(...scanFunctionBody(text, `supabase-edge://${ref}/${slug}/${f}`, compiled, opts?.env));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
},
|
|
183
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Compatibility shim. The canonical implementation now lives in
|
|
2
|
+
* sources/supabase-edge.js (matching the source id and the integration design).
|
|
3
|
+
* This file re-exports it so the registration in sources/index.js and existing
|
|
4
|
+
* imports keep resolving to ONE implementation with no behavior change. */
|
|
5
|
+
export { supabaseEdgeSource, parseSupabaseSecrets, parseFunctionList, scanFunctionBody } from "./supabase-edge.js";
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { hasCmd, run } from "./shell.js";
|
|
2
|
+
import { scanConfigEntries, entriesFromKV } from "./configscan.js";
|
|
3
|
+
|
|
4
|
+
/** Deployment TARGET → environment. The target is AUTHORITATIVE (Vercel tells us
|
|
5
|
+
* exactly where an env var deploys), so it's mapped straight through and passed as
|
|
6
|
+
* the explicit env to scanConfigEntries — never guessed, and it overrides both the
|
|
7
|
+
* integration's declared envTag AND any global --env. */
|
|
8
|
+
const TARGET_ENV = { production: "prod", preview: "staging", development: "dev" };
|
|
9
|
+
|
|
10
|
+
/* Pure parser (unit-tested). Parses the `vercel env ls` table into NAME + target
|
|
11
|
+
* rows. We pull NAMES ONLY (never values) so no secret can leak — a model id in a
|
|
12
|
+
* Vercel env var lives in the NAME (e.g. OPENAI_MODEL), and the value of a model
|
|
13
|
+
* env var is rarely a registry id and never worth a secret-on-disk risk. */
|
|
14
|
+
export function parseVercelEnvLs(stdout) {
|
|
15
|
+
const rows = [];
|
|
16
|
+
const lines = String(stdout || "").split(/\r?\n/);
|
|
17
|
+
for (const raw of lines) {
|
|
18
|
+
const line = raw.trim();
|
|
19
|
+
if (!line) continue;
|
|
20
|
+
// Skip the header + any framing/prefix lines (no env-var name there).
|
|
21
|
+
if (/^(name\b|vercel cli|>|environment variables|retrieving)/i.test(line)) continue;
|
|
22
|
+
// Columns are whitespace-separated: NAME VALUE(encrypted) ENVIRONMENTS …
|
|
23
|
+
const cols = line.split(/\s{2,}|\t/).map((c) => c.trim()).filter(Boolean);
|
|
24
|
+
if (!cols.length) continue;
|
|
25
|
+
const name = cols[0];
|
|
26
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue; // looks like an env-var name
|
|
27
|
+
// The environments column names one of production/preview/development.
|
|
28
|
+
const joined = cols.join(" ").toLowerCase();
|
|
29
|
+
const target = ["production", "preview", "development"].find((t) => joined.includes(t)) || "";
|
|
30
|
+
rows.push({ name, target });
|
|
31
|
+
}
|
|
32
|
+
return rows;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Vercel project env. LIVE integration: gated on the enabled toggle AND the
|
|
36
|
+
* `vercel` CLI being present (or a VERCEL_TOKEN in the env — the CLI auto-picks
|
|
37
|
+
* it up for non-interactive auth). Lists env-var NAMES only via `vercel env ls`
|
|
38
|
+
* (NEVER `vercel env pull` — that would write plaintext secrets to disk).
|
|
39
|
+
* opts: { vercelProject, vercelTeam }. */
|
|
40
|
+
export const vercelSource = {
|
|
41
|
+
id: "vercel",
|
|
42
|
+
label: "Vercel project env",
|
|
43
|
+
kind: "cli",
|
|
44
|
+
integration: true,
|
|
45
|
+
envTag: "unknown",
|
|
46
|
+
async available() {
|
|
47
|
+
return hasCmd("vercel") || !!process.env.VERCEL_TOKEN;
|
|
48
|
+
},
|
|
49
|
+
async authState() {
|
|
50
|
+
const r = await run("vercel", ["whoami"]);
|
|
51
|
+
if (!r.ok) return { connected: false, mode: "whoami", reason: (r.stderr || "not logged in").split("\n")[0] };
|
|
52
|
+
return { connected: true, mode: "whoami", account: r.stdout.trim().split("\n").pop() };
|
|
53
|
+
},
|
|
54
|
+
async collect(opts, compiled) {
|
|
55
|
+
const scope = opts?.vercelTeam ? ["--scope", opts.vercelTeam] : [];
|
|
56
|
+
const project = opts?.vercelProject || "default";
|
|
57
|
+
const out = [];
|
|
58
|
+
// One pull per target so the env is authoritative + per-row correct. We ask
|
|
59
|
+
// for NAMES only; `vercel env ls <target>` prints the table without values.
|
|
60
|
+
for (const target of ["production", "preview", "development"]) {
|
|
61
|
+
const r = await run("vercel", ["env", "ls", target, ...scope]);
|
|
62
|
+
if (!r.ok) continue;
|
|
63
|
+
for (const { name } of parseVercelEnvLs(r.stdout)) {
|
|
64
|
+
// NAME-only entry: empty value → detectInLine runs on the name only, no
|
|
65
|
+
// value to leak. The TARGET is the authoritative env (overrides everything).
|
|
66
|
+
const entries = entriesFromKV(name, "", `vercel://${project}/${target}#${name}`, target);
|
|
67
|
+
out.push(...scanConfigEntries(entries, compiled, { sourceType: "vercel", env: TARGET_ENV[target] }));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export { TARGET_ENV };
|
package/src/tui/app.js
CHANGED
|
@@ -15,6 +15,7 @@ import { WhatsNewView, meta as whatsnewMeta } from "./views/whatsnew.js";
|
|
|
15
15
|
import { AddView, meta as addMeta } from "./views/add.js";
|
|
16
16
|
import { AlertsView, meta as alertsMeta } from "./views/alerts.js";
|
|
17
17
|
import { AccountView, meta as accountMeta } from "./views/account.js";
|
|
18
|
+
import { IntegrationsView, meta as integrationsMeta } from "./views/integrations.js";
|
|
18
19
|
import { SignIn } from "./signin.js";
|
|
19
20
|
|
|
20
21
|
// `needsAuth: true` views show a sign-in card when there's no apiKey; Local +
|
|
@@ -27,9 +28,12 @@ const VIEWS = [
|
|
|
27
28
|
{ key: "add", label: "Add", title: "add", Comp: AddView, meta: addMeta, needsAuth: true },
|
|
28
29
|
{ key: "alerts", label: "Alerts", title: "alerts", Comp: AlertsView, meta: alertsMeta, needsAuth: true },
|
|
29
30
|
{ key: "account", label: "Account", title: "account", Comp: AccountView, meta: accountMeta, needsAuth: false },
|
|
31
|
+
// Sources toggle — local-first (needsAuth:false): the enabled set persists in
|
|
32
|
+
// integrations.json and drives `mm scan`; the web mirror is best-effort.
|
|
33
|
+
{ key: "integrations", label: "Sources", title: "sources", Comp: IntegrationsView, meta: integrationsMeta, needsAuth: false },
|
|
30
34
|
];
|
|
31
35
|
|
|
32
|
-
const GATE_KEYS = [{ k: "1-
|
|
36
|
+
const GATE_KEYS = [{ k: "1-8", label: "switch" }, { k: "7", label: "sign in" }];
|
|
33
37
|
|
|
34
38
|
// Lines of chrome around the body (topRule, lights, tabs, blank, prompt, blank,
|
|
35
39
|
// toast, status, keybar, bottomRule) — the body fills whatever rows remain.
|