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.
- package/CHANGELOG.md +133 -0
- package/README.md +182 -72
- package/README.zh-CN.md +186 -85
- package/assets/make-screenshots.ps1 +14 -8
- package/assets/screenshot-1.png +0 -0
- package/bin/rea-shim.mjs +356 -0
- package/check-updates.ps1 +68 -0
- package/cordis.patch.yml +39 -31
- package/docs/bundle-design.md +187 -0
- package/manifest/bundle.manifest.json +399 -0
- package/package.json +22 -3
- package/scripts/audit-manifest.mjs +445 -0
- package/scripts/rea-bundle-bootstrap.ps1 +192 -0
- package/scripts/setup.mjs +1453 -0
- package/scripts/setup.test.mjs +418 -0
- package/scripts/verify-branch.ps1 +131 -0
|
@@ -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);
|