dsh-plugin-rea 2.0.8 → 2.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +4 -1
- package/README.zh-CN.md +4 -1
- package/bin/rea-shim.mjs +105 -36
- package/docs/bundle-design.md +24 -1
- package/package.json +1 -1
- package/scripts/setup.mjs +19 -4
- package/scripts/setup.test.mjs +149 -5
- package/scripts/verify-branch.ps1 +30 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
|
|
4
4
|
|
|
5
|
+
## 2.0.9 — 2026-10-18
|
|
6
|
+
|
|
7
|
+
**2.0.8 的两处发布前问题(假回退、乱码)与若干文档缺口;不改正常路径,只让失败路径说真话。**
|
|
8
|
+
|
|
9
|
+
### 修复
|
|
10
|
+
|
|
11
|
+
- **`reexecFromCheckout` 的「回退」是假话**:spawn 失败时只打印「继续用镜像副本」,函数却返回了,`main()` 已经
|
|
12
|
+
return —— 没有 `exit` 事件、事件循环排空、进程以 **exit 0 静默退出**,行看起来启动成功却没有任何工具。现在这个
|
|
13
|
+
Promise **只在没接管时才 resolve**:子进程活着时本进程等待、由子进程的退出码决定;失败则 `main()` 继续用本地副本
|
|
14
|
+
启动,并如实说明在回退。
|
|
15
|
+
- **回退覆盖了升级竞态的主形态**:`spawn` 的目标是 `process.execPath`,spawn 级失败几乎只剩资源耗尽;真正常见的是
|
|
16
|
+
「哈希比对通过 → node 启动 → 文件已被换掉」→ node 以 `MODULE_NOT_FOUND` 退 1。现在**退出码非 0 且源文件已不存在**
|
|
17
|
+
时同样回退到本地副本(窗口毫秒级,代价是一次 `stat`),不再把竞态变成一行死掉的 MCP。
|
|
18
|
+
- **乱码 `鈥?` 混进用户可见输出**:2.0.7 在 GBK 编辑器里碰过 `bin/rea-shim.mjs`,16 处破折号被写成 `鈥?`,其中两条是
|
|
19
|
+
stderr 消息(首次自动供给、上次供给失败)—— 每个用户都会看到。全部还原为 `— `(含被吃掉的空格)。
|
|
20
|
+
- **开发时可能测到「已安装插件」而不是自己的改动**:checkout 副本会顺着 bundle 的 `mirror.json` 转发到插件目录。
|
|
21
|
+
现在 checkout(带 `cordis.patch.yml`)永不转发,只有镜像副本会;注释也改成与行为一致。
|
|
22
|
+
- **带 BOM 的 `mirror.json` / `bundle.json` 被当成「没有记录」**:`JSON.parse` 拒绝前导 BOM,而每个调用方都把解析失败
|
|
23
|
+
读成「无记录」。Windows 编辑器保存的 JSON 就会带 BOM,表现是镜像自愈静默不生效、bundle 被当成未供给。`bin/rea-shim.mjs`
|
|
24
|
+
与 `scripts/setup.mjs` 现在都经各自的 `readJson()` 去掉 `U+FEFF` 再解析。**两个文件读的是同一份记录,所以缺一不可**:
|
|
25
|
+
此前只有 shim 剥了 BOM,带 BOM 的 `mirror.json` 在 setup 眼里仍是「没有 checkout」,镜像 layout 下的 setup 于是把上一版
|
|
26
|
+
manifest 又镜了一遍并从它供给;`bundle.json` 同理被读成未供给。`verify-branch.ps1` 与 CI 都加了守卫:这两个文件里
|
|
27
|
+
出现裸的 `JSON.parse(readFileSync(...))` 即失败。
|
|
28
|
+
|
|
29
|
+
### 文档 / CI
|
|
30
|
+
|
|
31
|
+
- 两份 README 的钉版本示例改为**当前版本** `#v2.0.9`(2.0.8 那一轮已从 `#v2.0.0` 提到 `#v2.0.8`,版本号一动就得跟上,
|
|
32
|
+
否则读者拿到的 ref 不存在);README 的「机器上已有 REA」一节补上 Windows 限制:**强制结束 shim 不会带走被委派的 REA**。
|
|
33
|
+
- `docs/bundle-design.md`(随包发布)补上 2.0.7 的 launch mode 与 2.0.8 的镜像自愈,含 `mirror.json` 的
|
|
34
|
+
`shimSha256` / `pluginVersion` / `dshHome` 三个字段与控制流图。
|
|
35
|
+
- CI 新增两条:README 必须逐字引用 `engines.dsh`;README 的钉版本示例必须等于 `package.json` 的 version(`verify-branch.ps1`
|
|
36
|
+
同步加了第二条,避免同一个漂移第三次发生)。
|
|
37
|
+
- `systemReaCandidates` 的去重键改用传入的 `platform` 参数(测试缝一致,生产无变化)。
|
|
38
|
+
|
|
39
|
+
### 验证
|
|
40
|
+
|
|
41
|
+
- 单测 **71 条**(68 过 + 3 平台跳过;新增「checkout 副本不转发」「文件中途消失 → 回退」「带 BOM 的 `mirror.json`」
|
|
42
|
+
「带 BOM 的 `bundle.json`」四条);镜像转发测试改为真的运行镜像副本。
|
|
43
|
+
- `verify-branch.ps1` 44 项全绿;`audit-manifest` clean;`host-compat` 10 covered / 5 outside。
|
|
44
|
+
|
|
5
45
|
## 2.0.8 — 2026-10-18
|
|
6
46
|
|
|
7
47
|
**2.0.7 的启动检测在两种边界上会失控:一个包着本 shim 的 `rea` 会让探针递归成进程链;超时探针会漏掉一个孙进程。两条都已修,并补了复现测试。**
|
package/README.md
CHANGED
|
@@ -100,7 +100,7 @@ releases, checksum-verified). Then install the plugin for your profile:
|
|
|
100
100
|
```
|
|
101
101
|
dsh plugin --profile <profile> add dsh-plugin-rea
|
|
102
102
|
# or pin this release instead of tracking npm latest:
|
|
103
|
-
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.
|
|
103
|
+
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.9
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
> **On an Electron host (the DeepSeek Harness desktop app) the profile is owned by the application.**
|
|
@@ -161,6 +161,9 @@ runs **that**, exactly as the 1.x line did, and nothing is provisioned:
|
|
|
161
161
|
list says why a candidate was skipped (`"exit 7"`, `"inside the bundle"`, `"timeout"`).
|
|
162
162
|
- A delegated child carries `REA_BUNDLE_DELEGATED=1`, so a wrapper that calls this shim cannot make it delegate to
|
|
163
163
|
itself forever.
|
|
164
|
+
- **On Windows a forced kill of the shim does not reap the delegated REA.** There is no job object here: Task Manager
|
|
165
|
+
or a supervisor terminating the shim leaves your `rea` running with the row's pipes (a console Ctrl+C reaches the
|
|
166
|
+
whole process group and does stop it). On POSIX the child is in the same group and dies with the terminal.
|
|
164
167
|
|
|
165
168
|
## Configure — nothing
|
|
166
169
|
|
package/README.zh-CN.md
CHANGED
|
@@ -94,7 +94,7 @@ profile 安装插件:
|
|
|
94
94
|
```
|
|
95
95
|
dsh plugin --profile <profile> add dsh-plugin-rea
|
|
96
96
|
# 或钉住这个版本,而不是跟着 npm latest 走:
|
|
97
|
-
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.
|
|
97
|
+
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.9
|
|
98
98
|
```
|
|
99
99
|
|
|
100
100
|
> **Electron 宿主(DeepSeek Harness 桌面版)的 profile 由应用独占。** 那种机器上 `dsh plugin --profile desktop …`
|
|
@@ -149,6 +149,9 @@ node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
|
|
|
149
149
|
- `--shim-status` 会把判断结果打出来 —— `launch.mode`、`launch.reason`、`launch.systemRea`,`launch.rejected` 里
|
|
150
150
|
写的是每个候选被跳过的原因(`"exit 7"`、`"inside the bundle"`、`"timeout"`)。
|
|
151
151
|
- 被委派的子进程带着 `REA_BUNDLE_DELEGATED=1`,所以一个调用本 shim 的 wrapper 不会让它无限委派给自己。
|
|
152
|
+
- **Windows 上强制结束 shim 不会带走被委派的 REA**:这里没有 job object,任务管理器或 supervisor 终止 shim 时,
|
|
153
|
+
你自己的 `rea` 会继续占着这一行的管道(控制台 Ctrl+C 会发给整个进程组,那种情况下会一起停)。POSIX 上子进程
|
|
154
|
+
在同一进程组,随终端一起退出。
|
|
152
155
|
|
|
153
156
|
## 配置 —— 不需要
|
|
154
157
|
|
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,7 +10,7 @@
|
|
|
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
|
*/
|
|
@@ -26,6 +26,19 @@ const EX_CONFIG = 78;
|
|
|
26
26
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
27
27
|
const PLUGIN_ROOT = path.resolve(HERE, "..");
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* JSON from disk, tolerating the UTF-8 BOM a Windows editor writes.
|
|
31
|
+
*
|
|
32
|
+
* `JSON.parse` rejects a leading BOM, and every caller here reads a parse
|
|
33
|
+
* failure as "no record": a BOM would silently downgrade mirror.json to "no
|
|
34
|
+
* plugin checkout" and bundle.json to "not provisioned". Measured while testing
|
|
35
|
+
* this file — a hand-edited mirror.json written by Windows PowerShell behaved
|
|
36
|
+
* exactly that way.
|
|
37
|
+
*/
|
|
38
|
+
function readJson(file) {
|
|
39
|
+
return JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
/** DSH home, by the same rule setup.mjs and the published patch use. */
|
|
30
43
|
function dshHome() {
|
|
31
44
|
const fromEnv = process.env.DSH_HOME;
|
|
@@ -45,7 +58,7 @@ function recordedDshHome(root) {
|
|
|
45
58
|
if (recordedDshHomeCache !== null) return recordedDshHomeCache;
|
|
46
59
|
let value = null;
|
|
47
60
|
try {
|
|
48
|
-
const record =
|
|
61
|
+
const record = readJson(path.join(root, "mirror.json"));
|
|
49
62
|
if (typeof record?.dshHome === "string" && record.dshHome.trim() !== "" && path.isAbsolute(record.dshHome)) {
|
|
50
63
|
value = path.resolve(record.dshHome);
|
|
51
64
|
}
|
|
@@ -74,11 +87,11 @@ function dshHomeFor(root) {
|
|
|
74
87
|
* DSH_HOME: the harness strips every `DSH_*` name from an MCP child's
|
|
75
88
|
* environment (case-insensitively, because Windows is), so a custom DSH_HOME
|
|
76
89
|
* would send it to `~/.dsh`, where it would report "not provisioned" for a
|
|
77
|
-
* bundle sitting right next to it
|
|
90
|
+
* bundle sitting right next to it — and start a second ~800 MB run there.
|
|
78
91
|
* * The **checkout** copy (`<plugin>/bin/rea-shim.mjs`) is what a human runs, e.g.
|
|
79
92
|
* `npm run bundle:status`, from a shell where DSH_HOME *is* visible. Its own
|
|
80
93
|
* directory is the plugin, not the bundle, so resolving to itself would report
|
|
81
|
-
* "not provisioned" on a fully provisioned machine
|
|
94
|
+
* "not provisioned" on a fully provisioned machine — and a bare
|
|
82
95
|
* `node bin/rea-shim.mjs mcp` would provision ~800 MB into the checkout.
|
|
83
96
|
*
|
|
84
97
|
* Only the checkout ships `cordis.patch.yml`, so that file is the discriminator.
|
|
@@ -111,11 +124,11 @@ function usableSetup(dir) {
|
|
|
111
124
|
* The mirrored copy is a snapshot: after a plugin upgrade it still holds the
|
|
112
125
|
* previous manifest, so it would provision the previous versions forever. setup
|
|
113
126
|
* writes <bundle>/mirror.json naming the real checkout; prefer that while it is
|
|
114
|
-
* usable, and fall back to the local copy
|
|
127
|
+
* usable, and fall back to the local copy — which is also all a first run has.
|
|
115
128
|
*/
|
|
116
129
|
function provisioningScript(root) {
|
|
117
130
|
try {
|
|
118
|
-
const record =
|
|
131
|
+
const record = readJson(path.join(root, "mirror.json"));
|
|
119
132
|
const recorded = usableSetup(record && record.pluginRoot);
|
|
120
133
|
if (recorded) return recorded;
|
|
121
134
|
} catch {
|
|
@@ -167,7 +180,7 @@ function foreignReaRegistration(root) {
|
|
|
167
180
|
/**
|
|
168
181
|
* The provisioning lock is a small JSON record: { state, pid, at, exit? }.
|
|
169
182
|
* `state: "running"` is written when a run is started (setup's own pid once it
|
|
170
|
-
* exists), and the failing run rewrites it as `state: "failed"`
|
|
183
|
+
* exists), and the failing run rewrites it as `state: "failed"` — it is kept on
|
|
171
184
|
* purpose so a broken setup cannot be re-downloaded in a loop. A legacy
|
|
172
185
|
* plain-number lock is still honoured as "running".
|
|
173
186
|
*/
|
|
@@ -205,7 +218,7 @@ function pidAlive(pid) {
|
|
|
205
218
|
* a killed attempt is simply continued on the next start.
|
|
206
219
|
*
|
|
207
220
|
* Set REA_BUNDLE_AUTO_PROVISION=0 to require an explicit `setup.mjs` run.
|
|
208
|
-
* Returns { started, reason, logPath }
|
|
221
|
+
* Returns { started, reason, logPath } — the caller words its message from
|
|
209
222
|
* `reason`, because "provisioning started in the background" was also printed
|
|
210
223
|
* for a lock left behind by a run that had already failed, and for one whose
|
|
211
224
|
* process was long gone.
|
|
@@ -242,7 +255,7 @@ function maybeAutoProvision(root) {
|
|
|
242
255
|
// Reached when the lock is older than the freshness window, or its holder is
|
|
243
256
|
// gone and never wrote an outcome (harness restart, power loss, a kill): what
|
|
244
257
|
// it records is not happening any more. This removal is the whole point of the
|
|
245
|
-
// freshness rule
|
|
258
|
+
// freshness rule — without it the exclusive create below failed with EEXIST
|
|
246
259
|
// *forever*, auto-provisioning never ran again, and main() printed nothing at
|
|
247
260
|
// all for that outcome. The lock's own "honour it only while it is fresh"
|
|
248
261
|
// comment described behaviour the code did not have.
|
|
@@ -299,7 +312,7 @@ function fail(message, detail) {
|
|
|
299
312
|
if (detail) lines.push("", " detail: " + detail);
|
|
300
313
|
// Point at the real checkout when the record allows it: running the mirrored
|
|
301
314
|
// copy is fine, but it can only ever provision the manifest it was mirrored
|
|
302
|
-
// with
|
|
315
|
+
// with — and a bundle mirrored before the manifest travelled with it has no
|
|
303
316
|
// usable copy at all, so the honest answer is then "run the plugin's own script".
|
|
304
317
|
const root = bundleRoot();
|
|
305
318
|
const setupScript = provisioningScript(root);
|
|
@@ -333,7 +346,7 @@ function readBundleConfig(root) {
|
|
|
333
346
|
if (!existsSync(file)) return { ok: false, code: "missing", file };
|
|
334
347
|
let parsed;
|
|
335
348
|
try {
|
|
336
|
-
parsed =
|
|
349
|
+
parsed = readJson(file);
|
|
337
350
|
} catch (error) {
|
|
338
351
|
return { ok: false, code: "malformed", file, detail: String(error && error.message) };
|
|
339
352
|
}
|
|
@@ -350,7 +363,7 @@ function resolveRea(config, root) {
|
|
|
350
363
|
// The fallback has to match where setup actually installs the package:
|
|
351
364
|
// `npm install --prefix <root>/vendor` resolves into vendor/node_modules, so a
|
|
352
365
|
// fallback without that segment named a directory that never exists. It only
|
|
353
|
-
// fires when an older bundle.json has no paths.reaEntry
|
|
366
|
+
// fires when an older bundle.json has no paths.reaEntry — which is exactly when
|
|
354
367
|
// a wrong fallback turns a usable install into "the vendored REA CLI is gone".
|
|
355
368
|
const reaEntry = paths.reaEntry
|
|
356
369
|
?? path.join(root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs");
|
|
@@ -407,7 +420,7 @@ function childEnvironment(config) {
|
|
|
407
420
|
* the machine already has one, the honest thing to do is run it.
|
|
408
421
|
*
|
|
409
422
|
* 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
|
|
423
|
+
* Ghidra. So "use the system install" is not a new code path invented here — it
|
|
411
424
|
* is the 1.x contract, reachable from the 2.x row. `auto` (the default) prefers
|
|
412
425
|
* the system install and falls back to the provisioned bundle; `bundle` pins the
|
|
413
426
|
* old behaviour; `system` insists (and fails loudly instead of provisioning
|
|
@@ -466,7 +479,7 @@ function systemReaNames(platform = process.platform) {
|
|
|
466
479
|
/**
|
|
467
480
|
* Every path worth probing, most explicit first.
|
|
468
481
|
*
|
|
469
|
-
* `REA_BUNDLE_SYSTEM_REA` names one binary outright
|
|
482
|
+
* `REA_BUNDLE_SYSTEM_REA` names one binary outright — that is both the test
|
|
470
483
|
* seam and a real operator need (an install that is not on the harness's PATH,
|
|
471
484
|
* which on Desktop is not the user's shell PATH).
|
|
472
485
|
*/
|
|
@@ -488,7 +501,9 @@ function systemReaCandidates({ env = process.env, platform = process.platform }
|
|
|
488
501
|
}
|
|
489
502
|
const seen = new Set();
|
|
490
503
|
return candidates.filter((candidate) => {
|
|
491
|
-
|
|
504
|
+
// Keyed on the platform being RESOLVED, not on the one this process happens
|
|
505
|
+
// to run: the tests pass `platform` to exercise both shapes on one machine.
|
|
506
|
+
const key = platform === "win32" ? candidate.toLowerCase() : candidate;
|
|
492
507
|
if (seen.has(key)) return false;
|
|
493
508
|
seen.add(key);
|
|
494
509
|
return true;
|
|
@@ -724,7 +739,7 @@ function compareDotted(a, b) {
|
|
|
724
739
|
* the one this bundle provisioned would silently take the tool count back
|
|
725
740
|
* (6.4.0 ships 170 tools, 6.5.0 ships 207), so the newer of the two wins and
|
|
726
741
|
* the older is only used when asked for by name. `bundle` and `system` are
|
|
727
|
-
* honoured literally
|
|
742
|
+
* honoured literally — `system` fails loudly rather than provisioning 800 MB
|
|
728
743
|
* behind an operator who asked for their own REA.
|
|
729
744
|
*/
|
|
730
745
|
async function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false, env = process.env, platform = process.platform } = {}) {
|
|
@@ -863,7 +878,7 @@ function sha256(file) {
|
|
|
863
878
|
/** Where the installed plugin lives according to setup's record, or null. */
|
|
864
879
|
function pluginCheckout(root) {
|
|
865
880
|
try {
|
|
866
|
-
const record =
|
|
881
|
+
const record = readJson(path.join(root, "mirror.json"));
|
|
867
882
|
const dir = record?.pluginRoot;
|
|
868
883
|
if (typeof dir !== "string" || dir.trim() === "") return null;
|
|
869
884
|
const shim = path.join(dir, "bin", "rea-shim.mjs");
|
|
@@ -883,7 +898,7 @@ function pluginCheckout(root) {
|
|
|
883
898
|
function describeMirror(root, { self = fileURLToPath(import.meta.url) } = {}) {
|
|
884
899
|
let record = null;
|
|
885
900
|
try {
|
|
886
|
-
record =
|
|
901
|
+
record = readJson(path.join(root, "mirror.json"));
|
|
887
902
|
} catch {
|
|
888
903
|
return null;
|
|
889
904
|
}
|
|
@@ -910,32 +925,71 @@ function describeMirror(root, { self = fileURLToPath(import.meta.url) } = {}) {
|
|
|
910
925
|
};
|
|
911
926
|
}
|
|
912
927
|
|
|
913
|
-
/**
|
|
928
|
+
/**
|
|
929
|
+
* Run the checkout's launcher instead of this copy, once.
|
|
930
|
+
*
|
|
931
|
+
* The promise resolves ONLY when the re-exec did not take over, and the caller
|
|
932
|
+
* must then continue with its own launch path. While the child is alive the
|
|
933
|
+
* promise stays pending and the child's own exit becomes this process's exit —
|
|
934
|
+
* settling it at the `spawn` event was wrong: the event fires before the child
|
|
935
|
+
* has done anything, so a later "it actually failed" could no longer be seen, and
|
|
936
|
+
* the two handlers disagreed about who owns the exit code. That mistake made the
|
|
937
|
+
* file-vanishes-mid-flight case exit 0 silently.
|
|
938
|
+
*
|
|
939
|
+
* The first version of this printed "continuing with the mirrored copy" on
|
|
940
|
+
* `error` and returned: nothing continued, no `exit` event followed a failed
|
|
941
|
+
* spawn, the loop drained, and the row saw exit 0 with no tools and no message.
|
|
942
|
+
* A fallback that is announced but not performed is worse than none, because it
|
|
943
|
+
* hides the failure it was written for.
|
|
944
|
+
*/
|
|
914
945
|
function reexecFromCheckout(source, argv) {
|
|
915
946
|
const child = spawn(process.execPath, [source, ...argv], {
|
|
916
947
|
stdio: "inherit",
|
|
917
948
|
env: { ...process.env, REA_BUNDLE_SHIM_REEXEC: "1" },
|
|
918
949
|
windowsHide: true,
|
|
919
950
|
});
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
}
|
|
929
|
-
|
|
951
|
+
return new Promise((resolve) => {
|
|
952
|
+
let started = false;
|
|
953
|
+
child.once("spawn", () => {
|
|
954
|
+
started = true;
|
|
955
|
+
});
|
|
956
|
+
child.once("error", () => {
|
|
957
|
+
if (started) return;
|
|
958
|
+
resolve(false);
|
|
959
|
+
});
|
|
960
|
+
child.once("exit", (code, signal) => {
|
|
961
|
+
if (!started) {
|
|
962
|
+
// Exit without a spawn event: the child never ran. Let the caller fall
|
|
963
|
+
// back rather than exiting 0 on its behalf.
|
|
964
|
+
resolve(false);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (signal) {
|
|
968
|
+
const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
|
|
969
|
+
process.exit(128 + (signals[signal] ?? 0));
|
|
970
|
+
}
|
|
971
|
+
// A non-zero exit is normally the child's own answer and must be passed
|
|
972
|
+
// through untouched. One case is not the child's answer: an upgrade in
|
|
973
|
+
// flight replaced the file BETWEEN the check and node reading it, so node
|
|
974
|
+
// starts fine and dies with MODULE_NOT_FOUND. Passing that on turns a
|
|
975
|
+
// millisecond race into a dead row; if the source is gone by now, fall back
|
|
976
|
+
// to this copy instead. The window is small, the cost of covering it is one
|
|
977
|
+
// stat, and the alternative is a row that reports failure for a file that
|
|
978
|
+
// still exists a directory up.
|
|
979
|
+
if ((code ?? 0) !== 0 && !isFileSync(source)) {
|
|
980
|
+
resolve(false);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
process.exit(code ?? 0);
|
|
984
|
+
});
|
|
930
985
|
});
|
|
931
|
-
return null;
|
|
932
986
|
}
|
|
933
987
|
|
|
934
988
|
/**
|
|
935
989
|
* Usage text, also what a bare invocation prints.
|
|
936
990
|
*
|
|
937
991
|
* `package.json` exposes this file as the `rea-bundle-status` bin, and npm's bin
|
|
938
|
-
* shim can only forward the arguments a user typed
|
|
992
|
+
* shim can only forward the arguments a user typed — it cannot append a flag. So a
|
|
939
993
|
* bare `rea-bundle-status`, which is exactly what that name invites, used to fall
|
|
940
994
|
* through to the launch path: it started REA, and on an unprovisioned machine it
|
|
941
995
|
* began an ~800 MB download. Reporting usage instead makes the command's behaviour
|
|
@@ -973,9 +1027,24 @@ async function main() {
|
|
|
973
1027
|
// market upgrade never re-runs setup. One hop, guarded, and never for the
|
|
974
1028
|
// status query — there the staleness is reported instead.
|
|
975
1029
|
const mirror = describeMirror(root);
|
|
976
|
-
|
|
1030
|
+
// A CHECKOUT copy is the source, not a mirror: hopping from it into whatever
|
|
1031
|
+
// the bundle's record names would silently run a different plugin — a developer
|
|
1032
|
+
// testing a local edit would be testing the installed one instead. The
|
|
1033
|
+
// discriminator is the one bundleRoot() already uses: only the checkout ships
|
|
1034
|
+
// cordis.patch.yml.
|
|
1035
|
+
const isCheckout = existsSync(path.join(PLUGIN_ROOT, "cordis.patch.yml"));
|
|
1036
|
+
if (!isCheckout && mirror !== null && mirror.stale && process.env.REA_BUNDLE_SHIM_REEXEC !== "1" && !argv.includes("--shim-status")) {
|
|
977
1037
|
const source = pluginCheckout(root);
|
|
978
|
-
if (source !== null)
|
|
1038
|
+
if (source !== null) {
|
|
1039
|
+
// It resolves only when the re-exec did not take over; while the child
|
|
1040
|
+
// runs, this process is parked and the child's exit is the exit.
|
|
1041
|
+
await reexecFromCheckout(source, argv);
|
|
1042
|
+
// The re-exec did not take over: a spawn failure, a child that exited
|
|
1043
|
+
// before its spawn event, or one that started and found its own file gone
|
|
1044
|
+
// (see the exit handler). Say so and run this copy for real — the point of
|
|
1045
|
+
// the fallback.
|
|
1046
|
+
process.stderr.write(`rea-shim: could not run ${source}; running the mirrored copy instead.\n`);
|
|
1047
|
+
}
|
|
979
1048
|
}
|
|
980
1049
|
const loaded = readBundleConfig(root);
|
|
981
1050
|
// Decide what to run before asking whether the bundle is provisioned: a
|
|
@@ -1073,12 +1142,12 @@ async function main() {
|
|
|
1073
1142
|
const provisioning = maybeAutoProvision(root);
|
|
1074
1143
|
if (provisioning.started) {
|
|
1075
1144
|
process.stderr.write(
|
|
1076
|
-
`\n dsh-plugin-rea (bundle): first run
|
|
1145
|
+
`\n dsh-plugin-rea (bundle): first run — provisioning started in the background.\n progress: ${provisioning.logPath}\n`,
|
|
1077
1146
|
);
|
|
1078
1147
|
} else if (provisioning.reason === "last-run-failed") {
|
|
1079
1148
|
process.stderr.write(
|
|
1080
1149
|
`\n dsh-plugin-rea (bundle): the last provisioning attempt failed (exit ${provisioning.exit ?? "?"}).\n` +
|
|
1081
|
-
` Nothing is running right now
|
|
1150
|
+
` Nothing is running right now — read the log, then re-run setup.\n log: ${provisioning.logPath}\n`,
|
|
1082
1151
|
);
|
|
1083
1152
|
} else if (provisioning.reason === "in-flight") {
|
|
1084
1153
|
// A live run owns the lock. Saying nothing here is what made a wedged
|
package/docs/bundle-design.md
CHANGED
|
@@ -83,7 +83,10 @@ through `failOnStartupError: false` without breaking the harness.
|
|
|
83
83
|
```
|
|
84
84
|
dsh starts
|
|
85
85
|
└─ MCP row: node <bundle root>/bin/rea-shim.mjs (setup mirrors it there)
|
|
86
|
-
├─
|
|
86
|
+
├─ mirrored copy differs from the installed plugin → re-exec that copy, once
|
|
87
|
+
├─ a working `rea` on PATH is at least as new as the bundle
|
|
88
|
+
│ → exec it (the 1.x contract)
|
|
89
|
+
├─ bundle.json present & components verify → exec the provisioned REA
|
|
87
90
|
├─ missing, auto-provision allowed → start setup detached, print
|
|
88
91
|
│ remediation, exit non-zero
|
|
89
92
|
└─ missing, auto-provision refused → print exact command, exit
|
|
@@ -129,6 +132,26 @@ Two notes on stages that exist because their absence was a bug:
|
|
|
129
132
|
harness's own dialect, `args[0]` became `{"[object Object]": "..."}` and the
|
|
130
133
|
row could not launch at all. Any future expression with a ternary needs the
|
|
131
134
|
quotes; `audit-manifest.mjs` rejects the bare form.
|
|
135
|
+
- **Launch mode (2.0.7).** 2.x exists to supply a runtime a machine lacks, so a
|
|
136
|
+
machine that already has one should not be provisioned. Before touching bundle
|
|
137
|
+
state the shim asks whether `rea` is on PATH and answers `--version`; if it
|
|
138
|
+
does, and its version is not older than `bundle.json`'s `versions.reaAgents`,
|
|
139
|
+
the row execs that install exactly as the 1.x row did. The probe is asynchronous
|
|
140
|
+
and bounded (8 s: a `.cmd` shim needs ~2.4 s), runs with
|
|
141
|
+
`REA_BUNDLE_DELEGATED=1` and `REA_BUNDLE_AUTO_PROVISION=0` so a candidate that
|
|
142
|
+
wraps this shim cannot recurse or start an 800 MB run, and kills its whole tree
|
|
143
|
+
on timeout. `REA_BUNDLE_LAUNCH=auto|system|bundle` overrides the choice; an
|
|
144
|
+
unorderable pair (a prerelease on either side) keeps the bundle.
|
|
145
|
+
- **Self-healing mirror (2.0.8).** The row launches a COPY, and a market upgrade
|
|
146
|
+
runs no setup, so without this the copy keeps executing the code it was made
|
|
147
|
+
from — invisibly, because it still works. The copy therefore compares its own
|
|
148
|
+
hash with `<mirror.json.pluginRoot>/bin/rea-shim.mjs` and re-execs that file
|
|
149
|
+
when they differ: one hop, guarded by `REA_BUNDLE_SHIM_REEXEC`, and only from a
|
|
150
|
+
mirror (a checkout ships `cordis.patch.yml`, so it never hops into another
|
|
151
|
+
copy). `mirror.json` carries `shimSha256`, `pluginVersion` and `dshHome` —
|
|
152
|
+
the last one because the harness strips `DSH_*` from an MCP child, so the
|
|
153
|
+
duplicate-registration check would otherwise read `~/.dsh` on a machine with a
|
|
154
|
+
custom home. `--shim-status` reports `mirror.stale` instead of hopping.
|
|
132
155
|
- **3 (fetch).** Route order is deliberate and environment-dependent: direct
|
|
133
156
|
first, then the system proxy, and on Windows the .NET route ahead of both
|
|
134
157
|
because it follows the GitHub release-asset redirect that the Node routes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-rea",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.9",
|
|
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",
|
package/scripts/setup.mjs
CHANGED
|
@@ -954,6 +954,21 @@ function reuseExisting(name, root) {
|
|
|
954
954
|
|
|
955
955
|
/* ------------------------------------------------------------------- manifest */
|
|
956
956
|
|
|
957
|
+
/**
|
|
958
|
+
* JSON from disk, tolerating the UTF-8 BOM a Windows editor writes.
|
|
959
|
+
*
|
|
960
|
+
* `JSON.parse` rejects a leading BOM, and every caller in this file treats a
|
|
961
|
+
* parse failure as "no record". A BOM-ed mirror.json therefore meant "this is
|
|
962
|
+
* not a mirrored run", so setup went on to re-mirror the previous manifest;
|
|
963
|
+
* a BOM-ed bundle.json meant "nothing is provisioned". `bin/rea-shim.mjs`
|
|
964
|
+
* carries the same helper — the two must agree, or the same file means two
|
|
965
|
+
* different things depending on which one read it (a hand-edited record with
|
|
966
|
+
* PowerShell's BOM is exactly the case that produced this).
|
|
967
|
+
*/
|
|
968
|
+
function readJson(file) {
|
|
969
|
+
return JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
|
|
970
|
+
}
|
|
971
|
+
|
|
957
972
|
/**
|
|
958
973
|
* Where this run's plugin files actually live.
|
|
959
974
|
*
|
|
@@ -973,7 +988,7 @@ function sourceRoot() {
|
|
|
973
988
|
const self = path.resolve(PLUGIN_ROOT);
|
|
974
989
|
if (!samePath(self, options.root)) return self;
|
|
975
990
|
try {
|
|
976
|
-
const record =
|
|
991
|
+
const record = readJson(path.join(options.root, "mirror.json"));
|
|
977
992
|
const recorded = record?.pluginRoot;
|
|
978
993
|
if (typeof recorded === "string" && recorded.trim() !== "") {
|
|
979
994
|
const candidate = path.resolve(recorded);
|
|
@@ -987,7 +1002,7 @@ function sourceRoot() {
|
|
|
987
1002
|
|
|
988
1003
|
function loadManifest() {
|
|
989
1004
|
const file = path.join(sourceRoot(), "manifest", "bundle.manifest.json");
|
|
990
|
-
const parsed =
|
|
1005
|
+
const parsed = readJson(file);
|
|
991
1006
|
return parsed;
|
|
992
1007
|
}
|
|
993
1008
|
|
|
@@ -1002,7 +1017,7 @@ function loadManifest() {
|
|
|
1002
1017
|
function readBundleJson(file) {
|
|
1003
1018
|
if (!existsSync(file)) return null;
|
|
1004
1019
|
try {
|
|
1005
|
-
const parsed =
|
|
1020
|
+
const parsed = readJson(file);
|
|
1006
1021
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
1007
1022
|
} catch (error) {
|
|
1008
1023
|
log(` ${path.basename(file)} is not readable JSON (${String(error && error.message)}); ignoring it for this run`);
|
|
@@ -1648,7 +1663,7 @@ function writeMirrorRecord(context, source) {
|
|
|
1648
1663
|
/* the copy is missing or unreadable; the shim reports the comparison itself */
|
|
1649
1664
|
}
|
|
1650
1665
|
try {
|
|
1651
|
-
record.pluginVersion =
|
|
1666
|
+
record.pluginVersion = readJson(path.join(source, "package.json")).version ?? null;
|
|
1652
1667
|
} catch {
|
|
1653
1668
|
record.pluginVersion = null;
|
|
1654
1669
|
}
|
package/scripts/setup.test.mjs
CHANGED
|
@@ -1313,13 +1313,13 @@ function writeFakeRea(dir, { version = "6.9.0", exitForVersion = 0, runExit = 0,
|
|
|
1313
1313
|
return file;
|
|
1314
1314
|
}
|
|
1315
1315
|
|
|
1316
|
-
function runShimIsolated(env, args) {
|
|
1316
|
+
function runShimIsolated(env, args, shim = path.join(REPO_ROOT, "bin", "rea-shim.mjs")) {
|
|
1317
1317
|
const merged = { ...process.env, ...env };
|
|
1318
1318
|
// Windows keeps PATH and Path as separate variables (both are "in" the
|
|
1319
1319
|
// environment), and a child that reads Path while the test set PATH would see
|
|
1320
1320
|
// the machine's real search path instead of the fixture's.
|
|
1321
1321
|
if (process.platform === "win32" && Object.prototype.hasOwnProperty.call(env, "PATH")) delete merged.Path;
|
|
1322
|
-
return spawnSync(process.execPath, [
|
|
1322
|
+
return spawnSync(process.execPath, [shim, ...args], {
|
|
1323
1323
|
encoding: "utf8",
|
|
1324
1324
|
timeout: 60000,
|
|
1325
1325
|
env: merged,
|
|
@@ -1662,7 +1662,14 @@ test("a mirrored launcher re-execs the plugin's launcher when they differ", () =
|
|
|
1662
1662
|
const marker = path.join(f.root, "checkout-ran");
|
|
1663
1663
|
checkoutStub(f.checkout, marker);
|
|
1664
1664
|
try {
|
|
1665
|
-
|
|
1665
|
+
// The mirrored copy is what the row launches, and the checkout guard means a
|
|
1666
|
+
// checkout shim refuses to hop at all — so the fixture must be exercised as
|
|
1667
|
+
// the mirror it is meant to be.
|
|
1668
|
+
const run = runShimIsolated(
|
|
1669
|
+
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
|
1670
|
+
["mcp"],
|
|
1671
|
+
path.join(f.bundle, "bin", "rea-shim.mjs"),
|
|
1672
|
+
);
|
|
1666
1673
|
assert.equal(run.status, 7, `expected the checkout's status, got ${run.status}: ${run.stderr}`);
|
|
1667
1674
|
assert.equal(existsSync(marker), true, "the checkout launcher must have run");
|
|
1668
1675
|
} finally {
|
|
@@ -1678,6 +1685,7 @@ test("the re-exec happens once, and a pinned guard stops it", () => {
|
|
|
1678
1685
|
const run = runShimIsolated(
|
|
1679
1686
|
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle", REA_BUNDLE_SHIM_REEXEC: "1" },
|
|
1680
1687
|
["mcp"],
|
|
1688
|
+
path.join(f.bundle, "bin", "rea-shim.mjs"),
|
|
1681
1689
|
);
|
|
1682
1690
|
// The guard is set, so the mirrored copy runs its own logic: bundle pinned and
|
|
1683
1691
|
// nothing provisioned means EX_CONFIG, and the checkout never starts.
|
|
@@ -1693,7 +1701,11 @@ test("--shim-status reports a stale mirror instead of re-execing it", () => {
|
|
|
1693
1701
|
const marker = path.join(f.root, "checkout-ran");
|
|
1694
1702
|
checkoutStub(f.checkout, marker);
|
|
1695
1703
|
try {
|
|
1696
|
-
const run = runShimIsolated(
|
|
1704
|
+
const run = runShimIsolated(
|
|
1705
|
+
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
|
1706
|
+
["--shim-status"],
|
|
1707
|
+
path.join(f.bundle, "bin", "rea-shim.mjs"),
|
|
1708
|
+
);
|
|
1697
1709
|
const report = JSON.parse(run.stdout);
|
|
1698
1710
|
assert.equal(report.mirror.stale, true, JSON.stringify(report.mirror));
|
|
1699
1711
|
assert.match(report.mirror.reason, /different launcher/);
|
|
@@ -1703,12 +1715,65 @@ test("--shim-status reports a stale mirror instead of re-execing it", () => {
|
|
|
1703
1715
|
}
|
|
1704
1716
|
});
|
|
1705
1717
|
|
|
1718
|
+
test("a checkout shim does not hop into the installed plugin", () => {
|
|
1719
|
+
const f = mirrorFixture();
|
|
1720
|
+
const marker = path.join(f.root, "checkout-ran");
|
|
1721
|
+
checkoutStub(f.checkout, marker);
|
|
1722
|
+
try {
|
|
1723
|
+
// The repository's own shim is a CHECKOUT (it ships cordis.patch.yml). A
|
|
1724
|
+
// developer running it must keep testing the edit in front of them, not a
|
|
1725
|
+
// different plugin that the bundle's record happens to name.
|
|
1726
|
+
const run = runShimIsolated(
|
|
1727
|
+
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
|
1728
|
+
["mcp"],
|
|
1729
|
+
);
|
|
1730
|
+
assert.equal(run.status, 78, run.stderr);
|
|
1731
|
+
assert.equal(existsSync(marker), false, "a checkout must never hop into another copy");
|
|
1732
|
+
} finally {
|
|
1733
|
+
f.clean();
|
|
1734
|
+
}
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1737
|
+
test("a re-exec whose file vanishes mid-flight falls back to the mirrored copy", () => {
|
|
1738
|
+
// The upgrade race the spawn handshake cannot see: the file existed when the
|
|
1739
|
+
// mirror compared hashes, node started, and the file was replaced before node
|
|
1740
|
+
// read it — node exits 1 with MODULE_NOT_FOUND. Passing that through turns a
|
|
1741
|
+
// millisecond race into a dead row, so a non-zero exit with the source gone
|
|
1742
|
+
// must fall back instead.
|
|
1743
|
+
const f = mirrorFixture();
|
|
1744
|
+
const marker = path.join(f.root, "checkout-ran");
|
|
1745
|
+
const file = path.join(f.checkout, "bin", "rea-shim.mjs");
|
|
1746
|
+
writeFileSync(
|
|
1747
|
+
file,
|
|
1748
|
+
'import { rmSync, writeFileSync } from "node:fs";\n' +
|
|
1749
|
+
`writeFileSync(${JSON.stringify(marker)}, "checkout");\n` +
|
|
1750
|
+
`rmSync(${JSON.stringify(file)});\n` +
|
|
1751
|
+
"process.exit(3);\n",
|
|
1752
|
+
);
|
|
1753
|
+
try {
|
|
1754
|
+
const run = runShimIsolated(
|
|
1755
|
+
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
|
1756
|
+
["mcp"],
|
|
1757
|
+
path.join(f.bundle, "bin", "rea-shim.mjs"),
|
|
1758
|
+
);
|
|
1759
|
+
assert.equal(existsSync(marker), true, "the checkout launcher did run");
|
|
1760
|
+
assert.equal(run.status, 78, `expected the local copy's status, got ${run.status}: ${run.stderr}`);
|
|
1761
|
+
assert.match(run.stderr, /running the mirrored copy instead/);
|
|
1762
|
+
} finally {
|
|
1763
|
+
f.clean();
|
|
1764
|
+
}
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1706
1767
|
test("an identical mirror is not stale and is not re-exec'd", () => {
|
|
1707
1768
|
const f = mirrorFixture();
|
|
1708
1769
|
const same = path.join(f.checkout, "bin", "rea-shim.mjs");
|
|
1709
1770
|
copyFileSync(path.join(f.bundle, "bin", "rea-shim.mjs"), same);
|
|
1710
1771
|
try {
|
|
1711
|
-
const run = runShimIsolated(
|
|
1772
|
+
const run = runShimIsolated(
|
|
1773
|
+
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
|
1774
|
+
["--shim-status"],
|
|
1775
|
+
path.join(f.bundle, "bin", "rea-shim.mjs"),
|
|
1776
|
+
);
|
|
1712
1777
|
const report = JSON.parse(run.stdout);
|
|
1713
1778
|
assert.equal(report.mirror.stale, false, JSON.stringify(report.mirror));
|
|
1714
1779
|
assert.equal(report.mirror.reason, "this copy matches the plugin");
|
|
@@ -1745,3 +1810,82 @@ test("a mirrored shim finds the real DSH home when the environment is scrubbed",
|
|
|
1745
1810
|
f.clean();
|
|
1746
1811
|
}
|
|
1747
1812
|
});
|
|
1813
|
+
|
|
1814
|
+
/* ------------------------------------ records a Windows editor has BOM-ed */
|
|
1815
|
+
|
|
1816
|
+
// `bin/rea-shim.mjs` strips the UTF-8 BOM that PowerShell and Notepad write;
|
|
1817
|
+
// setup.mjs kept parsing raw. Both programs read the same two records, so a BOM
|
|
1818
|
+
// used to mean two different things: the shim found the checkout, setup did not.
|
|
1819
|
+
// A mirrored setup then re-mirrored the previous manifest and provisioned from
|
|
1820
|
+
// it, and a BOM-ed bundle.json read as "nothing is provisioned". These tests pin
|
|
1821
|
+
// setup's side of the contract, both files.
|
|
1822
|
+
|
|
1823
|
+
/** A checkout with a manifest whose node probe accepts any node, so `--only node` is deterministic. */
|
|
1824
|
+
function bomFixtureCheckout(root) {
|
|
1825
|
+
const checkout = path.join(root, "checkout");
|
|
1826
|
+
mkdirSync(path.join(checkout, "scripts"), { recursive: true });
|
|
1827
|
+
mkdirSync(path.join(checkout, "manifest"), { recursive: true });
|
|
1828
|
+
copyFileSync(SETUP_SOURCE, path.join(checkout, "scripts", "setup.mjs"));
|
|
1829
|
+
const manifest = JSON.parse(readFileSync(MANIFEST_SOURCE, "utf8"));
|
|
1830
|
+
manifest.components.node.probe.expect.regex = "^v";
|
|
1831
|
+
writeFileSync(path.join(checkout, "manifest", "bundle.manifest.json"), JSON.stringify(manifest, null, 2));
|
|
1832
|
+
writeFileSync(path.join(checkout, "package.json"), JSON.stringify({ name: "dsh-plugin-rea", version: "0.0.0-bom" }));
|
|
1833
|
+
return checkout;
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
test("a BOM-ed mirror.json still points a mirrored setup at the real checkout", () => {
|
|
1837
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-bom-mirror-"));
|
|
1838
|
+
try {
|
|
1839
|
+
const checkout = bomFixtureCheckout(root);
|
|
1840
|
+
const bundle = path.join(root, "bundle");
|
|
1841
|
+
mkdirSync(path.join(bundle, "scripts"), { recursive: true });
|
|
1842
|
+
copyFileSync(SETUP_SOURCE, path.join(bundle, "scripts", "setup.mjs"));
|
|
1843
|
+
// Exactly what `Out-File` / `Set-Content` leaves behind, and what a human
|
|
1844
|
+
// editing the record by hand produces on Windows.
|
|
1845
|
+
writeFileSync(
|
|
1846
|
+
path.join(bundle, "mirror.json"),
|
|
1847
|
+
"\uFEFF" + JSON.stringify({ schema: 1, pluginRoot: checkout, mirroredAt: "2026-10-18T00:00:00Z" }),
|
|
1848
|
+
);
|
|
1849
|
+
// The bundle has no manifest of its own: only the record can supply one.
|
|
1850
|
+
assert.equal(existsSync(path.join(bundle, "manifest", "bundle.manifest.json")), false);
|
|
1851
|
+
|
|
1852
|
+
const run = spawnSync(
|
|
1853
|
+
process.execPath,
|
|
1854
|
+
[path.join(bundle, "scripts", "setup.mjs"), "--dry-run", "--only", "node", "--root", bundle],
|
|
1855
|
+
{ encoding: "utf8", timeout: 120000 },
|
|
1856
|
+
);
|
|
1857
|
+
assert.equal(run.status, 0, run.stderr);
|
|
1858
|
+
// loadManifest() runs before any component: reaching the plan proves the
|
|
1859
|
+
// record was parsed. A BOM-blind parse fell through to the bundle and died
|
|
1860
|
+
// on a missing manifest.
|
|
1861
|
+
assert.match(run.stdout, /\[plan\] node/);
|
|
1862
|
+
} finally {
|
|
1863
|
+
rmSync(root, { recursive: true, force: true });
|
|
1864
|
+
}
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
test("a BOM-ed bundle.json is read instead of treated as unprovisioned", () => {
|
|
1868
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-bom-bundle-"));
|
|
1869
|
+
try {
|
|
1870
|
+
const checkout = bomFixtureCheckout(root);
|
|
1871
|
+
const bundle = path.join(root, "bundle");
|
|
1872
|
+
mkdirSync(bundle, { recursive: true });
|
|
1873
|
+
const args = [path.join(checkout, "scripts", "setup.mjs"), "--dry-run", "--only", "node", "--root", bundle];
|
|
1874
|
+
|
|
1875
|
+
// Control: with no record at all the component is planned, so the assertion
|
|
1876
|
+
// below cannot pass for a reason unrelated to reading the file.
|
|
1877
|
+
const fresh = spawnSync(process.execPath, args, { encoding: "utf8", timeout: 120000 });
|
|
1878
|
+
assert.equal(fresh.status, 0, fresh.stderr);
|
|
1879
|
+
assert.match(fresh.stdout, /\[plan\] node/);
|
|
1880
|
+
|
|
1881
|
+
writeFileSync(
|
|
1882
|
+
path.join(bundle, "bundle.json"),
|
|
1883
|
+
"\uFEFF" + JSON.stringify({ schema: 1, components: { node: { path: process.execPath, version: "v-bom-test" } } }, null, 2),
|
|
1884
|
+
);
|
|
1885
|
+
const run = spawnSync(process.execPath, args, { encoding: "utf8", timeout: 120000 });
|
|
1886
|
+
assert.equal(run.status, 0, run.stderr);
|
|
1887
|
+
assert.match(run.stdout, /already provisioned \(v-bom-test\)/, run.stdout);
|
|
1888
|
+
} finally {
|
|
1889
|
+
rmSync(root, { recursive: true, force: true });
|
|
1890
|
+
}
|
|
1891
|
+
});
|
|
@@ -17,6 +17,31 @@ foreach ($f in @('index.mjs','bin\rea-shim.mjs','scripts\setup.mjs','scripts\aud
|
|
|
17
17
|
Check $f ($LASTEXITCODE -eq 0) ($out -join ' ')
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
Write-Host "`n=== 1b. both JSON readers tolerate a BOM ===" -ForegroundColor Cyan
|
|
21
|
+
# bin/rea-shim.mjs and scripts/setup.mjs read the same mirror.json and bundle.json.
|
|
22
|
+
# A BOM strip on one side only makes one file mean "there is a checkout" to the
|
|
23
|
+
# shim and "there is none" to setup, and the mirrored setup then re-mirrors the
|
|
24
|
+
# previous manifest. A bare JSON.parse(readFileSync(...)) is that divergence.
|
|
25
|
+
$jsBom = Join-Path $env:TEMP 'rea-verify-bom.cjs'
|
|
26
|
+
@'
|
|
27
|
+
const fs = require("node:fs");
|
|
28
|
+
const blind = [];
|
|
29
|
+
for (const file of ["bin/rea-shim.mjs", "scripts/setup.mjs"]) {
|
|
30
|
+
const text = fs.readFileSync(file, "utf8");
|
|
31
|
+
for (const m of text.matchAll(/JSON\.parse\(\s*readFileSync\(/g)) {
|
|
32
|
+
// readJson() itself is the strip; anything else must not parse a file raw.
|
|
33
|
+
if (!/replace\(/.test(text.slice(m.index, m.index + 200))) {
|
|
34
|
+
blind.push(file + "@" + text.slice(0, m.index).split("\n").length);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!text.includes("replace(/^\\uFEFF/")) blind.push(file + ": no U+FEFF strip");
|
|
38
|
+
}
|
|
39
|
+
console.log("bom-blind reads: " + JSON.stringify(blind));
|
|
40
|
+
if (blind.length) process.exit(1);
|
|
41
|
+
'@ | Set-Content $jsBom -Encoding utf8
|
|
42
|
+
& node $jsBom 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
|
43
|
+
Check 'no BOM-blind JSON read' ($LASTEXITCODE -eq 0) 'see above'
|
|
44
|
+
|
|
20
45
|
Write-Host "`n=== 2. PowerShell parse + BOM ===" -ForegroundColor Cyan
|
|
21
46
|
# This script checks itself too: it is edited as often as the others, and an editor
|
|
22
47
|
# that drops the BOM would otherwise go unnoticed until someone adds CJK text here.
|
|
@@ -97,6 +122,11 @@ Check 'engines.dsh covers every released DSH host' ($LASTEXITCODE -eq 0) (($comp
|
|
|
97
122
|
foreach ($doc in @('README.md', 'README.zh-CN.md')) {
|
|
98
123
|
$text = Get-Content (Join-Path $root $doc) -Raw -Encoding UTF8
|
|
99
124
|
Check "$doc quotes the declared engines.dsh" $text.Contains([string]$pkg.engines.dsh) "expected $($pkg.engines.dsh)"
|
|
125
|
+
# The pin example is copy-pasteable, so it must name a ref that will exist: the
|
|
126
|
+
# version this tree publishes. It has drifted twice (v2.0.0 long after 2.0.5,
|
|
127
|
+
# then v2.0.8 while the tree was 2.0.9), and both times every other signal was
|
|
128
|
+
# green because nothing compared the two.
|
|
129
|
+
Check "$doc pins the current version" $text.Contains("dsh-plugin-rea#v$($pkg.version)") "expected dsh-plugin-rea#v$($pkg.version)"
|
|
100
130
|
}
|
|
101
131
|
foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test', 'bundle:host-compat')) {
|
|
102
132
|
Check "scripts.$script exists" ($pkg.scripts.PSObject.Properties.Name -contains $script) ''
|