dsh-plugin-rea 2.0.6 → 2.0.8
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 +86 -0
- package/README.md +30 -4
- package/README.zh-CN.md +28 -4
- package/bin/rea-shim.mjs +666 -31
- package/check-updates.ps1 +33 -5
- package/package.json +2 -2
- package/scripts/host-compat.mjs +29 -4
- package/scripts/rea-bundle-bootstrap.ps1 +5 -0
- package/scripts/setup.mjs +30 -1
- package/scripts/setup.test.mjs +509 -8
- package/scripts/verify-branch.ps1 +8 -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,12 +10,13 @@
|
|
|
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
|
-
import { spawn } from "node:child_process";
|
|
18
|
-
import {
|
|
17
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
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";
|
|
21
22
|
import process from "node:process";
|
|
@@ -32,6 +33,36 @@ function dshHome() {
|
|
|
32
33
|
return path.join(homedir(), ".dsh");
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The DSH home recorded by setup, or null.
|
|
38
|
+
*
|
|
39
|
+
* Only the mirrored copy needs this: the harness strips `DSH_*` from an MCP
|
|
40
|
+
* child, so with a custom home the environment cannot answer. Read once per
|
|
41
|
+
* process — there is one bundle root per process and this is a startup query.
|
|
42
|
+
*/
|
|
43
|
+
let recordedDshHomeCache = null;
|
|
44
|
+
function recordedDshHome(root) {
|
|
45
|
+
if (recordedDshHomeCache !== null) return recordedDshHomeCache;
|
|
46
|
+
let value = null;
|
|
47
|
+
try {
|
|
48
|
+
const record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
|
|
49
|
+
if (typeof record?.dshHome === "string" && record.dshHome.trim() !== "" && path.isAbsolute(record.dshHome)) {
|
|
50
|
+
value = path.resolve(record.dshHome);
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
value = null;
|
|
54
|
+
}
|
|
55
|
+
recordedDshHomeCache = value;
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The home to look in when the environment has been scrubbed. */
|
|
60
|
+
function dshHomeFor(root) {
|
|
61
|
+
const fromEnv = process.env.DSH_HOME;
|
|
62
|
+
if (fromEnv && fromEnv.trim() !== "") return path.resolve(fromEnv);
|
|
63
|
+
return recordedDshHome(root) ?? path.join(homedir(), ".dsh");
|
|
64
|
+
}
|
|
65
|
+
|
|
35
66
|
/**
|
|
36
67
|
* Where setup writes the machine-local truth.
|
|
37
68
|
*
|
|
@@ -43,11 +74,11 @@ function dshHome() {
|
|
|
43
74
|
* DSH_HOME: the harness strips every `DSH_*` name from an MCP child's
|
|
44
75
|
* environment (case-insensitively, because Windows is), so a custom DSH_HOME
|
|
45
76
|
* would send it to `~/.dsh`, where it would report "not provisioned" for a
|
|
46
|
-
* bundle sitting right next to it
|
|
77
|
+
* bundle sitting right next to it 鈥?and start a second ~800 MB run there.
|
|
47
78
|
* * The **checkout** copy (`<plugin>/bin/rea-shim.mjs`) is what a human runs, e.g.
|
|
48
79
|
* `npm run bundle:status`, from a shell where DSH_HOME *is* visible. Its own
|
|
49
80
|
* directory is the plugin, not the bundle, so resolving to itself would report
|
|
50
|
-
* "not provisioned" on a fully provisioned machine
|
|
81
|
+
* "not provisioned" on a fully provisioned machine 鈥?and a bare
|
|
51
82
|
* `node bin/rea-shim.mjs mcp` would provision ~800 MB into the checkout.
|
|
52
83
|
*
|
|
53
84
|
* Only the checkout ships `cordis.patch.yml`, so that file is the discriminator.
|
|
@@ -80,7 +111,7 @@ function usableSetup(dir) {
|
|
|
80
111
|
* The mirrored copy is a snapshot: after a plugin upgrade it still holds the
|
|
81
112
|
* previous manifest, so it would provision the previous versions forever. setup
|
|
82
113
|
* writes <bundle>/mirror.json naming the real checkout; prefer that while it is
|
|
83
|
-
* usable, and fall back to the local copy
|
|
114
|
+
* usable, and fall back to the local copy 鈥?which is also all a first run has.
|
|
84
115
|
*/
|
|
85
116
|
function provisioningScript(root) {
|
|
86
117
|
try {
|
|
@@ -104,13 +135,16 @@ function provisioningScript(root) {
|
|
|
104
135
|
*
|
|
105
136
|
* The parse is deliberately small: the two files it cares about are block
|
|
106
137
|
* mappings with one id/serverName per row, and anything it cannot attribute is
|
|
107
|
-
* ignored — a missed hint is acceptable, a false alarm is not.
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
138
|
+
* ignored — a missed hint is acceptable, a false alarm is not.
|
|
139
|
+
*
|
|
140
|
+
* Finding the file is the part that needed care: the harness strips every DSH_*
|
|
141
|
+
* variable from an MCP child, so a mirrored copy with a custom DSH_HOME used to
|
|
142
|
+
* read `~/.dsh` — the wrong file, and therefore a conflict it could never report.
|
|
143
|
+
* `setup.mjs` writes the home it ran under into `mirror.json`, and that record is
|
|
144
|
+
* what this uses when the environment has nothing to say.
|
|
111
145
|
*/
|
|
112
|
-
function foreignReaRegistration() {
|
|
113
|
-
const patch = path.join(
|
|
146
|
+
function foreignReaRegistration(root) {
|
|
147
|
+
const patch = path.join(dshHomeFor(root), "cordis.patch.yml");
|
|
114
148
|
let text;
|
|
115
149
|
try {
|
|
116
150
|
text = readFileSync(patch, "utf8");
|
|
@@ -133,7 +167,7 @@ function foreignReaRegistration() {
|
|
|
133
167
|
/**
|
|
134
168
|
* The provisioning lock is a small JSON record: { state, pid, at, exit? }.
|
|
135
169
|
* `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"`
|
|
170
|
+
* exists), and the failing run rewrites it as `state: "failed"` 鈥?it is kept on
|
|
137
171
|
* purpose so a broken setup cannot be re-downloaded in a loop. A legacy
|
|
138
172
|
* plain-number lock is still honoured as "running".
|
|
139
173
|
*/
|
|
@@ -171,7 +205,7 @@ function pidAlive(pid) {
|
|
|
171
205
|
* a killed attempt is simply continued on the next start.
|
|
172
206
|
*
|
|
173
207
|
* Set REA_BUNDLE_AUTO_PROVISION=0 to require an explicit `setup.mjs` run.
|
|
174
|
-
* Returns { started, reason, logPath }
|
|
208
|
+
* Returns { started, reason, logPath } 鈥?the caller words its message from
|
|
175
209
|
* `reason`, because "provisioning started in the background" was also printed
|
|
176
210
|
* for a lock left behind by a run that had already failed, and for one whose
|
|
177
211
|
* process was long gone.
|
|
@@ -208,7 +242,7 @@ function maybeAutoProvision(root) {
|
|
|
208
242
|
// Reached when the lock is older than the freshness window, or its holder is
|
|
209
243
|
// gone and never wrote an outcome (harness restart, power loss, a kill): what
|
|
210
244
|
// it records is not happening any more. This removal is the whole point of the
|
|
211
|
-
// freshness rule
|
|
245
|
+
// freshness rule 鈥?without it the exclusive create below failed with EEXIST
|
|
212
246
|
// *forever*, auto-provisioning never ran again, and main() printed nothing at
|
|
213
247
|
// all for that outcome. The lock's own "honour it only while it is fresh"
|
|
214
248
|
// comment described behaviour the code did not have.
|
|
@@ -265,15 +299,20 @@ function fail(message, detail) {
|
|
|
265
299
|
if (detail) lines.push("", " detail: " + detail);
|
|
266
300
|
// Point at the real checkout when the record allows it: running the mirrored
|
|
267
301
|
// copy is fine, but it can only ever provision the manifest it was mirrored
|
|
268
|
-
// with
|
|
302
|
+
// with 鈥?and a bundle mirrored before the manifest travelled with it has no
|
|
269
303
|
// usable copy at all, so the honest answer is then "run the plugin's own script".
|
|
270
|
-
const
|
|
304
|
+
const root = bundleRoot();
|
|
305
|
+
const setupScript = provisioningScript(root);
|
|
271
306
|
lines.push(
|
|
272
307
|
"",
|
|
273
308
|
" Finish setup with:",
|
|
274
309
|
setupScript
|
|
275
|
-
|
|
276
|
-
|
|
310
|
+
// `--root` is not decoration: a shim started with REA_BUNDLE_ROOT (or with
|
|
311
|
+
// DSH_HOME unset in a row's scrubbed environment) resolves a root that
|
|
312
|
+
// setup's own default need not agree with, and a remediation command that
|
|
313
|
+
// provisions into another root leaves this row failing exactly as before.
|
|
314
|
+
? " node \"" + setupScript + "\" --root \"" + root + "\""
|
|
315
|
+
: " node \"<installed plugin directory>/scripts/setup.mjs\" --root \"" + root + "\"",
|
|
277
316
|
);
|
|
278
317
|
if (!setupScript) {
|
|
279
318
|
lines.push("", " (Run it from the installed plugin directory: the copy in this bundle has no manifest.)");
|
|
@@ -311,7 +350,7 @@ function resolveRea(config, root) {
|
|
|
311
350
|
// The fallback has to match where setup actually installs the package:
|
|
312
351
|
// `npm install --prefix <root>/vendor` resolves into vendor/node_modules, so a
|
|
313
352
|
// fallback without that segment named a directory that never exists. It only
|
|
314
|
-
// fires when an older bundle.json has no paths.reaEntry
|
|
353
|
+
// fires when an older bundle.json has no paths.reaEntry 鈥?which is exactly when
|
|
315
354
|
// a wrong fallback turns a usable install into "the vendored REA CLI is gone".
|
|
316
355
|
const reaEntry = paths.reaEntry
|
|
317
356
|
?? path.join(root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs");
|
|
@@ -363,11 +402,540 @@ function childEnvironment(config) {
|
|
|
363
402
|
return env;
|
|
364
403
|
}
|
|
365
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Launch modes. 2.x exists to supply a runtime the machine does not have; when
|
|
407
|
+
* the machine already has one, the honest thing to do is run it.
|
|
408
|
+
*
|
|
409
|
+
* The 1.x line was exactly that: `rea mcp` off PATH, user-managed Node and
|
|
410
|
+
* Ghidra. So "use the system install" is not a new code path invented here 鈥?it
|
|
411
|
+
* is the 1.x contract, reachable from the 2.x row. `auto` (the default) prefers
|
|
412
|
+
* the system install and falls back to the provisioned bundle; `bundle` pins the
|
|
413
|
+
* old behaviour; `system` insists (and fails loudly instead of provisioning
|
|
414
|
+
* 800 MB behind an operator who asked for their own REA).
|
|
415
|
+
*/
|
|
416
|
+
const LAUNCH_MODES = ["auto", "system", "bundle"];
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* How long a candidate may take to answer `--version`.
|
|
420
|
+
*
|
|
421
|
+
* Measured on a real npm-global install: `rea --version` through its `.cmd`
|
|
422
|
+
* shim takes ~2.4 s cold (cmd.exe -> node -> the CLI's own module graph), and
|
|
423
|
+
* 2.8-3.0 s with four probes in flight. A 3 s budget was therefore a coin flip:
|
|
424
|
+
* half the runs rejected a perfectly good REA and silently fell back to the
|
|
425
|
+
* bundle. 8 s leaves room for a slow disk and a loaded machine while still
|
|
426
|
+
* bounding the one-off cost at startup.
|
|
427
|
+
*/
|
|
428
|
+
const PROBE_TIMEOUT_MS = 8_000;
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* What the environment says about the launch mode.
|
|
432
|
+
*
|
|
433
|
+
* An unknown value is reported and treated as `auto`: the row is launched by
|
|
434
|
+
* the harness with nobody watching stderr, so refusing to start over a typo
|
|
435
|
+
* would read as "the plugin is broken". `auto` is the mode that still works.
|
|
436
|
+
*/
|
|
437
|
+
function launchPreference(env = process.env) {
|
|
438
|
+
const raw = (env.REA_BUNDLE_LAUNCH ?? "").trim().toLowerCase();
|
|
439
|
+
if (raw === "") return { value: "auto", invalid: null };
|
|
440
|
+
return LAUNCH_MODES.includes(raw)
|
|
441
|
+
? { value: raw, invalid: null }
|
|
442
|
+
: { value: "auto", invalid: raw };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** True when `child` is `parent` or lives under it (case-insensitive on Windows). */
|
|
446
|
+
function isInside(child, parent) {
|
|
447
|
+
const normalise = (value) => (process.platform === "win32" ? value.toLowerCase() : value);
|
|
448
|
+
const target = normalise(path.resolve(child));
|
|
449
|
+
const base = normalise(path.resolve(parent));
|
|
450
|
+
if (target === base) return true;
|
|
451
|
+
const relative = path.relative(base, target);
|
|
452
|
+
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Executable names a REA install provides on this platform.
|
|
457
|
+
*
|
|
458
|
+
* npm's global install writes a shell script plus `.cmd`/`.ps1` shims on
|
|
459
|
+
* Windows, and only the `.cmd` one is spawnable without a shell. `rea.exe` is
|
|
460
|
+
* accepted for a native/standalone install.
|
|
461
|
+
*/
|
|
462
|
+
function systemReaNames(platform = process.platform) {
|
|
463
|
+
return platform === "win32" ? ["rea.cmd", "rea.exe", "rea.bat"] : ["rea"];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Every path worth probing, most explicit first.
|
|
468
|
+
*
|
|
469
|
+
* `REA_BUNDLE_SYSTEM_REA` names one binary outright 鈥?that is both the test
|
|
470
|
+
* seam and a real operator need (an install that is not on the harness's PATH,
|
|
471
|
+
* which on Desktop is not the user's shell PATH).
|
|
472
|
+
*/
|
|
473
|
+
function systemReaCandidates({ env = process.env, platform = process.platform } = {}) {
|
|
474
|
+
const candidates = [];
|
|
475
|
+
const explicit = (env.REA_BUNDLE_SYSTEM_REA ?? "").trim();
|
|
476
|
+
if (explicit !== "") candidates.push(path.resolve(explicit));
|
|
477
|
+
const searchPath = env.PATH ?? env.Path ?? "";
|
|
478
|
+
for (const raw of searchPath.split(path.delimiter)) {
|
|
479
|
+
// A quoted PATH entry (`PATH="C:\Program Files\v66";…`) is legal in Windows
|
|
480
|
+
// environments and `path.join` would treat the quotes as part of the name, so
|
|
481
|
+
// the install would be invisible. A relative entry is skipped rather than
|
|
482
|
+
// resolved: its meaning depends on the cwd THIS process happens to have, and
|
|
483
|
+
// a candidate found that way would be launched by a path that means something
|
|
484
|
+
// else to whoever reads the status report.
|
|
485
|
+
const dir = raw.trim().replace(/^"(.*)"$/, "$1");
|
|
486
|
+
if (dir === "" || !path.isAbsolute(dir)) continue;
|
|
487
|
+
for (const name of systemReaNames(platform)) candidates.push(path.join(dir, name));
|
|
488
|
+
}
|
|
489
|
+
const seen = new Set();
|
|
490
|
+
return candidates.filter((candidate) => {
|
|
491
|
+
const key = process.platform === "win32" ? candidate.toLowerCase() : candidate;
|
|
492
|
+
if (seen.has(key)) return false;
|
|
493
|
+
seen.add(key);
|
|
494
|
+
return true;
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Kill a process and its children.
|
|
500
|
+
*
|
|
501
|
+
* `spawnSync`'s timeout kills only the process it started — for a `.cmd` shim
|
|
502
|
+
* that is cmd.exe, and the `node` running inside it keeps the inherited stdio
|
|
503
|
+
* handles open and keeps working. Taskkill's `/T` is the one portable-enough way
|
|
504
|
+
* to get the tree on Windows; POSIX uses the process itself (the candidate is a
|
|
505
|
+
* shebang script there, so there is no intermediate shell).
|
|
506
|
+
*/
|
|
507
|
+
function killTree(pid, platform = process.platform) {
|
|
508
|
+
if (typeof pid !== "number" || pid <= 0) return;
|
|
509
|
+
try {
|
|
510
|
+
if (platform === "win32") {
|
|
511
|
+
const taskkill = path.join(process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows", "System32", "taskkill.exe");
|
|
512
|
+
spawnSync(taskkill, ["/pid", String(pid), "/T", "/F"], {
|
|
513
|
+
stdio: "ignore",
|
|
514
|
+
windowsHide: true,
|
|
515
|
+
timeout: 5_000,
|
|
516
|
+
});
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
try {
|
|
520
|
+
process.kill(pid, "SIGKILL");
|
|
521
|
+
} catch {
|
|
522
|
+
/* already gone */
|
|
523
|
+
}
|
|
524
|
+
} catch {
|
|
525
|
+
/* best effort: a failed cleanup must not change the verdict */
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** True when the path is a regular file (a directory named `rea.cmd` is not). */
|
|
530
|
+
function isFileSync(file) {
|
|
531
|
+
try {
|
|
532
|
+
return statSync(file).isFile();
|
|
533
|
+
} catch {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** A command line that survives `cmd /c` on Windows: quoted, quotes doubled. */
|
|
539
|
+
function cmdQuote(value) {
|
|
540
|
+
return `"${String(value).replace(/"/g, '""')}"`;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* The shell used to run a `.cmd`/`.bat` candidate.
|
|
545
|
+
*
|
|
546
|
+
* Absolute on purpose: `spawn(comspec())` is resolved against the CHILD's PATH,
|
|
547
|
+
* and an MCP row inherits a scrubbed environment. A machine with a working REA
|
|
548
|
+
* but a PATH that does not include System32 would then be judged "no REA",
|
|
549
|
+
* which is precisely the wrong answer for the feature that exists to find it.
|
|
550
|
+
*/
|
|
551
|
+
function comspec() {
|
|
552
|
+
const fromEnv = process.env.ComSpec ?? process.env.COMSPEC;
|
|
553
|
+
// A ComSpec that names nothing is a real (if unusual) state, and trusting it
|
|
554
|
+
// would judge every `.cmd` REA "spawn-failed" — i.e. report the machine as
|
|
555
|
+
// having no REA because its shell pointer is broken. Fall back to the path
|
|
556
|
+
// Windows itself would use.
|
|
557
|
+
if (typeof fromEnv === "string" && fromEnv.trim() !== "" && isFileSync(fromEnv)) return fromEnv;
|
|
558
|
+
const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows";
|
|
559
|
+
return path.join(systemRoot, "System32", "cmd.exe");
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Ask a candidate whether it is a working REA.
|
|
564
|
+
*
|
|
565
|
+
* A file existing on PATH is not the same as a working install: npx leftovers,
|
|
566
|
+
* half-removed globals and a shim whose Node is gone all sit on PATH and all
|
|
567
|
+
* fail at once. One bounded `--version` separates them, and a slow or hanging
|
|
568
|
+
* answer counts as "not usable" — the fallback (the bundle) still works, while
|
|
569
|
+
* delegating to a broken binary would take the tools down with it.
|
|
570
|
+
*
|
|
571
|
+
* Two things here are not incidental:
|
|
572
|
+
*
|
|
573
|
+
* * The probe runs with `REA_BUNDLE_DELEGATED=1`. A candidate that is itself a
|
|
574
|
+
* wrapper around this shim would otherwise be probed by a nested shim that
|
|
575
|
+
* probes the wrapper — measured: a process chain that keeps respawning
|
|
576
|
+
* itself. The flag makes the nested run take the bundle path and return.
|
|
577
|
+
* * It is asynchronous, not `spawnSync` with a timeout. `spawnSync` reaps the
|
|
578
|
+
* child it started, and its timeout kills only that child: for a `.cmd` shim
|
|
579
|
+
* that means cmd.exe dies while the REA inside it keeps running and holds
|
|
580
|
+
* the pipes. Killing the TREE is only possible while the parent is alive,
|
|
581
|
+
* which means owning the wait ourselves.
|
|
582
|
+
*/
|
|
583
|
+
function probeRea(file, { platform = process.platform, timeout = PROBE_TIMEOUT_MS } = {}) {
|
|
584
|
+
return new Promise((resolve) => {
|
|
585
|
+
// `existsSync` says nothing about the shape: a DIRECTORY named `rea.cmd`
|
|
586
|
+
// exists, fails to spawn, and would be reported as a broken install instead
|
|
587
|
+
// of as a name collision. Only a regular file is a candidate.
|
|
588
|
+
if (!isFileSync(file)) {
|
|
589
|
+
resolve({ ok: false, reason: "not a file" });
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const useShell = platform === "win32" && /\.(cmd|bat)$/i.test(file);
|
|
593
|
+
// `REA_BUNDLE_AUTO_PROVISION=0` is part of the same guard: if the candidate
|
|
594
|
+
// turns out to be a wrapper around THIS shim, the nested run must not start
|
|
595
|
+
// an 800 MB provisioning job just because a probe asked it for a version.
|
|
596
|
+
const env = { ...process.env, REA_BUNDLE_DELEGATED: "1", REA_BUNDLE_AUTO_PROVISION: "0" };
|
|
597
|
+
// `cmd /d /s /c "<line>"`: Windows only. Two details are load-bearing.
|
|
598
|
+
// `/s` strips the first and last quote, so the whole line is quoted; and the
|
|
599
|
+
// arguments are passed VERBATIM, because Node otherwise backslash-escapes the
|
|
600
|
+
// quotes it sees and cmd.exe does not understand `\"` — every candidate then
|
|
601
|
+
// answers "exit 1", which reads as "no REA on this machine".
|
|
602
|
+
const child = useShell
|
|
603
|
+
? spawn(comspec(), ["/d", "/s", "/c", `"${[cmdQuote(file), "--version"].join(" ")}"`], {
|
|
604
|
+
env,
|
|
605
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
606
|
+
windowsHide: true,
|
|
607
|
+
windowsVerbatimArguments: true,
|
|
608
|
+
})
|
|
609
|
+
: spawn(file, ["--version"], { env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
610
|
+
let output = "";
|
|
611
|
+
let settled = false;
|
|
612
|
+
let timer = null;
|
|
613
|
+
const finish = (result) => {
|
|
614
|
+
if (settled) return;
|
|
615
|
+
settled = true;
|
|
616
|
+
if (timer !== null) clearTimeout(timer);
|
|
617
|
+
resolve(result);
|
|
618
|
+
};
|
|
619
|
+
timer = setTimeout(() => {
|
|
620
|
+
killTree(child.pid, platform);
|
|
621
|
+
finish({ ok: false, reason: "timeout" });
|
|
622
|
+
}, timeout);
|
|
623
|
+
child.stdout.on("data", (chunk) => { output += String(chunk); });
|
|
624
|
+
child.stderr.on("data", (chunk) => { output += String(chunk); });
|
|
625
|
+
child.on("error", () => finish({ ok: false, reason: "spawn-failed" }));
|
|
626
|
+
child.on("close", (code) => {
|
|
627
|
+
if (code !== 0) {
|
|
628
|
+
finish({ ok: false, reason: `exit ${String(code)}` });
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
// A REA install prints its version. Anything else (a shim that swallowed
|
|
632
|
+
// the flag, an unrelated program that shares the name) is not a usable CLI.
|
|
633
|
+
const version = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/.exec(output.trim())?.[1] ?? null;
|
|
634
|
+
finish(version === null
|
|
635
|
+
? { ok: false, reason: "no version in --version output" }
|
|
636
|
+
: { ok: true, version });
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* The system REA this machine already has, or why it was rejected.
|
|
643
|
+
*
|
|
644
|
+
* Candidates inside this bundle or this checkout are skipped: a REA that the
|
|
645
|
+
* provisioner installed is the `bundle` mode, and delegating to it would be a
|
|
646
|
+
* loop through a path the bundle already covers.
|
|
647
|
+
*/
|
|
648
|
+
async function detectSystemRea({ env = process.env, platform = process.platform, root, pluginRoot = PLUGIN_ROOT } = {}) {
|
|
649
|
+
const rejected = [];
|
|
650
|
+
let profiled = 0;
|
|
651
|
+
const explicit = (env.REA_BUNDLE_SYSTEM_REA ?? "").trim();
|
|
652
|
+
// Probing costs a process start per candidate, and a row has to answer before
|
|
653
|
+
// its tools exist. The first existing entry is what the shell would run, so a
|
|
654
|
+
// couple of fallbacks is plenty; a machine whose first three `rea`s are all
|
|
655
|
+
// broken is a repair job, not something this decision should keep paying for.
|
|
656
|
+
const MAX_PROBES = 3;
|
|
657
|
+
for (const candidate of systemReaCandidates({ env, platform })) {
|
|
658
|
+
if (isInside(candidate, root) || isInside(candidate, pluginRoot)) {
|
|
659
|
+
// A REA the provisioner installed is `bundle` mode; delegating to it would
|
|
660
|
+
// be a loop back into the thing this decision is choosing between.
|
|
661
|
+
if (rejected.length < 8) rejected.push({ file: candidate, reason: "inside the bundle" });
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
// The candidate list is PATH x every executable name: one missing name per
|
|
665
|
+
// directory is normal and is not a finding. Only a regular file that EXISTS
|
|
666
|
+
// and then fails the probe is worth reporting.
|
|
667
|
+
try {
|
|
668
|
+
if (!statSync(candidate).isFile()) continue;
|
|
669
|
+
} catch {
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (profiled >= MAX_PROBES) {
|
|
673
|
+
if (rejected.length < 8) rejected.push({ file: candidate, reason: "not probed (budget)" });
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
profiled += 1;
|
|
677
|
+
const probe = await probeRea(candidate, { platform });
|
|
678
|
+
if (probe.ok) return { found: true, file: candidate, version: probe.version };
|
|
679
|
+
if (rejected.length < 8) rejected.push({ file: candidate, reason: probe.reason });
|
|
680
|
+
}
|
|
681
|
+
// Say what actually happened, not what a flag says: an explicit path that was
|
|
682
|
+
// skipped because it lives inside the bundle is a different answer from one
|
|
683
|
+
// that does not exist.
|
|
684
|
+
const explicitPath = explicit === "" ? null : path.resolve(explicit);
|
|
685
|
+
const explicitRejection = explicitPath === null
|
|
686
|
+
? null
|
|
687
|
+
: (rejected.find(entry => entry.file === explicitPath) ?? null);
|
|
688
|
+
let reason;
|
|
689
|
+
if (explicitPath !== null && explicitRejection !== null) {
|
|
690
|
+
reason = `REA_BUNDLE_SYSTEM_REA was rejected (${explicitRejection.reason}): ${explicitPath}`;
|
|
691
|
+
} else if (explicitPath !== null && !isFileSync(explicitPath)) {
|
|
692
|
+
reason = existsSync(explicitPath)
|
|
693
|
+
? `REA_BUNDLE_SYSTEM_REA is not a file: ${explicitPath}`
|
|
694
|
+
: `REA_BUNDLE_SYSTEM_REA does not exist: ${explicitPath}`;
|
|
695
|
+
} else {
|
|
696
|
+
reason = profiled === 0 ? "no rea on PATH" : "no usable rea on PATH";
|
|
697
|
+
}
|
|
698
|
+
return { found: false, reason, rejected };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Numeric dotted comparison (6.4.0 vs 6.5.0). null when either side is not a
|
|
703
|
+
* plain x.y.z — a prerelease or a vendor tag is not something this decision
|
|
704
|
+
* should pretend to order.
|
|
705
|
+
*/
|
|
706
|
+
function compareDotted(a, b) {
|
|
707
|
+
const parse = (value) => {
|
|
708
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(value ?? "").trim());
|
|
709
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
710
|
+
};
|
|
711
|
+
const left = parse(a);
|
|
712
|
+
const right = parse(b);
|
|
713
|
+
if (left === null || right === null) return null;
|
|
714
|
+
for (let index = 0; index < 3; index += 1) {
|
|
715
|
+
if (left[index] !== right[index]) return left[index] < right[index] ? -1 : 1;
|
|
716
|
+
}
|
|
717
|
+
return 0;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Which REA this invocation should run.
|
|
722
|
+
*
|
|
723
|
+
* `auto` prefers the system install, but not blindly: a global REA older than
|
|
724
|
+
* the one this bundle provisioned would silently take the tool count back
|
|
725
|
+
* (6.4.0 ships 170 tools, 6.5.0 ships 207), so the newer of the two wins and
|
|
726
|
+
* the older is only used when asked for by name. `bundle` and `system` are
|
|
727
|
+
* honoured literally 鈥?`system` fails loudly rather than provisioning 800 MB
|
|
728
|
+
* behind an operator who asked for their own REA.
|
|
729
|
+
*/
|
|
730
|
+
async function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false, env = process.env, platform = process.platform } = {}) {
|
|
731
|
+
const preference = launchPreference(env);
|
|
732
|
+
if (preference.value === "bundle") {
|
|
733
|
+
return { preference: preference.value, invalid: preference.invalid, mode: "bundle", reason: "pinned by REA_BUNDLE_LAUNCH=bundle" };
|
|
734
|
+
}
|
|
735
|
+
// A delegated child that ends up back in this shim (a wrapper script named
|
|
736
|
+
// `rea` that calls us, a PATH that resolves to a launcher) would recurse
|
|
737
|
+
// forever: the probe cannot break it, because the grandchild starts a fresh
|
|
738
|
+
// one. One flag, written into the child's environment, ends the loop — the
|
|
739
|
+
// nested run takes the bundle path, which either works or fails loudly.
|
|
740
|
+
if ((env.REA_BUNDLE_DELEGATED ?? "") === "1") {
|
|
741
|
+
return {
|
|
742
|
+
preference: preference.value,
|
|
743
|
+
invalid: preference.invalid,
|
|
744
|
+
mode: "bundle",
|
|
745
|
+
reason: "REA_BUNDLE_DELEGATED=1: already running under a delegated system REA, refusing to delegate again",
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
const system = await detectSystemRea({ env, platform, root });
|
|
749
|
+
if (system.found) {
|
|
750
|
+
const order = bundleVersion === null ? null : compareDotted(system.version, bundleVersion);
|
|
751
|
+
const pinned = preference.value === "system";
|
|
752
|
+
// No provisioned build at all: there is nothing to compare against, and the
|
|
753
|
+
// machine's own REA is exactly what this mode exists to use.
|
|
754
|
+
if (!bundleProvisioned || pinned || (order !== null && order >= 0)) {
|
|
755
|
+
return {
|
|
756
|
+
preference: preference.value,
|
|
757
|
+
invalid: preference.invalid,
|
|
758
|
+
mode: "system",
|
|
759
|
+
reason: pinned
|
|
760
|
+
? `pinned by REA_BUNDLE_LAUNCH=system (system ${system.version}, bundle ${bundleVersion ?? "none"})`
|
|
761
|
+
: !bundleProvisioned
|
|
762
|
+
? `a working REA is on PATH (${system.version}) and this machine has no provisioned bundle`
|
|
763
|
+
: `the REA on PATH (${system.version}) is at least as new as the provisioned one (${bundleVersion})`,
|
|
764
|
+
system,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
return {
|
|
768
|
+
preference: preference.value,
|
|
769
|
+
invalid: preference.invalid,
|
|
770
|
+
mode: "bundle",
|
|
771
|
+
reason: order === null
|
|
772
|
+
? `the REA on PATH (${system.version}) cannot be compared with the provisioned one (${bundleVersion}); keeping the bundle — set REA_BUNDLE_LAUNCH=system to use the system install`
|
|
773
|
+
: `the REA on PATH (${system.version}) is older than the provisioned one (${bundleVersion}); set REA_BUNDLE_LAUNCH=system to use it anyway`,
|
|
774
|
+
system,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
if (preference.value === "system") {
|
|
778
|
+
return {
|
|
779
|
+
preference: preference.value,
|
|
780
|
+
invalid: preference.invalid,
|
|
781
|
+
mode: "system",
|
|
782
|
+
reason: `REA_BUNDLE_LAUNCH=system, but no usable REA was found (${system.reason})`,
|
|
783
|
+
system,
|
|
784
|
+
unusable: true,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
return {
|
|
788
|
+
preference: preference.value,
|
|
789
|
+
invalid: preference.invalid,
|
|
790
|
+
mode: "bundle",
|
|
791
|
+
reason: `no usable system REA (${system.reason})`,
|
|
792
|
+
system,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Launch the system REA exactly the way the 1.x row did. */
|
|
797
|
+
function launchSystemRea(system, argv) {
|
|
798
|
+
const args = argv.length > 0 ? argv : ["mcp"];
|
|
799
|
+
const useShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(system.file);
|
|
800
|
+
// `REA_BUNDLE_DELEGATED` marks the child as "already the system REA". Without
|
|
801
|
+
// it a wrapper on PATH that calls this shim would delegate to itself forever.
|
|
802
|
+
const env = { ...process.env, REA_BUNDLE_DELEGATED: "1" };
|
|
803
|
+
// Only the PATH is quoted. Quoting the arguments too would hand them to a
|
|
804
|
+
// `.cmd`'s `%*` WITH their quotes (`"mcp"` instead of `mcp`) — measured — which
|
|
805
|
+
// is a different command line than the 1.x row produced. The row passes `mcp`
|
|
806
|
+
// and nothing else, so plain tokens are the contract here; an argument that
|
|
807
|
+
// needs shell quoting is not something this path supports (cmd would also
|
|
808
|
+
// expand `%NAME%` inside it).
|
|
809
|
+
const child = useShell
|
|
810
|
+
? spawn(comspec(), ["/d", "/s", "/c", `"${[cmdQuote(system.file), ...args].join(" ")}"`], {
|
|
811
|
+
env,
|
|
812
|
+
stdio: "inherit",
|
|
813
|
+
windowsHide: true,
|
|
814
|
+
windowsVerbatimArguments: true,
|
|
815
|
+
})
|
|
816
|
+
: spawn(system.file, args, { env, stdio: "inherit", windowsHide: true });
|
|
817
|
+
// No signal forwarding on Windows, deliberately. `child.kill()` there is
|
|
818
|
+
// TerminateProcess on the cmd.exe WRAPPER — the REA inside it survives, still
|
|
819
|
+
// holding the inherited pipes — and a console Ctrl+C is delivered to the whole
|
|
820
|
+
// process group anyway, so the child already has it. A hard kill of this shim
|
|
821
|
+
// (what a supervisor or Task Manager does) runs no JavaScript at all, so the
|
|
822
|
+
// wrapper must not pretend otherwise: on Windows the delegated REA outlives a
|
|
823
|
+
// forcibly killed shim. On POSIX the child is in this process's group and gets
|
|
824
|
+
// the terminal's signal directly; `process.exit` below then ends the shim.
|
|
825
|
+
child.on("error", (error) => {
|
|
826
|
+
process.stderr.write(`rea-shim: could not start ${system.file}\n ${String(error && error.message)}\n`);
|
|
827
|
+
process.exit(EX_CONFIG);
|
|
828
|
+
});
|
|
829
|
+
child.on("exit", (code, signal) => {
|
|
830
|
+
if (signal) {
|
|
831
|
+
const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
|
|
832
|
+
process.exit(128 + (signals[signal] ?? 0));
|
|
833
|
+
}
|
|
834
|
+
process.exit(code ?? 0);
|
|
835
|
+
});
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* The mirror problem.
|
|
841
|
+
*
|
|
842
|
+
* The published row launches `<bundle>/bin/rea-shim.mjs` — a COPY made by
|
|
843
|
+
* `setup.mjs`. Upgrading the plugin from the market replaces the package but runs
|
|
844
|
+
* no setup, so the copy keeps executing the code it was made from: every fix in
|
|
845
|
+
* a new plugin version reaches a user only if they happen to re-run setup. That
|
|
846
|
+
* is invisible (the old copy keeps working) and it is exactly the wrong
|
|
847
|
+
* direction for a safety fix.
|
|
848
|
+
*
|
|
849
|
+
* The repair has to live in the copy, because the copy is what runs. So the
|
|
850
|
+
* copy compares itself with the plugin's own `bin/rea-shim.mjs`, recorded in
|
|
851
|
+
* `mirror.json` by setup, and re-execs that file when the two differ. One hop,
|
|
852
|
+
* guarded by an environment flag, and only from mirror to checkout — the
|
|
853
|
+
* checkout copy has no `mirror.json` and would find itself anyway.
|
|
854
|
+
*
|
|
855
|
+
* A plugin version older than 2.0.8 cannot do this for its own upgrades: there
|
|
856
|
+
* is nothing in it to run. `--shim-status` reports `mirror.stale` so the
|
|
857
|
+
* condition is at least visible, and `check-updates.ps1` says to re-run setup.
|
|
858
|
+
*/
|
|
859
|
+
function sha256(file) {
|
|
860
|
+
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** Where the installed plugin lives according to setup's record, or null. */
|
|
864
|
+
function pluginCheckout(root) {
|
|
865
|
+
try {
|
|
866
|
+
const record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
|
|
867
|
+
const dir = record?.pluginRoot;
|
|
868
|
+
if (typeof dir !== "string" || dir.trim() === "") return null;
|
|
869
|
+
const shim = path.join(dir, "bin", "rea-shim.mjs");
|
|
870
|
+
return isFileSync(shim) ? shim : null;
|
|
871
|
+
} catch {
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Whether this copy is behind the plugin it was mirrored from.
|
|
878
|
+
*
|
|
879
|
+
* `self` is this file, `source` the checkout's. A checkout copy (it ships
|
|
880
|
+
* `cordis.patch.yml`) is never stale against itself; a mirror with no record, or
|
|
881
|
+
* whose recorded checkout is gone, is not re-pointed anywhere.
|
|
882
|
+
*/
|
|
883
|
+
function describeMirror(root, { self = fileURLToPath(import.meta.url) } = {}) {
|
|
884
|
+
let record = null;
|
|
885
|
+
try {
|
|
886
|
+
record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
|
|
887
|
+
} catch {
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
const source = pluginCheckout(root);
|
|
891
|
+
let stale = false;
|
|
892
|
+
let reason = "no usable plugin checkout in mirror.json";
|
|
893
|
+
if (source !== null) {
|
|
894
|
+
if (path.resolve(source) === path.resolve(self)) {
|
|
895
|
+
reason = "this copy is the plugin checkout";
|
|
896
|
+
} else {
|
|
897
|
+
try {
|
|
898
|
+
stale = sha256(source) !== sha256(self);
|
|
899
|
+
reason = stale ? "the plugin ships a different launcher than this mirrored copy" : "this copy matches the plugin";
|
|
900
|
+
} catch {
|
|
901
|
+
reason = "could not compare the two launchers";
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return {
|
|
906
|
+
pluginRoot: typeof record?.pluginRoot === "string" ? record.pluginRoot : null,
|
|
907
|
+
mirroredAt: typeof record?.mirroredAt === "string" ? record.mirroredAt : null,
|
|
908
|
+
stale,
|
|
909
|
+
reason,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** Run the checkout's launcher instead of this copy, once. */
|
|
914
|
+
function reexecFromCheckout(source, argv) {
|
|
915
|
+
const child = spawn(process.execPath, [source, ...argv], {
|
|
916
|
+
stdio: "inherit",
|
|
917
|
+
env: { ...process.env, REA_BUNDLE_SHIM_REEXEC: "1" },
|
|
918
|
+
windowsHide: true,
|
|
919
|
+
});
|
|
920
|
+
child.on("error", () => {
|
|
921
|
+
// Falling back is better than failing: the copy still knows how to launch.
|
|
922
|
+
process.stderr.write(`rea-shim: could not run ${source}; continuing with the mirrored copy.\n`);
|
|
923
|
+
});
|
|
924
|
+
child.on("exit", (code, signal) => {
|
|
925
|
+
if (signal) {
|
|
926
|
+
const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
|
|
927
|
+
process.exit(128 + (signals[signal] ?? 0));
|
|
928
|
+
}
|
|
929
|
+
process.exit(code ?? 0);
|
|
930
|
+
});
|
|
931
|
+
return null;
|
|
932
|
+
}
|
|
933
|
+
|
|
366
934
|
/**
|
|
367
935
|
* Usage text, also what a bare invocation prints.
|
|
368
936
|
*
|
|
369
937
|
* `package.json` exposes this file as the `rea-bundle-status` bin, and npm's bin
|
|
370
|
-
* shim can only forward the arguments a user typed
|
|
938
|
+
* shim can only forward the arguments a user typed 鈥?it cannot append a flag. So a
|
|
371
939
|
* bare `rea-bundle-status`, which is exactly what that name invites, used to fall
|
|
372
940
|
* through to the launch path: it started REA, and on an unprovisioned machine it
|
|
373
941
|
* began an ~800 MB download. Reporting usage instead makes the command's behaviour
|
|
@@ -375,19 +943,23 @@ function childEnvironment(config) {
|
|
|
375
943
|
*/
|
|
376
944
|
function usage() {
|
|
377
945
|
return [
|
|
378
|
-
"rea-shim: launches
|
|
946
|
+
"rea-shim: launches a REA MCP server for a DSH MCP row.",
|
|
379
947
|
"",
|
|
380
948
|
" node bin/rea-shim.mjs [rea arguments...] (normal use: 'mcp')",
|
|
381
|
-
" node bin/rea-shim.mjs --shim-status provisioning status, no launch",
|
|
949
|
+
" node bin/rea-shim.mjs --shim-status launch decision and provisioning status, no launch",
|
|
382
950
|
" node bin/rea-shim.mjs --shim-print-env the environment it would export",
|
|
383
951
|
"",
|
|
384
952
|
` bundle root: ${bundleRoot()}`,
|
|
385
953
|
` plugin root: ${PLUGIN_ROOT}`,
|
|
386
954
|
"",
|
|
955
|
+
" REA_BUNDLE_LAUNCH=auto|system|bundle (default auto: use the REA already",
|
|
956
|
+
" on PATH when it answers --version, otherwise the provisioned bundle)",
|
|
957
|
+
" REA_BUNDLE_SYSTEM_REA=<path> probe this binary instead of PATH",
|
|
958
|
+
"",
|
|
387
959
|
].join("\n");
|
|
388
960
|
}
|
|
389
961
|
|
|
390
|
-
function main() {
|
|
962
|
+
async function main() {
|
|
391
963
|
const argv = process.argv.slice(2);
|
|
392
964
|
|
|
393
965
|
if (argv.includes("--shim-help") || argv.length === 0) {
|
|
@@ -396,11 +968,30 @@ function main() {
|
|
|
396
968
|
}
|
|
397
969
|
|
|
398
970
|
const root = bundleRoot();
|
|
971
|
+
// Self-healing before anything else: a mirrored copy that predates the plugin
|
|
972
|
+
// it was made from would otherwise keep running that older code forever, and a
|
|
973
|
+
// market upgrade never re-runs setup. One hop, guarded, and never for the
|
|
974
|
+
// status query — there the staleness is reported instead.
|
|
975
|
+
const mirror = describeMirror(root);
|
|
976
|
+
if (mirror !== null && mirror.stale && process.env.REA_BUNDLE_SHIM_REEXEC !== "1" && !argv.includes("--shim-status")) {
|
|
977
|
+
const source = pluginCheckout(root);
|
|
978
|
+
if (source !== null) return reexecFromCheckout(source, argv);
|
|
979
|
+
}
|
|
399
980
|
const loaded = readBundleConfig(root);
|
|
981
|
+
// Decide what to run before asking whether the bundle is provisioned: a
|
|
982
|
+
// machine with its own working REA must not be sent into an ~800 MB
|
|
983
|
+
// provisioning run just because this bundle has no state yet.
|
|
984
|
+
const bundleVersion = loaded.ok ? (loaded.config.versions?.reaAgents ?? null) : null;
|
|
985
|
+
const launch = await resolveLaunch({ root, bundleVersion, bundleProvisioned: loaded.ok });
|
|
986
|
+
if (launch.invalid !== null) {
|
|
987
|
+
process.stderr.write(
|
|
988
|
+
`rea-shim: REA_BUNDLE_LAUNCH="${launch.invalid}" is not one of ${LAUNCH_MODES.join("/")}; using auto.\n`,
|
|
989
|
+
);
|
|
990
|
+
}
|
|
400
991
|
// Upstream can add a second row for the same serverName; the shim cannot stop
|
|
401
992
|
// DSH from loading both rows, but saying so is better than doubled tools with
|
|
402
993
|
// no stated cause. The status report carries the same fact for diagnostics.
|
|
403
|
-
const duplicate = foreignReaRegistration();
|
|
994
|
+
const duplicate = foreignReaRegistration(root);
|
|
404
995
|
if (duplicate && !argv.includes("--shim-status")) {
|
|
405
996
|
process.stderr.write(
|
|
406
997
|
`\n dsh-plugin-rea (bundle): duplicate REA registration.\n` +
|
|
@@ -417,7 +1008,22 @@ function main() {
|
|
|
417
1008
|
provisioned: loaded.ok,
|
|
418
1009
|
reason: loaded.ok ? undefined : loaded.code,
|
|
419
1010
|
autoProvision: process.env.REA_BUNDLE_AUTO_PROVISION ?? "(default)",
|
|
1011
|
+
launch: {
|
|
1012
|
+
preference: launch.preference,
|
|
1013
|
+
mode: launch.mode,
|
|
1014
|
+
reason: launch.reason,
|
|
1015
|
+
// Reported, not implied: `mode: "system"` with `unusable: true` is a
|
|
1016
|
+
// request that cannot be met, and a caller reading only the mode would
|
|
1017
|
+
// draw the opposite conclusion.
|
|
1018
|
+
unusable: launch.unusable === true,
|
|
1019
|
+
systemRea: launch.system?.found ? { file: launch.system.file, version: launch.system.version } : null,
|
|
1020
|
+
},
|
|
420
1021
|
};
|
|
1022
|
+
if (launch.system?.rejected?.length) report.launch.rejected = launch.system.rejected;
|
|
1023
|
+
// A mirrored copy that lags the plugin keeps working, which is exactly why
|
|
1024
|
+
// nobody notices it: the row runs old code until the mirror is refreshed.
|
|
1025
|
+
// Reported here so `--shim-status` answers "am I running what I installed?".
|
|
1026
|
+
if (mirror !== null) report.mirror = mirror;
|
|
421
1027
|
if (duplicate) report.duplicateRegistration = duplicate;
|
|
422
1028
|
if (loaded.ok) {
|
|
423
1029
|
report.paths = loaded.config.paths ?? {};
|
|
@@ -425,7 +1031,33 @@ function main() {
|
|
|
425
1031
|
report.rea = resolveRea(loaded.config, root);
|
|
426
1032
|
}
|
|
427
1033
|
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
|
|
428
|
-
|
|
1034
|
+
// The row works either way round — a system launch needs no bundle, a bundle
|
|
1035
|
+
// launch needs a provisioned one — but a PINNED system launch with nothing to
|
|
1036
|
+
// launch is a failure whether or not the bundle happens to be provisioned,
|
|
1037
|
+
// and reporting 0 there is how a broken row looks healthy until it starts.
|
|
1038
|
+
const usable = launch.mode === "system" ? launch.unusable !== true : loaded.ok;
|
|
1039
|
+
return usable ? 0 : EX_CONFIG;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (launch.mode === "system") {
|
|
1043
|
+
if (launch.unusable) {
|
|
1044
|
+
process.stderr.write(
|
|
1045
|
+
`\n dsh-plugin-rea (bundle): REA_BUNDLE_LAUNCH=system was requested, but no usable REA was found.\n` +
|
|
1046
|
+
` ${launch.reason}\n\n` +
|
|
1047
|
+
` Install REA (\`npm install -g rea-agents\`), point REA_BUNDLE_SYSTEM_REA at it,\n` +
|
|
1048
|
+
` or unset REA_BUNDLE_LAUNCH to use the provisioned bundle instead.\n`,
|
|
1049
|
+
);
|
|
1050
|
+
return EX_CONFIG;
|
|
1051
|
+
}
|
|
1052
|
+
if (argv.includes("--shim-print-env")) {
|
|
1053
|
+
process.stdout.write(
|
|
1054
|
+
JSON.stringify({ launch: "system", rea: launch.system.file, version: launch.system.version }, null, 2) + "\n",
|
|
1055
|
+
);
|
|
1056
|
+
return 0;
|
|
1057
|
+
}
|
|
1058
|
+
// The 1.x contract, from the 2.x row: the machine's own REA, its own Node,
|
|
1059
|
+
// its own PATH. Nothing is provisioned and no bundle state is required.
|
|
1060
|
+
return launchSystemRea(launch.system, argv);
|
|
429
1061
|
}
|
|
430
1062
|
|
|
431
1063
|
if (!loaded.ok) {
|
|
@@ -441,12 +1073,12 @@ function main() {
|
|
|
441
1073
|
const provisioning = maybeAutoProvision(root);
|
|
442
1074
|
if (provisioning.started) {
|
|
443
1075
|
process.stderr.write(
|
|
444
|
-
`\n dsh-plugin-rea (bundle): first run
|
|
1076
|
+
`\n dsh-plugin-rea (bundle): first run 鈥?provisioning started in the background.\n progress: ${provisioning.logPath}\n`,
|
|
445
1077
|
);
|
|
446
1078
|
} else if (provisioning.reason === "last-run-failed") {
|
|
447
1079
|
process.stderr.write(
|
|
448
1080
|
`\n dsh-plugin-rea (bundle): the last provisioning attempt failed (exit ${provisioning.exit ?? "?"}).\n` +
|
|
449
|
-
` Nothing is running right now
|
|
1081
|
+
` Nothing is running right now 鈥?read the log, then re-run setup.\n log: ${provisioning.logPath}\n`,
|
|
450
1082
|
);
|
|
451
1083
|
} else if (provisioning.reason === "in-flight") {
|
|
452
1084
|
// A live run owns the lock. Saying nothing here is what made a wedged
|
|
@@ -457,7 +1089,7 @@ function main() {
|
|
|
457
1089
|
} else if (provisioning.reason === "spawn-failed" || provisioning.reason === "unwritable") {
|
|
458
1090
|
process.stderr.write(
|
|
459
1091
|
`\n dsh-plugin-rea (bundle): provisioning could not be started (${provisioning.reason}).\n` +
|
|
460
|
-
` Run it by hand:\n node "${path.join(root, "scripts", "setup.mjs")}"\n`,
|
|
1092
|
+
` Run it by hand:\n node "${path.join(root, "scripts", "setup.mjs")}" --root "${root}"\n`,
|
|
461
1093
|
);
|
|
462
1094
|
}
|
|
463
1095
|
return fail(message, loaded.detail);
|
|
@@ -504,5 +1136,8 @@ function main() {
|
|
|
504
1136
|
return null;
|
|
505
1137
|
}
|
|
506
1138
|
|
|
507
|
-
|
|
1139
|
+
// The launch decision awaits a probe, so `main` is async; the status it returns
|
|
1140
|
+
// is this process's exit code, and a launch path resolves to null while the
|
|
1141
|
+
// child (already wired to `process.exit`) keeps the loop alive.
|
|
1142
|
+
const status = await main();
|
|
508
1143
|
if (typeof status === "number") process.exit(status);
|