dsh-plugin-rea 2.0.2 → 2.0.4

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/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: 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),
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", () => socket.setTimeout(idleTimeout));
329
+ socket.once("connect", () => {
330
+ connected = true;
331
+ socket.setTimeout(idleTimeout);
332
+ });
313
333
  });
314
- req.on("timeout", () => req.destroy(new Error(`no response within ${idleTimeout}ms`)));
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
- 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)` : ""}`);
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 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
- };
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
- * Windows fallback route: let PowerShell/.NET perform the transfer.
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, this is not redundant with the Node routes. The local proxy
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), so it is a fallback, and
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
- "$file = [System.IO.File]::Create($out)",
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
- /** Extract with the platform's own tools. Returns true on success. */
613
- function extract(archive, destination, format) {
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
- 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;
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
- const result = spawnSync("unzip", ["-q", "-o", archive, "-d", destination], { stdio: options.json ? "ignore" : "inherit" });
623
- return result.status === 0;
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 (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;
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
- throw new Error(`unsupported archive format: ${format}`);
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: existsSync(file), detail: file };
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
- step(name, "skip", `reusing existing ${name} from ${reused.how}: ${reused.path}`);
882
- return { path: reused.path, reused: true };
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 = JSON.parse(readFileSync(path.join(root, "bundle.json"), "utf8"));
893
- const recorded = current.components?.[name];
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 && !options.only) {
1194
- step(name, "FAIL", `skipped: required component "${missingRequirement}" is unavailable`);
1195
- results[name] = null;
1196
- continue;
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 results.node?.path === "string" && existsSync(results.node.path)
1218
- ? results.node.path
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: 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")
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: results.jadxHeadlessMcp?.path
1231
- ? path.join(results.jadxHeadlessMcp.path, results.jadxHeadlessMcp.jarName ?? "")
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: 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,
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(results).map(([name, value]) => [name, value ?? { path: null, unavailable: true }]),
1503
+ Object.entries(merged).map(([name, value]) => [name, value ?? { path: null, unavailable: true }]),
1254
1504
  ),
1255
- unverifiedChecksums: Object.entries(results)
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