dsh-plugin-rea 1.0.6 → 2.0.1

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,1453 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bundle setup — provision everything a fresh machine needs so that the
4
+ * `mcp-rea` row works, then record the result in <bundle root>/bundle.json.
5
+ *
6
+ * Design rules:
7
+ * - Idempotent: every stage skips work whose result already verifies.
8
+ * - Resumable: archives are fetched to a cache and reused; a killed run
9
+ * continues instead of restarting.
10
+ * - Honest: a checksum the vendor does not publish is recorded on first fetch
11
+ * (TOFU) and enforced afterwards; it is never invented.
12
+ * - Non-destructive: nothing outside the bundle root, the cache, and
13
+ * bundle.json is written. Existing REA/Ghidra/JDK installs are adopted with
14
+ * --reuse rather than duplicated.
15
+ *
16
+ * Usage:
17
+ * node scripts/setup.mjs [options]
18
+ * --root <dir> bundle root (default <DSH_HOME>/plugins/rea-bundle)
19
+ * --cache <dir> download cache (default <root>/cache)
20
+ * --reuse adopt existing node/ghidra/jdk found on this machine
21
+ * --dry-run print the plan; download nothing
22
+ * --json machine-readable result on stdout
23
+ * --only <a,b,c> restrict to named components
24
+ * --yes never prompt (implied; this script has no prompts)
25
+ * --offline refuse network; only satisfy from cache/reuse
26
+ */
27
+ import { spawnSync } from "node:child_process";
28
+ import { createHash } from "node:crypto";
29
+ import { appendFileSync, closeSync, copyFileSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
30
+ import { request as httpRequest } from "node:http";
31
+ import { request as httpsRequest } from "node:https";
32
+ import { homedir } from "node:os";
33
+ import path from "node:path";
34
+ import process from "node:process";
35
+ import { fileURLToPath } from "node:url";
36
+
37
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
38
+ const PLUGIN_ROOT = path.resolve(HERE, "..");
39
+ const IS_WINDOWS = process.platform === "win32";
40
+ const EXE = IS_WINDOWS ? ".exe" : "";
41
+
42
+ /** Windows treats two spellings of one path as equal; the mirror checks must too. */
43
+ function samePath(left, right) {
44
+ const a = path.resolve(left);
45
+ const b = path.resolve(right);
46
+ return IS_WINDOWS ? a.toLowerCase() === b.toLowerCase() : a === b;
47
+ }
48
+
49
+ /* ------------------------------------------------------------------ options */
50
+
51
+ /**
52
+ * DSH home, by the same rule bin/rea-shim.mjs and the published patch use:
53
+ * the environment first, then the conventional location. Both checks are
54
+ * trim-based, because `??` treats an empty variable as a value: with
55
+ * DSH_HOME="" the old expression built the relative root
56
+ * "plugins/rea-bundle", and the shim would then look for bundle.json in the
57
+ * harness's cwd instead of the home the rest of the system agrees on.
58
+ */
59
+ function dshHome() {
60
+ const fromEnv = process.env.DSH_HOME;
61
+ if (fromEnv && fromEnv.trim() !== "") return path.resolve(fromEnv);
62
+ return path.join(homedir(), ".dsh");
63
+ }
64
+
65
+ /** Bundle root: explicit override first, then <DSH_HOME>/plugins/rea-bundle. */
66
+ function defaultBundleRoot() {
67
+ const override = process.env.REA_BUNDLE_ROOT;
68
+ if (override && override.trim() !== "") return path.resolve(override);
69
+ return path.join(dshHome(), "plugins", "rea-bundle");
70
+ }
71
+
72
+ function parseArgs(argv) {
73
+ const options = {
74
+ root: defaultBundleRoot(),
75
+ cache: null,
76
+ reuse: false,
77
+ dryRun: false,
78
+ json: false,
79
+ only: null,
80
+ offline: false,
81
+ // Two different clocks, because one number cannot serve both: a route must
82
+ // be declared dead quickly when the socket never opens, but a slow-but-alive
83
+ // transfer must not be killed for taking long overall (measured here:
84
+ // 0.35–0.54 MiB/s, where 570 MB legitimately takes ~30 minutes).
85
+ connectTimeoutMs: Number(process.env.REA_BUNDLE_CONNECT_TIMEOUT_MS ?? 15000),
86
+ idleTimeoutMs: Number(process.env.REA_BUNDLE_IDLE_TIMEOUT_MS ?? 120000),
87
+ retries: Number(process.env.REA_BUNDLE_FETCH_RETRIES ?? 4),
88
+ };
89
+ for (let i = 0; i < argv.length; i += 1) {
90
+ const arg = argv[i];
91
+ if (arg === "--root") options.root = argv[++i] ?? "";
92
+ else if (arg === "--cache") options.cache = argv[++i] ?? "";
93
+ else if (arg === "--reuse") options.reuse = true;
94
+ else if (arg === "--dry-run") options.dryRun = true;
95
+ else if (arg === "--json") options.json = true;
96
+ else if (arg === "--offline") options.offline = true;
97
+ else if (arg === "--yes") continue;
98
+ else if (arg === "--only") options.only = (argv[++i] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
99
+ else if (arg === "--help" || arg === "-h") options.help = true;
100
+ else throw new Error(`unknown option: ${arg}`);
101
+ }
102
+ // Absolute, always. bundle.json records these paths and the shim resolves them
103
+ // against whatever cwd the harness happens to have, so a relative --root would
104
+ // make every recorded component look deleted — the shim reports "the recorded
105
+ // Node runtime is gone" and starts provisioning again, on every single start.
106
+ if (String(options.root).trim() === "") throw new Error("--root needs a directory");
107
+ options.root = path.resolve(options.root);
108
+ if (!options.cache || String(options.cache).trim() === "") options.cache = path.join(options.root, "cache");
109
+ options.cache = path.resolve(options.cache);
110
+ return options;
111
+ }
112
+
113
+ /* -------------------------------------------------------------------- output */
114
+
115
+ const state = { log: [], steps: [] };
116
+
117
+ /**
118
+ * Log file. When the shim starts provisioning detached, stdio is discarded, so
119
+ * this file is the only progress channel — and the shim tells the operator
120
+ * exactly this path. Resolved lazily: this module-level helper is defined before
121
+ * the options are parsed.
122
+ */
123
+ function logFilePath() {
124
+ return path.join(options.root, "logs", "setup.log");
125
+ }
126
+ function appendLog(line) {
127
+ try {
128
+ const file = logFilePath();
129
+ mkdirSync(path.dirname(file), { recursive: true });
130
+ appendFileSync(file, line + "\n");
131
+ } catch {
132
+ /* logging must never be fatal */
133
+ }
134
+ }
135
+
136
+ function log(message) {
137
+ state.log.push(message);
138
+ appendLog(message);
139
+ if (!options.json) process.stdout.write(message + "\n");
140
+ }
141
+ function step(name, status, detail) {
142
+ state.steps.push({ name, status, detail });
143
+ const mark = status === "ok" ? "ok " : status === "skip" ? "skip" : status === "plan" ? "plan" : "FAIL";
144
+ log(`[${mark}] ${name}${detail ? " — " + detail : ""}`);
145
+ }
146
+
147
+ /* ------------------------------------------------------------------- hashing */
148
+
149
+ /**
150
+ * Hash a file with a stream. Reading it whole would allocate the file's size
151
+ * (the Ghidra archive is 570 MB) and could not be resumed across a short read.
152
+ */
153
+ function sha256File(file) {
154
+ const hash = createHash("sha256");
155
+ const descriptor = openSync(file, "r");
156
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
157
+ try {
158
+ for (;;) {
159
+ const read = readSync(descriptor, buffer, 0, buffer.length, null);
160
+ if (read <= 0) break;
161
+ hash.update(buffer.subarray(0, read));
162
+ }
163
+ } finally {
164
+ closeSync(descriptor);
165
+ }
166
+ return hash.digest("hex");
167
+ }
168
+
169
+ /* ------------------------------------------------------------ proxy support */
170
+
171
+ /**
172
+ * Where the network expects us to go through a proxy. Resolved once and cached:
173
+ * the Windows lookup spawns reg.exe twice, and doing that per download was
174
+ * pointless work on the same answer.
175
+ *
176
+ * This matters more than it looks: Node's global fetch ignores the system proxy
177
+ * settings entirely, while .NET/PowerShell honour them. On a machine with
178
+ * WinINET's ProxyEnable=1 (here: 127.0.0.1:7890) `Invoke-WebRequest` reaches
179
+ * github.com fine and Node gets ECONNRESET/timeout on the very same URL — which
180
+ * is exactly the failure this function exists to remove.
181
+ *
182
+ * Order: explicit environment first (standard names, either case), then the
183
+ * Windows registry, then NO_PROXY handling.
184
+ */
185
+ let proxyResolution;
186
+ function resolveProxy() {
187
+ if (proxyResolution !== undefined) return proxyResolution;
188
+ const fromEnv =
189
+ process.env.HTTPS_PROXY ?? process.env.https_proxy ??
190
+ process.env.HTTP_PROXY ?? process.env.http_proxy ??
191
+ process.env.ALL_PROXY ?? process.env.all_proxy ?? "";
192
+ let server = fromEnv.trim();
193
+
194
+ if (!server && IS_WINDOWS) {
195
+ const result = spawnSync(
196
+ "reg",
197
+ ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings", "/v", "ProxyEnable"],
198
+ { encoding: "utf8" },
199
+ );
200
+ const enabled = /ProxyEnable\s+REG_DWORD\s+0x1/i.test(result.stdout ?? "");
201
+ if (enabled) {
202
+ const value = spawnSync(
203
+ "reg",
204
+ ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings", "/v", "ProxyServer"],
205
+ { encoding: "utf8" },
206
+ );
207
+ const match = /ProxyServer\s+REG_SZ\s+(.+)/i.exec(value.stdout ?? "");
208
+ if (match) server = match[1].trim();
209
+ }
210
+ }
211
+ if (!server) return null;
212
+
213
+ // A registry value may be a per-scheme list: "http=host:port;https=host:port".
214
+ if (server.includes("=")) {
215
+ const parts = Object.fromEntries(
216
+ server.split(";").filter(Boolean).map((entry) => {
217
+ const [scheme, target] = entry.split("=");
218
+ return [scheme.trim().toLowerCase(), (target ?? "").trim()];
219
+ }),
220
+ );
221
+ server = parts.https ?? parts.http ?? "";
222
+ }
223
+ if (!server) {
224
+ proxyResolution = null;
225
+ return null;
226
+ }
227
+ if (!/^[a-z]+:\/\//i.test(server)) server = `http://${server}`;
228
+
229
+ let parsed;
230
+ try {
231
+ parsed = new URL(server);
232
+ } catch {
233
+ proxyResolution = null;
234
+ return null;
235
+ }
236
+ const bypass = (process.env.NO_PROXY ?? process.env.no_proxy ?? "").split(",").map((s) => s.trim()).filter(Boolean);
237
+ proxyResolution = {
238
+ host: parsed.hostname,
239
+ port: Number(parsed.port || 8080),
240
+ auth: parsed.username
241
+ ? "Basic " + Buffer.from(`${decodeURIComponent(parsed.username)}:${decodeURIComponent(parsed.password)}`).toString("base64")
242
+ : null,
243
+ bypass,
244
+ display: `${parsed.hostname}:${parsed.port || 8080}`,
245
+ };
246
+ return proxyResolution;
247
+ }
248
+
249
+ function bypassesProxy(proxy, target) {
250
+ if (!proxy) return false;
251
+ const host = target.hostname.toLowerCase();
252
+ return proxy.bypass.some((entry) => {
253
+ const pattern = entry.toLowerCase();
254
+ if (pattern === "*") return true;
255
+ const bare = pattern.replace(/^\./, "");
256
+ return host === bare || host.endsWith(`.${bare}`);
257
+ });
258
+ }
259
+
260
+ /**
261
+ * Candidate routes for one URL, in the order they should be tried.
262
+ *
263
+ * Neither route is universally right, and "a proxy is configured" does not mean
264
+ * "a proxy is needed": on the machine this was written for, the local proxy
265
+ * answers github.com correctly but returns 403 for nodejs.org and 404 for
266
+ * dl.google.com, while both are fine directly. The reverse also happens
267
+ * (github.com reset by the network, fine through the proxy). So the direct
268
+ * route is tried first and the proxy is a fallback — with the winner logged, so
269
+ * the behaviour is visible instead of mysterious.
270
+ */
271
+ function candidateRoutes(proxy, target) {
272
+ const routes = [{ proxy: null, label: "direct" }];
273
+ if (proxy && !bypassesProxy(proxy, target)) {
274
+ routes.push({ proxy, label: `proxy ${proxy.display}` });
275
+ }
276
+ return routes;
277
+ }
278
+
279
+ /**
280
+ * One HTTPS GET, returning { status, headers, stream, via }. Goes through a
281
+ * CONNECT tunnel when `proxy` is given, otherwise connects directly.
282
+ */
283
+ function openGet(url, { proxy, headers, connectTimeoutMs }) {
284
+ return new Promise((resolve, reject) => {
285
+ const target = new URL(url);
286
+ const useProxy = Boolean(proxy);
287
+ const requestHeaders = { "user-agent": "dsh-plugin-rea-bundle", accept: "*/*", ...headers };
288
+ const connectTimeout = connectTimeoutMs;
289
+ const idleTimeout = options.idleTimeoutMs;
290
+ // Idle, not total: every received chunk pushes the deadline out, so a slow
291
+ // but healthy transfer survives while a stalled one is cut.
292
+ const touch = (socket) => () => socket.setTimeout(idleTimeout);
293
+ const done = (res, via) => {
294
+ res.on("data", touch(res.socket));
295
+ resolve({ status: res.statusCode, headers: res.headers, stream: res, via });
296
+ };
297
+
298
+ if (!useProxy) {
299
+ const req = httpsRequest(
300
+ {
301
+ host: target.hostname,
302
+ port: target.port || 443,
303
+ path: target.pathname + target.search,
304
+ method: "GET",
305
+ headers: requestHeaders,
306
+ timeout: connectTimeout,
307
+ },
308
+ (res) => done(res, "direct"),
309
+ );
310
+ req.on("socket", (socket) => {
311
+ socket.setTimeout(connectTimeout);
312
+ socket.once("connect", () => socket.setTimeout(idleTimeout));
313
+ });
314
+ req.on("timeout", () => req.destroy(new Error(`no response within ${idleTimeout}ms`)));
315
+ req.on("error", reject);
316
+ req.end();
317
+ return;
318
+ }
319
+
320
+ // Establish a tunnel with CONNECT, then run TLS inside it.
321
+ const connectHeaders = { host: `${target.hostname}:${target.port || 443}` };
322
+ if (proxy.auth) connectHeaders["proxy-authorization"] = proxy.auth;
323
+ const tunnel = httpRequest({
324
+ host: proxy.host,
325
+ port: proxy.port,
326
+ method: "CONNECT",
327
+ path: `${target.hostname}:${target.port || 443}`,
328
+ headers: connectHeaders,
329
+ timeout: connectTimeout,
330
+ });
331
+ tunnel.on("timeout", () => tunnel.destroy(new Error(`proxy CONNECT timeout after ${connectTimeout}ms`)));
332
+ tunnel.on("error", reject);
333
+ tunnel.on("connect", (res, socket) => {
334
+ if (res.statusCode !== 200) {
335
+ socket.destroy();
336
+ reject(new Error(`proxy CONNECT refused with ${res.statusCode}`));
337
+ return;
338
+ }
339
+ const inner = httpsRequest(
340
+ {
341
+ socket,
342
+ agent: false,
343
+ servername: target.hostname,
344
+ path: target.pathname + target.search,
345
+ method: "GET",
346
+ headers: requestHeaders,
347
+ timeout: idleTimeout,
348
+ },
349
+ (response) => done(response, `proxy ${proxy.display}`),
350
+ );
351
+ inner.on("timeout", () => inner.destroy(new Error(`no data within ${idleTimeout}ms`)));
352
+ inner.on("error", reject);
353
+ inner.end();
354
+ });
355
+ tunnel.end();
356
+ });
357
+ }
358
+
359
+ /* ------------------------------------------------------------ download + unzip */
360
+
361
+ /** The cache file name a URL maps to; used in messages before a download runs. */
362
+ function archiveNameFor(url) {
363
+ try {
364
+ return path.basename(new URL(url).pathname);
365
+ } catch {
366
+ return url;
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Fetch one URL to `destination`, verifying size and checksum.
372
+ *
373
+ * Transfers retry with backoff, resume a partial file through a Range request,
374
+ * and go through the proxy when one applies (see resolveProxy for why that
375
+ * matters). A server that ignores Range is handled by restarting the file.
376
+ */
377
+ async function download(url, destination, expected, label) {
378
+ const temporary = destination + ".part";
379
+ const connectTimeoutMs = options.connectTimeoutMs;
380
+ const attempts = Math.max(1, options.retries);
381
+ const proxy = resolveProxy();
382
+
383
+ // Resume an interrupted run: a partial file plus a Range request continues
384
+ // where the last attempt stopped instead of paying for the bytes twice.
385
+ let have = existsSync(temporary) ? statSync(temporary).size : 0;
386
+ if (have > 0 && expected.size && have > expected.size) {
387
+ rmSync(temporary, { force: true });
388
+ have = 0;
389
+ }
390
+ if (have > 0 && have === expected.size) {
391
+ log(` ${label}: partial file already complete`);
392
+ } else {
393
+ log(` downloading ${label}${have > 0 ? ` (resuming at ${(have / 1048576).toFixed(1)} MiB)` : ""}`);
394
+ }
395
+
396
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
397
+ try {
398
+ // --offline is a promise, not a hint: nothing network-facing may run, so
399
+ // the refusal happens before either route is attempted rather than after
400
+ // the first one fails.
401
+ if (options.offline) {
402
+ throw new Error(`${label} is not in the cache (${archiveNameFor(url)}) and --offline forbids fetching it`);
403
+ }
404
+ // Route order differs by design. On Windows the .NET stack is tried first:
405
+ // it honours the system proxy and follows GitHub's release-asset redirect
406
+ // to release-assets.githubusercontent.com correctly, which is exactly what
407
+ // the Node routes fail at here (direct = reset/timeout, proxy = a 301 loop
408
+ // rewriting back to github.com). It also completed nodejs.org's 37 MB zip
409
+ // where the Node route was truncated by 860 bytes on every attempt
410
+ // (nodejs.org advertises no accept-ranges, so a resume can never close
411
+ // that gap). Verified by the same size + checksum gate either way.
412
+ let via = null;
413
+ let nodeFailure = null;
414
+ const tryPowerShellFirst = IS_WINDOWS;
415
+ const first = tryPowerShellFirst
416
+ ? async () => {
417
+ rmSync(temporary, { force: true });
418
+ const written = downloadViaPowerShell(url, temporary);
419
+ log(` ${label}: via powershell (${(written / 1048576).toFixed(1)} MiB)`);
420
+ return "powershell";
421
+ }
422
+ : async () => {
423
+ const response = await openViaBestRoute(url, { connectTimeoutMs, have });
424
+ await writeBody(response.stream, response.headers, temporary, have, response.status, label);
425
+ return response.via;
426
+ };
427
+ const second = tryPowerShellFirst
428
+ ? async () => {
429
+ const response = await openViaBestRoute(url, { connectTimeoutMs, have });
430
+ await writeBody(response.stream, response.headers, temporary, have, response.status, label);
431
+ return response.via;
432
+ }
433
+ : async () => {
434
+ rmSync(temporary, { force: true });
435
+ const written = downloadViaPowerShell(url, temporary);
436
+ return `powershell (${(written / 1048576).toFixed(1)} MiB)`;
437
+ };
438
+
439
+ try {
440
+ via = await first();
441
+ } catch (primaryFailure) {
442
+ log(` ${label}: first route failed (${String(primaryFailure?.message ?? primaryFailure).slice(0, 140)})`);
443
+ nodeFailure = primaryFailure;
444
+ via = await second();
445
+ }
446
+ if (attempt === 1 && via) log(` ${label}: route ${via}`);
447
+ const produced = statSync(temporary).size;
448
+
449
+ if (expected.size && produced !== expected.size) {
450
+ if (produced < expected.size) {
451
+ // A few bytes missing at the tail is this network's signature (860 and
452
+ // 15875 bytes observed on otherwise complete transfers). Asking for
453
+ // the exact remainder is cheaper than re-fetching 37 MB, so the next
454
+ // attempt resumes from `produced`; a fresh start is forced once the
455
+ // attempts run out, so a marginal short read cannot accumulate.
456
+ if (attempt < attempts) {
457
+ log(` attempt ${attempt}/${attempts} ended short by ${expected.size - produced} byte(s); resuming`);
458
+ have = produced;
459
+ continue;
460
+ }
461
+ }
462
+ rmSync(temporary, { force: true });
463
+ throw new Error(`size mismatch: got ${produced}, expected ${expected.size}`);
464
+ }
465
+ const digest = sha256File(temporary);
466
+ if (expected.sha256 && expected.sha256 !== "unverified" && digest !== expected.sha256) {
467
+ // A bad body is re-downloaded from scratch rather than resumed onto.
468
+ rmSync(temporary, { force: true });
469
+ throw new Error(`sha256 mismatch: got ${digest}, expected ${expected.sha256}`);
470
+ }
471
+ rmSync(destination, { force: true });
472
+ const { renameSync } = await import("node:fs");
473
+ renameSync(temporary, destination);
474
+ return { sha256: digest, size: produced, tofu: !expected.sha256 || expected.sha256 === "unverified" };
475
+ } catch (error) {
476
+ if (attempt < attempts) {
477
+ const wait = 2000 * attempt;
478
+ log(` attempt ${attempt}/${attempts} failed (${String(error?.message ?? error)}); retrying in ${wait / 1000}s`);
479
+ have = existsSync(temporary) ? statSync(temporary).size : 0;
480
+ await new Promise((resolve) => setTimeout(resolve, wait));
481
+ continue;
482
+ }
483
+ throw new Error(`${url} failed after ${attempts} attempt(s) — ${String(error?.message ?? error)}`);
484
+ }
485
+ }
486
+ throw new Error(`download gave up after ${attempts} attempt(s): ${url}`);
487
+ }
488
+
489
+ /**
490
+ * Windows fallback route: let PowerShell/.NET perform the transfer.
491
+ *
492
+ * Measured here, this is not redundant with the Node routes. The local proxy
493
+ * rewrites GitHub's release-asset redirect back to itself (an infinite 301
494
+ * loop), while a direct Node connection to github.com times out or is reset.
495
+ * The .NET stack honours the system proxy, follows the redirect chain to
496
+ * release-assets.githubusercontent.com correctly, and reports the full asset
497
+ * length. It is slower (~0.13 MiB/s vs ~0.5 MiB/s), so it is a fallback, and
498
+ * the result is verified by the same size + checksum gate as every other route.
499
+ */
500
+ function downloadViaPowerShell(url, destination) {
501
+ const script = [
502
+ "$ErrorActionPreference = 'Stop'",
503
+ "$ProgressPreference = 'SilentlyContinue'",
504
+ `$url = '${url.replace(/'/g, "''")}'`,
505
+ `$out = '${destination.replace(/'/g, "''")}'`,
506
+ "$req = [System.Net.HttpWebRequest]::Create($url)",
507
+ "$req.UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) dsh-plugin-rea-bundle'",
508
+ "$req.AllowAutoRedirect = $true",
509
+ "$req.Timeout = 60000",
510
+ "$resp = $req.GetResponse()",
511
+ "$stream = $resp.GetResponseStream()",
512
+ "$file = [System.IO.File]::Create($out)",
513
+ "$stream.CopyTo($file)",
514
+ "$file.Close()",
515
+ "$stream.Close()",
516
+ "$resp.Close()",
517
+ "Write-Output ('bytes=' + (Get-Item $out).Length)",
518
+ ].join("; ");
519
+ const result = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], {
520
+ encoding: "utf8",
521
+ timeout: 0,
522
+ });
523
+ if (result.status !== 0) {
524
+ throw new Error(`powershell route failed (${result.status}): ${`${result.stderr ?? ""}${result.stdout ?? ""}`.trim().split("\n").slice(-2).join(" | ").slice(0, 200)}`);
525
+ }
526
+ return existsSync(destination) ? statSync(destination).size : 0;
527
+ }
528
+
529
+ /**
530
+ * Try each candidate route until one yields a usable response. A route fails on
531
+ * a transport error, or on an HTTP status that is not 2xx/3xx — a proxy that
532
+ * answers 403/404 for a host is not a working route for that host.
533
+ */
534
+ async function openViaBestRoute(url, { connectTimeoutMs, have }) {
535
+ const proxies = proxyCache.get(url) ?? resolveProxy();
536
+ proxyCache.set(url, proxies);
537
+ const target = new URL(url);
538
+ const routes = candidateRoutes(proxies, target);
539
+ const failures = [];
540
+ for (const route of routes) {
541
+ try {
542
+ const response = await openGetFollowing(url, { proxy: route.proxy, connectTimeoutMs, have });
543
+ return { ...response, via: route.label === "direct" ? "direct" : route.label };
544
+ } catch (error) {
545
+ failures.push(`${route.label}: ${String(error?.message ?? error)}`);
546
+ }
547
+ }
548
+ throw new Error(failures.join(" | "));
549
+ }
550
+
551
+ const proxyCache = new Map();
552
+
553
+ /** GET with redirects followed by hand (node:http does not follow them). */
554
+ async function openGetFollowing(url, { proxy, connectTimeoutMs, have, hops = 0 }) {
555
+ if (hops > 5) throw new Error(`too many redirects starting at ${url}`);
556
+ const response = await openGet(url, {
557
+ proxy,
558
+ connectTimeoutMs,
559
+ headers: have > 0 ? { range: `bytes=${have}-` } : undefined,
560
+ });
561
+ if (response.status >= 300 && response.status < 400 && response.headers.location) {
562
+ response.stream.resume(); // drain the redirect body
563
+ const next = new URL(response.headers.location, url).toString();
564
+ return openGetFollowing(next, { proxy, connectTimeoutMs, have, hops: hops + 1 });
565
+ }
566
+ if (response.status !== 200 && response.status !== 206) {
567
+ response.stream.resume();
568
+ throw new Error(`HTTP ${response.status} for ${url}`);
569
+ }
570
+ return response;
571
+ }
572
+
573
+ /** Stream a response body onto the partial file, resuming or restarting. */
574
+ async function writeBody(stream, headers, temporary, have, status, label) {
575
+ const restarted = have > 0 && status !== 206;
576
+ if (restarted) {
577
+ rmSync(temporary, { force: true });
578
+ have = 0;
579
+ }
580
+ const length = Number(headers["content-length"] ?? headers["Content-Length"] ?? 0);
581
+ const total = length + (restarted ? 0 : have);
582
+ const out = createWriteStream(temporary, restarted ? {} : { flags: "a" });
583
+ let seen = have;
584
+ let nextMark = 0;
585
+
586
+ // Progress is reported from the write side; pipe() owns the backpressure, so
587
+ // a 570 MB body never accumulates in memory and a slow disk cannot stall the
588
+ // promise. Settle on the write stream's lifecycle only: the response can end
589
+ // while its last bytes are still buffered, and resolving from that handler
590
+ // raced the promise and left it unsettled (setup hung after a full transfer).
591
+ out.on("pipe", (source) => {
592
+ source.on("data", (chunk) => {
593
+ seen += chunk.length;
594
+ if (total > 0 && seen >= nextMark) {
595
+ const percent = ((seen / total) * 100).toFixed(1);
596
+ if (!options.json) process.stdout.write(`\r ${label}: ${percent}% (${(seen / 1048576).toFixed(1)} MiB)`);
597
+ nextMark = seen + Math.max(total / 20, 1048576);
598
+ }
599
+ });
600
+ });
601
+
602
+ await new Promise((resolve, reject) => {
603
+ out.on("finish", resolve);
604
+ out.on("error", reject);
605
+ stream.on("error", reject);
606
+ stream.pipe(out);
607
+ });
608
+ if (!options.json) process.stdout.write("\r" + " ".repeat(72) + "\r");
609
+ return seen;
610
+ }
611
+
612
+ /** Extract with the platform's own tools. Returns true on success. */
613
+ function extract(archive, destination, format) {
614
+ mkdirSync(destination, { recursive: true });
615
+ if (format === "zip") {
616
+ if (IS_WINDOWS) {
617
+ const result = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-Command",
618
+ `Expand-Archive -LiteralPath '${archive.replace(/'/g, "''")}' -DestinationPath '${destination.replace(/'/g, "''")}' -Force`],
619
+ { stdio: options.json ? "ignore" : "inherit" });
620
+ return result.status === 0;
621
+ }
622
+ const result = spawnSync("unzip", ["-q", "-o", archive, "-d", destination], { stdio: options.json ? "ignore" : "inherit" });
623
+ return result.status === 0;
624
+ }
625
+ if (format === "tar.gz" || format === "tar.xz") {
626
+ const result = spawnSync("tar", ["-xf", archive, "-C", destination], { stdio: options.json ? "ignore" : "inherit" });
627
+ return result.status === 0;
628
+ }
629
+ throw new Error(`unsupported archive format: ${format}`);
630
+ }
631
+
632
+ /* --------------------------------------------------------------- host probing */
633
+
634
+ function hostKey() {
635
+ return `${process.platform}-${process.arch}`;
636
+ }
637
+
638
+ /**
639
+ * Substitute probe tokens. `{root}` is the bundle root; component tokens
640
+ * ({node},{jdk},{ghidra},{jvm},{vendor}) resolve through the components already
641
+ * provisioned or adopted this run, so a reused JDK is found at its real location
642
+ * instead of a vendor path that was never created.
643
+ */
644
+ function substituteProbe(value, context) {
645
+ const vendor = context.vendor;
646
+ let text = String(value);
647
+ // A token whose component is unresolved stays literal: the probe then fails
648
+ // with a readable ENOENT (and the component reports "unavailable") instead of
649
+ // throwing out of path.join.
650
+ const swap = (token, resolved) => {
651
+ if (typeof resolved === "string" && resolved !== "") text = text.replaceAll(token, resolved);
652
+ };
653
+ const vendorPath = (name) => {
654
+ const resolved = context.resolved?.[name];
655
+ if (resolved && typeof resolved.path === "string") return resolved.path;
656
+ // A component cannot resolve its own token before it is recorded. While
657
+ // provisioning `node`, {node} means the interpreter already running this
658
+ // script; while provisioning `jdk` or `ghidra`, the copy being unpacked
659
+ // right now is the answer (and {jvm} is its bin/java). Without this the
660
+ // component's own probe fails with a literal "{jvm}" even though the
661
+ // extraction succeeded — which silently discarded a fully downloaded JDK
662
+ // and a 543 MB Ghidra archive.
663
+ if (name === "node") {
664
+ // The component's own probe runs before it is recorded, so this is the one
665
+ // place that must find the vendored runtime directly. {node} means the
666
+ // interpreter, never the directory: returning vendor/node made the probe
667
+ // spawn a folder and fail with ENOENT even though the extraction was fine.
668
+ const installed = path.join(context.vendor, "node");
669
+ const executables = [path.join(installed, `node${EXE}`), path.join(installed, "bin", "node")];
670
+ return executables.find((candidate) => existsSync(candidate)) ?? process.execPath;
671
+ }
672
+ if (name === "jdk") return path.join(context.vendor, "jdk");
673
+ if (name === "ghidra") return path.join(context.vendor, "ghidra");
674
+ return null;
675
+ };
676
+ // Order matters. The path tokens that already carry a Windows extension
677
+ // ({node}, {jvm}) are expanded FIRST, and {exe} LAST. Doing it the other way
678
+ // round turned the apktool probe into
679
+ // "<...>\jdk\bin\java.exe\bin\java.exe": {jvm} resolved to a path ending in
680
+ // java.exe and then {exe} appended a second one.
681
+ swap("{vendor}", vendor);
682
+ swap("{vendorDir}", vendor);
683
+ swap("{root}", context.root);
684
+ swap("{node}", vendorPath("node"));
685
+ swap("{jdk}", vendorPath("jdk"));
686
+ swap("{ghidra}", vendorPath("ghidra"));
687
+ const jdk = vendorPath("jdk");
688
+ if (jdk) {
689
+ // {jdk} resolves to the JDK's java executable (that is what `binaries`
690
+ // records so a dependent component has something spawnable), while
691
+ // REA_JADX_MCP_JAR-style probes want JAVA_HOME. Derive it defensively:
692
+ // strip a trailing bin/java[.exe] if present, otherwise use the path as-is.
693
+ const home = /[\\/]bin[\\/]java(\.exe)?$/i.test(jdk) ? path.dirname(path.dirname(jdk)) : jdk;
694
+ swap("{jvm}", path.join(home, "bin", `java${EXE}`));
695
+ swap("{javahome}", home);
696
+ }
697
+ // {exe} is replaced unconditionally: on POSIX it is the empty string, and the
698
+ // `resolved !== ""` guard in swap() would leave a literal "adb{exe}" behind,
699
+ // which then fails its probe with ENOENT on Linux and macOS.
700
+ text = text.replaceAll("{exe}", EXE);
701
+ return text.replaceAll("/", path.sep);
702
+ }
703
+
704
+ function runProbe(probe, context) {
705
+ if (probe.file) {
706
+ const file = substituteProbe(probe.file, context);
707
+ return { ok: existsSync(file), detail: file };
708
+ }
709
+ if (!probe.argv) return { ok: true, detail: "no probe" };
710
+ const argv = probe.argv.map((value) => substituteProbe(value, context));
711
+ const result = spawnSync(argv[0], argv.slice(1), { encoding: "utf8", timeout: 120000 });
712
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
713
+ if (result.error) return { ok: false, detail: `${String(result.error.message)} (${argv[0]})` };
714
+ if (result.status !== 0) return { ok: false, detail: `exit ${result.status}: ${output.trim().slice(0, 200)}` };
715
+ const expect = probe.expect ?? {};
716
+ if (expect.contains && !output.includes(expect.contains)) return { ok: false, detail: `missing "${expect.contains}"` };
717
+ if (expect.regex && !new RegExp(expect.regex, "m").test(output)) return { ok: false, detail: `no match for /${expect.regex}/` };
718
+ return { ok: true, detail: output.trim().split("\n")[0].slice(0, 120) };
719
+ }
720
+
721
+ /**
722
+ * Normalise a component's sources into an ordered list of fetch entries.
723
+ * Two shapes exist in the manifest:
724
+ * sources: { "win32-x64": {...}, all: {...} } — one artifact
725
+ * entries: { all: [...], byPlatform: { "win32-x64": [...] } } — many files
726
+ */
727
+ function resolveEntries(component) {
728
+ if (component.entries) {
729
+ const key = hostKey();
730
+ const byPlatform = component.entries.byPlatform ?? {};
731
+ return [...(component.entries.all ?? []), ...(byPlatform[key] ?? [])];
732
+ }
733
+ const single = candidateSource(component);
734
+ return single ? [single] : [];
735
+ }
736
+
737
+ function candidateSource(component) {
738
+ const key = hostKey();
739
+ const sources = component.sources ?? {};
740
+ return sources[key] ?? sources.all ?? null;
741
+ }
742
+
743
+ /**
744
+ * A reused Node must satisfy REA's engines, not merely exist.
745
+ *
746
+ * `manifest.components.node.probe` is the contract and its `why` says the reuse
747
+ * path must reject an incompatible runtime *here*: adopting Node 20 recorded it
748
+ * in bundle.json and the failure surfaced much later as an unexplained REA start
749
+ * failure. A candidate that cannot be run at all is rejected for the same reason
750
+ * — on Windows a `.cmd`/`.bat` wrapper fails execFile with EINVAL, so it could
751
+ * never have launched REA either.
752
+ */
753
+ function reusableNodeChecked(candidate) {
754
+ const probe = manifest.components?.node?.probe ?? {};
755
+ const argv = (probe.argv ?? []).map((token) => (token === "{node}" ? candidate : token));
756
+ if (argv.length === 0) return { ok: true };
757
+ const run = spawnSync(argv[0], argv.slice(1), { encoding: "utf8", timeout: 30000, windowsHide: true });
758
+ if (run.error || run.status !== 0) {
759
+ return { ok: false, detail: `it could not be run (${run.error?.code ?? `exit ${run.status}`})` };
760
+ }
761
+ const stdout = String(run.stdout ?? "");
762
+ const firstLine = stdout.trim().split(/\r?\n/)[0] ?? "";
763
+ const regex = probe.expect?.regex;
764
+ if (typeof regex === "string" && regex !== "" && !new RegExp(regex, "u").test(stdout)) {
765
+ return { ok: false, detail: `it reports "${firstLine}", which REA's engines reject` };
766
+ }
767
+ return { ok: true };
768
+ }
769
+
770
+ /** Adopt an existing installation instead of downloading a duplicate. */
771
+ function reuseExisting(name, root) {
772
+ if (!options.reuse) return null;
773
+ const fileExists = (file) => file && existsSync(file);
774
+ if (name === "node") {
775
+ const candidates = [];
776
+ const override = process.env.REA_BUNDLE_NODE ?? null;
777
+ if (fileExists(override)) candidates.push({ path: override, how: "REA_BUNDLE_NODE" });
778
+ // Every PATH hit is considered, not just the first: a machine with an
779
+ // incompatible Node earlier on PATH still has a usable one later on.
780
+ const found = spawnSync(IS_WINDOWS ? "where" : "which", ["node"], { encoding: "utf8" });
781
+ for (const line of String(found.stdout ?? "").split(/\r?\n/).map((s) => s.trim()).filter(Boolean)) {
782
+ candidates.push({ path: line, how: "PATH" });
783
+ }
784
+ const seen = new Set();
785
+ for (const candidate of candidates) {
786
+ const key = path.resolve(candidate.path);
787
+ if (seen.has(key)) continue;
788
+ seen.add(key);
789
+ const checked = reusableNodeChecked(candidate.path);
790
+ if (checked.ok) return candidate;
791
+ step("node", "plan", `not reusing ${candidate.how} node ${candidate.path}: ${checked.detail}`);
792
+ }
793
+ return null;
794
+ }
795
+ if (name === "jdk") {
796
+ for (const [variable, how] of [["JAVA_HOME", "JAVA_HOME"]]) {
797
+ const home = process.env[variable];
798
+ if (home && existsSync(path.join(home, "bin", `javac${EXE}`))) return { path: home, how };
799
+ }
800
+ return null;
801
+ }
802
+ if (name === "ghidra") {
803
+ const install = process.env.GHIDRA_INSTALL_DIR;
804
+ if (install && existsSync(path.join(install, "support", IS_WINDOWS ? "analyzeHeadless.bat" : "analyzeHeadless"))) {
805
+ return { path: install, how: "GHIDRA_INSTALL_DIR" };
806
+ }
807
+ return null;
808
+ }
809
+ if (name === "browser") {
810
+ const localAppData = process.env.LOCALAPPDATA ?? "";
811
+ const candidates = IS_WINDOWS
812
+ ? [
813
+ // Chrome per-user install is the common shape on Windows and is
814
+ // missed by a Program Files-only probe.
815
+ localAppData && path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
816
+ path.join(process.env.ProgramFiles ?? "C:\\Program Files", "Google", "Chrome", "Application", "chrome.exe"),
817
+ path.join(process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)", "Google", "Chrome", "Application", "chrome.exe"),
818
+ path.join(process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)", "Microsoft", "Edge", "Application", "msedge.exe"),
819
+ path.join(process.env.ProgramFiles ?? "C:\\Program Files", "Microsoft", "Edge", "Application", "msedge.exe"),
820
+ ].filter(Boolean)
821
+ : process.platform === "darwin"
822
+ ? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"]
823
+ : ["/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/microsoft-edge"];
824
+ for (const candidate of candidates) if (existsSync(candidate)) return { path: candidate, how: "installed browser" };
825
+ return null;
826
+ }
827
+ return null;
828
+ }
829
+
830
+ /* ------------------------------------------------------------------- manifest */
831
+
832
+ /**
833
+ * Where this run's plugin files actually live.
834
+ *
835
+ * Two layouts exist. Normally setup runs from the plugin checkout and
836
+ * PLUGIN_ROOT is the truth. But the row launches the *mirrored* copy at
837
+ * <bundle>/bin/rea-shim.mjs, whose remediation text tells the operator to run
838
+ * <bundle>/scripts/setup.mjs — and in that layout PLUGIN_ROOT *is* the bundle
839
+ * root, so the mirror step would copy every file onto itself and loadManifest()
840
+ * would read a manifest that only the mirror step can create.
841
+ *
842
+ * setup therefore records the real checkout in <bundle>/mirror.json and a
843
+ * mirrored run reads it back. The record is advisory: it is honoured only while
844
+ * that directory still holds a manifest, so a moved or deleted plugin degrades
845
+ * to the mirrored copy instead of failing.
846
+ */
847
+ function sourceRoot() {
848
+ const self = path.resolve(PLUGIN_ROOT);
849
+ if (!samePath(self, options.root)) return self;
850
+ try {
851
+ const record = JSON.parse(readFileSync(path.join(options.root, "mirror.json"), "utf8"));
852
+ const recorded = record?.pluginRoot;
853
+ if (typeof recorded === "string" && recorded.trim() !== "") {
854
+ const candidate = path.resolve(recorded);
855
+ if (existsSync(path.join(candidate, "manifest", "bundle.manifest.json"))) return candidate;
856
+ }
857
+ } catch {
858
+ /* no record, or not readable: the mirrored copy is the only source left */
859
+ }
860
+ return self;
861
+ }
862
+
863
+ function loadManifest() {
864
+ const file = path.join(sourceRoot(), "manifest", "bundle.manifest.json");
865
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
866
+ return parsed;
867
+ }
868
+
869
+ const INSTALL_ORDER = ["node", "jdk", "ghidra", "apktool", "jadxHeadlessMcp", "androidPlatformTools", "reaAgents"];
870
+
871
+ /* -------------------------------------------------------------------- stages */
872
+
873
+ async function provisionComponent(name, component, context) {
874
+ const root = context.root;
875
+ const vendor = path.join(root, "vendor");
876
+
877
+ // 1) adopt an existing install first: a reused component is authoritative and
878
+ // makes the recorded-state check below redundant (and its message wrong).
879
+ const reused = reuseExisting(name, root);
880
+ if (reused) {
881
+ step(name, "skip", `reusing existing ${name} from ${reused.how}: ${reused.path}`);
882
+ return { path: reused.path, reused: true };
883
+ }
884
+
885
+ // 2) already present in the bundle and still verifiable?
886
+ //
887
+ // A recorded entry is a claim, not a fact: a previous run may have recorded a
888
+ // component it could not provision (path null / unavailable), and files can be
889
+ // deleted afterwards. Only a recorded path that exists AND passes the probe is
890
+ // trusted; anything else falls through to provisioning again.
891
+ if (existsSync(path.join(root, "bundle.json"))) {
892
+ const current = JSON.parse(readFileSync(path.join(root, "bundle.json"), "utf8"));
893
+ const recorded = current.components?.[name];
894
+ if (recorded && typeof recorded.path === "string" && existsSync(recorded.path)) {
895
+ const probe = runProbe(component.probe ?? {}, context);
896
+ if (probe.ok) {
897
+ step(name, "skip", `already provisioned (${recorded.version ?? "recorded"})`);
898
+ return recorded;
899
+ }
900
+ step(name, "plan", `recorded but unverifiable: ${probe.detail}`);
901
+ } else if (recorded) {
902
+ step(name, "plan", "recorded without a usable path; provisioning again");
903
+ }
904
+ }
905
+
906
+ // 3) the browser is never downloaded
907
+ if (name === "browser") {
908
+ step(name, "skip", "no Chromium-family browser found; browser scenarios stay unavailable");
909
+ return null;
910
+ }
911
+
912
+ if (name === "reaAgents") {
913
+ return provisionReaAgents(component, context);
914
+ }
915
+
916
+ const entries = resolveEntries(component);
917
+ if (entries.length === 0) {
918
+ step(name, "FAIL", `no source for ${hostKey()}`);
919
+ return null;
920
+ }
921
+
922
+ /* Multi-file components: fetch and place every file, then probe once. */
923
+ if (component.kind === "multi-file" || component.kind === "file") {
924
+ const placed = [];
925
+ for (const entry of entries) {
926
+ const targetDirectory = path.join(vendor, entry.moveTo ?? component.unpack?.moveTo ?? name);
927
+ if (options.dryRun) {
928
+ step(`${name}:${entry.id ?? "file"}`, "plan", `${entry.url} -> ${path.join(targetDirectory, entry.saveAs ?? path.basename(new URL(entry.url).pathname))}`);
929
+ continue;
930
+ }
931
+ const archiveName = path.basename(new URL(entry.url).pathname);
932
+ const archivePath = path.join(context.cache, archiveName);
933
+ let fetched = null;
934
+ if (existsSync(archivePath)) {
935
+ const digest = sha256File(archivePath);
936
+ const size = statSync(archivePath).size;
937
+ if ((!entry.size || size === entry.size) && (entry.sha256 === "unverified" || digest === entry.sha256)) {
938
+ fetched = { sha256: digest, tofu: !entry.sha256 || entry.sha256 === "unverified" };
939
+ log(` using cached ${archiveName}`);
940
+ } else {
941
+ rmSync(archivePath, { force: true });
942
+ }
943
+ }
944
+ if (!fetched) {
945
+ if (options.offline) {
946
+ step(`${name}:${entry.id ?? "file"}`, "FAIL", `offline and ${archiveName} is not in the cache`);
947
+ continue;
948
+ }
949
+ try {
950
+ fetched = await download(entry.url, archivePath, entry, `${name} ${entry.version ?? ""}`);
951
+ } catch (error) {
952
+ step(`${name}:${entry.id ?? "file"}`, "FAIL", String(error.message));
953
+ continue;
954
+ }
955
+ }
956
+ mkdirSync(targetDirectory, { recursive: true });
957
+ const target = path.join(targetDirectory, entry.saveAs ?? archiveName);
958
+ const { copyFileSync } = await import("node:fs");
959
+ copyFileSync(archivePath, target);
960
+ placed.push({ target, sha256: fetched.sha256, tofu: fetched.tofu });
961
+ }
962
+ if (options.dryRun) return { planned: true };
963
+ const probe = runProbe(component.probe ?? {}, context);
964
+ if (placed.length === 0) {
965
+ step(name, "FAIL", "nothing could be fetched");
966
+ return null;
967
+ }
968
+ step(name, probe.ok ? "ok" : "FAIL", `${placed.map((item) => item.target).join(", ")}${probe.ok ? "" : " — " + probe.detail}`);
969
+ return probe.ok
970
+ ? {
971
+ path: path.dirname(placed[0].target),
972
+ jarName: path.basename(placed[0].target),
973
+ version: entries[0].version,
974
+ sha256: placed[0].sha256,
975
+ tofu: placed.some((item) => item.tofu),
976
+ }
977
+ : null;
978
+ }
979
+
980
+ const source = entries[0];
981
+ if (options.dryRun) {
982
+ step(name, "plan", `${source.url} -> ${path.join(vendor, component.unpack?.moveTo ?? name)}`);
983
+ return { planned: true, version: source.version };
984
+ }
985
+
986
+ const archiveName = path.basename(new URL(source.url).pathname);
987
+ const archivePath = path.join(context.cache, archiveName);
988
+ let fetched;
989
+ if (existsSync(archivePath)) {
990
+ const size = statSync(archivePath).size;
991
+ const digest = sha256File(archivePath);
992
+ const matches = (!source.size || size === source.size) && (source.sha256 === "unverified" || digest === source.sha256);
993
+ if (matches) {
994
+ fetched = { sha256: digest, size, cached: true };
995
+ log(` using cached ${archiveName}`);
996
+ } else {
997
+ rmSync(archivePath, { force: true });
998
+ }
999
+ }
1000
+ if (!fetched) {
1001
+ if (options.offline) {
1002
+ step(name, "FAIL", `offline and ${archiveName} is not in the cache`);
1003
+ return null;
1004
+ }
1005
+ try {
1006
+ fetched = await download(source.url, archivePath, source, `${name} ${source.version ?? ""}`);
1007
+ } catch (error) {
1008
+ step(name, "FAIL", String(error.message));
1009
+ return null;
1010
+ }
1011
+ }
1012
+
1013
+ // single-file components
1014
+ if (source.format === "file") {
1015
+ const targetDirectory = path.join(vendor, component.unpack?.moveTo ?? name);
1016
+ mkdirSync(targetDirectory, { recursive: true });
1017
+ const target = path.join(targetDirectory, source.saveAs ?? archiveName);
1018
+ const { copyFileSync } = await import("node:fs");
1019
+ copyFileSync(archivePath, target);
1020
+ const probe = runProbe(component.probe ?? {}, context);
1021
+ step(name, probe.ok ? "ok" : "FAIL", `${target}${probe.ok ? "" : " — " + probe.detail}`);
1022
+ return probe.ok ? { path: target, version: source.version, sha256: fetched.sha256 } : null;
1023
+ }
1024
+
1025
+ const target = path.join(vendor, component.unpack?.moveTo ?? name);
1026
+ rmSync(target, { recursive: true, force: true });
1027
+ const staging = target + ".staging";
1028
+ rmSync(staging, { recursive: true, force: true });
1029
+ const unpacked = extract(archivePath, staging, source.format);
1030
+ if (!unpacked) {
1031
+ step(name, "FAIL", `extraction failed for ${archiveName}`);
1032
+ return null;
1033
+ }
1034
+ const strip = component.unpack?.stripRootComponents ?? 0;
1035
+ let effective = staging;
1036
+ for (let level = 0; level < strip; level += 1) {
1037
+ // Descend into the archive's single root directory. If the level is not a
1038
+ // single directory (some archives are already flat), stop rather than guess.
1039
+ const { readdirSync } = await import("node:fs");
1040
+ const entries = readdirSync(effective).filter((entry) => !entry.startsWith("."));
1041
+ if (entries.length !== 1 || !statSync(path.join(effective, entries[0])).isDirectory()) break;
1042
+ effective = path.join(effective, entries[0]);
1043
+ }
1044
+ const { renameSync } = await import("node:fs");
1045
+ rmSync(target, { recursive: true, force: true });
1046
+ renameSync(effective, target);
1047
+ rmSync(staging, { recursive: true, force: true });
1048
+
1049
+ const probe = runProbe(component.probe ?? {}, context);
1050
+ step(name, probe.ok ? "ok" : "FAIL", `${target}${probe.ok ? "" : " — " + probe.detail}`);
1051
+ if (!probe.ok) return null;
1052
+ // A component's resolved path is a DIRECTORY, but `requires` needs something
1053
+ // spawnable: without this, {node} would resolve to vendor/node and the probe
1054
+ // for a dependent component would try to launch a directory (ENOENT).
1055
+ let resolvedPath = target;
1056
+ for (const relative of component.binaries ?? []) {
1057
+ const candidate = path.join(target, ...relative.split("/").map((part) => part.replaceAll("{exe}", EXE)));
1058
+ if (existsSync(candidate)) {
1059
+ resolvedPath = candidate;
1060
+ break;
1061
+ }
1062
+ }
1063
+ return { path: resolvedPath, root: target, version: source.version, sha256: fetched.sha256, tofu: fetched.tofu };
1064
+ }
1065
+
1066
+ /**
1067
+ * The registry tarball for rea-agents ships NO node_modules (verified: 1161
1068
+ * entries, 0 under node_modules), so unpacking it alone yields a CLI that
1069
+ * cannot import its own dependencies. A real install is therefore required to
1070
+ * resolve the closure into the vendor directory.
1071
+ */
1072
+ async function provisionReaAgents(component, context) {
1073
+ const installRoot = path.join(context.root, "vendor");
1074
+ // npm --prefix installs into <root>/node_modules, not <root>/<name>.
1075
+ const vendor = path.join(installRoot, "node_modules", "rea-agents");
1076
+ const version = component.sources.npm.version;
1077
+ const probe = () =>
1078
+ runProbe(
1079
+ { argv: ["{node}", path.join(vendor, "scripts", "rea.mjs"), "--version"], expect: { regex: `^${version.replace(/\./g, "\\.")}` } },
1080
+ context,
1081
+ );
1082
+
1083
+ if (existsSync(path.join(vendor, "scripts", "rea.mjs"))) {
1084
+ const current = probe();
1085
+ if (current.ok) {
1086
+ step("reaAgents", "skip", `already vendored (${version})`);
1087
+ return { path: vendor, version };
1088
+ }
1089
+ log(` vendored copy is unusable (${current.detail}); reinstalling`);
1090
+ }
1091
+
1092
+ if (options.dryRun) {
1093
+ step("reaAgents", "plan", `npm install ${component.sources.npm.registry.replace("https://registry.npmjs.org/", "")}@${version} --prefix ${installRoot}`);
1094
+ return { planned: true, version };
1095
+ }
1096
+
1097
+ /*
1098
+ * A plain pack+unpack cannot work here: the published tarball carries no
1099
+ * node_modules, so the CLI would fail with ERR_MODULE_NOT_FOUND on its own
1100
+ * dependencies. A real install resolves the dependency closure.
1101
+ *
1102
+ * Lifecycle scripts are skipped on purpose. REA needs no build step, the
1103
+ * vendored copy must be byte-predictable, and pnpm/npm would otherwise be
1104
+ * free to run arbitrary package scripts during a provisioning step.
1105
+ */
1106
+ mkdirSync(installRoot, { recursive: true });
1107
+
1108
+ /*
1109
+ * Resolve npm, do not assume it.
1110
+ *
1111
+ * On the machine this bundle exists for, `npm` is only on PATH because a
1112
+ * system Node happens to be installed; the bootstrap's whole job is the case
1113
+ * where it is not. A bare `spawnSync("npm", ...)` therefore dies with ENOENT
1114
+ * on exactly the target machine — and did, until this was fixed. The vendored
1115
+ * or discovered Node ships its own npm-cli.js, which is used instead.
1116
+ */
1117
+ const nodeRuntime = context.resolved.node?.path && existsSync(context.resolved.node.path)
1118
+ ? context.resolved.node.path
1119
+ : process.execPath;
1120
+ const npmCandidates = [
1121
+ // Portable Windows zip: <node>\node_modules\npm\bin\npm-cli.js
1122
+ path.join(path.dirname(nodeRuntime), "node_modules", "npm", "bin", "npm-cli.js"),
1123
+ // POSIX tarball: <node>/lib/node_modules/npm/bin/npm-cli.js
1124
+ path.join(path.dirname(nodeRuntime), "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
1125
+ // An installed Node: <prefix>/lib/node_modules/..., also covered above.
1126
+ path.join(path.dirname(nodeRuntime), "..", "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
1127
+ ];
1128
+ const npmCli = npmCandidates.find((candidate) => existsSync(candidate)) ?? null;
1129
+
1130
+ const installArgs = [
1131
+ "install",
1132
+ `rea-agents@${version}`,
1133
+ "--prefix", installRoot,
1134
+ "--no-save",
1135
+ "--ignore-scripts",
1136
+ "--no-audit",
1137
+ "--no-fund",
1138
+ "--loglevel", "error",
1139
+ ];
1140
+ if (!npmCli) {
1141
+ step("reaAgents", "FAIL", `no npm-cli.js next to ${nodeRuntime}; cannot resolve the dependency closure without npm`);
1142
+ return null;
1143
+ }
1144
+ log(` ${path.relative(installRoot, npmCli) || npmCli} (vendored npm, not PATH)`);
1145
+ const install = spawnSync(nodeRuntime, [npmCli, ...installArgs], {
1146
+ cwd: installRoot,
1147
+ encoding: "utf8",
1148
+ timeout: 1800000,
1149
+ });
1150
+ if (install.status !== 0) {
1151
+ step("reaAgents", "FAIL", `npm install failed (${install.status}): ${`${install.stderr ?? ""}${install.stdout ?? ""}`.trim().split("\n").slice(-3).join(" | ").slice(0, 300)}`);
1152
+ return null;
1153
+ }
1154
+
1155
+ const current = probe();
1156
+ step("reaAgents", current.ok ? "ok" : "FAIL", `${vendor}${current.ok ? "" : " — " + current.detail}`);
1157
+ return current.ok ? { path: vendor, version } : null;
1158
+ }
1159
+
1160
+ /* ---------------------------------------------------------------------- main */
1161
+
1162
+ const options = parseArgs(process.argv.slice(2));
1163
+ if (options.help) {
1164
+ process.stdout.write(readFileSync(fileURLToPath(import.meta.url), "utf8").split("*/")[0].replace(/^\/\*\*?/, "") + "\n");
1165
+ process.exit(0);
1166
+ }
1167
+
1168
+ mkdirSync(options.root, { recursive: true });
1169
+ mkdirSync(options.cache, { recursive: true });
1170
+
1171
+ log(`rea bundle setup`);
1172
+ log(` plugin ${sourceRoot()}`);
1173
+ log(` root ${options.root}`);
1174
+ log(` cache ${options.cache}`);
1175
+ log(` host ${hostKey()} node ${process.versions.node}`);
1176
+ log(` mode ${options.dryRun ? "dry-run " : ""}${options.reuse ? "reuse " : ""}${options.offline ? "offline" : "online"}`.trimEnd());
1177
+ log("");
1178
+
1179
+ const manifest = loadManifest();
1180
+ const context = {
1181
+ root: options.root,
1182
+ cache: options.cache,
1183
+ vendor: path.join(options.root, "vendor"),
1184
+ resolved: {},
1185
+ };
1186
+ const results = {};
1187
+
1188
+ for (const name of INSTALL_ORDER) {
1189
+ const component = manifest.components[name];
1190
+ if (!component) continue;
1191
+ if (options.only && !options.only.includes(name)) continue;
1192
+ const missingRequirement = (component.requires ?? []).find((requirement) => !context.resolved[requirement]);
1193
+ if (missingRequirement && !options.only) {
1194
+ step(name, "FAIL", `skipped: required component "${missingRequirement}" is unavailable`);
1195
+ results[name] = null;
1196
+ continue;
1197
+ }
1198
+ const resolvedValue = await provisionComponent(name, component, context);
1199
+ results[name] = resolvedValue;
1200
+ // A component satisfies another's `requires` when it has a real path — or when
1201
+ // this run only *planned* it. In dry-run every component returns
1202
+ // `{ planned: true }` with no path, so counting only real paths made each
1203
+ // dependent plan entry look like a missing dependency: on a bare machine, which
1204
+ // is exactly the machine the README tells to run `--dry-run` first, the whole
1205
+ // chain reported FAIL and the command exited 1.
1206
+ if (resolvedValue && (typeof resolvedValue.path === "string" || resolvedValue.planned === true)) {
1207
+ context.resolved[name] = resolvedValue;
1208
+ }
1209
+ }
1210
+
1211
+ /* browser is adopted, never downloaded */
1212
+ const browser = reuseExisting("browser", options.root);
1213
+ if (browser) step("browser", "skip", `adopting ${browser.path}`);
1214
+
1215
+ /* --------------------------------------------------------------- wire + probe */
1216
+
1217
+ const nodePath = typeof results.node?.path === "string" && existsSync(results.node.path)
1218
+ ? results.node.path
1219
+ : process.execPath;
1220
+
1221
+ const paths = {
1222
+ node: nodePath,
1223
+ reaEntry: path.join(options.root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs"),
1224
+ ghidra: results.ghidra?.path ?? null,
1225
+ jdk: results.jdk?.path ?? null,
1226
+ adb: results.androidPlatformTools?.path ? path.join(results.androidPlatformTools.path, `adb${EXE}`) : null,
1227
+ apktoolCommand: results.apktool?.path
1228
+ ? path.join(results.apktool.path, IS_WINDOWS ? "apktool.bat" : "apktool")
1229
+ : null,
1230
+ jadxJar: results.jadxHeadlessMcp?.path
1231
+ ? path.join(results.jadxHeadlessMcp.path, results.jadxHeadlessMcp.jarName ?? "")
1232
+ : null,
1233
+ browser: browser?.path ?? null,
1234
+ ilspycmd: process.env.REA_ILSPY_CMD_PATH ?? null,
1235
+ };
1236
+
1237
+ if (!options.dryRun) {
1238
+ const bundleConfig = {
1239
+ schema: 1,
1240
+ provisionedAt: new Date().toISOString(),
1241
+ host: { platform: process.platform, arch: process.arch, node: process.versions.node },
1242
+ plugin: { name: "dsh-plugin-rea", root: sourceRoot() },
1243
+ paths,
1244
+ versions: {
1245
+ reaAgents: results.reaAgents?.version ?? null,
1246
+ ghidra: results.ghidra?.version ?? null,
1247
+ jdk: results.jdk?.version ?? null,
1248
+ apktool: results.apktool?.version ?? null,
1249
+ jadxHeadlessMcp: results.jadxHeadlessMcp?.version ?? null,
1250
+ node: results.node?.version ?? process.versions.node,
1251
+ },
1252
+ components: Object.fromEntries(
1253
+ Object.entries(results).map(([name, value]) => [name, value ?? { path: null, unavailable: true }]),
1254
+ ),
1255
+ unverifiedChecksums: Object.entries(results)
1256
+ .filter(([, value]) => value?.tofu)
1257
+ .map(([name]) => name),
1258
+ };
1259
+ writeFileSync(path.join(options.root, "bundle.json"), JSON.stringify(bundleConfig, null, 2) + "\n");
1260
+ step("wire", "ok", path.join(options.root, "bundle.json"));
1261
+
1262
+ // The row launches <DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs; create it
1263
+ // before probing, so the probe exercises exactly what the row will start.
1264
+ mirrorLauncher(context);
1265
+
1266
+ // Readiness probe: REA's own verdict, run through the vendored CLI.
1267
+ //
1268
+ // `rea doctor` exits non-zero when any check is unmet, and on Windows some
1269
+ // checks are unmet by upstream design (Hopper is macOS-only; IDA needs a
1270
+ // user-owned IDA). A non-zero exit is therefore NOT a provisioning failure:
1271
+ // what matters is that the CLI runs, emits parseable JSON, and reports the
1272
+ // tool catalog. Unmet checks are reported, not treated as errors.
1273
+ const reaEntry = paths.reaEntry;
1274
+ if (existsSync(reaEntry)) {
1275
+ const doctor = spawnSync(nodePath, [reaEntry, "doctor", "--json"], { encoding: "utf8", timeout: 300000 });
1276
+ const stdout = doctor.stdout ?? "";
1277
+ let parsed = null;
1278
+ try {
1279
+ parsed = JSON.parse(stdout);
1280
+ } catch {
1281
+ parsed = null;
1282
+ }
1283
+ if (parsed) {
1284
+ const unmet = (parsed.checks ?? []).filter((check) => !check.ok);
1285
+ const tools = parsed.identity?.catalog?.counts?.mcp_tools ?? "?";
1286
+ step("probe", "ok", `cli=${parsed.identity?.cli_package_version ?? "?"} healthy=${parsed.healthy} tools=${tools} unmet=${unmet.length}`);
1287
+ for (const check of unmet) log(` unmet: ${check.name} — ${check.remediation ?? check.detail ?? ""}`);
1288
+ } else {
1289
+ step(
1290
+ "probe",
1291
+ "FAIL",
1292
+ `rea doctor produced no JSON (exit ${doctor.status}): ${`${stdout}${doctor.stderr ?? ""}`.trim().slice(0, 200)}`,
1293
+ );
1294
+ }
1295
+ } else {
1296
+ step("probe", "FAIL", "vendored REA CLI is missing; the MCP row will report unprovisioned");
1297
+ }
1298
+
1299
+ // The real end-to-end check: launch the exact shim the MCP row will use
1300
+ // (the mirrored copy, not the source tree) and confirm it relays the CLI.
1301
+ const mirroredShim = path.join(options.root, "bin", "rea-shim.mjs");
1302
+ if (existsSync(mirroredShim)) {
1303
+ const shim = spawnSync(process.execPath, [mirroredShim, "--version"], {
1304
+ encoding: "utf8",
1305
+ timeout: 120000,
1306
+ env: { ...process.env, REA_BUNDLE_ROOT: options.root },
1307
+ });
1308
+ const output = `${shim.stdout ?? ""}${shim.stderr ?? ""}`.trim();
1309
+ const ok = shim.status === 0 && /\d+\.\d+\.\d+/.test(output);
1310
+ step("shim", ok ? "ok" : "FAIL", ok ? `resolved to REA ${output.split(/\r?\n/)[0]}` : output.slice(0, 240));
1311
+ } else {
1312
+ step("shim", "FAIL", `the launcher the patch expects is missing: ${mirroredShim}`);
1313
+ }
1314
+ } else {
1315
+ step("wire", "plan", `would write ${path.join(options.root, "bundle.json")}`);
1316
+ }
1317
+
1318
+ /* ------------------------------------------------------------------ mirroring */
1319
+
1320
+ /**
1321
+ * Copy the launcher into the bundle root.
1322
+ *
1323
+ * The published patch points at `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`
1324
+ * and nothing else creates that file, so without this step the MCP row would
1325
+ * reference a path that never exists — provisioning could succeed completely and
1326
+ * the row would still fail with MODULE_NOT_FOUND. Everything the shim needs at
1327
+ * run time is mirrored next to it, because the shim derives its plugin root from
1328
+ * its own location.
1329
+ *
1330
+ * Runs before the probe so the probe tests what the row actually launches.
1331
+ */
1332
+ function mirrorLauncher(context) {
1333
+ const source = sourceRoot();
1334
+ const sources = [
1335
+ ["bin/rea-shim.mjs", path.join("bin", "rea-shim.mjs")],
1336
+ ["scripts/setup.mjs", path.join("scripts", "setup.mjs")],
1337
+ // The manifest travels with the mirrored setup: that copy resolves its own
1338
+ // plugin root to the bundle root, so loadManifest() would otherwise read
1339
+ // <bundle>/manifest/bundle.manifest.json — a file only this step can create.
1340
+ // Without it, every later run of the mirror dies in loadManifest() with a
1341
+ // bare ENOENT (invisible when the shim started it detached), and the shim's
1342
+ // own remediation text sends the operator straight into that failure.
1343
+ ["manifest/bundle.manifest.json", path.join("manifest", "bundle.manifest.json")],
1344
+ ];
1345
+ const copied = [];
1346
+ for (const [from, to] of sources) {
1347
+ const origin = path.join(source, ...from.split("/"));
1348
+ if (!existsSync(origin)) {
1349
+ step("mirror", "FAIL", `cannot mirror ${from}: not found in ${source}`);
1350
+ return false;
1351
+ }
1352
+ const target = path.join(context.root, ...to.split("/"));
1353
+ if (samePath(origin, target)) {
1354
+ // Running from the mirror, with no usable record of the original checkout:
1355
+ // the file is already the one the row launches. Copying it onto itself
1356
+ // would report success while doing nothing.
1357
+ copied.push(`${to.replaceAll("\\", "/")} (in place)`);
1358
+ continue;
1359
+ }
1360
+ try {
1361
+ mkdirSync(path.dirname(target), { recursive: true });
1362
+ copyFileSync(origin, target);
1363
+ } catch (error) {
1364
+ // A read-only or occupied bundle root must produce a readable step failure
1365
+ // rather than a raw stack — when the shim starts this detached, stderr is
1366
+ // discarded and the log is the only channel the operator has.
1367
+ step("mirror", "FAIL", `cannot write ${target}: ${String(error && error.message)}`);
1368
+ return false;
1369
+ }
1370
+ copied.push(to.replaceAll("\\", "/"));
1371
+ }
1372
+ writeMirrorRecord(context, source);
1373
+ step("mirror", "ok", `${path.join(context.root, "bin", "rea-shim.mjs")} (${copied.join(", ")})`);
1374
+ return true;
1375
+ }
1376
+
1377
+ /**
1378
+ * Remember which checkout these files came from, so a later mirrored run — or a
1379
+ * plugin upgrade, which replaces the checkout but not the bundle — can go back
1380
+ * to the real source instead of copying the bundle onto itself and provisioning
1381
+ * the previous manifest forever. Advisory only; see sourceRoot().
1382
+ */
1383
+ function writeMirrorRecord(context, source) {
1384
+ const record = { schema: 1, pluginRoot: source, mirroredAt: new Date().toISOString() };
1385
+ try {
1386
+ mkdirSync(context.root, { recursive: true });
1387
+ writeFileSync(path.join(context.root, "mirror.json"), JSON.stringify(record, null, 2) + "\n");
1388
+ } catch (error) {
1389
+ // An unwritable root must not fail provisioning; the only cost is that a
1390
+ // mirrored run cannot find the original checkout again.
1391
+ log(` mirror.json not written: ${String(error && error.message)}`);
1392
+ }
1393
+ }
1394
+
1395
+ /* -------------------------------------------------------------------- summary */
1396
+
1397
+ /**
1398
+ * The manifest may declare a component optional (adb today: without a device
1399
+ * those tools simply report no devices). Ignoring that field made an unreachable
1400
+ * dl.google.com fail the whole run — on a machine where everything REA actually
1401
+ * needs had provisioned fine. An optional failure is reported, never hidden, and
1402
+ * it never fails the run; the component is simply left unrecorded, so the next
1403
+ * run retries it.
1404
+ *
1405
+ * A component that another component `requires` is never treated as optional,
1406
+ * whatever the manifest says: exiting 0 because a dependency "was optional"
1407
+ * would strand the dependent component with no diagnostic.
1408
+ */
1409
+ const requiredByOthers = (name) =>
1410
+ INSTALL_ORDER.some((other) => other !== name && (manifest.components?.[other]?.requires ?? []).includes(name));
1411
+ const optionalFailure = (entry) => {
1412
+ const component = String(entry.name).split(":")[0];
1413
+ return manifest.components?.[component]?.optional === true && !requiredByOthers(component);
1414
+ };
1415
+
1416
+ const failures = state.steps.filter((entry) => entry.status === "FAIL");
1417
+ const failed = failures.filter((entry) => !optionalFailure(entry));
1418
+ const optionalUnavailable = failures.filter(optionalFailure);
1419
+
1420
+ if (options.json) {
1421
+ process.stdout.write(
1422
+ JSON.stringify(
1423
+ { ok: failed.length === 0, steps: state.steps, log: state.log, optionalUnavailable: optionalUnavailable.map((entry) => entry.name) },
1424
+ null,
1425
+ 2,
1426
+ ) + "\n",
1427
+ );
1428
+ } else {
1429
+ log("");
1430
+ log(failed.length === 0 ? "setup complete." : `setup finished with ${failed.length} unmet step(s).`);
1431
+ for (const entry of failed) log(` unmet: ${entry.name} — ${entry.detail}`);
1432
+ if (optionalUnavailable.length > 0) {
1433
+ log("");
1434
+ log("optional component(s) unavailable — the bundle works without them:");
1435
+ for (const entry of optionalUnavailable) log(` optional: ${entry.name} — ${entry.detail}`);
1436
+ }
1437
+ log("");
1438
+ if (failed.length === 0) {
1439
+ // Release the provisioning lock the shim set, so a later first run on a new
1440
+ // machine (or after a manual wipe) can start again. A failed run keeps the
1441
+ // lock, but the shim treats a lock older than six hours as stale.
1442
+ rmSync(path.join(options.root, "setup.lock"), { force: true });
1443
+ log("restart DSH (or reload the profile) so the MCP row reconnects.");
1444
+ // Point at the mirrored copy, not at this checkout's: the mirror resolves its
1445
+ // bundle from its own location, so this command is right for the root this run
1446
+ // actually provisioned — including a custom --root.
1447
+ log(`status: node ${path.join(options.root, "bin", "rea-shim.mjs")} --shim-status`);
1448
+ } else {
1449
+ log("some components are unmet; fix the cause and re-run this command.");
1450
+ }
1451
+ log(`log: ${logFilePath()}`);
1452
+ }
1453
+ process.exit(failed.length === 0 ? 0 : 1);