@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.
@@ -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-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.
@@ -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
- return app.waitUntilExit();
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
+ );