dsh-plugin-rea 2.0.7 → 2.0.9
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 +78 -0
- package/README.md +14 -5
- package/README.zh-CN.md +12 -4
- package/bin/rea-shim.mjs +331 -95
- package/check-updates.ps1 +33 -5
- package/docs/bundle-design.md +24 -1
- package/package.json +1 -1
- package/scripts/setup.mjs +43 -3
- package/scripts/setup.test.mjs +327 -2
- package/scripts/verify-branch.ps1 +38 -0
package/bin/rea-shim.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* rea-shim
|
|
3
|
+
* rea-shim — the process the DSH MCP row launches.
|
|
4
4
|
*
|
|
5
5
|
* Why this exists: the published `cordis.patch.yml` must carry no machine path,
|
|
6
6
|
* yet REA needs absolute paths for every dependency. The shim is the seam: it
|
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
* It downloads nothing, writes nothing, and never touches PATH.
|
|
11
11
|
*
|
|
12
12
|
* Exit codes: 0 child exited normally * child's own status
|
|
13
|
-
* 78 EX_CONFIG bundle not provisioned (or unusable)
|
|
13
|
+
* 78 EX_CONFIG bundle not provisioned (or unusable) — the row has
|
|
14
14
|
* `failOnStartupError: false`, so the harness stays up
|
|
15
15
|
* and the message is the operator's remediation.
|
|
16
16
|
*/
|
|
17
17
|
import { spawn, spawnSync } from "node:child_process";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
18
19
|
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
19
20
|
import { homedir } from "node:os";
|
|
20
21
|
import path from "node:path";
|
|
@@ -25,6 +26,19 @@ const EX_CONFIG = 78;
|
|
|
25
26
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
26
27
|
const PLUGIN_ROOT = path.resolve(HERE, "..");
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* JSON from disk, tolerating the UTF-8 BOM a Windows editor writes.
|
|
31
|
+
*
|
|
32
|
+
* `JSON.parse` rejects a leading BOM, and every caller here reads a parse
|
|
33
|
+
* failure as "no record": a BOM would silently downgrade mirror.json to "no
|
|
34
|
+
* plugin checkout" and bundle.json to "not provisioned". Measured while testing
|
|
35
|
+
* this file — a hand-edited mirror.json written by Windows PowerShell behaved
|
|
36
|
+
* exactly that way.
|
|
37
|
+
*/
|
|
38
|
+
function readJson(file) {
|
|
39
|
+
return JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
/** DSH home, by the same rule setup.mjs and the published patch use. */
|
|
29
43
|
function dshHome() {
|
|
30
44
|
const fromEnv = process.env.DSH_HOME;
|
|
@@ -32,6 +46,36 @@ function dshHome() {
|
|
|
32
46
|
return path.join(homedir(), ".dsh");
|
|
33
47
|
}
|
|
34
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The DSH home recorded by setup, or null.
|
|
51
|
+
*
|
|
52
|
+
* Only the mirrored copy needs this: the harness strips `DSH_*` from an MCP
|
|
53
|
+
* child, so with a custom home the environment cannot answer. Read once per
|
|
54
|
+
* process — there is one bundle root per process and this is a startup query.
|
|
55
|
+
*/
|
|
56
|
+
let recordedDshHomeCache = null;
|
|
57
|
+
function recordedDshHome(root) {
|
|
58
|
+
if (recordedDshHomeCache !== null) return recordedDshHomeCache;
|
|
59
|
+
let value = null;
|
|
60
|
+
try {
|
|
61
|
+
const record = readJson(path.join(root, "mirror.json"));
|
|
62
|
+
if (typeof record?.dshHome === "string" && record.dshHome.trim() !== "" && path.isAbsolute(record.dshHome)) {
|
|
63
|
+
value = path.resolve(record.dshHome);
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
value = null;
|
|
67
|
+
}
|
|
68
|
+
recordedDshHomeCache = value;
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The home to look in when the environment has been scrubbed. */
|
|
73
|
+
function dshHomeFor(root) {
|
|
74
|
+
const fromEnv = process.env.DSH_HOME;
|
|
75
|
+
if (fromEnv && fromEnv.trim() !== "") return path.resolve(fromEnv);
|
|
76
|
+
return recordedDshHome(root) ?? path.join(homedir(), ".dsh");
|
|
77
|
+
}
|
|
78
|
+
|
|
35
79
|
/**
|
|
36
80
|
* Where setup writes the machine-local truth.
|
|
37
81
|
*
|
|
@@ -43,11 +87,11 @@ function dshHome() {
|
|
|
43
87
|
* DSH_HOME: the harness strips every `DSH_*` name from an MCP child's
|
|
44
88
|
* environment (case-insensitively, because Windows is), so a custom DSH_HOME
|
|
45
89
|
* would send it to `~/.dsh`, where it would report "not provisioned" for a
|
|
46
|
-
* bundle sitting right next to it
|
|
90
|
+
* bundle sitting right next to it — and start a second ~800 MB run there.
|
|
47
91
|
* * The **checkout** copy (`<plugin>/bin/rea-shim.mjs`) is what a human runs, e.g.
|
|
48
92
|
* `npm run bundle:status`, from a shell where DSH_HOME *is* visible. Its own
|
|
49
93
|
* directory is the plugin, not the bundle, so resolving to itself would report
|
|
50
|
-
* "not provisioned" on a fully provisioned machine
|
|
94
|
+
* "not provisioned" on a fully provisioned machine — and a bare
|
|
51
95
|
* `node bin/rea-shim.mjs mcp` would provision ~800 MB into the checkout.
|
|
52
96
|
*
|
|
53
97
|
* Only the checkout ships `cordis.patch.yml`, so that file is the discriminator.
|
|
@@ -80,11 +124,11 @@ function usableSetup(dir) {
|
|
|
80
124
|
* The mirrored copy is a snapshot: after a plugin upgrade it still holds the
|
|
81
125
|
* previous manifest, so it would provision the previous versions forever. setup
|
|
82
126
|
* writes <bundle>/mirror.json naming the real checkout; prefer that while it is
|
|
83
|
-
* usable, and fall back to the local copy
|
|
127
|
+
* usable, and fall back to the local copy — which is also all a first run has.
|
|
84
128
|
*/
|
|
85
129
|
function provisioningScript(root) {
|
|
86
130
|
try {
|
|
87
|
-
const record =
|
|
131
|
+
const record = readJson(path.join(root, "mirror.json"));
|
|
88
132
|
const recorded = usableSetup(record && record.pluginRoot);
|
|
89
133
|
if (recorded) return recorded;
|
|
90
134
|
} catch {
|
|
@@ -104,13 +148,16 @@ function provisioningScript(root) {
|
|
|
104
148
|
*
|
|
105
149
|
* The parse is deliberately small: the two files it cares about are block
|
|
106
150
|
* mappings with one id/serverName per row, and anything it cannot attribute is
|
|
107
|
-
* ignored
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
151
|
+
* ignored — a missed hint is acceptable, a false alarm is not.
|
|
152
|
+
*
|
|
153
|
+
* Finding the file is the part that needed care: the harness strips every DSH_*
|
|
154
|
+
* variable from an MCP child, so a mirrored copy with a custom DSH_HOME used to
|
|
155
|
+
* read `~/.dsh` — the wrong file, and therefore a conflict it could never report.
|
|
156
|
+
* `setup.mjs` writes the home it ran under into `mirror.json`, and that record is
|
|
157
|
+
* what this uses when the environment has nothing to say.
|
|
111
158
|
*/
|
|
112
|
-
function foreignReaRegistration() {
|
|
113
|
-
const patch = path.join(
|
|
159
|
+
function foreignReaRegistration(root) {
|
|
160
|
+
const patch = path.join(dshHomeFor(root), "cordis.patch.yml");
|
|
114
161
|
let text;
|
|
115
162
|
try {
|
|
116
163
|
text = readFileSync(patch, "utf8");
|
|
@@ -133,7 +180,7 @@ function foreignReaRegistration() {
|
|
|
133
180
|
/**
|
|
134
181
|
* The provisioning lock is a small JSON record: { state, pid, at, exit? }.
|
|
135
182
|
* `state: "running"` is written when a run is started (setup's own pid once it
|
|
136
|
-
* exists), and the failing run rewrites it as `state: "failed"`
|
|
183
|
+
* exists), and the failing run rewrites it as `state: "failed"` — it is kept on
|
|
137
184
|
* purpose so a broken setup cannot be re-downloaded in a loop. A legacy
|
|
138
185
|
* plain-number lock is still honoured as "running".
|
|
139
186
|
*/
|
|
@@ -171,7 +218,7 @@ function pidAlive(pid) {
|
|
|
171
218
|
* a killed attempt is simply continued on the next start.
|
|
172
219
|
*
|
|
173
220
|
* Set REA_BUNDLE_AUTO_PROVISION=0 to require an explicit `setup.mjs` run.
|
|
174
|
-
* Returns { started, reason, logPath }
|
|
221
|
+
* Returns { started, reason, logPath } — the caller words its message from
|
|
175
222
|
* `reason`, because "provisioning started in the background" was also printed
|
|
176
223
|
* for a lock left behind by a run that had already failed, and for one whose
|
|
177
224
|
* process was long gone.
|
|
@@ -208,7 +255,7 @@ function maybeAutoProvision(root) {
|
|
|
208
255
|
// Reached when the lock is older than the freshness window, or its holder is
|
|
209
256
|
// gone and never wrote an outcome (harness restart, power loss, a kill): what
|
|
210
257
|
// it records is not happening any more. This removal is the whole point of the
|
|
211
|
-
// freshness rule
|
|
258
|
+
// freshness rule — without it the exclusive create below failed with EEXIST
|
|
212
259
|
// *forever*, auto-provisioning never ran again, and main() printed nothing at
|
|
213
260
|
// all for that outcome. The lock's own "honour it only while it is fresh"
|
|
214
261
|
// comment described behaviour the code did not have.
|
|
@@ -265,7 +312,7 @@ function fail(message, detail) {
|
|
|
265
312
|
if (detail) lines.push("", " detail: " + detail);
|
|
266
313
|
// Point at the real checkout when the record allows it: running the mirrored
|
|
267
314
|
// copy is fine, but it can only ever provision the manifest it was mirrored
|
|
268
|
-
// with
|
|
315
|
+
// with — and a bundle mirrored before the manifest travelled with it has no
|
|
269
316
|
// usable copy at all, so the honest answer is then "run the plugin's own script".
|
|
270
317
|
const root = bundleRoot();
|
|
271
318
|
const setupScript = provisioningScript(root);
|
|
@@ -299,7 +346,7 @@ function readBundleConfig(root) {
|
|
|
299
346
|
if (!existsSync(file)) return { ok: false, code: "missing", file };
|
|
300
347
|
let parsed;
|
|
301
348
|
try {
|
|
302
|
-
parsed =
|
|
349
|
+
parsed = readJson(file);
|
|
303
350
|
} catch (error) {
|
|
304
351
|
return { ok: false, code: "malformed", file, detail: String(error && error.message) };
|
|
305
352
|
}
|
|
@@ -316,7 +363,7 @@ function resolveRea(config, root) {
|
|
|
316
363
|
// The fallback has to match where setup actually installs the package:
|
|
317
364
|
// `npm install --prefix <root>/vendor` resolves into vendor/node_modules, so a
|
|
318
365
|
// fallback without that segment named a directory that never exists. It only
|
|
319
|
-
// fires when an older bundle.json has no paths.reaEntry
|
|
366
|
+
// fires when an older bundle.json has no paths.reaEntry — which is exactly when
|
|
320
367
|
// a wrong fallback turns a usable install into "the vendored REA CLI is gone".
|
|
321
368
|
const reaEntry = paths.reaEntry
|
|
322
369
|
?? path.join(root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs");
|
|
@@ -373,7 +420,7 @@ function childEnvironment(config) {
|
|
|
373
420
|
* the machine already has one, the honest thing to do is run it.
|
|
374
421
|
*
|
|
375
422
|
* The 1.x line was exactly that: `rea mcp` off PATH, user-managed Node and
|
|
376
|
-
* Ghidra. So "use the system install" is not a new code path invented here
|
|
423
|
+
* Ghidra. So "use the system install" is not a new code path invented here — it
|
|
377
424
|
* is the 1.x contract, reachable from the 2.x row. `auto` (the default) prefers
|
|
378
425
|
* the system install and falls back to the provisioned bundle; `bundle` pins the
|
|
379
426
|
* old behaviour; `system` insists (and fails loudly instead of provisioning
|
|
@@ -432,7 +479,7 @@ function systemReaNames(platform = process.platform) {
|
|
|
432
479
|
/**
|
|
433
480
|
* Every path worth probing, most explicit first.
|
|
434
481
|
*
|
|
435
|
-
* `REA_BUNDLE_SYSTEM_REA` names one binary outright
|
|
482
|
+
* `REA_BUNDLE_SYSTEM_REA` names one binary outright — that is both the test
|
|
436
483
|
* seam and a real operator need (an install that is not on the harness's PATH,
|
|
437
484
|
* which on Desktop is not the user's shell PATH).
|
|
438
485
|
*/
|
|
@@ -454,7 +501,9 @@ function systemReaCandidates({ env = process.env, platform = process.platform }
|
|
|
454
501
|
}
|
|
455
502
|
const seen = new Set();
|
|
456
503
|
return candidates.filter((candidate) => {
|
|
457
|
-
|
|
504
|
+
// Keyed on the platform being RESOLVED, not on the one this process happens
|
|
505
|
+
// to run: the tests pass `platform` to exercise both shapes on one machine.
|
|
506
|
+
const key = platform === "win32" ? candidate.toLowerCase() : candidate;
|
|
458
507
|
if (seen.has(key)) return false;
|
|
459
508
|
seen.add(key);
|
|
460
509
|
return true;
|
|
@@ -516,7 +565,11 @@ function cmdQuote(value) {
|
|
|
516
565
|
*/
|
|
517
566
|
function comspec() {
|
|
518
567
|
const fromEnv = process.env.ComSpec ?? process.env.COMSPEC;
|
|
519
|
-
|
|
568
|
+
// A ComSpec that names nothing is a real (if unusual) state, and trusting it
|
|
569
|
+
// would judge every `.cmd` REA "spawn-failed" — i.e. report the machine as
|
|
570
|
+
// having no REA because its shell pointer is broken. Fall back to the path
|
|
571
|
+
// Windows itself would use.
|
|
572
|
+
if (typeof fromEnv === "string" && fromEnv.trim() !== "" && isFileSync(fromEnv)) return fromEnv;
|
|
520
573
|
const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows";
|
|
521
574
|
return path.join(systemRoot, "System32", "cmd.exe");
|
|
522
575
|
}
|
|
@@ -527,50 +580,77 @@ function comspec() {
|
|
|
527
580
|
* A file existing on PATH is not the same as a working install: npx leftovers,
|
|
528
581
|
* half-removed globals and a shim whose Node is gone all sit on PATH and all
|
|
529
582
|
* fail at once. One bounded `--version` separates them, and a slow or hanging
|
|
530
|
-
* answer counts as "not usable"
|
|
583
|
+
* answer counts as "not usable" — the fallback (the bundle) still works, while
|
|
531
584
|
* delegating to a broken binary would take the tools down with it.
|
|
585
|
+
*
|
|
586
|
+
* Two things here are not incidental:
|
|
587
|
+
*
|
|
588
|
+
* * The probe runs with `REA_BUNDLE_DELEGATED=1`. A candidate that is itself a
|
|
589
|
+
* wrapper around this shim would otherwise be probed by a nested shim that
|
|
590
|
+
* probes the wrapper — measured: a process chain that keeps respawning
|
|
591
|
+
* itself. The flag makes the nested run take the bundle path and return.
|
|
592
|
+
* * It is asynchronous, not `spawnSync` with a timeout. `spawnSync` reaps the
|
|
593
|
+
* child it started, and its timeout kills only that child: for a `.cmd` shim
|
|
594
|
+
* that means cmd.exe dies while the REA inside it keeps running and holds
|
|
595
|
+
* the pipes. Killing the TREE is only possible while the parent is alive,
|
|
596
|
+
* which means owning the wait ourselves.
|
|
532
597
|
*/
|
|
533
598
|
function probeRea(file, { platform = process.platform, timeout = PROBE_TIMEOUT_MS } = {}) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
if (!
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
599
|
+
return new Promise((resolve) => {
|
|
600
|
+
// `existsSync` says nothing about the shape: a DIRECTORY named `rea.cmd`
|
|
601
|
+
// exists, fails to spawn, and would be reported as a broken install instead
|
|
602
|
+
// of as a name collision. Only a regular file is a candidate.
|
|
603
|
+
if (!isFileSync(file)) {
|
|
604
|
+
resolve({ ok: false, reason: "not a file" });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const useShell = platform === "win32" && /\.(cmd|bat)$/i.test(file);
|
|
608
|
+
// `REA_BUNDLE_AUTO_PROVISION=0` is part of the same guard: if the candidate
|
|
609
|
+
// turns out to be a wrapper around THIS shim, the nested run must not start
|
|
610
|
+
// an 800 MB provisioning job just because a probe asked it for a version.
|
|
611
|
+
const env = { ...process.env, REA_BUNDLE_DELEGATED: "1", REA_BUNDLE_AUTO_PROVISION: "0" };
|
|
612
|
+
// `cmd /d /s /c "<line>"`: Windows only. Two details are load-bearing.
|
|
613
|
+
// `/s` strips the first and last quote, so the whole line is quoted; and the
|
|
614
|
+
// arguments are passed VERBATIM, because Node otherwise backslash-escapes the
|
|
615
|
+
// quotes it sees and cmd.exe does not understand `\"` — every candidate then
|
|
616
|
+
// answers "exit 1", which reads as "no REA on this machine".
|
|
617
|
+
const child = useShell
|
|
618
|
+
? spawn(comspec(), ["/d", "/s", "/c", `"${[cmdQuote(file), "--version"].join(" ")}"`], {
|
|
619
|
+
env,
|
|
620
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
621
|
+
windowsHide: true,
|
|
622
|
+
windowsVerbatimArguments: true,
|
|
623
|
+
})
|
|
624
|
+
: spawn(file, ["--version"], { env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
625
|
+
let output = "";
|
|
626
|
+
let settled = false;
|
|
627
|
+
let timer = null;
|
|
628
|
+
const finish = (result) => {
|
|
629
|
+
if (settled) return;
|
|
630
|
+
settled = true;
|
|
631
|
+
if (timer !== null) clearTimeout(timer);
|
|
632
|
+
resolve(result);
|
|
633
|
+
};
|
|
634
|
+
timer = setTimeout(() => {
|
|
635
|
+
killTree(child.pid, platform);
|
|
636
|
+
finish({ ok: false, reason: "timeout" });
|
|
637
|
+
}, timeout);
|
|
638
|
+
child.stdout.on("data", (chunk) => { output += String(chunk); });
|
|
639
|
+
child.stderr.on("data", (chunk) => { output += String(chunk); });
|
|
640
|
+
child.on("error", () => finish({ ok: false, reason: "spawn-failed" }));
|
|
641
|
+
child.on("close", (code) => {
|
|
642
|
+
if (code !== 0) {
|
|
643
|
+
finish({ ok: false, reason: `exit ${String(code)}` });
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
// A REA install prints its version. Anything else (a shim that swallowed
|
|
647
|
+
// the flag, an unrelated program that shares the name) is not a usable CLI.
|
|
648
|
+
const version = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/.exec(output.trim())?.[1] ?? null;
|
|
649
|
+
finish(version === null
|
|
650
|
+
? { ok: false, reason: "no version in --version output" }
|
|
651
|
+
: { ok: true, version });
|
|
652
|
+
});
|
|
653
|
+
});
|
|
574
654
|
}
|
|
575
655
|
|
|
576
656
|
/**
|
|
@@ -580,7 +660,7 @@ function probeRea(file, { platform = process.platform, timeout = PROBE_TIMEOUT_M
|
|
|
580
660
|
* provisioner installed is the `bundle` mode, and delegating to it would be a
|
|
581
661
|
* loop through a path the bundle already covers.
|
|
582
662
|
*/
|
|
583
|
-
function detectSystemRea({ env = process.env, platform = process.platform, root, pluginRoot = PLUGIN_ROOT } = {}) {
|
|
663
|
+
async function detectSystemRea({ env = process.env, platform = process.platform, root, pluginRoot = PLUGIN_ROOT } = {}) {
|
|
584
664
|
const rejected = [];
|
|
585
665
|
let profiled = 0;
|
|
586
666
|
const explicit = (env.REA_BUNDLE_SYSTEM_REA ?? "").trim();
|
|
@@ -609,7 +689,7 @@ function detectSystemRea({ env = process.env, platform = process.platform, root,
|
|
|
609
689
|
continue;
|
|
610
690
|
}
|
|
611
691
|
profiled += 1;
|
|
612
|
-
const probe = probeRea(candidate, { platform });
|
|
692
|
+
const probe = await probeRea(candidate, { platform });
|
|
613
693
|
if (probe.ok) return { found: true, file: candidate, version: probe.version };
|
|
614
694
|
if (rejected.length < 8) rejected.push({ file: candidate, reason: probe.reason });
|
|
615
695
|
}
|
|
@@ -624,7 +704,9 @@ function detectSystemRea({ env = process.env, platform = process.platform, root,
|
|
|
624
704
|
if (explicitPath !== null && explicitRejection !== null) {
|
|
625
705
|
reason = `REA_BUNDLE_SYSTEM_REA was rejected (${explicitRejection.reason}): ${explicitPath}`;
|
|
626
706
|
} else if (explicitPath !== null && !isFileSync(explicitPath)) {
|
|
627
|
-
reason =
|
|
707
|
+
reason = existsSync(explicitPath)
|
|
708
|
+
? `REA_BUNDLE_SYSTEM_REA is not a file: ${explicitPath}`
|
|
709
|
+
: `REA_BUNDLE_SYSTEM_REA does not exist: ${explicitPath}`;
|
|
628
710
|
} else {
|
|
629
711
|
reason = profiled === 0 ? "no rea on PATH" : "no usable rea on PATH";
|
|
630
712
|
}
|
|
@@ -657,10 +739,10 @@ function compareDotted(a, b) {
|
|
|
657
739
|
* the one this bundle provisioned would silently take the tool count back
|
|
658
740
|
* (6.4.0 ships 170 tools, 6.5.0 ships 207), so the newer of the two wins and
|
|
659
741
|
* the older is only used when asked for by name. `bundle` and `system` are
|
|
660
|
-
* honoured literally
|
|
742
|
+
* honoured literally — `system` fails loudly rather than provisioning 800 MB
|
|
661
743
|
* behind an operator who asked for their own REA.
|
|
662
744
|
*/
|
|
663
|
-
function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false, env = process.env, platform = process.platform } = {}) {
|
|
745
|
+
async function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false, env = process.env, platform = process.platform } = {}) {
|
|
664
746
|
const preference = launchPreference(env);
|
|
665
747
|
if (preference.value === "bundle") {
|
|
666
748
|
return { preference: preference.value, invalid: preference.invalid, mode: "bundle", reason: "pinned by REA_BUNDLE_LAUNCH=bundle" };
|
|
@@ -678,7 +760,7 @@ function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false,
|
|
|
678
760
|
reason: "REA_BUNDLE_DELEGATED=1: already running under a delegated system REA, refusing to delegate again",
|
|
679
761
|
};
|
|
680
762
|
}
|
|
681
|
-
const system = detectSystemRea({ env, platform, root });
|
|
763
|
+
const system = await detectSystemRea({ env, platform, root });
|
|
682
764
|
if (system.found) {
|
|
683
765
|
const order = bundleVersion === null ? null : compareDotted(system.version, bundleVersion);
|
|
684
766
|
const pinned = preference.value === "system";
|
|
@@ -747,25 +829,14 @@ function launchSystemRea(system, argv) {
|
|
|
747
829
|
windowsVerbatimArguments: true,
|
|
748
830
|
})
|
|
749
831
|
: spawn(system.file, args, { env, stdio: "inherit", windowsHide: true });
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
// keeps the inherited pipes open, which looks exactly like "the tools still
|
|
759
|
-
// work after I stopped the harness". `taskkill /T` is what actually ends the
|
|
760
|
-
// tree, so that is what a signal asks for here.
|
|
761
|
-
if (process.platform === "win32") {
|
|
762
|
-
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
763
|
-
process.on(signal, () => {
|
|
764
|
-
killTree(child.pid, "win32");
|
|
765
|
-
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
766
|
-
});
|
|
767
|
-
}
|
|
768
|
-
}
|
|
832
|
+
// No signal forwarding on Windows, deliberately. `child.kill()` there is
|
|
833
|
+
// TerminateProcess on the cmd.exe WRAPPER — the REA inside it survives, still
|
|
834
|
+
// holding the inherited pipes — and a console Ctrl+C is delivered to the whole
|
|
835
|
+
// process group anyway, so the child already has it. A hard kill of this shim
|
|
836
|
+
// (what a supervisor or Task Manager does) runs no JavaScript at all, so the
|
|
837
|
+
// wrapper must not pretend otherwise: on Windows the delegated REA outlives a
|
|
838
|
+
// forcibly killed shim. On POSIX the child is in this process's group and gets
|
|
839
|
+
// the terminal's signal directly; `process.exit` below then ends the shim.
|
|
769
840
|
child.on("error", (error) => {
|
|
770
841
|
process.stderr.write(`rea-shim: could not start ${system.file}\n ${String(error && error.message)}\n`);
|
|
771
842
|
process.exit(EX_CONFIG);
|
|
@@ -780,11 +851,145 @@ function launchSystemRea(system, argv) {
|
|
|
780
851
|
return null;
|
|
781
852
|
}
|
|
782
853
|
|
|
854
|
+
/**
|
|
855
|
+
* The mirror problem.
|
|
856
|
+
*
|
|
857
|
+
* The published row launches `<bundle>/bin/rea-shim.mjs` — a COPY made by
|
|
858
|
+
* `setup.mjs`. Upgrading the plugin from the market replaces the package but runs
|
|
859
|
+
* no setup, so the copy keeps executing the code it was made from: every fix in
|
|
860
|
+
* a new plugin version reaches a user only if they happen to re-run setup. That
|
|
861
|
+
* is invisible (the old copy keeps working) and it is exactly the wrong
|
|
862
|
+
* direction for a safety fix.
|
|
863
|
+
*
|
|
864
|
+
* The repair has to live in the copy, because the copy is what runs. So the
|
|
865
|
+
* copy compares itself with the plugin's own `bin/rea-shim.mjs`, recorded in
|
|
866
|
+
* `mirror.json` by setup, and re-execs that file when the two differ. One hop,
|
|
867
|
+
* guarded by an environment flag, and only from mirror to checkout — the
|
|
868
|
+
* checkout copy has no `mirror.json` and would find itself anyway.
|
|
869
|
+
*
|
|
870
|
+
* A plugin version older than 2.0.8 cannot do this for its own upgrades: there
|
|
871
|
+
* is nothing in it to run. `--shim-status` reports `mirror.stale` so the
|
|
872
|
+
* condition is at least visible, and `check-updates.ps1` says to re-run setup.
|
|
873
|
+
*/
|
|
874
|
+
function sha256(file) {
|
|
875
|
+
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** Where the installed plugin lives according to setup's record, or null. */
|
|
879
|
+
function pluginCheckout(root) {
|
|
880
|
+
try {
|
|
881
|
+
const record = readJson(path.join(root, "mirror.json"));
|
|
882
|
+
const dir = record?.pluginRoot;
|
|
883
|
+
if (typeof dir !== "string" || dir.trim() === "") return null;
|
|
884
|
+
const shim = path.join(dir, "bin", "rea-shim.mjs");
|
|
885
|
+
return isFileSync(shim) ? shim : null;
|
|
886
|
+
} catch {
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Whether this copy is behind the plugin it was mirrored from.
|
|
893
|
+
*
|
|
894
|
+
* `self` is this file, `source` the checkout's. A checkout copy (it ships
|
|
895
|
+
* `cordis.patch.yml`) is never stale against itself; a mirror with no record, or
|
|
896
|
+
* whose recorded checkout is gone, is not re-pointed anywhere.
|
|
897
|
+
*/
|
|
898
|
+
function describeMirror(root, { self = fileURLToPath(import.meta.url) } = {}) {
|
|
899
|
+
let record = null;
|
|
900
|
+
try {
|
|
901
|
+
record = readJson(path.join(root, "mirror.json"));
|
|
902
|
+
} catch {
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
const source = pluginCheckout(root);
|
|
906
|
+
let stale = false;
|
|
907
|
+
let reason = "no usable plugin checkout in mirror.json";
|
|
908
|
+
if (source !== null) {
|
|
909
|
+
if (path.resolve(source) === path.resolve(self)) {
|
|
910
|
+
reason = "this copy is the plugin checkout";
|
|
911
|
+
} else {
|
|
912
|
+
try {
|
|
913
|
+
stale = sha256(source) !== sha256(self);
|
|
914
|
+
reason = stale ? "the plugin ships a different launcher than this mirrored copy" : "this copy matches the plugin";
|
|
915
|
+
} catch {
|
|
916
|
+
reason = "could not compare the two launchers";
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return {
|
|
921
|
+
pluginRoot: typeof record?.pluginRoot === "string" ? record.pluginRoot : null,
|
|
922
|
+
mirroredAt: typeof record?.mirroredAt === "string" ? record.mirroredAt : null,
|
|
923
|
+
stale,
|
|
924
|
+
reason,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Run the checkout's launcher instead of this copy, once.
|
|
930
|
+
*
|
|
931
|
+
* The promise resolves ONLY when the re-exec did not take over, and the caller
|
|
932
|
+
* must then continue with its own launch path. While the child is alive the
|
|
933
|
+
* promise stays pending and the child's own exit becomes this process's exit —
|
|
934
|
+
* settling it at the `spawn` event was wrong: the event fires before the child
|
|
935
|
+
* has done anything, so a later "it actually failed" could no longer be seen, and
|
|
936
|
+
* the two handlers disagreed about who owns the exit code. That mistake made the
|
|
937
|
+
* file-vanishes-mid-flight case exit 0 silently.
|
|
938
|
+
*
|
|
939
|
+
* The first version of this printed "continuing with the mirrored copy" on
|
|
940
|
+
* `error` and returned: nothing continued, no `exit` event followed a failed
|
|
941
|
+
* spawn, the loop drained, and the row saw exit 0 with no tools and no message.
|
|
942
|
+
* A fallback that is announced but not performed is worse than none, because it
|
|
943
|
+
* hides the failure it was written for.
|
|
944
|
+
*/
|
|
945
|
+
function reexecFromCheckout(source, argv) {
|
|
946
|
+
const child = spawn(process.execPath, [source, ...argv], {
|
|
947
|
+
stdio: "inherit",
|
|
948
|
+
env: { ...process.env, REA_BUNDLE_SHIM_REEXEC: "1" },
|
|
949
|
+
windowsHide: true,
|
|
950
|
+
});
|
|
951
|
+
return new Promise((resolve) => {
|
|
952
|
+
let started = false;
|
|
953
|
+
child.once("spawn", () => {
|
|
954
|
+
started = true;
|
|
955
|
+
});
|
|
956
|
+
child.once("error", () => {
|
|
957
|
+
if (started) return;
|
|
958
|
+
resolve(false);
|
|
959
|
+
});
|
|
960
|
+
child.once("exit", (code, signal) => {
|
|
961
|
+
if (!started) {
|
|
962
|
+
// Exit without a spawn event: the child never ran. Let the caller fall
|
|
963
|
+
// back rather than exiting 0 on its behalf.
|
|
964
|
+
resolve(false);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (signal) {
|
|
968
|
+
const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
|
|
969
|
+
process.exit(128 + (signals[signal] ?? 0));
|
|
970
|
+
}
|
|
971
|
+
// A non-zero exit is normally the child's own answer and must be passed
|
|
972
|
+
// through untouched. One case is not the child's answer: an upgrade in
|
|
973
|
+
// flight replaced the file BETWEEN the check and node reading it, so node
|
|
974
|
+
// starts fine and dies with MODULE_NOT_FOUND. Passing that on turns a
|
|
975
|
+
// millisecond race into a dead row; if the source is gone by now, fall back
|
|
976
|
+
// to this copy instead. The window is small, the cost of covering it is one
|
|
977
|
+
// stat, and the alternative is a row that reports failure for a file that
|
|
978
|
+
// still exists a directory up.
|
|
979
|
+
if ((code ?? 0) !== 0 && !isFileSync(source)) {
|
|
980
|
+
resolve(false);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
process.exit(code ?? 0);
|
|
984
|
+
});
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
|
|
783
988
|
/**
|
|
784
989
|
* Usage text, also what a bare invocation prints.
|
|
785
990
|
*
|
|
786
991
|
* `package.json` exposes this file as the `rea-bundle-status` bin, and npm's bin
|
|
787
|
-
* shim can only forward the arguments a user typed
|
|
992
|
+
* shim can only forward the arguments a user typed — it cannot append a flag. So a
|
|
788
993
|
* bare `rea-bundle-status`, which is exactly what that name invites, used to fall
|
|
789
994
|
* through to the launch path: it started REA, and on an unprovisioned machine it
|
|
790
995
|
* began an ~800 MB download. Reporting usage instead makes the command's behaviour
|
|
@@ -808,7 +1013,7 @@ function usage() {
|
|
|
808
1013
|
].join("\n");
|
|
809
1014
|
}
|
|
810
1015
|
|
|
811
|
-
function main() {
|
|
1016
|
+
async function main() {
|
|
812
1017
|
const argv = process.argv.slice(2);
|
|
813
1018
|
|
|
814
1019
|
if (argv.includes("--shim-help") || argv.length === 0) {
|
|
@@ -817,12 +1022,36 @@ function main() {
|
|
|
817
1022
|
}
|
|
818
1023
|
|
|
819
1024
|
const root = bundleRoot();
|
|
1025
|
+
// Self-healing before anything else: a mirrored copy that predates the plugin
|
|
1026
|
+
// it was made from would otherwise keep running that older code forever, and a
|
|
1027
|
+
// market upgrade never re-runs setup. One hop, guarded, and never for the
|
|
1028
|
+
// status query — there the staleness is reported instead.
|
|
1029
|
+
const mirror = describeMirror(root);
|
|
1030
|
+
// A CHECKOUT copy is the source, not a mirror: hopping from it into whatever
|
|
1031
|
+
// the bundle's record names would silently run a different plugin — a developer
|
|
1032
|
+
// testing a local edit would be testing the installed one instead. The
|
|
1033
|
+
// discriminator is the one bundleRoot() already uses: only the checkout ships
|
|
1034
|
+
// cordis.patch.yml.
|
|
1035
|
+
const isCheckout = existsSync(path.join(PLUGIN_ROOT, "cordis.patch.yml"));
|
|
1036
|
+
if (!isCheckout && mirror !== null && mirror.stale && process.env.REA_BUNDLE_SHIM_REEXEC !== "1" && !argv.includes("--shim-status")) {
|
|
1037
|
+
const source = pluginCheckout(root);
|
|
1038
|
+
if (source !== null) {
|
|
1039
|
+
// It resolves only when the re-exec did not take over; while the child
|
|
1040
|
+
// runs, this process is parked and the child's exit is the exit.
|
|
1041
|
+
await reexecFromCheckout(source, argv);
|
|
1042
|
+
// The re-exec did not take over: a spawn failure, a child that exited
|
|
1043
|
+
// before its spawn event, or one that started and found its own file gone
|
|
1044
|
+
// (see the exit handler). Say so and run this copy for real — the point of
|
|
1045
|
+
// the fallback.
|
|
1046
|
+
process.stderr.write(`rea-shim: could not run ${source}; running the mirrored copy instead.\n`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
820
1049
|
const loaded = readBundleConfig(root);
|
|
821
1050
|
// Decide what to run before asking whether the bundle is provisioned: a
|
|
822
1051
|
// machine with its own working REA must not be sent into an ~800 MB
|
|
823
1052
|
// provisioning run just because this bundle has no state yet.
|
|
824
1053
|
const bundleVersion = loaded.ok ? (loaded.config.versions?.reaAgents ?? null) : null;
|
|
825
|
-
const launch = resolveLaunch({ root, bundleVersion, bundleProvisioned: loaded.ok });
|
|
1054
|
+
const launch = await resolveLaunch({ root, bundleVersion, bundleProvisioned: loaded.ok });
|
|
826
1055
|
if (launch.invalid !== null) {
|
|
827
1056
|
process.stderr.write(
|
|
828
1057
|
`rea-shim: REA_BUNDLE_LAUNCH="${launch.invalid}" is not one of ${LAUNCH_MODES.join("/")}; using auto.\n`,
|
|
@@ -831,7 +1060,7 @@ function main() {
|
|
|
831
1060
|
// Upstream can add a second row for the same serverName; the shim cannot stop
|
|
832
1061
|
// DSH from loading both rows, but saying so is better than doubled tools with
|
|
833
1062
|
// no stated cause. The status report carries the same fact for diagnostics.
|
|
834
|
-
const duplicate = foreignReaRegistration();
|
|
1063
|
+
const duplicate = foreignReaRegistration(root);
|
|
835
1064
|
if (duplicate && !argv.includes("--shim-status")) {
|
|
836
1065
|
process.stderr.write(
|
|
837
1066
|
`\n dsh-plugin-rea (bundle): duplicate REA registration.\n` +
|
|
@@ -860,6 +1089,10 @@ function main() {
|
|
|
860
1089
|
},
|
|
861
1090
|
};
|
|
862
1091
|
if (launch.system?.rejected?.length) report.launch.rejected = launch.system.rejected;
|
|
1092
|
+
// A mirrored copy that lags the plugin keeps working, which is exactly why
|
|
1093
|
+
// nobody notices it: the row runs old code until the mirror is refreshed.
|
|
1094
|
+
// Reported here so `--shim-status` answers "am I running what I installed?".
|
|
1095
|
+
if (mirror !== null) report.mirror = mirror;
|
|
863
1096
|
if (duplicate) report.duplicateRegistration = duplicate;
|
|
864
1097
|
if (loaded.ok) {
|
|
865
1098
|
report.paths = loaded.config.paths ?? {};
|
|
@@ -909,12 +1142,12 @@ function main() {
|
|
|
909
1142
|
const provisioning = maybeAutoProvision(root);
|
|
910
1143
|
if (provisioning.started) {
|
|
911
1144
|
process.stderr.write(
|
|
912
|
-
`\n dsh-plugin-rea (bundle): first run
|
|
1145
|
+
`\n dsh-plugin-rea (bundle): first run — provisioning started in the background.\n progress: ${provisioning.logPath}\n`,
|
|
913
1146
|
);
|
|
914
1147
|
} else if (provisioning.reason === "last-run-failed") {
|
|
915
1148
|
process.stderr.write(
|
|
916
1149
|
`\n dsh-plugin-rea (bundle): the last provisioning attempt failed (exit ${provisioning.exit ?? "?"}).\n` +
|
|
917
|
-
` Nothing is running right now
|
|
1150
|
+
` Nothing is running right now — read the log, then re-run setup.\n log: ${provisioning.logPath}\n`,
|
|
918
1151
|
);
|
|
919
1152
|
} else if (provisioning.reason === "in-flight") {
|
|
920
1153
|
// A live run owns the lock. Saying nothing here is what made a wedged
|
|
@@ -972,5 +1205,8 @@ function main() {
|
|
|
972
1205
|
return null;
|
|
973
1206
|
}
|
|
974
1207
|
|
|
975
|
-
|
|
1208
|
+
// The launch decision awaits a probe, so `main` is async; the status it returns
|
|
1209
|
+
// is this process's exit code, and a launch path resolves to null while the
|
|
1210
|
+
// child (already wired to `process.exit`) keeps the loop alive.
|
|
1211
|
+
const status = await main();
|
|
976
1212
|
if (typeof status === "number") process.exit(status);
|