@modelstatus/cli 0.1.35 → 0.1.37
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/index.js +113 -0
- package/src/sources/scan-process.js +238 -0
- package/src/sources/scan-worker.js +148 -0
- package/src/tui/app.js +40 -1
- package/src/tui/game/DkGame.js +18 -184
- package/src/tui/game/dk-core.js +507 -226
- package/src/tui/game/dk-render.js +46 -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/scan.js +94 -80
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelstatus/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.37",
|
|
4
4
|
"description": "Track which AI models you use, where, and never get surprised by a retirement. Free offline model-health for any repo (mm status), browser sign-in for cloud inventory + alerts.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llm",
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { resolveAuth, loadConfig, saveConfig, clearAuth, configFilePath } from "./config.js";
|
|
5
6
|
import { createClient } from "./api.js";
|
|
@@ -15,6 +16,73 @@ import { maybeCheckForUpdate } from "./updater.js";
|
|
|
15
16
|
import { track, maybeAnalyticsNotice } from "./telemetry.js";
|
|
16
17
|
import { BUILD_VERSION } from "./version.js";
|
|
17
18
|
|
|
19
|
+
// TRUE BACKGROUND SCAN — hidden worker dispatch. MUST be the first executable
|
|
20
|
+
// statement, BEFORE main()/maybeAnalyticsNotice/track/maybeCheckForUpdate: those
|
|
21
|
+
// write to stdout (the analytics notice) and trigger the self-updater, but the
|
|
22
|
+
// worker's stdout MUST be PURE NDJSON. The dynamic import pulls in ONLY
|
|
23
|
+
// filesystem.js + detect/core + registry (no ink, upload, auth, telemetry).
|
|
24
|
+
//
|
|
25
|
+
// argv is env-agnostic: under node argv = [node, /abs/src/index.js, sentinel, …];
|
|
26
|
+
// under the bun-compiled binary argv = [/abs/binary, /$bunfs/…, sentinel, …]. In
|
|
27
|
+
// BOTH the sentinel is at index 2 and worker args at index 3+. (See
|
|
28
|
+
// src/sources/scan-process.js buildSpawnArgs for the matching parent spawn.)
|
|
29
|
+
if (process.argv[2] === "__mm_scan_worker") {
|
|
30
|
+
const { runWorker } = await import("./sources/scan-worker.js");
|
|
31
|
+
await runWorker(process.argv.slice(3)); // never returns — calls process.exit
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// HIDDEN bench seam (NOT a public command; absent from help). Runs the REAL
|
|
35
|
+
// game loop with a per-frame timestamp tap → stdout NDJSON, AND starts the REAL
|
|
36
|
+
// background scan subprocess (the production self-re-exec) so the SHIPPED binary
|
|
37
|
+
// is measured doing BOTH jobs at once — the exact concurrent path `mm play`
|
|
38
|
+
// drives. Used by test/game-jitter-compiled.test.mjs to gate the bun-compiled
|
|
39
|
+
// artifact (where the runtime, and thus the self-re-exec branch, differs from
|
|
40
|
+
// node). Headless: drives the loop via its _inject IO seams (no TTY needed).
|
|
41
|
+
// mm __bench_frames --seconds=N --with-scan=DIR [--ndjson]
|
|
42
|
+
if (process.argv[2] === "__bench_frames") {
|
|
43
|
+
const args = process.argv.slice(3);
|
|
44
|
+
const getv = (k, d) => { const a = args.find((x) => x.startsWith(`--${k}=`)); return a ? a.slice(k.length + 3) : d; };
|
|
45
|
+
const seconds = Number(getv("seconds", "4")) || 4;
|
|
46
|
+
const scanDir = getv("with-scan", null);
|
|
47
|
+
const now = () => Number(process.hrtime.bigint() / 1000000n);
|
|
48
|
+
const emit = (o) => process.stdout.write(JSON.stringify(o) + "\n");
|
|
49
|
+
|
|
50
|
+
const { runGame } = await import("./tui/game/loop.js");
|
|
51
|
+
let scan = null;
|
|
52
|
+
if (scanDir) {
|
|
53
|
+
const { startScanProcess, isBunRuntime } = await import("./sources/scan-process.js");
|
|
54
|
+
scan = startScanProcess({ root: scanDir }, {});
|
|
55
|
+
// Echo the self-re-exec proof: the worker's runtime + spawn argv0, so the
|
|
56
|
+
// test KNOWS the subprocess path didn't silently fall back to in-process.
|
|
57
|
+
emit({ t: "reexec", bun: isBunRuntime(), parentExec: process.execPath, childPid: scan.child.pid, childSpawnFile: process.execPath });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Fake IO so the loop runs without a TTY; wrap schedule to timestamp each tick.
|
|
61
|
+
const writes = [];
|
|
62
|
+
const out = { columns: 80, rows: 24, isTTY: false, write: (s) => { if (s) writes.push(s.length); return true; }, setRawMode() { return this; }, on() {}, removeListener() {}, resume() {}, pause() {} };
|
|
63
|
+
const input = { isTTY: false, isRaw: false, setRawMode(v) { this.isRaw = v; return this; }, on() {}, removeListener() {}, resume() {}, pause() {}, write() { return true; } };
|
|
64
|
+
const seqs = ["\x1b[C", "\x1b[D", " ", "\x1b[A"]; let ki = 0;
|
|
65
|
+
const drive = setInterval(() => { (input._d || []).forEach((f) => f(Buffer.from(seqs[ki++ % seqs.length]))); }, 45);
|
|
66
|
+
input.on = (ev, fn) => { if (ev === "data") (input._d ||= []).push(fn); };
|
|
67
|
+
input.removeListener = (ev, fn) => { if (ev === "data") input._d = (input._d || []).filter((f) => f !== fn); };
|
|
68
|
+
|
|
69
|
+
let prev = null;
|
|
70
|
+
const p = runGame({
|
|
71
|
+
width: 80, height: 24, scanStore: scan,
|
|
72
|
+
_inject: {
|
|
73
|
+
out, inp: input, proc: process, now,
|
|
74
|
+
schedule: (fn, ms) => setTimeout(() => { const t = now(); if (prev != null) emit({ t: "frame", dtMs: t - prev }); prev = t; fn(); }, ms),
|
|
75
|
+
cancel: (h) => clearTimeout(h),
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
setTimeout(() => { (input._d || []).forEach((f) => f(Buffer.from("q"))); }, seconds * 1000);
|
|
79
|
+
await p;
|
|
80
|
+
clearInterval(drive);
|
|
81
|
+
emit({ t: "done", filesScanned: scan ? scan.stats.filesScanned : 0, candidates: scan ? scan.stats.candidateCount : 0, phase: scan ? scan.stats.phase : "none" });
|
|
82
|
+
try { scan && scan.abort(); } catch {}
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
|
|
18
86
|
function parseArgs(argv) {
|
|
19
87
|
const flags = {};
|
|
20
88
|
const positional = [];
|
|
@@ -134,6 +202,49 @@ async function cmdUpgrade(_positional, flags) {
|
|
|
134
202
|
}
|
|
135
203
|
}
|
|
136
204
|
|
|
205
|
+
/** `mm play [dir]` — the standalone game: NO Ink, ever. Runs the direct-ANSI
|
|
206
|
+
* 60Hz loop while a background scan subprocess walks `dir` (its progress feeds
|
|
207
|
+
* the in-game HUD). Pure terminal, restores cleanly on every exit path. */
|
|
208
|
+
async function cmdPlay(positional, flags) {
|
|
209
|
+
if (!process.stdout.isTTY) {
|
|
210
|
+
console.error("`mm play` needs an interactive terminal (a TTY).");
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
const dir = path.resolve(positional[1] || flags.dir || ".");
|
|
214
|
+
const { runGame } = await import("./tui/game/loop.js");
|
|
215
|
+
|
|
216
|
+
// Best-effort background scan of `dir` so the HUD shows live progress. We
|
|
217
|
+
// pre-fetch the signed registry once and hand the worker a cache file so it
|
|
218
|
+
// skips the network; on any failure the game still plays (scanStore stays in
|
|
219
|
+
// its error/empty state, which the HUD renders as "scan unavailable").
|
|
220
|
+
let scanHandle = null;
|
|
221
|
+
let cacheFile = null;
|
|
222
|
+
try {
|
|
223
|
+
const { startScanProcess } = await import("./sources/scan-process.js");
|
|
224
|
+
try {
|
|
225
|
+
const { getRegistry } = await import("./registry/fetch.js");
|
|
226
|
+
const snapshot = await getRegistry({ offline: !!flags.offline }).catch(() => null);
|
|
227
|
+
if (snapshot) {
|
|
228
|
+
cacheFile = path.join(os.tmpdir(), `mm-reg-${process.pid}.json`);
|
|
229
|
+
fs.writeFileSync(cacheFile, JSON.stringify(snapshot));
|
|
230
|
+
}
|
|
231
|
+
} catch { /* no cache — worker fetches itself */ }
|
|
232
|
+
scanHandle = startScanProcess({ root: dir, registryCachePath: cacheFile || undefined }, {});
|
|
233
|
+
} catch { scanHandle = null; }
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
await runGame({
|
|
237
|
+
width: process.stdout.columns || 80,
|
|
238
|
+
height: process.stdout.rows || 24,
|
|
239
|
+
level: 1,
|
|
240
|
+
scanStore: scanHandle,
|
|
241
|
+
});
|
|
242
|
+
} finally {
|
|
243
|
+
try { scanHandle && scanHandle.abort(); } catch { /* ignore */ }
|
|
244
|
+
try { if (cacheFile) fs.unlinkSync(cacheFile); } catch { /* ignore */ }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
137
248
|
async function launchTui(initialView, flags) {
|
|
138
249
|
// Pass apiKey straight through (may be null). The TUI's Bootstrap wrapper
|
|
139
250
|
// renders an interactive SignIn screen when there's no key, then swaps to
|
|
@@ -584,6 +695,7 @@ Usage:
|
|
|
584
695
|
mm integrations Manage live integrations (list | enable <id> | disable <id> | env <id> <tag>)
|
|
585
696
|
mm clear Delete all tracked usages from your inventory (--all also wipes projects/rules; --yes to skip the prompt)
|
|
586
697
|
mm upgrade Open Stripe checkout and poll until Pro is active
|
|
698
|
+
mm play [dir] Play Donkey Kong while a background scan walks the dir (just for fun)
|
|
587
699
|
mm tui Force-launch the TUI (logs you in first if needed)
|
|
588
700
|
|
|
589
701
|
Scan sources (--sources; default = filesystem + enabled integrations; "all" for everything):
|
|
@@ -648,6 +760,7 @@ async function main() {
|
|
|
648
760
|
else if (cmd === "sources") await cmdSources(positional, flags);
|
|
649
761
|
else if (cmd === "integrations") cmdIntegrations(positional, flags);
|
|
650
762
|
else if (cmd === "clear") await cmdClear(positional, flags);
|
|
763
|
+
else if (cmd === "play") await cmdPlay(positional, flags);
|
|
651
764
|
else if (cmd === "upgrade") await cmdUpgrade(positional, flags);
|
|
652
765
|
else if (cmd === "tui" || !cmd) await launchTui(positional[1], flags);
|
|
653
766
|
else if (cmd === "help" || flags.help) console.log(HELP);
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/* TRUE BACKGROUND SCAN — PARENT side.
|
|
2
|
+
*
|
|
3
|
+
* Spawns the scan as a SEPARATE OS process (self-re-exec of the running runtime
|
|
4
|
+
* via the hidden `__mm_scan_worker` sentinel; see src/index.js top dispatch +
|
|
5
|
+
* src/sources/scan-worker.js), readline-parses its NDJSON stdout into a plain
|
|
6
|
+
* mutable `stats` store, and re-dispatches onto the SAME named-handler shape
|
|
7
|
+
* scan-runner.js uses ({onCandidate,onProgress,onSkip,onDone,onError}) — so this
|
|
8
|
+
* is the mechanical swap the scan-runner.js header comment promised, except the
|
|
9
|
+
* walk now runs in its OWN event loop (no cooperative-yield lag during a game /
|
|
10
|
+
* heavy IO), and survives an Ink unmount because it's a real subprocess.
|
|
11
|
+
*
|
|
12
|
+
* Returned handle mirrors scan-runner.js's {abort,pause,resume,paused} plus a
|
|
13
|
+
* read-only `stats` getter and the `child` reference. The game HUD reads `stats`
|
|
14
|
+
* every render and NEVER awaits the scan.
|
|
15
|
+
*
|
|
16
|
+
* Dual-runtime spawn (the ONE place runtime matters — both branches verified):
|
|
17
|
+
* - Compiled binary (bun --compile): process.execPath IS the binary; re-exec
|
|
18
|
+
* it with just the worker args. argv[1] is a virtual /$bunfs path and is NOT
|
|
19
|
+
* spawnable, so do NOT prepend it.
|
|
20
|
+
* - node / npm: process.execPath is node; prepend process.argv[1] (the real
|
|
21
|
+
* absolute src/index.js path) so node loads the entry, which dispatches.
|
|
22
|
+
*/
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
24
|
+
import readline from "node:readline";
|
|
25
|
+
|
|
26
|
+
/** True inside the bun-compiled binary, false under node. Defined-in-bun signal. */
|
|
27
|
+
export function isBunRuntime() {
|
|
28
|
+
return typeof globalThis.Bun !== "undefined" || process.versions.bun != null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the argv passed to spawn() for `process.execPath`.
|
|
33
|
+
* @param {string[]} workerArgs ['--root', dir, ...] worker flags (NO sentinel)
|
|
34
|
+
* @returns {string[]} full spawn argv (sentinel + flags, with argv[1] prepended under node)
|
|
35
|
+
*/
|
|
36
|
+
export function buildSpawnArgs(workerArgs) {
|
|
37
|
+
const wargs = ["__mm_scan_worker", ...workerArgs];
|
|
38
|
+
// Compiled binary: re-exec the binary itself (execPath) with just the worker
|
|
39
|
+
// args. node: execPath is node, so prepend the real entry script (argv[1]).
|
|
40
|
+
return isBunRuntime() ? wargs : [process.argv[1], ...wargs];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Start a background filesystem scan in a separate OS process.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} opts
|
|
47
|
+
* @param {string} opts.root dir to walk (required)
|
|
48
|
+
* @param {string[]} [opts.exclude] extra ignore patterns (csv-joined for the worker)
|
|
49
|
+
* @param {object} [opts.env] extra env merged over process.env for the child
|
|
50
|
+
* @param {string} [opts.registryCachePath] pre-fetched snapshot JSON path (worker skips the network)
|
|
51
|
+
*
|
|
52
|
+
* @param {object} [handlers]
|
|
53
|
+
* @param {(candidate:object)=>void} [handlers.onCandidate]
|
|
54
|
+
* @param {(p:object)=>void} [handlers.onProgress] {filesScanned,dirsSeen,catalogsSkipped,currentDir}
|
|
55
|
+
* @param {(s:object)=>void} [handlers.onSkip] {path,distinct,catalogsSkipped}
|
|
56
|
+
* @param {(r:object)=>void} [handlers.onDone] {candidates,filesScanned,dirsSeen,catalogsSkipped,scannedAt} — suppressed after abort()
|
|
57
|
+
* @param {(err:Error)=>void} [handlers.onError]
|
|
58
|
+
*
|
|
59
|
+
* @returns {{abort:()=>void, pause:()=>void, resume:()=>void, paused:boolean, stats:object, child:import('node:child_process').ChildProcess}}
|
|
60
|
+
*/
|
|
61
|
+
export function startScanProcess(opts, handlers = {}) {
|
|
62
|
+
const { root, exclude, env, registryCachePath } = opts || {};
|
|
63
|
+
const {
|
|
64
|
+
onCandidate = () => {},
|
|
65
|
+
onProgress = () => {},
|
|
66
|
+
onSkip = () => {},
|
|
67
|
+
onDone = () => {},
|
|
68
|
+
onError = () => {},
|
|
69
|
+
} = handlers;
|
|
70
|
+
|
|
71
|
+
// Worker flags. Sentinel + argv[1] handling lives in buildSpawnArgs.
|
|
72
|
+
const workerArgs = ["--root", root];
|
|
73
|
+
if (exclude && exclude.length) workerArgs.push("--exclude", exclude.join(","));
|
|
74
|
+
if (registryCachePath) workerArgs.push("--registry-cache", registryCachePath);
|
|
75
|
+
|
|
76
|
+
const child = spawn(process.execPath, buildSpawnArgs(workerArgs), {
|
|
77
|
+
// ignore stdin; PIPE stdout (NDJSON) + stderr (diagnostics). NEVER 'inherit'
|
|
78
|
+
// — the worker shares no terminal with the game, so its output can't touch
|
|
79
|
+
// the alt-screen frame.
|
|
80
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
81
|
+
env: { ...process.env, ...(env || {}) },
|
|
82
|
+
detached: false,
|
|
83
|
+
windowsHide: true,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// The HUD store — a plain mutable object the loop reads each render, read-only.
|
|
87
|
+
const stats = {
|
|
88
|
+
phase: "scanning", // scanning | done | error
|
|
89
|
+
filesScanned: 0,
|
|
90
|
+
candidateCount: 0,
|
|
91
|
+
dirsSeen: 0,
|
|
92
|
+
catalogsSkipped: 0,
|
|
93
|
+
currentDir: "",
|
|
94
|
+
scannedAt: null,
|
|
95
|
+
error: null,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
let aborted = false;
|
|
99
|
+
let paused = false;
|
|
100
|
+
let finalized = false; // exactly-once phase finalization (done/error/exit)
|
|
101
|
+
let stderrBuf = "";
|
|
102
|
+
|
|
103
|
+
function handleLine(line) {
|
|
104
|
+
if (!line) return;
|
|
105
|
+
let msg;
|
|
106
|
+
try {
|
|
107
|
+
msg = JSON.parse(line);
|
|
108
|
+
} catch {
|
|
109
|
+
return; // drop a malformed / partial-leftover line, never throw
|
|
110
|
+
}
|
|
111
|
+
switch (msg.t) {
|
|
112
|
+
case "prog":
|
|
113
|
+
// dir-only prog lines carry just dirsSeen; full prog lines carry counts.
|
|
114
|
+
if (typeof msg.filesScanned === "number") stats.filesScanned = msg.filesScanned;
|
|
115
|
+
if (typeof msg.dirsSeen === "number") stats.dirsSeen = msg.dirsSeen;
|
|
116
|
+
if (typeof msg.catalogsSkipped === "number") stats.catalogsSkipped = msg.catalogsSkipped;
|
|
117
|
+
if (typeof msg.currentDir === "string") stats.currentDir = msg.currentDir;
|
|
118
|
+
if (!aborted) {
|
|
119
|
+
onProgress({
|
|
120
|
+
filesScanned: stats.filesScanned,
|
|
121
|
+
dirsSeen: stats.dirsSeen,
|
|
122
|
+
catalogsSkipped: stats.catalogsSkipped,
|
|
123
|
+
currentDir: stats.currentDir,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
case "cand":
|
|
128
|
+
// COUNTS only on the hot path: the loop never renders cand payloads, so
|
|
129
|
+
// parse pressure during a game is near-zero. The full Candidate[] is
|
|
130
|
+
// read once from the done line.
|
|
131
|
+
stats.candidateCount++;
|
|
132
|
+
if (!aborted) onCandidate(msg.candidate);
|
|
133
|
+
break;
|
|
134
|
+
case "skip":
|
|
135
|
+
if (typeof msg.catalogsSkipped === "number") stats.catalogsSkipped = msg.catalogsSkipped;
|
|
136
|
+
if (!aborted) onSkip({ path: msg.path, distinct: msg.distinct, catalogsSkipped: msg.catalogsSkipped });
|
|
137
|
+
break;
|
|
138
|
+
case "done":
|
|
139
|
+
if (finalized) return;
|
|
140
|
+
finalized = true;
|
|
141
|
+
stats.phase = "done";
|
|
142
|
+
stats.filesScanned = msg.filesScanned ?? stats.filesScanned;
|
|
143
|
+
stats.dirsSeen = msg.dirsSeen ?? stats.dirsSeen;
|
|
144
|
+
stats.catalogsSkipped = msg.catalogsSkipped ?? stats.catalogsSkipped;
|
|
145
|
+
stats.candidateCount = (msg.candidates || []).length;
|
|
146
|
+
stats.scannedAt = msg.scannedAt ?? Date.now();
|
|
147
|
+
if (!aborted) onDone({
|
|
148
|
+
candidates: msg.candidates || [],
|
|
149
|
+
filesScanned: stats.filesScanned,
|
|
150
|
+
dirsSeen: stats.dirsSeen,
|
|
151
|
+
catalogsSkipped: stats.catalogsSkipped,
|
|
152
|
+
scannedAt: stats.scannedAt,
|
|
153
|
+
});
|
|
154
|
+
break;
|
|
155
|
+
case "err":
|
|
156
|
+
if (finalized) return;
|
|
157
|
+
finalized = true;
|
|
158
|
+
stats.phase = "error";
|
|
159
|
+
stats.error = msg.message || "scan failed";
|
|
160
|
+
if (!aborted) onError(new Error(stats.error));
|
|
161
|
+
break;
|
|
162
|
+
default:
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
168
|
+
rl.on("line", handleLine);
|
|
169
|
+
|
|
170
|
+
child.stderr.on("data", (d) => {
|
|
171
|
+
stderrBuf += d.toString();
|
|
172
|
+
if (stderrBuf.length > 64_000) stderrBuf = stderrBuf.slice(-64_000); // bound
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
child.on("error", (err) => {
|
|
176
|
+
// Spawn failure (e.g. execPath missing) — surface unless we asked to die.
|
|
177
|
+
if (finalized || aborted) return;
|
|
178
|
+
finalized = true;
|
|
179
|
+
stats.phase = "error";
|
|
180
|
+
stats.error = err?.message || "failed to start scan worker";
|
|
181
|
+
onError(err instanceof Error ? err : new Error(stats.error));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
child.on("exit", (code, signal) => {
|
|
185
|
+
// If the child exited without a done/err line and we didn't abort it, treat
|
|
186
|
+
// a non-zero/signal exit as an error so the HUD doesn't hang on "scanning".
|
|
187
|
+
if (finalized || aborted) return;
|
|
188
|
+
if (code === 0) return; // a 0 exit always followed a done line above
|
|
189
|
+
finalized = true;
|
|
190
|
+
stats.phase = "error";
|
|
191
|
+
stats.error = (stderrBuf.trim().split("\n").pop() || `scan worker exited (code ${code}, signal ${signal})`);
|
|
192
|
+
onError(new Error(stats.error));
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
abort() {
|
|
197
|
+
// Idempotent. Suppresses onDone/onError and kills the child (cross-platform).
|
|
198
|
+
if (aborted) return;
|
|
199
|
+
aborted = true;
|
|
200
|
+
try {
|
|
201
|
+
child.kill("SIGTERM");
|
|
202
|
+
} catch {
|
|
203
|
+
/* already gone */
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
pause() {
|
|
207
|
+
if (paused) return;
|
|
208
|
+
paused = true;
|
|
209
|
+
// SIGSTOP genuinely freezes the child on posix; on win32 it's a no-op on
|
|
210
|
+
// the process but we still flip the flag so the UI reflects intent.
|
|
211
|
+
if (process.platform !== "win32") {
|
|
212
|
+
try {
|
|
213
|
+
child.kill("SIGSTOP");
|
|
214
|
+
} catch {
|
|
215
|
+
/* ignore */
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
resume() {
|
|
220
|
+
if (!paused) return;
|
|
221
|
+
paused = false;
|
|
222
|
+
if (process.platform !== "win32") {
|
|
223
|
+
try {
|
|
224
|
+
child.kill("SIGCONT");
|
|
225
|
+
} catch {
|
|
226
|
+
/* ignore */
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
get paused() {
|
|
231
|
+
return paused;
|
|
232
|
+
},
|
|
233
|
+
get stats() {
|
|
234
|
+
return stats;
|
|
235
|
+
},
|
|
236
|
+
child,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -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
|
+
}
|
package/src/tui/app.js
CHANGED
|
@@ -219,7 +219,46 @@ function Bootstrap(props) {
|
|
|
219
219
|
return h(App, { ...props, apiKey, onSignedIn: (k) => { track("signed_in"); setApiKey(k); } });
|
|
220
220
|
}
|
|
221
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
|
+
|
|
222
243
|
export function runApp(opts) {
|
|
244
|
+
appController._opts = opts;
|
|
223
245
|
const app = render(h(Bootstrap, opts));
|
|
224
|
-
|
|
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
|
+
});
|
|
225
264
|
}
|