dsh-plugin-rea 2.0.2 → 2.0.3
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 +92 -0
- package/README.md +16 -7
- package/README.zh-CN.md +15 -6
- package/assets/make-screenshots.ps1 +2 -2
- package/assets/screenshot-1.png +0 -0
- package/bin/rea-shim.mjs +112 -19
- package/check-updates.ps1 +90 -15
- package/docs/bundle-design.md +6 -5
- package/manifest/bundle.manifest.json +48 -41
- package/package.json +2 -3
- package/scripts/audit-manifest.mjs +98 -0
- package/scripts/rea-bundle-bootstrap.ps1 +37 -10
- package/scripts/setup.mjs +352 -87
- package/scripts/setup.test.mjs +683 -4
- package/examples/cordis.patch.posix.yml +0 -18
- package/examples/cordis.patch.with-env.yml +0 -42
package/scripts/setup.mjs
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import { spawnSync } from "node:child_process";
|
|
28
28
|
import { createHash } from "node:crypto";
|
|
29
|
-
import { appendFileSync, closeSync, copyFileSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { appendFileSync, chmodSync, closeSync, copyFileSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
30
30
|
import { request as httpRequest } from "node:http";
|
|
31
31
|
import { request as httpsRequest } from "node:https";
|
|
32
32
|
import { homedir } from "node:os";
|
|
@@ -69,6 +69,22 @@ function defaultBundleRoot() {
|
|
|
69
69
|
return path.join(dshHome(), "plugins", "rea-bundle");
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* A numeric environment override, or the default.
|
|
74
|
+
*
|
|
75
|
+
* `Number("abc")` is NaN, and NaN does not stay local: `REA_BUNDLE_FETCH_RETRIES=abc`
|
|
76
|
+
* produced "gave up after NaN attempt(s)", and a NaN socket timeout behaves as 0
|
|
77
|
+
* (immediate expiry) rather than as "no override". Anything that is not a positive
|
|
78
|
+
* finite number is therefore ignored, and the effective values are logged below so
|
|
79
|
+
* an override is never invisible.
|
|
80
|
+
*/
|
|
81
|
+
function envNumber(name, fallback) {
|
|
82
|
+
const raw = process.env[name];
|
|
83
|
+
if (raw === undefined || String(raw).trim() === "") return fallback;
|
|
84
|
+
const value = Number(raw);
|
|
85
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
function parseArgs(argv) {
|
|
73
89
|
const options = {
|
|
74
90
|
root: defaultBundleRoot(),
|
|
@@ -82,9 +98,9 @@ function parseArgs(argv) {
|
|
|
82
98
|
// be declared dead quickly when the socket never opens, but a slow-but-alive
|
|
83
99
|
// transfer must not be killed for taking long overall (measured here:
|
|
84
100
|
// 0.35–0.54 MiB/s, where 570 MB legitimately takes ~30 minutes).
|
|
85
|
-
connectTimeoutMs:
|
|
86
|
-
idleTimeoutMs:
|
|
87
|
-
retries:
|
|
101
|
+
connectTimeoutMs: envNumber("REA_BUNDLE_CONNECT_TIMEOUT_MS", 15000),
|
|
102
|
+
idleTimeoutMs: envNumber("REA_BUNDLE_IDLE_TIMEOUT_MS", 120000),
|
|
103
|
+
retries: envNumber("REA_BUNDLE_FETCH_RETRIES", 4),
|
|
88
104
|
};
|
|
89
105
|
for (let i = 0; i < argv.length; i += 1) {
|
|
90
106
|
const arg = argv[i];
|
|
@@ -307,11 +323,20 @@ function openGet(url, { proxy, headers, connectTimeoutMs }) {
|
|
|
307
323
|
},
|
|
308
324
|
(res) => done(res, "direct"),
|
|
309
325
|
);
|
|
326
|
+
let connected = false;
|
|
310
327
|
req.on("socket", (socket) => {
|
|
311
328
|
socket.setTimeout(connectTimeout);
|
|
312
|
-
socket.once("connect", () =>
|
|
329
|
+
socket.once("connect", () => {
|
|
330
|
+
connected = true;
|
|
331
|
+
socket.setTimeout(idleTimeout);
|
|
332
|
+
});
|
|
313
333
|
});
|
|
314
|
-
|
|
334
|
+
// The socket carries the connect deadline until it connects and the idle
|
|
335
|
+
// deadline afterwards. Reporting the idle number for a connection that never
|
|
336
|
+
// happened sent the reader after the wrong setting.
|
|
337
|
+
req.on("timeout", () =>
|
|
338
|
+
req.destroy(new Error(connected ? `no data within ${idleTimeout}ms` : `no connection within ${connectTimeout}ms`)),
|
|
339
|
+
);
|
|
315
340
|
req.on("error", reject);
|
|
316
341
|
req.end();
|
|
317
342
|
return;
|
|
@@ -387,11 +412,27 @@ async function download(url, destination, expected, label) {
|
|
|
387
412
|
rmSync(temporary, { force: true });
|
|
388
413
|
have = 0;
|
|
389
414
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
415
|
+
// A `.part` already holding every expected byte is finished work, not a resume
|
|
416
|
+
// point: a crash between the last write and the rename leaves exactly this
|
|
417
|
+
// shape. Resuming it sends `Range: bytes=<size>-`, which servers answer with
|
|
418
|
+
// 416, so four attempts later a complete download was reported failed (and on
|
|
419
|
+
// Windows the "resume" simply deleted it and fetched 570 MB again). Verify it
|
|
420
|
+
// and finish the rename instead.
|
|
421
|
+
if (have > 0 && expected.size && have === expected.size) {
|
|
422
|
+
const digest = sha256File(temporary);
|
|
423
|
+
const matches = !expected.sha256 || expected.sha256 === "unverified" || digest === expected.sha256;
|
|
424
|
+
if (matches) {
|
|
425
|
+
log(` ${label}: partial file was already complete; verified and kept`);
|
|
426
|
+
rmSync(destination, { force: true });
|
|
427
|
+
renameSync(temporary, destination);
|
|
428
|
+
return { sha256: digest, size: have, tofu: !expected.sha256 || expected.sha256 === "unverified" };
|
|
429
|
+
}
|
|
430
|
+
log(` ${label}: partial file is complete but the checksum does not match; starting over`);
|
|
431
|
+
rmSync(temporary, { force: true });
|
|
432
|
+
have = 0;
|
|
394
433
|
}
|
|
434
|
+
if (have > 0) log(` downloading ${label} (resuming at ${(have / 1048576).toFixed(1)} MiB)`);
|
|
435
|
+
else log(` downloading ${label}`);
|
|
395
436
|
|
|
396
437
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
397
438
|
try {
|
|
@@ -412,29 +453,29 @@ async function download(url, destination, expected, label) {
|
|
|
412
453
|
let via = null;
|
|
413
454
|
let nodeFailure = null;
|
|
414
455
|
const tryPowerShellFirst = IS_WINDOWS;
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
456
|
+
const viaPowerShell = async () => {
|
|
457
|
+
// Resume through the .NET route as well: it is the Windows default, it
|
|
458
|
+
// honours the system proxy, and the 570 MB Ghidra archive is the transfer
|
|
459
|
+
// that matters. Deleting the partial file first — what this used to do —
|
|
460
|
+
// made every retry a full re-download, the opposite of the resumable
|
|
461
|
+
// behaviour this module promises. downloadViaPowerShell appends only when
|
|
462
|
+
// the server answers 206.
|
|
463
|
+
const written = downloadViaPowerShell(url, temporary, have);
|
|
464
|
+
log(` ${label}: via powershell (${(written / 1048576).toFixed(1)} MiB)`);
|
|
465
|
+
return "powershell";
|
|
466
|
+
};
|
|
467
|
+
const viaNode = async () => {
|
|
468
|
+
// Both routes write into the same .part. Trusting the `have` captured
|
|
469
|
+
// before this attempt appended a full 200 response onto the bytes the
|
|
470
|
+
// failed route had already written, producing a file that could only fail
|
|
471
|
+
// the size gate on the way out. Read the file's real length instead.
|
|
472
|
+
have = existsSync(temporary) ? statSync(temporary).size : 0;
|
|
473
|
+
const response = await openViaBestRoute(url, { connectTimeoutMs, have });
|
|
474
|
+
await writeBody(response.stream, response.headers, temporary, have, response.status, label);
|
|
475
|
+
return response.via;
|
|
476
|
+
};
|
|
477
|
+
const first = tryPowerShellFirst ? viaPowerShell : viaNode;
|
|
478
|
+
const second = tryPowerShellFirst ? viaNode : viaPowerShell;
|
|
438
479
|
|
|
439
480
|
try {
|
|
440
481
|
via = await first();
|
|
@@ -469,7 +510,6 @@ async function download(url, destination, expected, label) {
|
|
|
469
510
|
throw new Error(`sha256 mismatch: got ${digest}, expected ${expected.sha256}`);
|
|
470
511
|
}
|
|
471
512
|
rmSync(destination, { force: true });
|
|
472
|
-
const { renameSync } = await import("node:fs");
|
|
473
513
|
renameSync(temporary, destination);
|
|
474
514
|
return { sha256: digest, size: produced, tofu: !expected.sha256 || expected.sha256 === "unverified" };
|
|
475
515
|
} catch (error) {
|
|
@@ -487,30 +527,46 @@ async function download(url, destination, expected, label) {
|
|
|
487
527
|
}
|
|
488
528
|
|
|
489
529
|
/**
|
|
490
|
-
*
|
|
530
|
+
* The PowerShell/.NET route: on Windows this is the **primary** route (see
|
|
531
|
+
* `tryPowerShellFirst` in download()), and everywhere else it exists only as the
|
|
532
|
+
* last-resort fallback.
|
|
491
533
|
*
|
|
492
|
-
* Measured here,
|
|
534
|
+
* Measured here, it is not redundant with the Node routes. The local proxy
|
|
493
535
|
* rewrites GitHub's release-asset redirect back to itself (an infinite 301
|
|
494
536
|
* loop), while a direct Node connection to github.com times out or is reset.
|
|
495
537
|
* The .NET stack honours the system proxy, follows the redirect chain to
|
|
496
538
|
* release-assets.githubusercontent.com correctly, and reports the full asset
|
|
497
|
-
* length. It is slower (~0.13 MiB/s vs ~0.5 MiB/s),
|
|
539
|
+
* length. It is slower (~0.13 MiB/s vs ~0.5 MiB/s), which is why the routes are
|
|
540
|
+
* ordered per platform rather than universally, and
|
|
498
541
|
* the result is verified by the same size + checksum gate as every other route.
|
|
499
542
|
*/
|
|
500
|
-
function downloadViaPowerShell(url, destination) {
|
|
543
|
+
function downloadViaPowerShell(url, destination, have = 0) {
|
|
544
|
+
const resume = Number.isFinite(Number(have)) && Number(have) > 0;
|
|
501
545
|
const script = [
|
|
502
546
|
"$ErrorActionPreference = 'Stop'",
|
|
503
547
|
"$ProgressPreference = 'SilentlyContinue'",
|
|
504
548
|
`$url = '${url.replace(/'/g, "''")}'`,
|
|
505
549
|
`$out = '${destination.replace(/'/g, "''")}'`,
|
|
550
|
+
`$have = ${resume ? Math.trunc(Number(have)) : 0}`,
|
|
506
551
|
"$req = [System.Net.HttpWebRequest]::Create($url)",
|
|
507
552
|
"$req.UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) dsh-plugin-rea-bundle'",
|
|
508
553
|
"$req.AllowAutoRedirect = $true",
|
|
554
|
+
// The response stage. The body-copy stage needs its own deadline: with only
|
|
555
|
+
// Timeout set, a stalled socket inside CopyTo hung forever (spawnSync runs
|
|
556
|
+
// with no total timeout by design, because a slow-but-alive 570 MB transfer
|
|
557
|
+
// is legitimate).
|
|
509
558
|
"$req.Timeout = 60000",
|
|
559
|
+
"$req.ReadWriteTimeout = 120000",
|
|
560
|
+
// Resume instead of starting over. A server that ignores the range answers
|
|
561
|
+
// 200, and the file is then rewritten from the start rather than appended to.
|
|
562
|
+
"if ($have -gt 0) { $req.AddRange($have) }",
|
|
510
563
|
"$resp = $req.GetResponse()",
|
|
564
|
+
"$code = [int]$resp.StatusCode",
|
|
511
565
|
"$stream = $resp.GetResponseStream()",
|
|
512
|
-
"$
|
|
566
|
+
"if ($have -gt 0 -and $code -eq 206) { $mode = [System.IO.FileMode]::Append } else { $mode = [System.IO.FileMode]::Create }",
|
|
567
|
+
"$file = New-Object System.IO.FileStream($out, $mode, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)",
|
|
513
568
|
"$stream.CopyTo($file)",
|
|
569
|
+
"$file.Flush()",
|
|
514
570
|
"$file.Close()",
|
|
515
571
|
"$stream.Close()",
|
|
516
572
|
"$resp.Close()",
|
|
@@ -609,24 +665,72 @@ async function writeBody(stream, headers, temporary, have, status, label) {
|
|
|
609
665
|
return seen;
|
|
610
666
|
}
|
|
611
667
|
|
|
612
|
-
/**
|
|
613
|
-
function
|
|
668
|
+
/** The provisioned JDK's own `jar`, when one is resolved — a guaranteed unzipper. */
|
|
669
|
+
function jdkJarCommand(context) {
|
|
670
|
+
const jdk = context?.resolved?.jdk?.path;
|
|
671
|
+
if (typeof jdk !== "string" || jdk === "") return null;
|
|
672
|
+
const home = /[\\/]bin[\\/][^\\/]+$/.test(jdk) ? path.dirname(path.dirname(jdk)) : jdk;
|
|
673
|
+
const candidate = path.join(home, "bin", `jar${EXE}`);
|
|
674
|
+
return existsSync(candidate) ? candidate : null;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Extract with whatever the host actually has.
|
|
679
|
+
*
|
|
680
|
+
* Ghidra ships only as a zip on every platform, so "no unzip" was a certain
|
|
681
|
+
* failure on a minimal Linux image rather than an occasional one. The fallbacks
|
|
682
|
+
* are `python3 -m zipfile` and — since a JDK is provisioned before Ghidra — that
|
|
683
|
+
* JDK's own `jar`. Tarballs fall back to python3, whose tarfile reads xz without
|
|
684
|
+
* the xz binary. A host with none of them is told which tools were tried instead
|
|
685
|
+
* of getting a bare "extraction failed".
|
|
686
|
+
*/
|
|
687
|
+
function extract(archive, destination, format, context) {
|
|
614
688
|
mkdirSync(destination, { recursive: true });
|
|
689
|
+
const stdio = options.json ? "ignore" : "inherit";
|
|
690
|
+
const attempts = [];
|
|
615
691
|
if (format === "zip") {
|
|
616
692
|
if (IS_WINDOWS) {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
693
|
+
// bsdtar ships with Windows 10+ and unpacks a zip far faster than
|
|
694
|
+
// Expand-Archive's single-threaded .NET implementation — the 543 MB Ghidra
|
|
695
|
+
// archive is the case that matters. A failing tar still falls through to
|
|
696
|
+
// Expand-Archive.
|
|
697
|
+
attempts.push({ command: "tar", args: ["-xf", archive, "-C", destination] });
|
|
698
|
+
attempts.push({
|
|
699
|
+
command: "powershell",
|
|
700
|
+
args: [
|
|
701
|
+
"-NoProfile",
|
|
702
|
+
"-NonInteractive",
|
|
703
|
+
"-Command",
|
|
704
|
+
`Expand-Archive -LiteralPath '${archive.replace(/'/g, "''")}' -DestinationPath '${destination.replace(/'/g, "''")}' -Force`,
|
|
705
|
+
],
|
|
706
|
+
});
|
|
707
|
+
} else {
|
|
708
|
+
attempts.push({ command: "unzip", args: ["-q", "-o", archive, "-d", destination] });
|
|
709
|
+
attempts.push({ command: "python3", args: ["-m", "zipfile", "-e", archive, destination] });
|
|
710
|
+
const jar = jdkJarCommand(context);
|
|
711
|
+
// `jar xf` unpacks into the working directory, hence cwd.
|
|
712
|
+
if (jar) attempts.push({ command: jar, args: ["xf", archive], cwd: destination });
|
|
621
713
|
}
|
|
622
|
-
|
|
623
|
-
|
|
714
|
+
} else if (format === "tar.gz" || format === "tar.xz" || format === "tar") {
|
|
715
|
+
attempts.push({ command: "tar", args: ["-xf", archive, "-C", destination] });
|
|
716
|
+
attempts.push({
|
|
717
|
+
command: "python3",
|
|
718
|
+
args: ["-c", "import sys,tarfile;tarfile.open(sys.argv[1]).extractall(sys.argv[2])", archive, destination],
|
|
719
|
+
});
|
|
720
|
+
} else {
|
|
721
|
+
throw new Error(`unsupported archive format: ${format}`);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
let anyToolRan = false;
|
|
725
|
+
for (const attempt of attempts) {
|
|
726
|
+
const result = spawnSync(attempt.command, attempt.args, { stdio, cwd: attempt.cwd });
|
|
727
|
+
if (result.status === 0) return true;
|
|
728
|
+
if (!(result.error && result.error.code === "ENOENT")) anyToolRan = true;
|
|
624
729
|
}
|
|
625
|
-
if (
|
|
626
|
-
|
|
627
|
-
return result.status === 0;
|
|
730
|
+
if (!anyToolRan) {
|
|
731
|
+
log(` no ${format === "zip" ? "zip" : "tar"} extractor available (tried ${attempts.map((attempt) => path.basename(attempt.command)).join(", ")}); install unzip or python3`);
|
|
628
732
|
}
|
|
629
|
-
|
|
733
|
+
return false;
|
|
630
734
|
}
|
|
631
735
|
|
|
632
736
|
/* --------------------------------------------------------------- host probing */
|
|
@@ -704,7 +808,28 @@ function substituteProbe(value, context) {
|
|
|
704
808
|
function runProbe(probe, context) {
|
|
705
809
|
if (probe.file) {
|
|
706
810
|
const file = substituteProbe(probe.file, context);
|
|
707
|
-
return { ok:
|
|
811
|
+
if (!existsSync(file)) return { ok: false, detail: `${file} is missing` };
|
|
812
|
+
// `expect` used to be ignored for file probes, which made the Ghidra probe's
|
|
813
|
+
// `expect.contains: "application.version="` dead configuration: any file at
|
|
814
|
+
// that path passed, including a directory or a stray placeholder. Read the
|
|
815
|
+
// file — these are small metadata files — and honour the contract. The bound
|
|
816
|
+
// exists so a probe can never be pointed at a 543 MB tree by accident.
|
|
817
|
+
const expect = probe.expect ?? {};
|
|
818
|
+
if (expect.contains || expect.regex) {
|
|
819
|
+
let text = "";
|
|
820
|
+
try {
|
|
821
|
+
text = readFileSync(file, "utf8").slice(0, 65536);
|
|
822
|
+
} catch (error) {
|
|
823
|
+
return { ok: false, detail: `${file} could not be read: ${String(error && error.message)}` };
|
|
824
|
+
}
|
|
825
|
+
if (expect.contains && !text.includes(expect.contains)) {
|
|
826
|
+
return { ok: false, detail: `${file} does not contain "${expect.contains}"` };
|
|
827
|
+
}
|
|
828
|
+
if (expect.regex && !new RegExp(expect.regex, "m").test(text)) {
|
|
829
|
+
return { ok: false, detail: `${file} does not match /${expect.regex}/` };
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return { ok: true, detail: file };
|
|
708
833
|
}
|
|
709
834
|
if (!probe.argv) return { ok: true, detail: "no probe" };
|
|
710
835
|
const argv = probe.argv.map((value) => substituteProbe(value, context));
|
|
@@ -866,6 +991,25 @@ function loadManifest() {
|
|
|
866
991
|
return parsed;
|
|
867
992
|
}
|
|
868
993
|
|
|
994
|
+
/**
|
|
995
|
+
* Read a bundle.json, tolerating one that is not valid JSON.
|
|
996
|
+
*
|
|
997
|
+
* A half-written file (disk full, a kill mid-write) used to throw straight out of
|
|
998
|
+
* provisioning — the run died before it could replace the file it could not
|
|
999
|
+
* parse, and the shim then failed on it forever. Treating it as absent lets the
|
|
1000
|
+
* normal path read what is usable and rewrite the record at the end.
|
|
1001
|
+
*/
|
|
1002
|
+
function readBundleJson(file) {
|
|
1003
|
+
if (!existsSync(file)) return null;
|
|
1004
|
+
try {
|
|
1005
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
1006
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
log(` ${path.basename(file)} is not readable JSON (${String(error && error.message)}); ignoring it for this run`);
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
869
1013
|
const INSTALL_ORDER = ["node", "jdk", "ghidra", "apktool", "jadxHeadlessMcp", "androidPlatformTools", "reaAgents"];
|
|
870
1014
|
|
|
871
1015
|
/* -------------------------------------------------------------------- stages */
|
|
@@ -878,8 +1022,26 @@ async function provisionComponent(name, component, context) {
|
|
|
878
1022
|
// makes the recorded-state check below redundant (and its message wrong).
|
|
879
1023
|
const reused = reuseExisting(name, root);
|
|
880
1024
|
if (reused) {
|
|
881
|
-
|
|
882
|
-
|
|
1025
|
+
// A candidate is a claim until the component's own probe agrees. Adoption
|
|
1026
|
+
// tested only that javac / analyzeHeadless / node exists, so a JDK 11 (Ghidra
|
|
1027
|
+
// 12.1 needs 21+) or a Ghidra 11 install was recorded in bundle.json and the
|
|
1028
|
+
// failure surfaced much later, inside REA. Run the same probe a provisioned
|
|
1029
|
+
// copy has to pass, with the candidate planted so the probe's own tokens
|
|
1030
|
+
// ({jvm}, {ghidra}) resolve to it instead of to a vendor path it never had.
|
|
1031
|
+
const planted = context.resolved[name];
|
|
1032
|
+
context.resolved[name] = { path: reused.path };
|
|
1033
|
+
let probe;
|
|
1034
|
+
try {
|
|
1035
|
+
probe = runProbe(component.probe ?? {}, context);
|
|
1036
|
+
} finally {
|
|
1037
|
+
if (planted === undefined) delete context.resolved[name];
|
|
1038
|
+
else context.resolved[name] = planted;
|
|
1039
|
+
}
|
|
1040
|
+
if (probe.ok) {
|
|
1041
|
+
step(name, "skip", `reusing existing ${name} from ${reused.how}: ${reused.path}`);
|
|
1042
|
+
return { path: reused.path, reused: true };
|
|
1043
|
+
}
|
|
1044
|
+
step(name, "plan", `not reusing ${reused.how} ${name} at ${reused.path}: ${probe.detail}`);
|
|
883
1045
|
}
|
|
884
1046
|
|
|
885
1047
|
// 2) already present in the bundle and still verifiable?
|
|
@@ -889,8 +1051,8 @@ async function provisionComponent(name, component, context) {
|
|
|
889
1051
|
// deleted afterwards. Only a recorded path that exists AND passes the probe is
|
|
890
1052
|
// trusted; anything else falls through to provisioning again.
|
|
891
1053
|
if (existsSync(path.join(root, "bundle.json"))) {
|
|
892
|
-
const current =
|
|
893
|
-
const recorded = current
|
|
1054
|
+
const current = readBundleJson(path.join(root, "bundle.json"));
|
|
1055
|
+
const recorded = current?.components?.[name];
|
|
894
1056
|
if (recorded && typeof recorded.path === "string" && existsSync(recorded.path)) {
|
|
895
1057
|
const probe = runProbe(component.probe ?? {}, context);
|
|
896
1058
|
if (probe.ok) {
|
|
@@ -955,8 +1117,19 @@ async function provisionComponent(name, component, context) {
|
|
|
955
1117
|
}
|
|
956
1118
|
mkdirSync(targetDirectory, { recursive: true });
|
|
957
1119
|
const target = path.join(targetDirectory, entry.saveAs ?? archiveName);
|
|
958
|
-
const { copyFileSync } = await import("node:fs");
|
|
959
1120
|
copyFileSync(archivePath, target);
|
|
1121
|
+
// A wrapper script has to be executable. copyFileSync lands the 0644 that
|
|
1122
|
+
// the archive carries and nothing else in this file changes a mode, while
|
|
1123
|
+
// REA starts REA_APKTOOL_COMMAND through execFile/no shell — so on POSIX a
|
|
1124
|
+
// 0644 wrapper fails with EACCES. The component's own probe misses it
|
|
1125
|
+
// because it runs `java -jar` and never executes the wrapper.
|
|
1126
|
+
if (entry.executable === true) {
|
|
1127
|
+
try {
|
|
1128
|
+
chmodSync(target, 0o755);
|
|
1129
|
+
} catch {
|
|
1130
|
+
/* a filesystem that cannot carry the bit must not fail the component */
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
960
1133
|
placed.push({ target, sha256: fetched.sha256, tofu: fetched.tofu });
|
|
961
1134
|
}
|
|
962
1135
|
if (options.dryRun) return { planned: true };
|
|
@@ -1015,7 +1188,6 @@ async function provisionComponent(name, component, context) {
|
|
|
1015
1188
|
const targetDirectory = path.join(vendor, component.unpack?.moveTo ?? name);
|
|
1016
1189
|
mkdirSync(targetDirectory, { recursive: true });
|
|
1017
1190
|
const target = path.join(targetDirectory, source.saveAs ?? archiveName);
|
|
1018
|
-
const { copyFileSync } = await import("node:fs");
|
|
1019
1191
|
copyFileSync(archivePath, target);
|
|
1020
1192
|
const probe = runProbe(component.probe ?? {}, context);
|
|
1021
1193
|
step(name, probe.ok ? "ok" : "FAIL", `${target}${probe.ok ? "" : " — " + probe.detail}`);
|
|
@@ -1026,7 +1198,7 @@ async function provisionComponent(name, component, context) {
|
|
|
1026
1198
|
rmSync(target, { recursive: true, force: true });
|
|
1027
1199
|
const staging = target + ".staging";
|
|
1028
1200
|
rmSync(staging, { recursive: true, force: true });
|
|
1029
|
-
const unpacked = extract(archivePath, staging, source.format);
|
|
1201
|
+
const unpacked = extract(archivePath, staging, source.format, context);
|
|
1030
1202
|
if (!unpacked) {
|
|
1031
1203
|
step(name, "FAIL", `extraction failed for ${archiveName}`);
|
|
1032
1204
|
return null;
|
|
@@ -1036,12 +1208,10 @@ async function provisionComponent(name, component, context) {
|
|
|
1036
1208
|
for (let level = 0; level < strip; level += 1) {
|
|
1037
1209
|
// Descend into the archive's single root directory. If the level is not a
|
|
1038
1210
|
// single directory (some archives are already flat), stop rather than guess.
|
|
1039
|
-
const { readdirSync } = await import("node:fs");
|
|
1040
1211
|
const entries = readdirSync(effective).filter((entry) => !entry.startsWith("."));
|
|
1041
1212
|
if (entries.length !== 1 || !statSync(path.join(effective, entries[0])).isDirectory()) break;
|
|
1042
1213
|
effective = path.join(effective, entries[0]);
|
|
1043
1214
|
}
|
|
1044
|
-
const { renameSync } = await import("node:fs");
|
|
1045
1215
|
rmSync(target, { recursive: true, force: true });
|
|
1046
1216
|
renameSync(effective, target);
|
|
1047
1217
|
rmSync(staging, { recursive: true, force: true });
|
|
@@ -1094,6 +1264,15 @@ async function provisionReaAgents(component, context) {
|
|
|
1094
1264
|
return { planned: true, version };
|
|
1095
1265
|
}
|
|
1096
1266
|
|
|
1267
|
+
// `--offline is a promise, not a hint` — and this is the one step that cannot be
|
|
1268
|
+
// served from the cache: it is an npm install, not a download. It used to run
|
|
1269
|
+
// anyway, so a machine with every archive pre-cached still went to the registry
|
|
1270
|
+
// (or failed there) after the flag had promised it would not.
|
|
1271
|
+
if (options.offline) {
|
|
1272
|
+
step("reaAgents", "FAIL", `--offline and the vendored REA at ${vendor} is not usable; npm install needs the registry (drop --offline or pre-install it)`);
|
|
1273
|
+
return null;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1097
1276
|
/*
|
|
1098
1277
|
* A plain pack+unpack cannot work here: the published tarball carries no
|
|
1099
1278
|
* node_modules, so the CLI would fail with ERR_MODULE_NOT_FOUND on its own
|
|
@@ -1149,6 +1328,10 @@ async function provisionReaAgents(component, context) {
|
|
|
1149
1328
|
});
|
|
1150
1329
|
if (install.status !== 0) {
|
|
1151
1330
|
step("reaAgents", "FAIL", `npm install failed (${install.status}): ${`${install.stderr ?? ""}${install.stdout ?? ""}`.trim().split("\n").slice(-3).join(" | ").slice(0, 300)}`);
|
|
1331
|
+
// Every other component can fall back to the proxy route this script resolves
|
|
1332
|
+
// itself; npm cannot, and this step is the one that fails most often on a
|
|
1333
|
+
// network where the registry needs a mirror.
|
|
1334
|
+
log(" npm reads HTTPS_PROXY/HTTP_PROXY and NPM_CONFIG_REGISTRY from the environment (this script's own proxy routing does not apply here)");
|
|
1152
1335
|
return null;
|
|
1153
1336
|
}
|
|
1154
1337
|
|
|
@@ -1174,6 +1357,9 @@ log(` root ${options.root}`);
|
|
|
1174
1357
|
log(` cache ${options.cache}`);
|
|
1175
1358
|
log(` host ${hostKey()} node ${process.versions.node}`);
|
|
1176
1359
|
log(` mode ${options.dryRun ? "dry-run " : ""}${options.reuse ? "reuse " : ""}${options.offline ? "offline" : "online"}`.trimEnd());
|
|
1360
|
+
// Printed so an environment override is visible: a typo used to reach the socket
|
|
1361
|
+
// layer as NaN (and as "gave up after NaN attempt(s)") with nothing to see.
|
|
1362
|
+
log(` limits connect=${options.connectTimeoutMs}ms idle=${options.idleTimeoutMs}ms retries=${options.retries}`);
|
|
1177
1363
|
log("");
|
|
1178
1364
|
|
|
1179
1365
|
const manifest = loadManifest();
|
|
@@ -1185,17 +1371,72 @@ const context = {
|
|
|
1185
1371
|
};
|
|
1186
1372
|
const results = {};
|
|
1187
1373
|
|
|
1374
|
+
/**
|
|
1375
|
+
* What a previous run recorded.
|
|
1376
|
+
*
|
|
1377
|
+
* `--only` names a subset of components, so this run's `results` describes only
|
|
1378
|
+
* that subset — and writing bundle.json straight from `results` erased every
|
|
1379
|
+
* other component: the JDK and Ghidra disappeared from `paths`, the shim stopped
|
|
1380
|
+
* exporting JAVA_HOME/GHIDRA_INSTALL_DIR, so `setup.mjs --only androidPlatformTools`
|
|
1381
|
+
* silently disabled native analysis on a fully provisioned machine, and the next
|
|
1382
|
+
* full run had to unpack 750 MB again. Components this run does not touch are
|
|
1383
|
+
* carried over instead.
|
|
1384
|
+
*/
|
|
1385
|
+
const previous = readBundleJson(path.join(options.root, "bundle.json"));
|
|
1386
|
+
|
|
1387
|
+
/** One untouched component's record, kept only while its recorded path exists. */
|
|
1388
|
+
function carriedOver(name) {
|
|
1389
|
+
const record = previous?.components?.[name];
|
|
1390
|
+
if (!record || typeof record !== "object") return null;
|
|
1391
|
+
if (typeof record.path === "string" && record.path !== "") {
|
|
1392
|
+
if (existsSync(record.path)) return record;
|
|
1393
|
+
return { ...record, path: null, unavailable: true, reason: "the recorded path is gone" };
|
|
1394
|
+
}
|
|
1395
|
+
return record;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1188
1398
|
for (const name of INSTALL_ORDER) {
|
|
1189
1399
|
const component = manifest.components[name];
|
|
1190
1400
|
if (!component) continue;
|
|
1191
1401
|
if (options.only && !options.only.includes(name)) continue;
|
|
1192
1402
|
const missingRequirement = (component.requires ?? []).find((requirement) => !context.resolved[requirement]);
|
|
1193
|
-
if (missingRequirement
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1403
|
+
if (missingRequirement) {
|
|
1404
|
+
// Under --only the requirement is not being provisioned this run, but the
|
|
1405
|
+
// previous run's record still answers it — and that is the same answer a full
|
|
1406
|
+
// run would use. Skipping the check entirely made `--only apktool` resolve
|
|
1407
|
+
// {jvm} to the vendor/jdk that only a full run creates and report FAIL, on a
|
|
1408
|
+
// machine whose JDK was recorded right next to it.
|
|
1409
|
+
let carried = options.only ? carriedOver(missingRequirement) : null;
|
|
1410
|
+
if (!carried && options.only) {
|
|
1411
|
+
// A requirement can also be answered by the machine instead of the record.
|
|
1412
|
+
// `node` always can: a Node is already running this script, and
|
|
1413
|
+
// provisionReaAgents falls back to exactly that interpreter — without this,
|
|
1414
|
+
// `--only reaAgents` on a fresh root failed on a "missing" node the run is
|
|
1415
|
+
// itself made of. The others are answered when the caller passed --reuse.
|
|
1416
|
+
if (missingRequirement === "node") {
|
|
1417
|
+
carried = { path: process.execPath, reused: true, how: "the interpreter running setup" };
|
|
1418
|
+
} else {
|
|
1419
|
+
const reused = reuseExisting(missingRequirement, options.root);
|
|
1420
|
+
if (reused) carried = { path: reused.path, reused: true, how: reused.how };
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
if (!carried || typeof carried.path !== "string" || !existsSync(carried.path)) {
|
|
1424
|
+
step(name, "FAIL", `skipped: required component "${missingRequirement}" is unavailable`);
|
|
1425
|
+
results[name] = null;
|
|
1426
|
+
continue;
|
|
1427
|
+
}
|
|
1428
|
+
context.resolved[missingRequirement] = carried;
|
|
1429
|
+
}
|
|
1430
|
+
let resolvedValue = null;
|
|
1431
|
+
try {
|
|
1432
|
+
resolvedValue = await provisionComponent(name, component, context);
|
|
1433
|
+
} catch (error) {
|
|
1434
|
+
// An unexpected throw used to abort the run before it could write a summary
|
|
1435
|
+
// or record why its lock is still there. Report it as this component's
|
|
1436
|
+
// failure instead, so the exit code and the log stay truthful.
|
|
1437
|
+
step(name, "FAIL", `unexpected error: ${String(error?.message ?? error)}`);
|
|
1438
|
+
resolvedValue = null;
|
|
1197
1439
|
}
|
|
1198
|
-
const resolvedValue = await provisionComponent(name, component, context);
|
|
1199
1440
|
results[name] = resolvedValue;
|
|
1200
1441
|
// A component satisfies another's `requires` when it has a real path — or when
|
|
1201
1442
|
// this run only *planned* it. In dry-run every component returns
|
|
@@ -1208,27 +1449,36 @@ for (const name of INSTALL_ORDER) {
|
|
|
1208
1449
|
}
|
|
1209
1450
|
}
|
|
1210
1451
|
|
|
1452
|
+
/* Components this run does not touch keep the record the previous run wrote. */
|
|
1453
|
+
const merged = { ...results };
|
|
1454
|
+
for (const name of INSTALL_ORDER) {
|
|
1455
|
+
if (Object.prototype.hasOwnProperty.call(merged, name)) continue;
|
|
1456
|
+
if (!manifest.components[name]) continue;
|
|
1457
|
+
const carried = carriedOver(name);
|
|
1458
|
+
if (carried) merged[name] = carried;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1211
1461
|
/* browser is adopted, never downloaded */
|
|
1212
1462
|
const browser = reuseExisting("browser", options.root);
|
|
1213
1463
|
if (browser) step("browser", "skip", `adopting ${browser.path}`);
|
|
1214
1464
|
|
|
1215
1465
|
/* --------------------------------------------------------------- wire + probe */
|
|
1216
1466
|
|
|
1217
|
-
const nodePath = typeof
|
|
1218
|
-
?
|
|
1467
|
+
const nodePath = typeof merged.node?.path === "string" && existsSync(merged.node.path)
|
|
1468
|
+
? merged.node.path
|
|
1219
1469
|
: process.execPath;
|
|
1220
1470
|
|
|
1221
1471
|
const paths = {
|
|
1222
1472
|
node: nodePath,
|
|
1223
1473
|
reaEntry: path.join(options.root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs"),
|
|
1224
|
-
ghidra:
|
|
1225
|
-
jdk:
|
|
1226
|
-
adb:
|
|
1227
|
-
apktoolCommand:
|
|
1228
|
-
? path.join(
|
|
1474
|
+
ghidra: merged.ghidra?.path ?? null,
|
|
1475
|
+
jdk: merged.jdk?.path ?? null,
|
|
1476
|
+
adb: merged.androidPlatformTools?.path ? path.join(merged.androidPlatformTools.path, `adb${EXE}`) : null,
|
|
1477
|
+
apktoolCommand: merged.apktool?.path
|
|
1478
|
+
? path.join(merged.apktool.path, IS_WINDOWS ? "apktool.bat" : "apktool")
|
|
1229
1479
|
: null,
|
|
1230
|
-
jadxJar:
|
|
1231
|
-
? path.join(
|
|
1480
|
+
jadxJar: merged.jadxHeadlessMcp?.path
|
|
1481
|
+
? path.join(merged.jadxHeadlessMcp.path, merged.jadxHeadlessMcp.jarName ?? "")
|
|
1232
1482
|
: null,
|
|
1233
1483
|
browser: browser?.path ?? null,
|
|
1234
1484
|
ilspycmd: process.env.REA_ILSPY_CMD_PATH ?? null,
|
|
@@ -1242,17 +1492,17 @@ if (!options.dryRun) {
|
|
|
1242
1492
|
plugin: { name: "dsh-plugin-rea", root: sourceRoot() },
|
|
1243
1493
|
paths,
|
|
1244
1494
|
versions: {
|
|
1245
|
-
reaAgents:
|
|
1246
|
-
ghidra:
|
|
1247
|
-
jdk:
|
|
1248
|
-
apktool:
|
|
1249
|
-
jadxHeadlessMcp:
|
|
1250
|
-
node:
|
|
1495
|
+
reaAgents: merged.reaAgents?.version ?? null,
|
|
1496
|
+
ghidra: merged.ghidra?.version ?? null,
|
|
1497
|
+
jdk: merged.jdk?.version ?? null,
|
|
1498
|
+
apktool: merged.apktool?.version ?? null,
|
|
1499
|
+
jadxHeadlessMcp: merged.jadxHeadlessMcp?.version ?? null,
|
|
1500
|
+
node: merged.node?.version ?? process.versions.node,
|
|
1251
1501
|
},
|
|
1252
1502
|
components: Object.fromEntries(
|
|
1253
|
-
Object.entries(
|
|
1503
|
+
Object.entries(merged).map(([name, value]) => [name, value ?? { path: null, unavailable: true }]),
|
|
1254
1504
|
),
|
|
1255
|
-
unverifiedChecksums: Object.entries(
|
|
1505
|
+
unverifiedChecksums: Object.entries(merged)
|
|
1256
1506
|
.filter(([, value]) => value?.tofu)
|
|
1257
1507
|
.map(([name]) => name),
|
|
1258
1508
|
};
|
|
@@ -1417,6 +1667,25 @@ const failures = state.steps.filter((entry) => entry.status === "FAIL");
|
|
|
1417
1667
|
const failed = failures.filter((entry) => !optionalFailure(entry));
|
|
1418
1668
|
const optionalUnavailable = failures.filter(optionalFailure);
|
|
1419
1669
|
|
|
1670
|
+
/*
|
|
1671
|
+
* Lock bookkeeping is not presentation, and it used to sit inside the
|
|
1672
|
+
* non-`--json` branch: a successful `--json` run therefore left the lock behind,
|
|
1673
|
+
* and the shim then reported "provisioning started in the background" for six
|
|
1674
|
+
* hours on a fully provisioned machine. A failing run keeps the lock on purpose —
|
|
1675
|
+
* so a restart loop cannot re-download forever — but marks it failed, which lets
|
|
1676
|
+
* the shim say what actually happened instead of claiming a run is in flight.
|
|
1677
|
+
*/
|
|
1678
|
+
const lockFile = path.join(options.root, "setup.lock");
|
|
1679
|
+
if (failed.length === 0) {
|
|
1680
|
+
rmSync(lockFile, { force: true });
|
|
1681
|
+
} else {
|
|
1682
|
+
try {
|
|
1683
|
+
writeFileSync(lockFile, JSON.stringify({ state: "failed", pid: process.pid, at: Date.now(), exit: 1, log: logFilePath() }) + "\n");
|
|
1684
|
+
} catch {
|
|
1685
|
+
/* the lock is advisory: a read-only root must not break the report */
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1420
1689
|
if (options.json) {
|
|
1421
1690
|
process.stdout.write(
|
|
1422
1691
|
JSON.stringify(
|
|
@@ -1436,10 +1705,6 @@ if (options.json) {
|
|
|
1436
1705
|
}
|
|
1437
1706
|
log("");
|
|
1438
1707
|
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
1708
|
log("restart DSH (or reload the profile) so the MCP row reconnects.");
|
|
1444
1709
|
// Point at the mirrored copy, not at this checkout's: the mirror resolves its
|
|
1445
1710
|
// bundle from its own location, so this command is right for the root this run
|