dsh-plugin-rea 2.0.6 → 2.0.7
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 +48 -0
- package/README.md +20 -0
- package/README.zh-CN.md +20 -1
- package/bin/rea-shim.mjs +491 -23
- package/package.json +2 -2
- package/scripts/host-compat.mjs +29 -4
- package/scripts/rea-bundle-bootstrap.ps1 +5 -0
- package/scripts/setup.mjs +5 -1
- package/scripts/setup.test.mjs +328 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
|
|
4
4
|
|
|
5
|
+
## 2.0.7 — 2026-10-18
|
|
6
|
+
|
|
7
|
+
**机器上已有可用的 REA 时,2.x 直接按 1.x 的方式跑它(环境检测 + 委派),不再供给;同时把 2.0.6 的宿主声明收紧到真的能证明的范围。**
|
|
8
|
+
|
|
9
|
+
### 新增:环境检测 → 跑 1.x 的那条路
|
|
10
|
+
|
|
11
|
+
- **启动时判断该跑谁**:`rea` 在 PATH 上且能应答 `--version`,就用它(`rea mcp`,用户自己的 Node 与 PATH);否则走供给的
|
|
12
|
+
bundle。这正是 1.x 的契约,现在从 2.x 的行里也能到。
|
|
13
|
+
- **版本优先级**:系统 REA 比供给的旧就不抢(6.4.0 是 170 个工具、6.5.0 是 207 个,静默回退工具数是真回归)。相等或更新
|
|
14
|
+
才切;预发布/无法比较时保守留 bundle。
|
|
15
|
+
- **覆盖**:`REA_BUNDLE_LAUNCH=auto|system|bundle`(默认 auto)、`REA_BUNDLE_SYSTEM_REA=<path>` 点名不在 PATH 上的二进制。
|
|
16
|
+
`system` 在找不到时**报错退出**,而不是替用户去下 800 MB。
|
|
17
|
+
- **`--shim-status` 报告决定**:`launch.{preference,mode,reason,unusable,systemRea,rejected}`;`--shim-print-env` 在 system
|
|
18
|
+
模式下打印 `{launch,rea,version}`。
|
|
19
|
+
- **防自委派**:被委派的子进程带 `REA_BUNDLE_DELEGATED=1`,一个调用本 shim 的 wrapper 不会让委派无限递归。
|
|
20
|
+
|
|
21
|
+
### 修复(两轮独立复核,逐条可复现)
|
|
22
|
+
|
|
23
|
+
- **委派参数被加引号**:`cmd /c` 下 `.cmd` 的 `%*` 收到的是 `"mcp"` 而不是 `mcp` —— 探针能过、委派是坏的。现在只给路径加
|
|
24
|
+
引号,参数原样传(行固定传 `mcp`;需要 shell 引号的参数本就不在这条路上)。
|
|
25
|
+
- **探测预算 3s 与真机同量级**:实测经 `.cmd` 的 `rea --version` 为 2.4s(并发 2.8–3.0s),3s 预算等于掷硬币。改为 8s;超时
|
|
26
|
+
用 `taskkill /T /F` 杀进程树,不再只杀 cmd.exe 而留下一个占着管道的 REA。
|
|
27
|
+
- **Windows 信号转发是错的**:`child.kill("SIGINT")` 只 TerminateProcess(cmd.exe),里面的 REA 继续跑;现在 Windows 走
|
|
28
|
+
`taskkill /T`,POSIX 不重复转发(stdio inherit 已由进程组投递)。
|
|
29
|
+
- **`--shim-status` 退出码**:`system` 被点名但不可用时曾返回 0(bundle 恰好已供给),实际启动却 78;现在按「这一行能不能
|
|
30
|
+
起来」返回,并新增 `launch.unusable` 字段。
|
|
31
|
+
- **候选判定**:`existsSync` 换成 `statSync().isFile()`(名为 `rea.cmd` 的目录曾被当成坏安装);PATH 项去引号、跳过相对项;
|
|
32
|
+
显式路径被拒时理由如实报告。
|
|
33
|
+
- **`fail()` 的补救命令补上 `--root`**:shim 用 `REA_BUNDLE_ROOT` 解析出的根与 setup 的默认根可以不同,照着旧提示执行会供给
|
|
34
|
+
到另一个根。
|
|
35
|
+
- **`engine.dsh` 上限收紧**:`<0.3.0` 在 includePrerelease 语义下会放行 `0.3.0-alpha.1`/`0.3.0-rc.1`;改为 `<0.3.0-0`,并用
|
|
36
|
+
市场自己的 `satisfiesRange` 逐版本核对。
|
|
37
|
+
- **`host-compat.mjs` 的测试加固**:发布清单逐项钉住;新增「低地板必须被判为泄漏」的反向对照(原实现把 `leaked` 改成 `[]`
|
|
38
|
+
仍然全绿);`+build` 元数据按 semver 接受、`v` 前缀与零填充预发布按 semver 拒绝。
|
|
39
|
+
- **环境独立性**:`setup.mjs` 自检的 shim 步骤钉 `bundle` 模式(否则机器上更新的 REA 会冒充本次供给的产物);CI 两个 job
|
|
40
|
+
各加 job 级 `REA_BUNDLE_LAUNCH=bundle`;测试里的每个 shim 调用点都钉住模式,测试不再随机器变。
|
|
41
|
+
- **`rea-bundle-bootstrap.ps1` 认 `REA_BUNDLE_ROOT`**:四份代码里只有它忽略这个覆盖,导致 bootstrap 把 800 MB 装到
|
|
42
|
+
`<DSH_HOME>/plugins/rea-bundle`,而 shim/setup/check-updates 去 `REA_BUNDLE_ROOT` 找,看到空 bundle 再起一次。
|
|
43
|
+
- 中文 README 的「更新到 2.0.4」措辞改正为「更新到 2.0.5」。
|
|
44
|
+
|
|
45
|
+
### 验证
|
|
46
|
+
|
|
47
|
+
- 单测 **60 条**(新增 12 条:6 条启动模式 + 1 条清单钉住 + 1 条泄漏反向对照 + 1 条 semver 容差 + 3 条 PATH/空格路径/委派退出码),
|
|
48
|
+
Windows 57 过 + 3 平台跳过。
|
|
49
|
+
- `verify-branch.ps1` 全绿;`audit-manifest.mjs` clean;`host-compat.mjs` 10 covered / 5 correctly outside。
|
|
50
|
+
- 真机:全局 `rea` 6.4.0 与供给的 6.5.0 并存时 `auto` 留 bundle(不降工具数),`REA_BUNDLE_LAUNCH=system` 才切过去。
|
|
51
|
+
|
|
52
|
+
|
|
5
53
|
## 2.0.6 — 2026-10-18
|
|
6
54
|
|
|
7
55
|
**声明宿主版本:`engines.dsh` 覆盖 `0.1.7-rc.2` … `0.2.1-rc.2` 全部发布版本,市场卡片因此显示「兼容」而不是「未声明」。**
|
package/README.md
CHANGED
|
@@ -142,6 +142,26 @@ What was actually compared across that range is the row provider: `packages/mcp/
|
|
|
142
142
|
`env`, `failOnStartupError`), and `dsh.bundle.patch` exists at both tags. A host outside the range can still work; it is
|
|
143
143
|
simply not claimed.
|
|
144
144
|
|
|
145
|
+
### A REA you already have is used as-is
|
|
146
|
+
|
|
147
|
+
2.x exists to supply what a machine is missing. If REA is already installed — `rea` on PATH, the 1.x setup — the row
|
|
148
|
+
runs **that**, exactly as the 1.x line did, and nothing is provisioned:
|
|
149
|
+
|
|
150
|
+
| What the machine has | What the row runs |
|
|
151
|
+
|---|---|
|
|
152
|
+
| `rea` on PATH, at least as new as the provisioned build | your REA (`rea mcp`), with your Node and your PATH |
|
|
153
|
+
| `rea` on PATH but older than the provisioned build | the provisioned bundle — a stale global CLI would take the tool count backwards (6.4.0 ships 170 tools, 6.5.0 ships 207) |
|
|
154
|
+
| no `rea` on PATH | the provisioned bundle, provisioning itself on first use |
|
|
155
|
+
|
|
156
|
+
- `REA_BUNDLE_LAUNCH=auto|system|bundle` overrides the decision (`auto` is the default); `REA_BUNDLE_SYSTEM_REA=<path>`
|
|
157
|
+
names a binary that is not on PATH.
|
|
158
|
+
- The probe is the CLI's own `--version`, bounded at 8 s: a `.cmd` shim on Windows takes ~2.4 s to answer, so the check
|
|
159
|
+
runs once per start and only when a `rea` actually exists.
|
|
160
|
+
- `--shim-status` prints the decision — `launch.mode`, `launch.reason`, `launch.systemRea` — and its `launch.rejected`
|
|
161
|
+
list says why a candidate was skipped (`"exit 7"`, `"inside the bundle"`, `"timeout"`).
|
|
162
|
+
- A delegated child carries `REA_BUNDLE_DELEGATED=1`, so a wrapper that calls this shim cannot make it delegate to
|
|
163
|
+
itself forever.
|
|
164
|
+
|
|
145
165
|
## Configure — nothing
|
|
146
166
|
|
|
147
167
|
The shipped row carries **no machine path**:
|
package/README.zh-CN.md
CHANGED
|
@@ -131,6 +131,25 @@ node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
|
|
|
131
131
|
暴露的配置面与发布的行完全一致(`serverName`、`transport`、`command`、`args`、`env`、`failOnStartupError`),
|
|
132
132
|
并且两个 tag 都有 `dsh.bundle.patch`。范围之外的宿主可能照样能用,只是没有替它声明。
|
|
133
133
|
|
|
134
|
+
### 机器上已有 REA 就直接用它
|
|
135
|
+
|
|
136
|
+
2.x 存在的意义是补上机器缺的东西。如果 REA 已经装好——`rea` 在 PATH 上,也就是 1.x 的形态——这一行就**直接跑它**,
|
|
137
|
+
和 1.x 一模一样,不再做任何供给:
|
|
138
|
+
|
|
139
|
+
| 机器上的情况 | 这一行跑什么 |
|
|
140
|
+
|---|---|
|
|
141
|
+
| `rea` 在 PATH 上,且不比供给的版本旧 | 你自己那份(`rea mcp`),用你的 Node 和你的 PATH |
|
|
142
|
+
| `rea` 在 PATH 上,但比供给的版本旧 | 供给出来的 bundle —— 旧的全局 CLI 会把工具数往回退(6.4.0 是 170 个,6.5.0 是 207 个) |
|
|
143
|
+
| PATH 上没有 `rea` | 供给出来的 bundle,首次使用时自行供给 |
|
|
144
|
+
|
|
145
|
+
- `REA_BUNDLE_LAUNCH=auto|system|bundle` 覆盖这个判断(默认 `auto`);`REA_BUNDLE_SYSTEM_REA=<path>` 用来点名一个
|
|
146
|
+
不在 PATH 上的可执行文件。
|
|
147
|
+
- 探测用的是 CLI 自己的 `--version`,上限 8 秒:Windows 上的 `.cmd` 垫片要 ~2.4 秒才答得出来,所以这个检查每次启动
|
|
148
|
+
只做一次,而且只在真的存在 `rea` 时才做。
|
|
149
|
+
- `--shim-status` 会把判断结果打出来 —— `launch.mode`、`launch.reason`、`launch.systemRea`,`launch.rejected` 里
|
|
150
|
+
写的是每个候选被跳过的原因(`"exit 7"`、`"inside the bundle"`、`"timeout"`)。
|
|
151
|
+
- 被委派的子进程带着 `REA_BUNDLE_DELEGATED=1`,所以一个调用本 shim 的 wrapper 不会让它无限委派给自己。
|
|
152
|
+
|
|
134
153
|
## 配置 —— 不需要
|
|
135
154
|
|
|
136
155
|
发布的那一行**不含任何机器路径**:
|
|
@@ -264,7 +283,7 @@ shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.5.0}
|
|
|
264
283
|
### 在市场里更新
|
|
265
284
|
|
|
266
285
|
在哪儿装的就在哪儿更新。更新到 **2.0.5 及以上**是常规操作;而把已装的 2.0.1–2.0.4 通过应用内市场**更新到
|
|
267
|
-
2.0.
|
|
286
|
+
2.0.5**,在健康的 profile 上连续失败了三次:
|
|
268
287
|
|
|
269
288
|
> dsh-plugin-rea 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件
|
|
270
289
|
|
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,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
|
-
import { spawn } from "node:child_process";
|
|
18
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import process from "node:process";
|
|
@@ -43,11 +43,11 @@ function dshHome() {
|
|
|
43
43
|
* DSH_HOME: the harness strips every `DSH_*` name from an MCP child's
|
|
44
44
|
* environment (case-insensitively, because Windows is), so a custom DSH_HOME
|
|
45
45
|
* would send it to `~/.dsh`, where it would report "not provisioned" for a
|
|
46
|
-
* bundle sitting right next to it
|
|
46
|
+
* bundle sitting right next to it 鈥?and start a second ~800 MB run there.
|
|
47
47
|
* * The **checkout** copy (`<plugin>/bin/rea-shim.mjs`) is what a human runs, e.g.
|
|
48
48
|
* `npm run bundle:status`, from a shell where DSH_HOME *is* visible. Its own
|
|
49
49
|
* directory is the plugin, not the bundle, so resolving to itself would report
|
|
50
|
-
* "not provisioned" on a fully provisioned machine
|
|
50
|
+
* "not provisioned" on a fully provisioned machine 鈥?and a bare
|
|
51
51
|
* `node bin/rea-shim.mjs mcp` would provision ~800 MB into the checkout.
|
|
52
52
|
*
|
|
53
53
|
* Only the checkout ships `cordis.patch.yml`, so that file is the discriminator.
|
|
@@ -80,7 +80,7 @@ function usableSetup(dir) {
|
|
|
80
80
|
* The mirrored copy is a snapshot: after a plugin upgrade it still holds the
|
|
81
81
|
* previous manifest, so it would provision the previous versions forever. setup
|
|
82
82
|
* writes <bundle>/mirror.json naming the real checkout; prefer that while it is
|
|
83
|
-
* usable, and fall back to the local copy
|
|
83
|
+
* usable, and fall back to the local copy 鈥?which is also all a first run has.
|
|
84
84
|
*/
|
|
85
85
|
function provisioningScript(root) {
|
|
86
86
|
try {
|
|
@@ -104,7 +104,7 @@ function provisioningScript(root) {
|
|
|
104
104
|
*
|
|
105
105
|
* The parse is deliberately small: the two files it cares about are block
|
|
106
106
|
* mappings with one id/serverName per row, and anything it cannot attribute is
|
|
107
|
-
* ignored
|
|
107
|
+
* ignored 鈥?a missed hint is acceptable, a false alarm is not. The mirrored shim
|
|
108
108
|
* cannot see a custom DSH_HOME (the harness strips DSH_* from an MCP child), so
|
|
109
109
|
* this falls back to `~/.dsh` like the rest of the bundle; the row has the same
|
|
110
110
|
* limitation.
|
|
@@ -133,7 +133,7 @@ function foreignReaRegistration() {
|
|
|
133
133
|
/**
|
|
134
134
|
* The provisioning lock is a small JSON record: { state, pid, at, exit? }.
|
|
135
135
|
* `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"`
|
|
136
|
+
* exists), and the failing run rewrites it as `state: "failed"` 鈥?it is kept on
|
|
137
137
|
* purpose so a broken setup cannot be re-downloaded in a loop. A legacy
|
|
138
138
|
* plain-number lock is still honoured as "running".
|
|
139
139
|
*/
|
|
@@ -171,7 +171,7 @@ function pidAlive(pid) {
|
|
|
171
171
|
* a killed attempt is simply continued on the next start.
|
|
172
172
|
*
|
|
173
173
|
* Set REA_BUNDLE_AUTO_PROVISION=0 to require an explicit `setup.mjs` run.
|
|
174
|
-
* Returns { started, reason, logPath }
|
|
174
|
+
* Returns { started, reason, logPath } 鈥?the caller words its message from
|
|
175
175
|
* `reason`, because "provisioning started in the background" was also printed
|
|
176
176
|
* for a lock left behind by a run that had already failed, and for one whose
|
|
177
177
|
* process was long gone.
|
|
@@ -208,7 +208,7 @@ function maybeAutoProvision(root) {
|
|
|
208
208
|
// Reached when the lock is older than the freshness window, or its holder is
|
|
209
209
|
// gone and never wrote an outcome (harness restart, power loss, a kill): what
|
|
210
210
|
// it records is not happening any more. This removal is the whole point of the
|
|
211
|
-
// freshness rule
|
|
211
|
+
// freshness rule 鈥?without it the exclusive create below failed with EEXIST
|
|
212
212
|
// *forever*, auto-provisioning never ran again, and main() printed nothing at
|
|
213
213
|
// all for that outcome. The lock's own "honour it only while it is fresh"
|
|
214
214
|
// comment described behaviour the code did not have.
|
|
@@ -265,15 +265,20 @@ function fail(message, detail) {
|
|
|
265
265
|
if (detail) lines.push("", " detail: " + detail);
|
|
266
266
|
// Point at the real checkout when the record allows it: running the mirrored
|
|
267
267
|
// copy is fine, but it can only ever provision the manifest it was mirrored
|
|
268
|
-
// with
|
|
268
|
+
// with 鈥?and a bundle mirrored before the manifest travelled with it has no
|
|
269
269
|
// usable copy at all, so the honest answer is then "run the plugin's own script".
|
|
270
|
-
const
|
|
270
|
+
const root = bundleRoot();
|
|
271
|
+
const setupScript = provisioningScript(root);
|
|
271
272
|
lines.push(
|
|
272
273
|
"",
|
|
273
274
|
" Finish setup with:",
|
|
274
275
|
setupScript
|
|
275
|
-
|
|
276
|
-
|
|
276
|
+
// `--root` is not decoration: a shim started with REA_BUNDLE_ROOT (or with
|
|
277
|
+
// DSH_HOME unset in a row's scrubbed environment) resolves a root that
|
|
278
|
+
// setup's own default need not agree with, and a remediation command that
|
|
279
|
+
// provisions into another root leaves this row failing exactly as before.
|
|
280
|
+
? " node \"" + setupScript + "\" --root \"" + root + "\""
|
|
281
|
+
: " node \"<installed plugin directory>/scripts/setup.mjs\" --root \"" + root + "\"",
|
|
277
282
|
);
|
|
278
283
|
if (!setupScript) {
|
|
279
284
|
lines.push("", " (Run it from the installed plugin directory: the copy in this bundle has no manifest.)");
|
|
@@ -311,7 +316,7 @@ function resolveRea(config, root) {
|
|
|
311
316
|
// The fallback has to match where setup actually installs the package:
|
|
312
317
|
// `npm install --prefix <root>/vendor` resolves into vendor/node_modules, so a
|
|
313
318
|
// fallback without that segment named a directory that never exists. It only
|
|
314
|
-
// fires when an older bundle.json has no paths.reaEntry
|
|
319
|
+
// fires when an older bundle.json has no paths.reaEntry 鈥?which is exactly when
|
|
315
320
|
// a wrong fallback turns a usable install into "the vendored REA CLI is gone".
|
|
316
321
|
const reaEntry = paths.reaEntry
|
|
317
322
|
?? path.join(root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs");
|
|
@@ -363,11 +368,423 @@ function childEnvironment(config) {
|
|
|
363
368
|
return env;
|
|
364
369
|
}
|
|
365
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Launch modes. 2.x exists to supply a runtime the machine does not have; when
|
|
373
|
+
* the machine already has one, the honest thing to do is run it.
|
|
374
|
+
*
|
|
375
|
+
* 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 鈥?it
|
|
377
|
+
* is the 1.x contract, reachable from the 2.x row. `auto` (the default) prefers
|
|
378
|
+
* the system install and falls back to the provisioned bundle; `bundle` pins the
|
|
379
|
+
* old behaviour; `system` insists (and fails loudly instead of provisioning
|
|
380
|
+
* 800 MB behind an operator who asked for their own REA).
|
|
381
|
+
*/
|
|
382
|
+
const LAUNCH_MODES = ["auto", "system", "bundle"];
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* How long a candidate may take to answer `--version`.
|
|
386
|
+
*
|
|
387
|
+
* Measured on a real npm-global install: `rea --version` through its `.cmd`
|
|
388
|
+
* shim takes ~2.4 s cold (cmd.exe -> node -> the CLI's own module graph), and
|
|
389
|
+
* 2.8-3.0 s with four probes in flight. A 3 s budget was therefore a coin flip:
|
|
390
|
+
* half the runs rejected a perfectly good REA and silently fell back to the
|
|
391
|
+
* bundle. 8 s leaves room for a slow disk and a loaded machine while still
|
|
392
|
+
* bounding the one-off cost at startup.
|
|
393
|
+
*/
|
|
394
|
+
const PROBE_TIMEOUT_MS = 8_000;
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* What the environment says about the launch mode.
|
|
398
|
+
*
|
|
399
|
+
* An unknown value is reported and treated as `auto`: the row is launched by
|
|
400
|
+
* the harness with nobody watching stderr, so refusing to start over a typo
|
|
401
|
+
* would read as "the plugin is broken". `auto` is the mode that still works.
|
|
402
|
+
*/
|
|
403
|
+
function launchPreference(env = process.env) {
|
|
404
|
+
const raw = (env.REA_BUNDLE_LAUNCH ?? "").trim().toLowerCase();
|
|
405
|
+
if (raw === "") return { value: "auto", invalid: null };
|
|
406
|
+
return LAUNCH_MODES.includes(raw)
|
|
407
|
+
? { value: raw, invalid: null }
|
|
408
|
+
: { value: "auto", invalid: raw };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** True when `child` is `parent` or lives under it (case-insensitive on Windows). */
|
|
412
|
+
function isInside(child, parent) {
|
|
413
|
+
const normalise = (value) => (process.platform === "win32" ? value.toLowerCase() : value);
|
|
414
|
+
const target = normalise(path.resolve(child));
|
|
415
|
+
const base = normalise(path.resolve(parent));
|
|
416
|
+
if (target === base) return true;
|
|
417
|
+
const relative = path.relative(base, target);
|
|
418
|
+
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Executable names a REA install provides on this platform.
|
|
423
|
+
*
|
|
424
|
+
* npm's global install writes a shell script plus `.cmd`/`.ps1` shims on
|
|
425
|
+
* Windows, and only the `.cmd` one is spawnable without a shell. `rea.exe` is
|
|
426
|
+
* accepted for a native/standalone install.
|
|
427
|
+
*/
|
|
428
|
+
function systemReaNames(platform = process.platform) {
|
|
429
|
+
return platform === "win32" ? ["rea.cmd", "rea.exe", "rea.bat"] : ["rea"];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Every path worth probing, most explicit first.
|
|
434
|
+
*
|
|
435
|
+
* `REA_BUNDLE_SYSTEM_REA` names one binary outright 鈥?that is both the test
|
|
436
|
+
* seam and a real operator need (an install that is not on the harness's PATH,
|
|
437
|
+
* which on Desktop is not the user's shell PATH).
|
|
438
|
+
*/
|
|
439
|
+
function systemReaCandidates({ env = process.env, platform = process.platform } = {}) {
|
|
440
|
+
const candidates = [];
|
|
441
|
+
const explicit = (env.REA_BUNDLE_SYSTEM_REA ?? "").trim();
|
|
442
|
+
if (explicit !== "") candidates.push(path.resolve(explicit));
|
|
443
|
+
const searchPath = env.PATH ?? env.Path ?? "";
|
|
444
|
+
for (const raw of searchPath.split(path.delimiter)) {
|
|
445
|
+
// A quoted PATH entry (`PATH="C:\Program Files\v66";…`) is legal in Windows
|
|
446
|
+
// environments and `path.join` would treat the quotes as part of the name, so
|
|
447
|
+
// the install would be invisible. A relative entry is skipped rather than
|
|
448
|
+
// resolved: its meaning depends on the cwd THIS process happens to have, and
|
|
449
|
+
// a candidate found that way would be launched by a path that means something
|
|
450
|
+
// else to whoever reads the status report.
|
|
451
|
+
const dir = raw.trim().replace(/^"(.*)"$/, "$1");
|
|
452
|
+
if (dir === "" || !path.isAbsolute(dir)) continue;
|
|
453
|
+
for (const name of systemReaNames(platform)) candidates.push(path.join(dir, name));
|
|
454
|
+
}
|
|
455
|
+
const seen = new Set();
|
|
456
|
+
return candidates.filter((candidate) => {
|
|
457
|
+
const key = process.platform === "win32" ? candidate.toLowerCase() : candidate;
|
|
458
|
+
if (seen.has(key)) return false;
|
|
459
|
+
seen.add(key);
|
|
460
|
+
return true;
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Kill a process and its children.
|
|
466
|
+
*
|
|
467
|
+
* `spawnSync`'s timeout kills only the process it started — for a `.cmd` shim
|
|
468
|
+
* that is cmd.exe, and the `node` running inside it keeps the inherited stdio
|
|
469
|
+
* handles open and keeps working. Taskkill's `/T` is the one portable-enough way
|
|
470
|
+
* to get the tree on Windows; POSIX uses the process itself (the candidate is a
|
|
471
|
+
* shebang script there, so there is no intermediate shell).
|
|
472
|
+
*/
|
|
473
|
+
function killTree(pid, platform = process.platform) {
|
|
474
|
+
if (typeof pid !== "number" || pid <= 0) return;
|
|
475
|
+
try {
|
|
476
|
+
if (platform === "win32") {
|
|
477
|
+
const taskkill = path.join(process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows", "System32", "taskkill.exe");
|
|
478
|
+
spawnSync(taskkill, ["/pid", String(pid), "/T", "/F"], {
|
|
479
|
+
stdio: "ignore",
|
|
480
|
+
windowsHide: true,
|
|
481
|
+
timeout: 5_000,
|
|
482
|
+
});
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
process.kill(pid, "SIGKILL");
|
|
487
|
+
} catch {
|
|
488
|
+
/* already gone */
|
|
489
|
+
}
|
|
490
|
+
} catch {
|
|
491
|
+
/* best effort: a failed cleanup must not change the verdict */
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** True when the path is a regular file (a directory named `rea.cmd` is not). */
|
|
496
|
+
function isFileSync(file) {
|
|
497
|
+
try {
|
|
498
|
+
return statSync(file).isFile();
|
|
499
|
+
} catch {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** A command line that survives `cmd /c` on Windows: quoted, quotes doubled. */
|
|
505
|
+
function cmdQuote(value) {
|
|
506
|
+
return `"${String(value).replace(/"/g, '""')}"`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The shell used to run a `.cmd`/`.bat` candidate.
|
|
511
|
+
*
|
|
512
|
+
* Absolute on purpose: `spawn(comspec())` is resolved against the CHILD's PATH,
|
|
513
|
+
* and an MCP row inherits a scrubbed environment. A machine with a working REA
|
|
514
|
+
* but a PATH that does not include System32 would then be judged "no REA",
|
|
515
|
+
* which is precisely the wrong answer for the feature that exists to find it.
|
|
516
|
+
*/
|
|
517
|
+
function comspec() {
|
|
518
|
+
const fromEnv = process.env.ComSpec ?? process.env.COMSPEC;
|
|
519
|
+
if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv;
|
|
520
|
+
const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows";
|
|
521
|
+
return path.join(systemRoot, "System32", "cmd.exe");
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Ask a candidate whether it is a working REA.
|
|
526
|
+
*
|
|
527
|
+
* A file existing on PATH is not the same as a working install: npx leftovers,
|
|
528
|
+
* half-removed globals and a shim whose Node is gone all sit on PATH and all
|
|
529
|
+
* fail at once. One bounded `--version` separates them, and a slow or hanging
|
|
530
|
+
* answer counts as "not usable" 鈥?the fallback (the bundle) still works, while
|
|
531
|
+
* delegating to a broken binary would take the tools down with it.
|
|
532
|
+
*/
|
|
533
|
+
function probeRea(file, { platform = process.platform, timeout = PROBE_TIMEOUT_MS } = {}) {
|
|
534
|
+
// `existsSync` says nothing about the shape: a DIRECTORY named `rea.cmd` exists,
|
|
535
|
+
// fails to spawn, and would be reported as a broken install instead of as a
|
|
536
|
+
// name collision. Only a regular file is a candidate.
|
|
537
|
+
try {
|
|
538
|
+
if (!statSync(file).isFile()) return { ok: false, reason: "not a file" };
|
|
539
|
+
} catch {
|
|
540
|
+
return { ok: false, reason: "missing" };
|
|
541
|
+
}
|
|
542
|
+
const command = [file, "--version"];
|
|
543
|
+
// `cmd /d /s /c "<line>"`: Windows only. Two details are load-bearing.
|
|
544
|
+
// `/s` strips the first and last quote, so the whole line is quoted; and the
|
|
545
|
+
// arguments are passed VERBATIM, because Node otherwise backslash-escapes the
|
|
546
|
+
// quotes it sees and cmd.exe does not understand `\"` — every candidate then
|
|
547
|
+
// answers "exit 1", which reads as "no REA on this machine".
|
|
548
|
+
const result = platform === "win32" && /\.(cmd|bat)$/i.test(file)
|
|
549
|
+
? spawnSync(comspec(), ["/d", "/s", "/c", `"${[cmdQuote(file), "--version"].join(" ")}"`], {
|
|
550
|
+
encoding: "utf8",
|
|
551
|
+
timeout,
|
|
552
|
+
windowsHide: true,
|
|
553
|
+
windowsVerbatimArguments: true,
|
|
554
|
+
})
|
|
555
|
+
: spawnSync(command[0], command.slice(1), {
|
|
556
|
+
encoding: "utf8",
|
|
557
|
+
timeout,
|
|
558
|
+
windowsHide: true,
|
|
559
|
+
});
|
|
560
|
+
if (result.error) {
|
|
561
|
+
// A timed-out probe leaves its grandchild running: cmd.exe is killed, the
|
|
562
|
+
// REA inside it is not, and it keeps the inherited pipes open. Clean the tree
|
|
563
|
+
// before answering, or a hung candidate leaves a process behind per start.
|
|
564
|
+
if (result.error.code === "ETIMEDOUT") killTree(result.pid, platform);
|
|
565
|
+
return { ok: false, reason: result.error.code === "ETIMEDOUT" ? "timeout" : "spawn-failed" };
|
|
566
|
+
}
|
|
567
|
+
if (result.status !== 0) return { ok: false, reason: `exit ${String(result.status)}` };
|
|
568
|
+
const text = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
|
|
569
|
+
const version = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/.exec(text)?.[1] ?? null;
|
|
570
|
+
// A REA install prints its version. Anything else (a shim that swallowed the
|
|
571
|
+
// flag, an unrelated program that shares the name) is not a usable CLI.
|
|
572
|
+
if (version === null) return { ok: false, reason: "no version in --version output" };
|
|
573
|
+
return { ok: true, version };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* The system REA this machine already has, or why it was rejected.
|
|
578
|
+
*
|
|
579
|
+
* Candidates inside this bundle or this checkout are skipped: a REA that the
|
|
580
|
+
* provisioner installed is the `bundle` mode, and delegating to it would be a
|
|
581
|
+
* loop through a path the bundle already covers.
|
|
582
|
+
*/
|
|
583
|
+
function detectSystemRea({ env = process.env, platform = process.platform, root, pluginRoot = PLUGIN_ROOT } = {}) {
|
|
584
|
+
const rejected = [];
|
|
585
|
+
let profiled = 0;
|
|
586
|
+
const explicit = (env.REA_BUNDLE_SYSTEM_REA ?? "").trim();
|
|
587
|
+
// Probing costs a process start per candidate, and a row has to answer before
|
|
588
|
+
// its tools exist. The first existing entry is what the shell would run, so a
|
|
589
|
+
// couple of fallbacks is plenty; a machine whose first three `rea`s are all
|
|
590
|
+
// broken is a repair job, not something this decision should keep paying for.
|
|
591
|
+
const MAX_PROBES = 3;
|
|
592
|
+
for (const candidate of systemReaCandidates({ env, platform })) {
|
|
593
|
+
if (isInside(candidate, root) || isInside(candidate, pluginRoot)) {
|
|
594
|
+
// A REA the provisioner installed is `bundle` mode; delegating to it would
|
|
595
|
+
// be a loop back into the thing this decision is choosing between.
|
|
596
|
+
if (rejected.length < 8) rejected.push({ file: candidate, reason: "inside the bundle" });
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
// The candidate list is PATH x every executable name: one missing name per
|
|
600
|
+
// directory is normal and is not a finding. Only a regular file that EXISTS
|
|
601
|
+
// and then fails the probe is worth reporting.
|
|
602
|
+
try {
|
|
603
|
+
if (!statSync(candidate).isFile()) continue;
|
|
604
|
+
} catch {
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (profiled >= MAX_PROBES) {
|
|
608
|
+
if (rejected.length < 8) rejected.push({ file: candidate, reason: "not probed (budget)" });
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
profiled += 1;
|
|
612
|
+
const probe = probeRea(candidate, { platform });
|
|
613
|
+
if (probe.ok) return { found: true, file: candidate, version: probe.version };
|
|
614
|
+
if (rejected.length < 8) rejected.push({ file: candidate, reason: probe.reason });
|
|
615
|
+
}
|
|
616
|
+
// Say what actually happened, not what a flag says: an explicit path that was
|
|
617
|
+
// skipped because it lives inside the bundle is a different answer from one
|
|
618
|
+
// that does not exist.
|
|
619
|
+
const explicitPath = explicit === "" ? null : path.resolve(explicit);
|
|
620
|
+
const explicitRejection = explicitPath === null
|
|
621
|
+
? null
|
|
622
|
+
: (rejected.find(entry => entry.file === explicitPath) ?? null);
|
|
623
|
+
let reason;
|
|
624
|
+
if (explicitPath !== null && explicitRejection !== null) {
|
|
625
|
+
reason = `REA_BUNDLE_SYSTEM_REA was rejected (${explicitRejection.reason}): ${explicitPath}`;
|
|
626
|
+
} else if (explicitPath !== null && !isFileSync(explicitPath)) {
|
|
627
|
+
reason = `REA_BUNDLE_SYSTEM_REA does not exist: ${explicitPath}`;
|
|
628
|
+
} else {
|
|
629
|
+
reason = profiled === 0 ? "no rea on PATH" : "no usable rea on PATH";
|
|
630
|
+
}
|
|
631
|
+
return { found: false, reason, rejected };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Numeric dotted comparison (6.4.0 vs 6.5.0). null when either side is not a
|
|
636
|
+
* plain x.y.z — a prerelease or a vendor tag is not something this decision
|
|
637
|
+
* should pretend to order.
|
|
638
|
+
*/
|
|
639
|
+
function compareDotted(a, b) {
|
|
640
|
+
const parse = (value) => {
|
|
641
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(value ?? "").trim());
|
|
642
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
643
|
+
};
|
|
644
|
+
const left = parse(a);
|
|
645
|
+
const right = parse(b);
|
|
646
|
+
if (left === null || right === null) return null;
|
|
647
|
+
for (let index = 0; index < 3; index += 1) {
|
|
648
|
+
if (left[index] !== right[index]) return left[index] < right[index] ? -1 : 1;
|
|
649
|
+
}
|
|
650
|
+
return 0;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Which REA this invocation should run.
|
|
655
|
+
*
|
|
656
|
+
* `auto` prefers the system install, but not blindly: a global REA older than
|
|
657
|
+
* the one this bundle provisioned would silently take the tool count back
|
|
658
|
+
* (6.4.0 ships 170 tools, 6.5.0 ships 207), so the newer of the two wins and
|
|
659
|
+
* the older is only used when asked for by name. `bundle` and `system` are
|
|
660
|
+
* honoured literally 鈥?`system` fails loudly rather than provisioning 800 MB
|
|
661
|
+
* behind an operator who asked for their own REA.
|
|
662
|
+
*/
|
|
663
|
+
function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false, env = process.env, platform = process.platform } = {}) {
|
|
664
|
+
const preference = launchPreference(env);
|
|
665
|
+
if (preference.value === "bundle") {
|
|
666
|
+
return { preference: preference.value, invalid: preference.invalid, mode: "bundle", reason: "pinned by REA_BUNDLE_LAUNCH=bundle" };
|
|
667
|
+
}
|
|
668
|
+
// A delegated child that ends up back in this shim (a wrapper script named
|
|
669
|
+
// `rea` that calls us, a PATH that resolves to a launcher) would recurse
|
|
670
|
+
// forever: the probe cannot break it, because the grandchild starts a fresh
|
|
671
|
+
// one. One flag, written into the child's environment, ends the loop — the
|
|
672
|
+
// nested run takes the bundle path, which either works or fails loudly.
|
|
673
|
+
if ((env.REA_BUNDLE_DELEGATED ?? "") === "1") {
|
|
674
|
+
return {
|
|
675
|
+
preference: preference.value,
|
|
676
|
+
invalid: preference.invalid,
|
|
677
|
+
mode: "bundle",
|
|
678
|
+
reason: "REA_BUNDLE_DELEGATED=1: already running under a delegated system REA, refusing to delegate again",
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
const system = detectSystemRea({ env, platform, root });
|
|
682
|
+
if (system.found) {
|
|
683
|
+
const order = bundleVersion === null ? null : compareDotted(system.version, bundleVersion);
|
|
684
|
+
const pinned = preference.value === "system";
|
|
685
|
+
// No provisioned build at all: there is nothing to compare against, and the
|
|
686
|
+
// machine's own REA is exactly what this mode exists to use.
|
|
687
|
+
if (!bundleProvisioned || pinned || (order !== null && order >= 0)) {
|
|
688
|
+
return {
|
|
689
|
+
preference: preference.value,
|
|
690
|
+
invalid: preference.invalid,
|
|
691
|
+
mode: "system",
|
|
692
|
+
reason: pinned
|
|
693
|
+
? `pinned by REA_BUNDLE_LAUNCH=system (system ${system.version}, bundle ${bundleVersion ?? "none"})`
|
|
694
|
+
: !bundleProvisioned
|
|
695
|
+
? `a working REA is on PATH (${system.version}) and this machine has no provisioned bundle`
|
|
696
|
+
: `the REA on PATH (${system.version}) is at least as new as the provisioned one (${bundleVersion})`,
|
|
697
|
+
system,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
return {
|
|
701
|
+
preference: preference.value,
|
|
702
|
+
invalid: preference.invalid,
|
|
703
|
+
mode: "bundle",
|
|
704
|
+
reason: order === null
|
|
705
|
+
? `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`
|
|
706
|
+
: `the REA on PATH (${system.version}) is older than the provisioned one (${bundleVersion}); set REA_BUNDLE_LAUNCH=system to use it anyway`,
|
|
707
|
+
system,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
if (preference.value === "system") {
|
|
711
|
+
return {
|
|
712
|
+
preference: preference.value,
|
|
713
|
+
invalid: preference.invalid,
|
|
714
|
+
mode: "system",
|
|
715
|
+
reason: `REA_BUNDLE_LAUNCH=system, but no usable REA was found (${system.reason})`,
|
|
716
|
+
system,
|
|
717
|
+
unusable: true,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
return {
|
|
721
|
+
preference: preference.value,
|
|
722
|
+
invalid: preference.invalid,
|
|
723
|
+
mode: "bundle",
|
|
724
|
+
reason: `no usable system REA (${system.reason})`,
|
|
725
|
+
system,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Launch the system REA exactly the way the 1.x row did. */
|
|
730
|
+
function launchSystemRea(system, argv) {
|
|
731
|
+
const args = argv.length > 0 ? argv : ["mcp"];
|
|
732
|
+
const useShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(system.file);
|
|
733
|
+
// `REA_BUNDLE_DELEGATED` marks the child as "already the system REA". Without
|
|
734
|
+
// it a wrapper on PATH that calls this shim would delegate to itself forever.
|
|
735
|
+
const env = { ...process.env, REA_BUNDLE_DELEGATED: "1" };
|
|
736
|
+
// Only the PATH is quoted. Quoting the arguments too would hand them to a
|
|
737
|
+
// `.cmd`'s `%*` WITH their quotes (`"mcp"` instead of `mcp`) — measured — which
|
|
738
|
+
// is a different command line than the 1.x row produced. The row passes `mcp`
|
|
739
|
+
// and nothing else, so plain tokens are the contract here; an argument that
|
|
740
|
+
// needs shell quoting is not something this path supports (cmd would also
|
|
741
|
+
// expand `%NAME%` inside it).
|
|
742
|
+
const child = useShell
|
|
743
|
+
? spawn(comspec(), ["/d", "/s", "/c", `"${[cmdQuote(system.file), ...args].join(" ")}"`], {
|
|
744
|
+
env,
|
|
745
|
+
stdio: "inherit",
|
|
746
|
+
windowsHide: true,
|
|
747
|
+
windowsVerbatimArguments: true,
|
|
748
|
+
})
|
|
749
|
+
: spawn(system.file, args, { env, stdio: "inherit", windowsHide: true });
|
|
750
|
+
// Signal handling, per platform, because the two are not the same problem.
|
|
751
|
+
//
|
|
752
|
+
// POSIX: stdio is inherited and the child is in this process's group, so a
|
|
753
|
+
// terminal's Ctrl+C already reaches it. Forwarding again would deliver the
|
|
754
|
+
// signal twice.
|
|
755
|
+
//
|
|
756
|
+
// Windows: there is no process group to deliver to, and `child.kill("SIGINT")`
|
|
757
|
+
// is TerminateProcess on the cmd.exe WRAPPER — the REA inside it survives and
|
|
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
|
+
}
|
|
769
|
+
child.on("error", (error) => {
|
|
770
|
+
process.stderr.write(`rea-shim: could not start ${system.file}\n ${String(error && error.message)}\n`);
|
|
771
|
+
process.exit(EX_CONFIG);
|
|
772
|
+
});
|
|
773
|
+
child.on("exit", (code, signal) => {
|
|
774
|
+
if (signal) {
|
|
775
|
+
const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
|
|
776
|
+
process.exit(128 + (signals[signal] ?? 0));
|
|
777
|
+
}
|
|
778
|
+
process.exit(code ?? 0);
|
|
779
|
+
});
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
|
|
366
783
|
/**
|
|
367
784
|
* Usage text, also what a bare invocation prints.
|
|
368
785
|
*
|
|
369
786
|
* `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
|
|
787
|
+
* shim can only forward the arguments a user typed 鈥?it cannot append a flag. So a
|
|
371
788
|
* bare `rea-bundle-status`, which is exactly what that name invites, used to fall
|
|
372
789
|
* through to the launch path: it started REA, and on an unprovisioned machine it
|
|
373
790
|
* began an ~800 MB download. Reporting usage instead makes the command's behaviour
|
|
@@ -375,15 +792,19 @@ function childEnvironment(config) {
|
|
|
375
792
|
*/
|
|
376
793
|
function usage() {
|
|
377
794
|
return [
|
|
378
|
-
"rea-shim: launches
|
|
795
|
+
"rea-shim: launches a REA MCP server for a DSH MCP row.",
|
|
379
796
|
"",
|
|
380
797
|
" node bin/rea-shim.mjs [rea arguments...] (normal use: 'mcp')",
|
|
381
|
-
" node bin/rea-shim.mjs --shim-status provisioning status, no launch",
|
|
798
|
+
" node bin/rea-shim.mjs --shim-status launch decision and provisioning status, no launch",
|
|
382
799
|
" node bin/rea-shim.mjs --shim-print-env the environment it would export",
|
|
383
800
|
"",
|
|
384
801
|
` bundle root: ${bundleRoot()}`,
|
|
385
802
|
` plugin root: ${PLUGIN_ROOT}`,
|
|
386
803
|
"",
|
|
804
|
+
" REA_BUNDLE_LAUNCH=auto|system|bundle (default auto: use the REA already",
|
|
805
|
+
" on PATH when it answers --version, otherwise the provisioned bundle)",
|
|
806
|
+
" REA_BUNDLE_SYSTEM_REA=<path> probe this binary instead of PATH",
|
|
807
|
+
"",
|
|
387
808
|
].join("\n");
|
|
388
809
|
}
|
|
389
810
|
|
|
@@ -397,6 +818,16 @@ function main() {
|
|
|
397
818
|
|
|
398
819
|
const root = bundleRoot();
|
|
399
820
|
const loaded = readBundleConfig(root);
|
|
821
|
+
// Decide what to run before asking whether the bundle is provisioned: a
|
|
822
|
+
// machine with its own working REA must not be sent into an ~800 MB
|
|
823
|
+
// provisioning run just because this bundle has no state yet.
|
|
824
|
+
const bundleVersion = loaded.ok ? (loaded.config.versions?.reaAgents ?? null) : null;
|
|
825
|
+
const launch = resolveLaunch({ root, bundleVersion, bundleProvisioned: loaded.ok });
|
|
826
|
+
if (launch.invalid !== null) {
|
|
827
|
+
process.stderr.write(
|
|
828
|
+
`rea-shim: REA_BUNDLE_LAUNCH="${launch.invalid}" is not one of ${LAUNCH_MODES.join("/")}; using auto.\n`,
|
|
829
|
+
);
|
|
830
|
+
}
|
|
400
831
|
// Upstream can add a second row for the same serverName; the shim cannot stop
|
|
401
832
|
// DSH from loading both rows, but saying so is better than doubled tools with
|
|
402
833
|
// no stated cause. The status report carries the same fact for diagnostics.
|
|
@@ -417,7 +848,18 @@ function main() {
|
|
|
417
848
|
provisioned: loaded.ok,
|
|
418
849
|
reason: loaded.ok ? undefined : loaded.code,
|
|
419
850
|
autoProvision: process.env.REA_BUNDLE_AUTO_PROVISION ?? "(default)",
|
|
851
|
+
launch: {
|
|
852
|
+
preference: launch.preference,
|
|
853
|
+
mode: launch.mode,
|
|
854
|
+
reason: launch.reason,
|
|
855
|
+
// Reported, not implied: `mode: "system"` with `unusable: true` is a
|
|
856
|
+
// request that cannot be met, and a caller reading only the mode would
|
|
857
|
+
// draw the opposite conclusion.
|
|
858
|
+
unusable: launch.unusable === true,
|
|
859
|
+
systemRea: launch.system?.found ? { file: launch.system.file, version: launch.system.version } : null,
|
|
860
|
+
},
|
|
420
861
|
};
|
|
862
|
+
if (launch.system?.rejected?.length) report.launch.rejected = launch.system.rejected;
|
|
421
863
|
if (duplicate) report.duplicateRegistration = duplicate;
|
|
422
864
|
if (loaded.ok) {
|
|
423
865
|
report.paths = loaded.config.paths ?? {};
|
|
@@ -425,7 +867,33 @@ function main() {
|
|
|
425
867
|
report.rea = resolveRea(loaded.config, root);
|
|
426
868
|
}
|
|
427
869
|
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
|
|
428
|
-
|
|
870
|
+
// The row works either way round — a system launch needs no bundle, a bundle
|
|
871
|
+
// launch needs a provisioned one — but a PINNED system launch with nothing to
|
|
872
|
+
// launch is a failure whether or not the bundle happens to be provisioned,
|
|
873
|
+
// and reporting 0 there is how a broken row looks healthy until it starts.
|
|
874
|
+
const usable = launch.mode === "system" ? launch.unusable !== true : loaded.ok;
|
|
875
|
+
return usable ? 0 : EX_CONFIG;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (launch.mode === "system") {
|
|
879
|
+
if (launch.unusable) {
|
|
880
|
+
process.stderr.write(
|
|
881
|
+
`\n dsh-plugin-rea (bundle): REA_BUNDLE_LAUNCH=system was requested, but no usable REA was found.\n` +
|
|
882
|
+
` ${launch.reason}\n\n` +
|
|
883
|
+
` Install REA (\`npm install -g rea-agents\`), point REA_BUNDLE_SYSTEM_REA at it,\n` +
|
|
884
|
+
` or unset REA_BUNDLE_LAUNCH to use the provisioned bundle instead.\n`,
|
|
885
|
+
);
|
|
886
|
+
return EX_CONFIG;
|
|
887
|
+
}
|
|
888
|
+
if (argv.includes("--shim-print-env")) {
|
|
889
|
+
process.stdout.write(
|
|
890
|
+
JSON.stringify({ launch: "system", rea: launch.system.file, version: launch.system.version }, null, 2) + "\n",
|
|
891
|
+
);
|
|
892
|
+
return 0;
|
|
893
|
+
}
|
|
894
|
+
// The 1.x contract, from the 2.x row: the machine's own REA, its own Node,
|
|
895
|
+
// its own PATH. Nothing is provisioned and no bundle state is required.
|
|
896
|
+
return launchSystemRea(launch.system, argv);
|
|
429
897
|
}
|
|
430
898
|
|
|
431
899
|
if (!loaded.ok) {
|
|
@@ -441,12 +909,12 @@ function main() {
|
|
|
441
909
|
const provisioning = maybeAutoProvision(root);
|
|
442
910
|
if (provisioning.started) {
|
|
443
911
|
process.stderr.write(
|
|
444
|
-
`\n dsh-plugin-rea (bundle): first run
|
|
912
|
+
`\n dsh-plugin-rea (bundle): first run 鈥?provisioning started in the background.\n progress: ${provisioning.logPath}\n`,
|
|
445
913
|
);
|
|
446
914
|
} else if (provisioning.reason === "last-run-failed") {
|
|
447
915
|
process.stderr.write(
|
|
448
916
|
`\n dsh-plugin-rea (bundle): the last provisioning attempt failed (exit ${provisioning.exit ?? "?"}).\n` +
|
|
449
|
-
` Nothing is running right now
|
|
917
|
+
` Nothing is running right now 鈥?read the log, then re-run setup.\n log: ${provisioning.logPath}\n`,
|
|
450
918
|
);
|
|
451
919
|
} else if (provisioning.reason === "in-flight") {
|
|
452
920
|
// A live run owns the lock. Saying nothing here is what made a wedged
|
|
@@ -457,7 +925,7 @@ function main() {
|
|
|
457
925
|
} else if (provisioning.reason === "spawn-failed" || provisioning.reason === "unwritable") {
|
|
458
926
|
process.stderr.write(
|
|
459
927
|
`\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`,
|
|
928
|
+
` Run it by hand:\n node "${path.join(root, "scripts", "setup.mjs")}" --root "${root}"\n`,
|
|
461
929
|
);
|
|
462
930
|
}
|
|
463
931
|
return fail(message, loaded.detail);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-rea",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.7",
|
|
4
4
|
"description": "One-plugin bundle: provisions Node/REA/JDK/Ghidra for you, then registers the upstream REA MCP server as mcp__rea__* tools. · 一体包:自动准备 Node/REA/JDK/Ghidra,并把上游 REA 的 MCP 服务接入 DeepSeek Harness。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
},
|
|
64
64
|
"engines": {
|
|
65
65
|
"node": "^22.19.0 || ^24.11.0 || >=26.0.0",
|
|
66
|
-
"dsh": ">=0.1.7-rc.2 <0.3.0"
|
|
66
|
+
"dsh": ">=0.1.7-rc.2 <0.3.0-0"
|
|
67
67
|
},
|
|
68
68
|
"publishConfig": {
|
|
69
69
|
"registry": "https://registry.npmjs.org/"
|
package/scripts/host-compat.mjs
CHANGED
|
@@ -24,6 +24,15 @@
|
|
|
24
24
|
* shape for a gate. When a release is added upstream, add it here too — that
|
|
25
25
|
* edit is the reminder to re-check the row against the new host.
|
|
26
26
|
*
|
|
27
|
+
* SEMANTICS: this file evaluates the declaration the way `includePrerelease:
|
|
28
|
+
* true` does, and that choice is not stylistic. Both consumers of `engines.dsh`
|
|
29
|
+
* in this ecosystem pass that option — dshmarket's discovery
|
|
30
|
+
* (`satisfiesRange(host, declared, { includePrerelease: true })` in
|
|
31
|
+
* `deriveHostCompatibility`) and the host's own peer gate — so a plain semver
|
|
32
|
+
* reading, where `0.2.0-rc.2` does NOT satisfy `>=0.1.7-rc.2 <0.3.0-0` (a
|
|
33
|
+
* prerelease must be named by a comparator on its own tuple), would disagree
|
|
34
|
+
* with the verdict users actually see. The tests pin the option's effect.
|
|
35
|
+
*
|
|
27
36
|
* Usage: `node scripts/host-compat.mjs` (exit 0 = every listed release is
|
|
28
37
|
* covered by the declared range; exit 1 = at least one is not).
|
|
29
38
|
*/
|
|
@@ -59,6 +68,12 @@ export const HOST_RELEASES = [
|
|
|
59
68
|
/** Hosts that must fall OUTSIDE the declaration, for the other direction. */
|
|
60
69
|
export const OUTSIDE_HOSTS = [
|
|
61
70
|
'0.3.0',
|
|
71
|
+
// Prereleases of the NEXT line are the case a plain `<0.3.0` ceiling lets
|
|
72
|
+
// through: with `includePrerelease` they sort below `0.3.0` itself. The
|
|
73
|
+
// declared ceiling is `<0.3.0-0`, semver's idiom for "nothing from 0.3.0",
|
|
74
|
+
// and these entries are what proves it does what it says.
|
|
75
|
+
'0.3.0-rc.1',
|
|
76
|
+
'0.3.0-alpha.1',
|
|
62
77
|
'0.1.7-rc.1',
|
|
63
78
|
'0.1.7-alpha.2',
|
|
64
79
|
]
|
|
@@ -67,15 +82,25 @@ const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
|
|
|
67
82
|
|
|
68
83
|
/**
|
|
69
84
|
* Split a version into comparable parts.
|
|
85
|
+
*
|
|
86
|
+
* Build metadata (`+sha`) is dropped, as semver says it must be for precedence
|
|
87
|
+
* and as node-semver does for comparison: a host that reports
|
|
88
|
+
* `0.2.0-rc.2+build.7` is running 0.2.0-rc.2. Everything else is deliberately
|
|
89
|
+
* strict — a leading `v` or a zero-padded identifier is rejected rather than
|
|
90
|
+
* guessed at, because this gate exists to catch a declaration nobody checked and
|
|
91
|
+
* a guess would put the guess in charge.
|
|
70
92
|
* @param {string} version - a plain semver string, no range operators.
|
|
71
93
|
* @returns {{ parts: number[], pre: (string|number)[] } | null} null when unparseable.
|
|
72
94
|
*/
|
|
73
95
|
export function parseVersion(version) {
|
|
74
|
-
const
|
|
96
|
+
const cleaned = String(version ?? '').trim().replace(/\+.*$/, '')
|
|
97
|
+
const match = SEMVER.exec(cleaned)
|
|
75
98
|
if (match === null) return null
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
99
|
+
const raw = match[4] === undefined ? [] : match[4].split('.')
|
|
100
|
+
// semver forbids a zero-padded numeric prerelease identifier (`0.2.1-01`);
|
|
101
|
+
// accepting one would make this gate read a typo as a version.
|
|
102
|
+
if (raw.some(part => /^\d+$/.test(part) && part.length > 1 && part.startsWith('0'))) return null
|
|
103
|
+
const pre = raw.map(part => (/^\d+$/.test(part) ? Number(part) : part))
|
|
79
104
|
return { parts: [Number(match[1]), Number(match[2]), Number(match[3])], pre }
|
|
80
105
|
}
|
|
81
106
|
|
|
@@ -30,6 +30,11 @@ function Write-Step([string]$Message) { Write-Host "[bootstrap] $Message" }
|
|
|
30
30
|
|
|
31
31
|
# --- where things go -------------------------------------------------------
|
|
32
32
|
|
|
33
|
+
# Same precedence as setup.mjs, the shim and check-updates.ps1: explicit override,
|
|
34
|
+
# then DSH_HOME, then ~/.dsh. An override this script alone ignored would put
|
|
35
|
+
# ~800 MB into <DSH_HOME>/plugins/rea-bundle while every reader looked in
|
|
36
|
+
# REA_BUNDLE_ROOT, saw an empty bundle, and started a second run.
|
|
37
|
+
if (-not $Root -or $Root.Trim() -eq '') { $Root = $env:REA_BUNDLE_ROOT }
|
|
33
38
|
if (-not $Root -or $Root.Trim() -eq '') {
|
|
34
39
|
$home2 = $env:DSH_HOME
|
|
35
40
|
if (-not $home2 -or $home2.Trim() -eq '') { $home2 = Join-Path $HOME '.dsh' }
|
package/scripts/setup.mjs
CHANGED
|
@@ -1553,7 +1553,11 @@ if (!options.dryRun) {
|
|
|
1553
1553
|
const shim = spawnSync(process.execPath, [mirroredShim, "--version"], {
|
|
1554
1554
|
encoding: "utf8",
|
|
1555
1555
|
timeout: 120000,
|
|
1556
|
-
|
|
1556
|
+
// Pin the bundle path: this step exists to confirm the shim relays the REA
|
|
1557
|
+
// THIS run provisioned. Under the default `auto` mode a machine that also
|
|
1558
|
+
// has a newer global REA answers with its own version, and the step would
|
|
1559
|
+
// report success for something this run never verified.
|
|
1560
|
+
env: { ...process.env, REA_BUNDLE_ROOT: options.root, REA_BUNDLE_LAUNCH: "bundle" },
|
|
1557
1561
|
});
|
|
1558
1562
|
const output = `${shim.stdout ?? ""}${shim.stderr ?? ""}`.trim();
|
|
1559
1563
|
const ok = shim.status === 0 && /\d+\.\d+\.\d+/.test(output);
|
package/scripts/setup.test.mjs
CHANGED
|
@@ -213,7 +213,10 @@ const SHIM_SOURCE = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."
|
|
|
213
213
|
function shimBundleRoot(shimFile, env) {
|
|
214
214
|
const run = spawnSync(process.execPath, [shimFile, "--shim-status"], {
|
|
215
215
|
encoding: "utf8",
|
|
216
|
-
|
|
216
|
+
// These tests assert bundle-root resolution, not the launch decision. Pinning
|
|
217
|
+
// the mode keeps them independent of whether THIS machine has a REA on PATH
|
|
218
|
+
// (and keeps them from paying for a probe on every spawn).
|
|
219
|
+
env: { ...process.env, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle", ...env },
|
|
217
220
|
});
|
|
218
221
|
// --shim-status prints its report even when no bundle exists yet (exit 78).
|
|
219
222
|
return JSON.parse(run.stdout).bundleRoot;
|
|
@@ -821,6 +824,10 @@ function runShim(bundle, args, extraEnv = {}) {
|
|
|
821
824
|
...process.env,
|
|
822
825
|
DSH_HOME: path.join(path.dirname(bundle), "dsh-home"),
|
|
823
826
|
REA_BUNDLE_ROOT: bundle,
|
|
827
|
+
// The bundle path is what these tests assert. Without this pin the shim
|
|
828
|
+
// would take its `auto` branch and, on a machine that happens to have a REA
|
|
829
|
+
// on PATH, delegate to it instead of exercising the code under test.
|
|
830
|
+
REA_BUNDLE_LAUNCH: "bundle",
|
|
824
831
|
...extraEnv,
|
|
825
832
|
};
|
|
826
833
|
delete env.REA_BUNDLE_AUTO_PROVISION;
|
|
@@ -944,7 +951,7 @@ test("a second REA registration is reported instead of silently duplicated", ()
|
|
|
944
951
|
|
|
945
952
|
const status = spawnSync(process.execPath, [path.join(bundle, "bin", "rea-shim.mjs"), "--shim-status"], {
|
|
946
953
|
encoding: "utf8",
|
|
947
|
-
env: { ...process.env, DSH_HOME: home, REA_BUNDLE_ROOT: bundle },
|
|
954
|
+
env: { ...process.env, DSH_HOME: home, REA_BUNDLE_ROOT: bundle, REA_BUNDLE_LAUNCH: "bundle" },
|
|
948
955
|
});
|
|
949
956
|
assert.equal(JSON.parse(status.stdout).duplicateRegistration.id, "rea-mcp");
|
|
950
957
|
} finally {
|
|
@@ -1123,7 +1130,7 @@ test("the fallback vendored-REA path matches where setup installs it", () => {
|
|
|
1123
1130
|
const run = spawnSync(process.execPath, [path.join(REPO_ROOT, "bin", "rea-shim.mjs"), "--shim-status"], {
|
|
1124
1131
|
encoding: "utf8",
|
|
1125
1132
|
timeout: 60000,
|
|
1126
|
-
env: { ...process.env, REA_BUNDLE_ROOT: bundle, REA_BUNDLE_AUTO_PROVISION: "0" },
|
|
1133
|
+
env: { ...process.env, REA_BUNDLE_ROOT: bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
|
1127
1134
|
});
|
|
1128
1135
|
const report = JSON.parse(run.stdout);
|
|
1129
1136
|
assert.equal(report.rea.ok, true, JSON.stringify(report.rea));
|
|
@@ -1212,16 +1219,38 @@ test("the entry loads as a cordis plugin and does nothing", async () => {
|
|
|
1212
1219
|
test("the manifest declares a DSH host range the market can read", () => {
|
|
1213
1220
|
const range = declaredHostRange(REPO_ROOT);
|
|
1214
1221
|
assert.equal(typeof range, "string", "engines.dsh must be declared; without it the market reports the plugin as unknown");
|
|
1222
|
+
// The string is pinned, not just parsed: the ceiling decides which hosts are
|
|
1223
|
+
// admitted, and `<0.3.0` vs `<0.3.0-0` is exactly the difference between
|
|
1224
|
+
// "no 0.3.0" and "0.3.0-alpha.1 is fine" once prereleases are included.
|
|
1225
|
+
assert.equal(range, ">=0.1.7-rc.2 <0.3.0-0");
|
|
1215
1226
|
assert.throws(() => satisfiesDeclaredRange("0.2.0-rc.2", "not a range"), "the gate must reject an unparseable range");
|
|
1216
1227
|
assert.doesNotThrow(() => satisfiesDeclaredRange("0.2.0-rc.2", range));
|
|
1217
1228
|
});
|
|
1218
1229
|
|
|
1230
|
+
test("the release list this gate checks is the published one", () => {
|
|
1231
|
+
// Pinned literally: deleting an entry from the list would otherwise shrink the
|
|
1232
|
+
// coverage claim silently, and every test below would still pass.
|
|
1233
|
+
assert.deepEqual(HOST_RELEASES, [
|
|
1234
|
+
"0.2.1-rc.2",
|
|
1235
|
+
"0.2.1-rc.1",
|
|
1236
|
+
"0.2.1-alpha.5",
|
|
1237
|
+
"0.2.1-alpha.4",
|
|
1238
|
+
"0.2.1-alpha.3",
|
|
1239
|
+
"0.2.1-alpha.2",
|
|
1240
|
+
"0.2.1-alpha.1",
|
|
1241
|
+
"0.2.0-rc.2",
|
|
1242
|
+
"0.2.0-rc.1",
|
|
1243
|
+
"0.1.7-rc.2",
|
|
1244
|
+
]);
|
|
1245
|
+
assert.deepEqual(OUTSIDE_HOSTS, ["0.3.0", "0.3.0-rc.1", "0.3.0-alpha.1", "0.1.7-rc.1", "0.1.7-alpha.2"]);
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1219
1248
|
test("every released DSH host is inside the declared range, and nothing untested is", () => {
|
|
1220
1249
|
const range = declaredHostRange(REPO_ROOT);
|
|
1221
1250
|
const { covered, uncovered, leaked } = checkHostCoverage(range);
|
|
1222
1251
|
assert.deepEqual(uncovered, [], `these releases fall outside ${range}`);
|
|
1223
1252
|
assert.deepEqual(leaked, [], `the declaration also admits ${leaked.join(", ")}, which nobody tested`);
|
|
1224
|
-
assert.
|
|
1253
|
+
assert.deepEqual(covered, HOST_RELEASES, "every release in the list must be covered");
|
|
1225
1254
|
});
|
|
1226
1255
|
|
|
1227
1256
|
test("the host-range gate rejects a plausible but wrong declaration", () => {
|
|
@@ -1230,10 +1259,16 @@ test("the host-range gate rejects a plausible but wrong declaration", () => {
|
|
|
1230
1259
|
// prerelease sorts BELOW its release, it drops the whole 0.2.0-rc line as well
|
|
1231
1260
|
// as 0.1.7. That is the trap this test exists for: the typo looks like it names
|
|
1232
1261
|
// the running host.
|
|
1233
|
-
const drifted = checkHostCoverage(">=0.2.0 <0.3.0");
|
|
1262
|
+
const drifted = checkHostCoverage(">=0.2.0 <0.3.0-0");
|
|
1234
1263
|
assert.deepEqual(drifted.uncovered, ["0.2.0-rc.2", "0.2.0-rc.1", "0.1.7-rc.2"], "a 0.2.0 floor must be seen dropping the 0.2.0 prereleases");
|
|
1235
|
-
//
|
|
1236
|
-
|
|
1264
|
+
// The OTHER half of the gate needs its own control: a floor low enough to admit
|
|
1265
|
+
// a host nobody audited must be reported as a leak, not silently accepted.
|
|
1266
|
+
const loose = checkHostCoverage(">=0.1.0 <0.3.0-0");
|
|
1267
|
+
assert.deepEqual(loose.leaked, ["0.1.7-rc.1", "0.1.7-alpha.2"], "a low floor must be seen admitting the earlier prereleases");
|
|
1268
|
+
// And the ceiling is load bearing: the next minor line is a host nobody audited,
|
|
1269
|
+
// including its prereleases, which `<0.3.0` would have let through.
|
|
1270
|
+
assert.equal(satisfiesDeclaredRange("0.3.0", ">=0.1.7-rc.2 <0.3.0-0"), false);
|
|
1271
|
+
assert.equal(satisfiesDeclaredRange("0.3.0-rc.1", ">=0.1.7-rc.2 <0.3.0-0"), false);
|
|
1237
1272
|
});
|
|
1238
1273
|
|
|
1239
1274
|
test("prerelease ordering matches semver, not string order", () => {
|
|
@@ -1242,5 +1277,290 @@ test("prerelease ordering matches semver, not string order", () => {
|
|
|
1242
1277
|
assert.equal(compareVersions("0.2.0-rc.2", "0.2.0-rc.1"), 1);
|
|
1243
1278
|
// String comparison would sort "alpha.10" before "alpha.5"; semver does not.
|
|
1244
1279
|
assert.equal(compareVersions("0.2.1-alpha.10", "0.2.1-alpha.5"), 1);
|
|
1245
|
-
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
test("the evaluator reads the two tolerances semver does, and refuses the rest", () => {
|
|
1283
|
+
// Build metadata carries no precedence: a host reporting it is that version.
|
|
1284
|
+
assert.equal(satisfiesDeclaredRange("0.2.0-rc.2+build.7", ">=0.1.7-rc.2 <0.3.0-0"), true);
|
|
1285
|
+
// Everything else is refused rather than guessed at — a gate that accepts `v`
|
|
1286
|
+
// prefixes and zero-padded identifiers would also accept a typo.
|
|
1287
|
+
assert.throws(() => satisfiesDeclaredRange("v0.2.0-rc.2", ">=0.1.7-rc.2 <0.3.0-0"));
|
|
1288
|
+
assert.throws(() => satisfiesDeclaredRange("0.2.1-01", ">=0.1.7-rc.2 <0.3.0-0"));
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
/* ------------------------------------------------------- the launch decision */
|
|
1292
|
+
|
|
1293
|
+
// 2.x supplies a runtime the machine may not have; when it HAS one, the row must
|
|
1294
|
+
// use it — the 1.x contract. These tests own that decision, so every one of them
|
|
1295
|
+
// isolates PATH: without it the suite would silently depend on whatever `rea`
|
|
1296
|
+
// the machine running it happens to have, which is the one thing a test of a
|
|
1297
|
+
// machine-dependent decision must not do.
|
|
1298
|
+
|
|
1299
|
+
function writeFakeRea(dir, { version = "6.9.0", exitForVersion = 0, runExit = 0, record = null } = {}) {
|
|
1300
|
+
const file = path.join(dir, process.platform === "win32" ? "rea.cmd" : "rea");
|
|
1301
|
+
if (process.platform === "win32") {
|
|
1302
|
+
const lines = ["@echo off", 'if "%1"=="--version" (', ` echo ${version}`, ` exit /b ${exitForVersion}`, ")"];
|
|
1303
|
+
if (record !== null) lines.push(`echo %* > "${record}"`);
|
|
1304
|
+
lines.push(`exit /b ${runExit}`);
|
|
1305
|
+
writeFileSync(file, lines.join("\r\n") + "\r\n");
|
|
1306
|
+
} else {
|
|
1307
|
+
const lines = ["#!/bin/sh", `if [ "$1" = "--version" ]; then echo ${version}; exit ${exitForVersion}; fi`];
|
|
1308
|
+
if (record !== null) lines.push(`echo "$*" > "${record}"`);
|
|
1309
|
+
lines.push(`exit ${runExit}`);
|
|
1310
|
+
writeFileSync(file, lines.join("\n") + "\n");
|
|
1311
|
+
chmodSync(file, 0o755);
|
|
1312
|
+
}
|
|
1313
|
+
return file;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function runShimIsolated(env, args) {
|
|
1317
|
+
const merged = { ...process.env, ...env };
|
|
1318
|
+
// Windows keeps PATH and Path as separate variables (both are "in" the
|
|
1319
|
+
// environment), and a child that reads Path while the test set PATH would see
|
|
1320
|
+
// the machine's real search path instead of the fixture's.
|
|
1321
|
+
if (process.platform === "win32" && Object.prototype.hasOwnProperty.call(env, "PATH")) delete merged.Path;
|
|
1322
|
+
return spawnSync(process.execPath, [path.join(REPO_ROOT, "bin", "rea-shim.mjs"), ...args], {
|
|
1323
|
+
encoding: "utf8",
|
|
1324
|
+
timeout: 60000,
|
|
1325
|
+
env: merged,
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function launchFixture() {
|
|
1330
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-launch-"));
|
|
1331
|
+
const emptyPath = path.join(root, "empty-path");
|
|
1332
|
+
mkdirSync(emptyPath, { recursive: true });
|
|
1333
|
+
return {
|
|
1334
|
+
root,
|
|
1335
|
+
emptyPath,
|
|
1336
|
+
bundleRoot: path.join(root, "bundle"),
|
|
1337
|
+
bin: path.join(root, "bin"),
|
|
1338
|
+
record: path.join(root, "record.txt"),
|
|
1339
|
+
clean() {
|
|
1340
|
+
rmSync(root, { recursive: true, force: true });
|
|
1341
|
+
},
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
test("a machine with its own REA runs the 1.x way, and no bundle is required", () => {
|
|
1346
|
+
const f = launchFixture();
|
|
1347
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1348
|
+
const rea = writeFakeRea(f.bin, { version: "6.9.0", record: f.record });
|
|
1349
|
+
try {
|
|
1350
|
+
const env = {
|
|
1351
|
+
PATH: f.emptyPath,
|
|
1352
|
+
REA_BUNDLE_SYSTEM_REA: rea,
|
|
1353
|
+
REA_BUNDLE_ROOT: f.bundleRoot,
|
|
1354
|
+
REA_BUNDLE_AUTO_PROVISION: "0",
|
|
1355
|
+
REA_BUNDLE_LAUNCH: "",
|
|
1356
|
+
};
|
|
1357
|
+
const status = runShimIsolated(env, ["--shim-status"]);
|
|
1358
|
+
const report = JSON.parse(status.stdout);
|
|
1359
|
+
assert.equal(status.status, 0, status.stderr);
|
|
1360
|
+
assert.equal(report.launch.mode, "system", JSON.stringify(report.launch));
|
|
1361
|
+
assert.equal(report.launch.systemRea.version, "6.9.0");
|
|
1362
|
+
// Unprovisioned and still healthy: the row does not depend on the bundle.
|
|
1363
|
+
assert.equal(report.provisioned, false);
|
|
1364
|
+
assert.equal(existsSync(path.join(f.bundleRoot, "bundle.json")), false, "no provisioning may be started");
|
|
1365
|
+
|
|
1366
|
+
const run = runShimIsolated(env, ["mcp"]);
|
|
1367
|
+
assert.equal(run.status, 0, run.stderr);
|
|
1368
|
+
assert.equal(readFileSync(f.record, "utf8").trim(), "mcp", "the system REA must receive the row's arguments");
|
|
1369
|
+
} finally {
|
|
1370
|
+
f.clean();
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
test("REA_BUNDLE_LAUNCH=bundle keeps the bundle path even with a REA on PATH", () => {
|
|
1375
|
+
const f = launchFixture();
|
|
1376
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1377
|
+
const rea = writeFakeRea(f.bin);
|
|
1378
|
+
try {
|
|
1379
|
+
const run = runShimIsolated(
|
|
1380
|
+
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
|
1381
|
+
["--shim-status"],
|
|
1382
|
+
);
|
|
1383
|
+
const report = JSON.parse(run.stdout);
|
|
1384
|
+
assert.equal(report.launch.mode, "bundle");
|
|
1385
|
+
assert.equal(report.launch.reason, "pinned by REA_BUNDLE_LAUNCH=bundle");
|
|
1386
|
+
assert.equal(run.status, 78, "an unpinned bundle that is not provisioned still reports EX_CONFIG");
|
|
1387
|
+
} finally {
|
|
1388
|
+
f.clean();
|
|
1389
|
+
}
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
test("an older system REA does not displace a newer provisioned one unless pinned", () => {
|
|
1393
|
+
const f = launchFixture();
|
|
1394
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1395
|
+
mkdirSync(f.bundleRoot, { recursive: true });
|
|
1396
|
+
const rea = writeFakeRea(f.bin, { version: "6.4.0" });
|
|
1397
|
+
try {
|
|
1398
|
+
writeFileSync(
|
|
1399
|
+
path.join(f.bundleRoot, "bundle.json"),
|
|
1400
|
+
JSON.stringify({ schema: 1, paths: { node: process.execPath }, versions: { reaAgents: "6.5.0" } }),
|
|
1401
|
+
);
|
|
1402
|
+
const base = { PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0" };
|
|
1403
|
+
const auto = JSON.parse(runShimIsolated({ ...base, REA_BUNDLE_LAUNCH: "" }, ["--shim-status"]).stdout);
|
|
1404
|
+
assert.equal(auto.launch.mode, "bundle");
|
|
1405
|
+
assert.match(auto.launch.reason, /older than the provisioned one/);
|
|
1406
|
+
const pinned = JSON.parse(runShimIsolated({ ...base, REA_BUNDLE_LAUNCH: "system" }, ["--shim-status"]).stdout);
|
|
1407
|
+
assert.equal(pinned.launch.mode, "system");
|
|
1408
|
+
assert.match(pinned.launch.reason, /pinned by REA_BUNDLE_LAUNCH=system/);
|
|
1409
|
+
} finally {
|
|
1410
|
+
f.clean();
|
|
1411
|
+
}
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
test("a candidate that cannot answer --version is rejected, not launched", () => {
|
|
1415
|
+
const f = launchFixture();
|
|
1416
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1417
|
+
const rea = writeFakeRea(f.bin, { exitForVersion: 7 });
|
|
1418
|
+
try {
|
|
1419
|
+
const run = runShimIsolated(
|
|
1420
|
+
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
|
|
1421
|
+
["--shim-status"],
|
|
1422
|
+
);
|
|
1423
|
+
const report = JSON.parse(run.stdout);
|
|
1424
|
+
assert.equal(report.launch.mode, "bundle");
|
|
1425
|
+
assert.equal(report.launch.systemRea, null);
|
|
1426
|
+
assert.deepEqual(report.launch.rejected.map(r => r.reason), ["exit 7"]);
|
|
1427
|
+
assert.equal(run.status, 78);
|
|
1428
|
+
} finally {
|
|
1429
|
+
f.clean();
|
|
1430
|
+
}
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
test("REA_BUNDLE_LAUNCH=system refuses instead of provisioning", () => {
|
|
1434
|
+
const f = launchFixture();
|
|
1435
|
+
const missing = path.join(f.bin, "no-such-rea");
|
|
1436
|
+
try {
|
|
1437
|
+
const run = runShimIsolated(
|
|
1438
|
+
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: missing, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "1", REA_BUNDLE_LAUNCH: "system" },
|
|
1439
|
+
["mcp"],
|
|
1440
|
+
);
|
|
1441
|
+
assert.equal(run.status, 78, run.stdout);
|
|
1442
|
+
assert.match(run.stderr, /REA_BUNDLE_LAUNCH=system was requested/);
|
|
1443
|
+
assert.equal(existsSync(f.bundleRoot), false, "a pinned system launch must not create a bundle");
|
|
1444
|
+
} finally {
|
|
1445
|
+
f.clean();
|
|
1446
|
+
}
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
test("an unknown REA_BUNDLE_LAUNCH value is reported and treated as auto", () => {
|
|
1450
|
+
const f = launchFixture();
|
|
1451
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1452
|
+
const rea = writeFakeRea(f.bin, { version: "6.9.0" });
|
|
1453
|
+
try {
|
|
1454
|
+
const run = runShimIsolated(
|
|
1455
|
+
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "nonsense" },
|
|
1456
|
+
["--shim-status"],
|
|
1457
|
+
);
|
|
1458
|
+
assert.match(run.stderr, /REA_BUNDLE_LAUNCH="nonsense" is not one of/);
|
|
1459
|
+
assert.equal(JSON.parse(run.stdout).launch.mode, "system");
|
|
1460
|
+
} finally {
|
|
1461
|
+
f.clean();
|
|
1462
|
+
}
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
test("the system REA is found through PATH itself, not only through the override", () => {
|
|
1466
|
+
const f = launchFixture();
|
|
1467
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1468
|
+
writeFakeRea(f.bin, { version: "6.9.0" });
|
|
1469
|
+
try {
|
|
1470
|
+
const run = runShimIsolated(
|
|
1471
|
+
{ PATH: f.bin, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
|
|
1472
|
+
["--shim-status"],
|
|
1473
|
+
);
|
|
1474
|
+
const report = JSON.parse(run.stdout);
|
|
1475
|
+
assert.equal(report.launch.mode, "system", JSON.stringify(report.launch));
|
|
1476
|
+
assert.equal(report.launch.systemRea.version, "6.9.0");
|
|
1477
|
+
assert.equal(run.status, 0);
|
|
1478
|
+
} finally {
|
|
1479
|
+
f.clean();
|
|
1480
|
+
}
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
test("a REA installed under a path with a space is probed and launched", () => {
|
|
1484
|
+
const f = launchFixture();
|
|
1485
|
+
const spaced = path.join(f.root, "rea space");
|
|
1486
|
+
mkdirSync(spaced, { recursive: true });
|
|
1487
|
+
const rea = writeFakeRea(spaced, { version: "6.9.0", record: f.record });
|
|
1488
|
+
try {
|
|
1489
|
+
const env = { PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" };
|
|
1490
|
+
const status = JSON.parse(runShimIsolated(env, ["--shim-status"]).stdout);
|
|
1491
|
+
assert.equal(status.launch.mode, "system", JSON.stringify(status.launch));
|
|
1492
|
+
const run = runShimIsolated(env, ["mcp"]);
|
|
1493
|
+
assert.equal(run.status, 0, run.stderr);
|
|
1494
|
+
assert.equal(readFileSync(f.record, "utf8").trim(), "mcp");
|
|
1495
|
+
} finally {
|
|
1496
|
+
f.clean();
|
|
1497
|
+
}
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
test("the delegated process's exit status is the shim's exit status", () => {
|
|
1501
|
+
const f = launchFixture();
|
|
1502
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1503
|
+
const rea = writeFakeRea(f.bin, { version: "6.9.0", runExit: 9 });
|
|
1504
|
+
try {
|
|
1505
|
+
const run = runShimIsolated(
|
|
1506
|
+
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
|
|
1507
|
+
["mcp"],
|
|
1508
|
+
);
|
|
1509
|
+
assert.equal(run.status, 9, `the row must see REA's own status, got ${run.status}`);
|
|
1510
|
+
} finally {
|
|
1511
|
+
f.clean();
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
test("a REA inside the bundle is not treated as a system install", () => {
|
|
1516
|
+
const f = launchFixture();
|
|
1517
|
+
mkdirSync(f.bundleRoot, { recursive: true });
|
|
1518
|
+
const inside = writeFakeRea(f.bundleRoot, { version: "6.9.0" });
|
|
1519
|
+
try {
|
|
1520
|
+
const run = runShimIsolated(
|
|
1521
|
+
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: inside, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
|
|
1522
|
+
["--shim-status"],
|
|
1523
|
+
);
|
|
1524
|
+
const report = JSON.parse(run.stdout);
|
|
1525
|
+
assert.equal(report.launch.mode, "bundle");
|
|
1526
|
+
assert.equal(report.launch.systemRea, null);
|
|
1527
|
+
assert.deepEqual(report.launch.rejected.map(r => r.reason), ["inside the bundle"]);
|
|
1528
|
+
} finally {
|
|
1529
|
+
f.clean();
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
test("REA_BUNDLE_LAUNCH=bundle never probes the machine", () => {
|
|
1534
|
+
const f = launchFixture();
|
|
1535
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1536
|
+
// Any invocation at all leaves the record behind — including --version, which
|
|
1537
|
+
// is exactly what `auto` would run.
|
|
1538
|
+
const rea = writeFakeRea(f.bin, { version: "6.9.0", record: f.record });
|
|
1539
|
+
try {
|
|
1540
|
+
runShimIsolated(
|
|
1541
|
+
{ PATH: f.bin, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
|
1542
|
+
["--shim-status"],
|
|
1543
|
+
);
|
|
1544
|
+
assert.equal(existsSync(f.record), false, "the pinned bundle path must not pay for a probe");
|
|
1545
|
+
} finally {
|
|
1546
|
+
f.clean();
|
|
1547
|
+
}
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
test("--shim-print-env reports the system launch instead of a child environment", () => {
|
|
1551
|
+
const f = launchFixture();
|
|
1552
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1553
|
+
const rea = writeFakeRea(f.bin, { version: "6.9.0", record: f.record });
|
|
1554
|
+
try {
|
|
1555
|
+
const run = runShimIsolated(
|
|
1556
|
+
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
|
|
1557
|
+
["--shim-print-env"],
|
|
1558
|
+
);
|
|
1559
|
+
const printed = JSON.parse(run.stdout);
|
|
1560
|
+
assert.equal(printed.launch, "system");
|
|
1561
|
+
assert.equal(printed.version, "6.9.0");
|
|
1562
|
+
assert.equal(existsSync(f.record), false, "printing the environment must not start REA");
|
|
1563
|
+
} finally {
|
|
1564
|
+
f.clean();
|
|
1565
|
+
}
|
|
1246
1566
|
});
|