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 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.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.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 鈥?the process the DSH MCP row launches.
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) 鈥?the row has
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 = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
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 鈥?and start a second ~800 MB run there.
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 鈥?and a bare
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 鈥?which is also all a first run has.
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 = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
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"` 鈥?it is kept on
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 } 鈥?the caller words its message from
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 鈥?without it the exclusive create below failed with EEXIST
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 鈥?and a bundle mirrored before the manifest travelled with it has no
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 = JSON.parse(readFileSync(file, "utf8"));
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 鈥?which is exactly when
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 鈥?it
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 鈥?that is both the test
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
- const key = process.platform === "win32" ? candidate.toLowerCase() : candidate;
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 鈥?`system` fails loudly rather than provisioning 800 MB
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 = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
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 = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
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
- /** Run the checkout's launcher instead of this copy, once. */
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
- child.on("error", () => {
921
- // Falling back is better than failing: the copy still knows how to launch.
922
- process.stderr.write(`rea-shim: could not run ${source}; continuing with the mirrored copy.\n`);
923
- });
924
- child.on("exit", (code, signal) => {
925
- if (signal) {
926
- const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
927
- process.exit(128 + (signals[signal] ?? 0));
928
- }
929
- process.exit(code ?? 0);
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 鈥?it cannot append a flag. So a
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
- if (mirror !== null && mirror.stale && process.env.REA_BUNDLE_SHIM_REEXEC !== "1" && !argv.includes("--shim-status")) {
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) return reexecFromCheckout(source, argv);
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 鈥?provisioning started in the background.\n progress: ${provisioning.logPath}\n`,
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 鈥?read the log, then re-run setup.\n log: ${provisioning.logPath}\n`,
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
@@ -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
- ├─ bundle.json present & components verify → exec REA (normal path)
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.8",
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 = JSON.parse(readFileSync(path.join(options.root, "mirror.json"), "utf8"));
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 = JSON.parse(readFileSync(file, "utf8"));
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 = JSON.parse(readFileSync(file, "utf8"));
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 = JSON.parse(readFileSync(path.join(source, "package.json"), "utf8")).version ?? null;
1666
+ record.pluginVersion = readJson(path.join(source, "package.json")).version ?? null;
1652
1667
  } catch {
1653
1668
  record.pluginVersion = null;
1654
1669
  }
@@ -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, [path.join(REPO_ROOT, "bin", "rea-shim.mjs"), ...args], {
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
- const run = runShimIsolated({ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" }, ["mcp"]);
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({ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" }, ["--shim-status"]);
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({ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" }, ["--shim-status"]);
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) ''