@modelstatus/cli 0.1.34 → 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.
@@ -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) each shell out to
20
- * an ALREADY-AUTHENTICATED CLI, run read-only, scan locally, and REDACT every
21
- * snippet — only non-sensitive model ids ever leave the machine, never secrets.
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 = [filesystemSource, envSource, awsSecretsSource, k8sSource, helmSource, sqlSource];
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
- /** Which of the requested sources are usable right now (tool/creds present). */
36
- export async function availability(sourceIds, opts = {}) {
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
- report.push({ id, label: src?.label ?? id, available: src ? await src.available(opts) : false, known: !!src });
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
- export async function collectFrom(sourceIds, opts, patterns) {
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 || !(await src.available(opts))) continue;
55
- for (const c of await src.collect(opts, compiled)) {
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-7", label: "switch" }, { k: "7", label: "sign in" }];
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.