@modelstatus/cli 0.1.34 → 0.1.36
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 +219 -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-process.js +238 -0
- package/src/sources/scan-runner.js +127 -0
- package/src/sources/scan-worker.js +148 -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 +45 -2
- package/src/tui/game/DkGame.js +21 -0
- package/src/tui/game/dk-core.js +688 -0
- package/src/tui/game/dk-render.js +160 -0
- package/src/tui/game/input.js +169 -0
- package/src/tui/game/loop.js +337 -0
- package/src/tui/game/term.js +330 -0
- package/src/tui/views/add.js +1 -1
- package/src/tui/views/integrations.js +224 -0
- package/src/tui/views/inventory.js +31 -2
- package/src/tui/views/scan.js +116 -6
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/* TRUE BACKGROUND SCAN — CHILD (worker) side.
|
|
2
|
+
*
|
|
3
|
+
* Runs inside a self-re-exec'd OS process: the parent spawns `process.execPath`
|
|
4
|
+
* with the hidden `__mm_scan_worker` sentinel as argv[2] (see src/index.js top
|
|
5
|
+
* dispatch + src/sources/scan-process.js). This module is dynamic-imported by
|
|
6
|
+
* that dispatch, so it pulls in ONLY filesystem.js + detect/core + registry
|
|
7
|
+
* (NO ink, NO upload, NO auth, NO telemetry, NO updater) — the worker's stdout
|
|
8
|
+
* must be PURE NDJSON.
|
|
9
|
+
*
|
|
10
|
+
* Protocol: one JSON object per line, newline-terminated, FLUSHED. Tags:
|
|
11
|
+
* {t:"prog", filesScanned, dirsSeen, catalogsSkipped, currentDir}
|
|
12
|
+
* {t:"cand", candidate}
|
|
13
|
+
* {t:"skip", path, distinct, catalogsSkipped}
|
|
14
|
+
* {t:"done", candidates, filesScanned, dirsSeen, catalogsSkipped, scannedAt}
|
|
15
|
+
* {t:"err", message}
|
|
16
|
+
*
|
|
17
|
+
* Lifecycle: SIGTERM -> AbortController.abort() (clean stop). SIGSTOP/SIGCONT
|
|
18
|
+
* are handled by the OS (pause/resume) and need no listener. The terminal
|
|
19
|
+
* done/err line is flushed by deferring process.exit into the write callback,
|
|
20
|
+
* so the parent never loses the final line on exit.
|
|
21
|
+
*/
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import { scanFilesystemStreaming } from "./filesystem.js";
|
|
24
|
+
import { compilePatterns } from "../detect/core.js";
|
|
25
|
+
import { getRegistry } from "../registry/fetch.js";
|
|
26
|
+
|
|
27
|
+
/** Parse the worker's own argv (process.argv.slice(3)) — light, no parseArgs dep. */
|
|
28
|
+
function parseWorkerArgs(args) {
|
|
29
|
+
const out = { root: process.cwd(), exclude: [], registryCache: null };
|
|
30
|
+
for (let i = 0; i < args.length; i++) {
|
|
31
|
+
const a = args[i];
|
|
32
|
+
if (a === "--root") out.root = args[++i];
|
|
33
|
+
else if (a === "--exclude") out.exclude = (args[++i] || "").split(",").filter(Boolean);
|
|
34
|
+
else if (a === "--registry-cache") out.registryCache = args[++i];
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Flush-safe NDJSON writer. When `exitCode` is given, exit INSIDE the write
|
|
40
|
+
* callback so the final line is fully flushed to the pipe before we exit, and
|
|
41
|
+
* return a never-resolving Promise so the caller's `await` HANGS until that
|
|
42
|
+
* deferred process.exit fires — this prevents the top-level worker dispatch in
|
|
43
|
+
* index.js from falling through to main() (which would print HELP) between the
|
|
44
|
+
* promise resolving and the async exit landing. */
|
|
45
|
+
function makeEmitter() {
|
|
46
|
+
return function emit(obj, exitCode) {
|
|
47
|
+
const line = JSON.stringify(obj) + "\n";
|
|
48
|
+
if (typeof exitCode === "number") {
|
|
49
|
+
// Defer exit until this write drains — guarantees done/err is delivered.
|
|
50
|
+
process.stdout.write(line, () => process.exit(exitCode));
|
|
51
|
+
return new Promise(() => {}); // hang forever; the write cb exits the proc
|
|
52
|
+
}
|
|
53
|
+
process.stdout.write(line);
|
|
54
|
+
return undefined;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Worker entrypoint. `args` is process.argv.slice(3). Never returns (process.exit).
|
|
60
|
+
*/
|
|
61
|
+
export async function runWorker(args) {
|
|
62
|
+
const emit = makeEmitter();
|
|
63
|
+
const { root, exclude, registryCache } = parseWorkerArgs(args);
|
|
64
|
+
|
|
65
|
+
// (1) Registry snapshot: prefer the parent's pre-fetched cache file (skips the
|
|
66
|
+
// network); else fetch + verify ourselves. On total failure (offline, no
|
|
67
|
+
// cache) emit one err line and exit 1.
|
|
68
|
+
let snapshot;
|
|
69
|
+
try {
|
|
70
|
+
if (registryCache) {
|
|
71
|
+
snapshot = JSON.parse(fs.readFileSync(registryCache, "utf8"));
|
|
72
|
+
} else {
|
|
73
|
+
snapshot = await getRegistry();
|
|
74
|
+
}
|
|
75
|
+
} catch (e) {
|
|
76
|
+
return emit({ t: "err", message: `registry unavailable: ${e?.message ?? e}` }, 1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// (2) Compile detection patterns once.
|
|
80
|
+
let compiled;
|
|
81
|
+
try {
|
|
82
|
+
compiled = compilePatterns(snapshot.detection);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return emit({ t: "err", message: `bad registry snapshot: ${e?.message ?? e}` }, 1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// (7) Abort: parent SIGTERM -> abort the walk so the kill stops cleanly.
|
|
88
|
+
const ac = new AbortController();
|
|
89
|
+
process.on("SIGTERM", () => ac.abort());
|
|
90
|
+
|
|
91
|
+
// (4) Run the streaming scan, mapping the engine RAW onEvent protocol to NDJSON.
|
|
92
|
+
// yieldBudgetMs Infinity (count-only): the worker is ALONE in its event loop —
|
|
93
|
+
// no foreground frame loop to keep ticking — so the walk runs as fast as it
|
|
94
|
+
// can; the setImmediate-every-40-files yield still fires often enough to flush
|
|
95
|
+
// stdout + service the abort signal.
|
|
96
|
+
// Track the latest counters so the `done` line carries accurate finals even
|
|
97
|
+
// when the last event before completion was a candidate (which has no counts).
|
|
98
|
+
let filesScanned = 0;
|
|
99
|
+
let dirsSeen = 0;
|
|
100
|
+
let catalogsSkipped = 0;
|
|
101
|
+
try {
|
|
102
|
+
const candidates = await scanFilesystemStreaming(
|
|
103
|
+
{ root, signal: ac.signal, exclude, env: process.env.MM_ENV, yieldBudgetMs: Infinity },
|
|
104
|
+
compiled,
|
|
105
|
+
(ev) => {
|
|
106
|
+
switch (ev.type) {
|
|
107
|
+
case "dir":
|
|
108
|
+
// Fold into a prog line carrying only the running dir count.
|
|
109
|
+
dirsSeen = ev.dirsSeen;
|
|
110
|
+
emit({ t: "prog", dirsSeen: ev.dirsSeen });
|
|
111
|
+
break;
|
|
112
|
+
case "candidate":
|
|
113
|
+
emit({ t: "cand", candidate: ev.candidate });
|
|
114
|
+
break;
|
|
115
|
+
case "skip":
|
|
116
|
+
catalogsSkipped = ev.catalogsSkipped;
|
|
117
|
+
emit({ t: "skip", path: ev.path, distinct: ev.distinct, catalogsSkipped: ev.catalogsSkipped });
|
|
118
|
+
break;
|
|
119
|
+
case "progress":
|
|
120
|
+
filesScanned = ev.filesScanned;
|
|
121
|
+
dirsSeen = ev.dirsSeen;
|
|
122
|
+
catalogsSkipped = ev.catalogsSkipped;
|
|
123
|
+
emit({
|
|
124
|
+
t: "prog",
|
|
125
|
+
filesScanned: ev.filesScanned,
|
|
126
|
+
dirsSeen: ev.dirsSeen,
|
|
127
|
+
catalogsSkipped: ev.catalogsSkipped,
|
|
128
|
+
currentDir: ev.currentDir,
|
|
129
|
+
});
|
|
130
|
+
break;
|
|
131
|
+
default:
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// (5) Final summary rides the done line — the full Candidate[] so the parent
|
|
138
|
+
// seeds the Scan view without buffering every cand. Exit 0 in the write cb;
|
|
139
|
+
// await the hang so the dispatch never falls through to main().
|
|
140
|
+
await emit(
|
|
141
|
+
{ t: "done", candidates, filesScanned, dirsSeen, catalogsSkipped, scannedAt: Date.now() },
|
|
142
|
+
0,
|
|
143
|
+
);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
// Aborted walks resolve (return the partial set), so a throw here is real.
|
|
146
|
+
await emit({ t: "err", message: `${e?.message ?? e}` }, 1);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -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.
|
|
@@ -215,7 +219,46 @@ function Bootstrap(props) {
|
|
|
215
219
|
return h(App, { ...props, apiKey, onSignedIn: (k) => { track("signed_in"); setApiKey(k); } });
|
|
216
220
|
}
|
|
217
221
|
|
|
222
|
+
// Module-level controller so a view (the Scan tab) can UNMOUNT the whole Ink
|
|
223
|
+
// tree, run the direct-ANSI game loop (which owns its own raw mode + alt screen),
|
|
224
|
+
// then REMOUNT a fresh tree at a chosen tab. The game subprocess scan survives
|
|
225
|
+
// the unmount because it's a separate OS process, independent of Ink's lifecycle.
|
|
226
|
+
export const appController = {
|
|
227
|
+
_instance: null,
|
|
228
|
+
_opts: null,
|
|
229
|
+
/** Tear down the current Ink tree (releases raw mode + stdin listeners). */
|
|
230
|
+
unmount() {
|
|
231
|
+
try { this._instance && this._instance.unmount(); } catch { /* already gone */ }
|
|
232
|
+
this._instance = null;
|
|
233
|
+
},
|
|
234
|
+
/** Mount a fresh Ink tree, merging the base opts with `next` (e.g. a tab). */
|
|
235
|
+
remount(next = {}) {
|
|
236
|
+
const opts = { ...(this._opts || {}), ...next };
|
|
237
|
+
this._opts = opts;
|
|
238
|
+
this._instance = render(h(Bootstrap, opts));
|
|
239
|
+
return this._instance;
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
|
|
218
243
|
export function runApp(opts) {
|
|
244
|
+
appController._opts = opts;
|
|
219
245
|
const app = render(h(Bootstrap, opts));
|
|
220
|
-
|
|
246
|
+
appController._instance = app;
|
|
247
|
+
// waitUntilExit resolves when the CURRENT instance unmounts. A Scan-tab game
|
|
248
|
+
// launch unmounts + remounts, which would resolve this early; so we re-arm on
|
|
249
|
+
// each remount and only resolve when an instance exits WITHOUT a pending
|
|
250
|
+
// remount (i.e. a real quit). The `_exiting` flag is set by a clean exit().
|
|
251
|
+
return new Promise((resolve) => {
|
|
252
|
+
const arm = (inst) => {
|
|
253
|
+
inst.waitUntilExit().then(() => {
|
|
254
|
+
// If a remount happened (a new instance is live and differs), keep waiting.
|
|
255
|
+
if (appController._instance && appController._instance !== inst) {
|
|
256
|
+
arm(appController._instance);
|
|
257
|
+
} else {
|
|
258
|
+
resolve();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
arm(app);
|
|
263
|
+
});
|
|
221
264
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/* RETIRED. The old Ink-overlay Donkey Kong (a React component running a useTick
|
|
2
|
+
* frame loop, mounted as an overlay inside ScanView) has been replaced by the
|
|
3
|
+
* direct-ANSI, Ink-free game:
|
|
4
|
+
*
|
|
5
|
+
* - src/tui/game/loop.js — 60Hz fixed-timestep loop (own raw mode + alt screen)
|
|
6
|
+
* - src/tui/game/term.js — double-buffered diff renderer (never clears mid-play)
|
|
7
|
+
* - src/tui/game/input.js — raw-stdin held-key model
|
|
8
|
+
* - src/tui/game/dk-core.js— pure sub-cell fixed-point physics engine
|
|
9
|
+
*
|
|
10
|
+
* Reached via `mm play` (standalone) or the Scan-tab P key, which UNMOUNTS Ink,
|
|
11
|
+
* runs the loop, then REMOUNTS the TUI (see src/tui/views/scan.js launchGame +
|
|
12
|
+
* src/tui/app.js appController). This file is kept only as a tombstone — nothing
|
|
13
|
+
* imports it. It intentionally exports nothing and pulls in no dependencies.
|
|
14
|
+
*
|
|
15
|
+
* This file can be deleted outright; it is retained as a no-op so an accidental
|
|
16
|
+
* stale import fails loudly rather than resurrecting the old flicker-prone path.
|
|
17
|
+
*/
|
|
18
|
+
throw new Error(
|
|
19
|
+
"DkGame.js is retired — the Ink overlay game was replaced by the direct-ANSI loop " +
|
|
20
|
+
"(src/tui/game/loop.js). Use `mm play` or the Scan-tab P key. Do not import DkGame.js.",
|
|
21
|
+
);
|