mirror of
https://github.com/czcdr/dsh-plugin-rea.git
synced 2026-11-07 10:25:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95803a6833 | ||
|
|
7b02aa95b6 | ||
|
|
ebb912ace2 | ||
|
|
3f413e1989 | ||
|
|
edf3968319 | ||
|
|
e3b2a064af | ||
|
|
3b57a9d1ac | ||
|
|
5c22588001 | ||
|
|
0e3aa428bf | ||
|
|
37d2adb17a | ||
|
|
a6ee476bfd | ||
|
|
d0e2ffd7a6 |
No files matched your search
@@ -11,6 +11,11 @@ permissions:
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
# The shim's launch decision reads this machine's REA. No runner has one, so
|
||||
# the default `auto` would happen to take the bundle path today — pin it so
|
||||
# that stays true if an image ever ships REA.
|
||||
env:
|
||||
REA_BUNDLE_LAUNCH: bundle
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -25,13 +30,74 @@ jobs:
|
||||
const fail = (m) => { console.error('FAIL: ' + m); process.exit(1) };
|
||||
if (!p.dsh || !p.dsh.bundle || !p.dsh.bundle.patch) fail('package.json is missing dsh.bundle.patch');
|
||||
if (p.private) fail('package.json must not be private to be publishable');
|
||||
for (const f of ['cordis.patch.yml', 'README.md', 'LICENSE', 'check-updates.ps1']) {
|
||||
for (const f of ['cordis.patch.yml', 'index.mjs', 'README.md', 'LICENSE', 'check-updates.ps1']) {
|
||||
if (!p.files.includes(f)) fail('files[] must ship ' + f);
|
||||
}
|
||||
if (!require('fs').existsSync(p.dsh.bundle.patch)) fail('dsh.bundle.patch target does not exist');
|
||||
// The market's update path rolls a build back when package.json points
|
||||
// at no existing entry (dshmarket's hasLoadableEntry); this package
|
||||
// shipped no main at all until 2.0.5, so every update of a healthy
|
||||
// profile failed with "updated build has no loadable entry".
|
||||
if (typeof p.main !== 'string') fail('package.json must declare main');
|
||||
if (!require('fs').existsSync(p.main)) fail('main points at no shipped file: ' + p.main);
|
||||
// engines.dsh is what the market reads to say "compatible" instead of
|
||||
// "unknown"; without it the plugin is reported as undeclared.
|
||||
if (!p.engines || typeof p.engines.dsh !== 'string') fail('package.json must declare engines.dsh');
|
||||
console.log('manifest ok');
|
||||
"
|
||||
|
||||
- name: Declared DSH range covers every released host
|
||||
# A wrong range is silent on both sides: unparseable reads as "undeclared"
|
||||
# and a drifted floor drops hosts the README still lists.
|
||||
run: node scripts/host-compat.mjs
|
||||
|
||||
- name: The READMEs quote the declared DSH range verbatim
|
||||
# The same comparison verify-branch.ps1 makes locally. Without it here, a
|
||||
# stale quote survives every CI signal: the JSON, host-compat and the
|
||||
# audit all agree while the sentence a human reads names another ceiling
|
||||
# (that is how `<0.3.0` outlived the move to `<0.3.0-0`).
|
||||
run: |
|
||||
node -e "
|
||||
const p = require('./package.json');
|
||||
const { readFileSync } = require('fs');
|
||||
const fail = (m) => { console.error('FAIL: ' + m); process.exit(1) };
|
||||
for (const doc of ['README.md', 'README.zh-CN.md']) {
|
||||
const text = readFileSync(doc, 'utf8');
|
||||
if (!text.includes(p.engines.dsh)) fail(doc + ' does not quote engines.dsh (' + p.engines.dsh + ')');
|
||||
// The pin example is copy-pasteable: it must name the version this
|
||||
// tree publishes, or the reader gets a ref that does not exist.
|
||||
if (!text.includes('dsh-plugin-rea#v' + p.version)) fail(doc + ' does not pin dsh-plugin-rea#v' + p.version);
|
||||
}
|
||||
console.log('readme range ok');
|
||||
"
|
||||
|
||||
- name: Both JSON readers tolerate a BOM
|
||||
# verify-branch.ps1 runs the same check locally. bin/rea-shim.mjs and
|
||||
# scripts/setup.mjs read the same mirror.json/bundle.json, so a strip on
|
||||
# one side only makes the same file say "there is a checkout" to the shim
|
||||
# and "there is none" to setup — which shipped once: setup re-mirrored the
|
||||
# previous manifest. A bare JSON.parse(readFileSync(...)) is that bug.
|
||||
run: |
|
||||
node - <<'JS'
|
||||
const { readFileSync } = require('node:fs');
|
||||
const blind = [];
|
||||
for (const file of ['bin/rea-shim.mjs', 'scripts/setup.mjs']) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
for (const m of text.matchAll(/JSON\.parse\(\s*readFileSync\(/g)) {
|
||||
// readJson() itself is the strip; anything else must not parse a file raw.
|
||||
if (!/replace\(/.test(text.slice(m.index, m.index + 200))) {
|
||||
blind.push(file + ':' + text.slice(0, m.index).split('\n').length);
|
||||
}
|
||||
}
|
||||
if (!text.includes('replace(/^\\uFEFF/')) blind.push(file + ': no U+FEFF strip');
|
||||
}
|
||||
if (blind.length) {
|
||||
console.error('FAIL: BOM-blind JSON read at ' + blind.join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('bom-blind reads: none');
|
||||
JS
|
||||
|
||||
- name: JSON files parse
|
||||
run: |
|
||||
node -e "
|
||||
@@ -51,7 +117,7 @@ jobs:
|
||||
const meta = JSON.parse(execSync('npm pack --dry-run --json', { encoding: 'utf8' }))[0];
|
||||
const files = meta.files.map((f) => f.path);
|
||||
const need = [
|
||||
'cordis.patch.yml', 'README.md', 'README.zh-CN.md', 'LICENSE', 'CHANGELOG.md', 'check-updates.ps1',
|
||||
'cordis.patch.yml', 'index.mjs', 'README.md', 'README.zh-CN.md', 'LICENSE', 'CHANGELOG.md', 'check-updates.ps1',
|
||||
'screenshots.json', 'assets/screenshot-1.png', 'assets/screenshot-2.png',
|
||||
];
|
||||
const missing = need.filter((f) => !files.includes(f));
|
||||
@@ -169,6 +235,10 @@ jobs:
|
||||
# The tests are platform-sensitive on purpose: each platform exercises the
|
||||
# download route it actually chooses.
|
||||
runs-on: windows-latest
|
||||
# Same pin as the ubuntu job: the launch decision must not depend on what the
|
||||
# runner image happens to have installed.
|
||||
env:
|
||||
REA_BUNDLE_LAUNCH: bundle
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
+193
@@ -2,6 +2,199 @@
|
||||
|
||||
本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
|
||||
|
||||
## 2.0.9 — 2026-10-18
|
||||
|
||||
**2.0.8 的两处发布前问题(假回退、乱码)与若干文档缺口;不改正常路径,只让失败路径说真话。**
|
||||
|
||||
### 修复
|
||||
|
||||
- **`reexecFromCheckout` 的「回退」是假话**:spawn 失败时只打印「继续用镜像副本」,函数却返回了,`main()` 已经
|
||||
return —— 没有 `exit` 事件、事件循环排空、进程以 **exit 0 静默退出**,行看起来启动成功却没有任何工具。现在这个
|
||||
Promise **只在没接管时才 resolve**:子进程活着时本进程等待、由子进程的退出码决定;失败则 `main()` 继续用本地副本
|
||||
启动,并如实说明在回退。
|
||||
- **回退覆盖了升级竞态的主形态**:`spawn` 的目标是 `process.execPath`,spawn 级失败几乎只剩资源耗尽;真正常见的是
|
||||
「哈希比对通过 → node 启动 → 文件已被换掉」→ node 以 `MODULE_NOT_FOUND` 退 1。现在**退出码非 0 且源文件已不存在**
|
||||
时同样回退到本地副本(窗口毫秒级,代价是一次 `stat`),不再把竞态变成一行死掉的 MCP。
|
||||
- **乱码 `鈥?` 混进用户可见输出**:2.0.7 在 GBK 编辑器里碰过 `bin/rea-shim.mjs`,16 处破折号被写成 `鈥?`,其中两条是
|
||||
stderr 消息(首次自动供给、上次供给失败)—— 每个用户都会看到。全部还原为 `— `(含被吃掉的空格)。
|
||||
- **开发时可能测到「已安装插件」而不是自己的改动**:checkout 副本会顺着 bundle 的 `mirror.json` 转发到插件目录。
|
||||
现在 checkout(带 `cordis.patch.yml`)永不转发,只有镜像副本会;注释也改成与行为一致。
|
||||
- **带 BOM 的 `mirror.json` / `bundle.json` 被当成「没有记录」**:`JSON.parse` 拒绝前导 BOM,而每个调用方都把解析失败
|
||||
读成「无记录」。Windows 编辑器保存的 JSON 就会带 BOM,表现是镜像自愈静默不生效、bundle 被当成未供给。`bin/rea-shim.mjs`
|
||||
与 `scripts/setup.mjs` 现在都经各自的 `readJson()` 去掉 `U+FEFF` 再解析。**两个文件读的是同一份记录,所以缺一不可**:
|
||||
此前只有 shim 剥了 BOM,带 BOM 的 `mirror.json` 在 setup 眼里仍是「没有 checkout」,镜像 layout 下的 setup 于是把上一版
|
||||
manifest 又镜了一遍并从它供给;`bundle.json` 同理被读成未供给。`verify-branch.ps1` 与 CI 都加了守卫:这两个文件里
|
||||
出现裸的 `JSON.parse(readFileSync(...))` 即失败。
|
||||
|
||||
### 文档 / CI
|
||||
|
||||
- 两份 README 的钉版本示例改为**当前版本** `#v2.0.9`(2.0.8 那一轮已从 `#v2.0.0` 提到 `#v2.0.8`,版本号一动就得跟上,
|
||||
否则读者拿到的 ref 不存在);README 的「机器上已有 REA」一节补上 Windows 限制:**强制结束 shim 不会带走被委派的 REA**。
|
||||
- `docs/bundle-design.md`(随包发布)补上 2.0.7 的 launch mode 与 2.0.8 的镜像自愈,含 `mirror.json` 的
|
||||
`shimSha256` / `pluginVersion` / `dshHome` 三个字段与控制流图。
|
||||
- CI 新增两条:README 必须逐字引用 `engines.dsh`;README 的钉版本示例必须等于 `package.json` 的 version(`verify-branch.ps1`
|
||||
同步加了第二条,避免同一个漂移第三次发生)。
|
||||
- `systemReaCandidates` 的去重键改用传入的 `platform` 参数(测试缝一致,生产无变化)。
|
||||
|
||||
### 验证
|
||||
|
||||
- 单测 **71 条**(68 过 + 3 平台跳过;新增「checkout 副本不转发」「文件中途消失 → 回退」「带 BOM 的 `mirror.json`」
|
||||
「带 BOM 的 `bundle.json`」四条);镜像转发测试改为真的运行镜像副本。
|
||||
- `verify-branch.ps1` 44 项全绿;`audit-manifest` clean;`host-compat` 10 covered / 5 outside。
|
||||
|
||||
## 2.0.8 — 2026-10-18
|
||||
|
||||
**2.0.7 的启动检测在两种边界上会失控:一个包着本 shim 的 `rea` 会让探针递归成进程链;超时探针会漏掉一个孙进程。两条都已修,并补了复现测试。**
|
||||
|
||||
### 修复
|
||||
|
||||
- **重复注册检测在自定义 `DSH_HOME` 下失效**:harness 会从 MCP 子进程里抹掉所有 `DSH_*`,镜像副本因此读
|
||||
`~/.dsh` —— 那是错的文件,上游写进真实 home 的第二条 `serverName: rea` 永远报不出来。`setup.mjs` 现在把运行时
|
||||
的 home 记进 `mirror.json` 的 `dshHome`,shim 优先读环境、其次读这条记录。`check-updates.ps1` 的 profile 段也
|
||||
改用同一条 home 规则(此前硬编码 `$env:USERPROFILE`,自定义 home 时报「找不到 profile」)。
|
||||
- **镜像启动器不会自己更新**:行执行的是 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 这份**副本**,而市场升级
|
||||
只换包、不跑 setup —— 副本会一直跑它被复制时的代码,于是新版本里的修复对老用户静默失效(旧副本照常工作,最难发现)。
|
||||
现在镜像启动时会与 `mirror.json` 记录的插件目录比对哈希,不同就转发执行插件里的那一份(一跳,带
|
||||
`REA_BUNDLE_SHIM_REEXEC` 守卫,且只从镜像指向插件,不会反向)。`--shim-status` 新增 `mirror.stale`,
|
||||
`check-updates.ps1` 也会报「镜像落后,重跑 setup」;`mirror.json` 增加 `shimSha256` 与 `pluginVersion` 便于追溯。
|
||||
**2.0.8 之前的版本自身无法完成这一步**(副本里没有这段代码),从那些版本升级仍需手动跑一次 setup。
|
||||
- **探针递归**:`probeRea` 派生候选时没带 `REA_BUNDLE_DELEGATED=1`,而防自委派的守卫只在**委派**前生效。若 PATH 上是「调用本 shim 的 wrapper」,
|
||||
探针 → wrapper → 新 shim → 再探针……实测并发 node 峰值 83+、杀掉后仍自我再生。现在探针环境同时带 `REA_BUNDLE_DELEGATED=1` 与
|
||||
`REA_BUNDLE_AUTO_PROVISION=0`:嵌套运行直接走 bundle 并返回,实测 0.2 秒、零额外进程。
|
||||
- **超时探针泄漏孙进程**:`spawnSync` 的超时只杀它自己启动的 cmd.exe,而 `taskkill /T` 在 `spawnSync` 回收之后调用已找不到父进程 ——
|
||||
每次超时留一个 REA/ping 在跑。探针改为异步 `spawn` + 自己的计时器:超时发生在父进程仍存活时,`taskkill /T /F` 能真正清掉整棵树。
|
||||
实测 8.3 秒放弃、`rejected=["timeout"]`、无残留进程。
|
||||
- **Windows 信号处理器是死代码**:`child.kill("SIGTERM")` 在 Windows 上是 TerminateProcess,不可捕获;控制台的 Ctrl+C 又直达子进程。
|
||||
该分支已删除,并如实写明限制:**在 Windows 上强制结束 shim 不会带走被委派的 REA**(Node 没有 job object 可用)。
|
||||
- **`comspec()` 不校验**:`ComSpec` 指向不存在的文件时,正常的 `.cmd` REA 被判 `spawn-failed`(`auto` 静默退回 bundle,`system` 直接 78)。
|
||||
现在校验为常规文件,否则回落到 `%SystemRoot%\System32\cmd.exe`;实测 `ComSpec` 指向不存在路径仍能识别 6.9.0。
|
||||
- **文案**:显式路径是目录时报「does not exist」,改为「is not a file」。
|
||||
|
||||
### 验证
|
||||
|
||||
- 单测 **67 条**(2.0.7 是 60 条,本轮 +7:wrapper 递归必须被判为 `exit 78` 且 20 秒内收敛;`ComSpec` 失效仍识别 `.cmd` REA;
|
||||
镜像自愈的转发/守卫/`stale` 上报/同内容不转发;`DSH_HOME` 被抹时从 `mirror.json` 找到真实 home 并报出重复注册):
|
||||
Windows 64 过 + 3 平台跳过。
|
||||
- `verify-branch.ps1` 全绿(39 项),含「两份 README 必须写出 package.json 里的 `engines.dsh` 原文」;`audit-manifest` clean;
|
||||
`host-compat` 10 covered / 5 correctly outside。
|
||||
- 手工复现三例:wrapper 递归 0.2 秒收敛、0 残留进程;挂起候选 8.3 秒放弃且无 `PING` 残留;`ComSpec` 指向空路径仍识别系统 REA。
|
||||
|
||||
|
||||
## 2.0.7 — 2026-10-18
|
||||
|
||||
**机器上已有可用的 REA 时,2.x 直接按 1.x 的方式跑它(环境检测 + 委派),不再供给;同时把 2.0.6 的宿主声明收紧到真的能证明的范围。**
|
||||
|
||||
### 新增:环境检测 → 跑 1.x 的那条路
|
||||
|
||||
- **启动时判断该跑谁**:`rea` 在 PATH 上且能应答 `--version`,就用它(`rea mcp`,用户自己的 Node 与 PATH);否则走供给的
|
||||
bundle。这正是 1.x 的契约,现在从 2.x 的行里也能到。
|
||||
- **版本优先级**:系统 REA 比供给的旧就不抢(6.4.0 是 170 个工具、6.5.0 是 207 个,静默回退工具数是真回归)。相等或更新
|
||||
才切;预发布/无法比较时保守留 bundle。
|
||||
- **覆盖**:`REA_BUNDLE_LAUNCH=auto|system|bundle`(默认 auto)、`REA_BUNDLE_SYSTEM_REA=<path>` 点名不在 PATH 上的二进制。
|
||||
`system` 在找不到时**报错退出**,而不是替用户去下 800 MB。
|
||||
- **`--shim-status` 报告决定**:`launch.{preference,mode,reason,unusable,systemRea,rejected}`;`--shim-print-env` 在 system
|
||||
模式下打印 `{launch,rea,version}`。
|
||||
- **防自委派**:被委派的子进程带 `REA_BUNDLE_DELEGATED=1`,一个调用本 shim 的 wrapper 不会让委派无限递归。
|
||||
|
||||
### 修复(两轮独立复核,逐条可复现)
|
||||
|
||||
- **委派参数被加引号**:`cmd /c` 下 `.cmd` 的 `%*` 收到的是 `"mcp"` 而不是 `mcp` —— 探针能过、委派是坏的。现在只给路径加
|
||||
引号,参数原样传(行固定传 `mcp`;需要 shell 引号的参数本就不在这条路上)。
|
||||
- **探测预算 3s 与真机同量级**:实测经 `.cmd` 的 `rea --version` 为 2.4s(并发 2.8–3.0s),3s 预算等于掷硬币。改为 8s;超时
|
||||
用 `taskkill /T /F` 杀进程树,不再只杀 cmd.exe 而留下一个占着管道的 REA。
|
||||
- **Windows 信号转发是错的**:`child.kill("SIGINT")` 只 TerminateProcess(cmd.exe),里面的 REA 继续跑;现在 Windows 走
|
||||
`taskkill /T`,POSIX 不重复转发(stdio inherit 已由进程组投递)。
|
||||
- **`--shim-status` 退出码**:`system` 被点名但不可用时曾返回 0(bundle 恰好已供给),实际启动却 78;现在按「这一行能不能
|
||||
起来」返回,并新增 `launch.unusable` 字段。
|
||||
- **候选判定**:`existsSync` 换成 `statSync().isFile()`(名为 `rea.cmd` 的目录曾被当成坏安装);PATH 项去引号、跳过相对项;
|
||||
显式路径被拒时理由如实报告。
|
||||
- **`fail()` 的补救命令补上 `--root`**:shim 用 `REA_BUNDLE_ROOT` 解析出的根与 setup 的默认根可以不同,照着旧提示执行会供给
|
||||
到另一个根。
|
||||
- **`engine.dsh` 上限收紧**:`<0.3.0` 在 includePrerelease 语义下会放行 `0.3.0-alpha.1`/`0.3.0-rc.1`;改为 `<0.3.0-0`,并用
|
||||
市场自己的 `satisfiesRange` 逐版本核对。
|
||||
- **`host-compat.mjs` 的测试加固**:发布清单逐项钉住;新增「低地板必须被判为泄漏」的反向对照(原实现把 `leaked` 改成 `[]`
|
||||
仍然全绿);`+build` 元数据按 semver 接受、`v` 前缀与零填充预发布按 semver 拒绝。
|
||||
- **环境独立性**:`setup.mjs` 自检的 shim 步骤钉 `bundle` 模式(否则机器上更新的 REA 会冒充本次供给的产物);CI 两个 job
|
||||
各加 job 级 `REA_BUNDLE_LAUNCH=bundle`;测试里的每个 shim 调用点都钉住模式,测试不再随机器变。
|
||||
- **`rea-bundle-bootstrap.ps1` 认 `REA_BUNDLE_ROOT`**:四份代码里只有它忽略这个覆盖,导致 bootstrap 把 800 MB 装到
|
||||
`<DSH_HOME>/plugins/rea-bundle`,而 shim/setup/check-updates 去 `REA_BUNDLE_ROOT` 找,看到空 bundle 再起一次。
|
||||
- 中文 README 的「更新到 2.0.4」措辞改正为「更新到 2.0.5」。
|
||||
|
||||
### 验证
|
||||
|
||||
- 单测 **60 条**(新增 12 条:6 条启动模式 + 1 条清单钉住 + 1 条泄漏反向对照 + 1 条 semver 容差 + 3 条 PATH/空格路径/委派退出码),
|
||||
Windows 57 过 + 3 平台跳过。
|
||||
- `verify-branch.ps1` 全绿;`audit-manifest.mjs` clean;`host-compat.mjs` 10 covered / 5 correctly outside。
|
||||
- 真机:全局 `rea` 6.4.0 与供给的 6.5.0 并存时 `auto` 留 bundle(不降工具数),`REA_BUNDLE_LAUNCH=system` 才切过去。
|
||||
|
||||
|
||||
## 2.0.6 — 2026-10-18
|
||||
|
||||
**声明宿主版本:`engines.dsh` 覆盖 `0.1.7-rc.2` … `0.2.1-rc.2` 全部发布版本,市场卡片因此显示「兼容」而不是「未声明」。**
|
||||
|
||||
### 兼容声明
|
||||
|
||||
- **`engines.dsh: ">=0.1.7-rc.2 <0.3.0"`**:市场侧的 `deriveHostCompatibility` 只认两类声明 —— `engines.dsh`
|
||||
与指向 `@deepseek-ai/dsh*` 的 `peerDependencies`;两者都没有就是 `unknown`(卡片「兼容性未知」)。
|
||||
这里只声明前者,刻意不声明 peer:`@deepseek-ai/dsh-mcp-client` 在 npm 上只有 `0.0.1-rc.1`,
|
||||
而宿主随包内置的是 0.2.x,声明 peer 会让 pnpm 解析出一个与宿主不一致的版本,反而制造假冲突。
|
||||
- **范围来自核对,不是估计**:`scripts/host-compat.mjs` 内置 dsh 发布列表(0.1.7-rc.2 至 0.2.1-rc.2 共 10 个),
|
||||
逐个验证是否被声明覆盖,并反向确认 `0.3.0` 与 `0.1.7-rc.1`、`0.1.7-alpha.2` 落在范围之外。
|
||||
- **跨版本比对过的东西**:`packages/mcp/mcp-client` 在 `dsh-v0.1.7-rc.2` 与 `dsh-v0.2.1-rc.2` 两个 tag 上,
|
||||
配置面与发布的行一致(`serverName`/`transport`/`command`/`args`/`env`/`failOnStartupError` 均存在且同名),
|
||||
且两个 tag 都有 `dsh.bundle.patch`。范围声明的是「核对过的宿主」,不是「应该能用」。
|
||||
|
||||
### 上游(只更新仓库,暂不带入)
|
||||
|
||||
- 上游已发布 **rea-agents 6.6.0**(GitHub release `rea-agents-6.6.0`,2026-10-18);npm `latest` 仍是 **6.5.0**,
|
||||
所以本包的钉(`manifest/bundle.manifest.json` 的 `pins.reaAgents` 与 `components.reaAgents.sources.npm.version`)
|
||||
**保持 6.5.0 不动**。已核 6.6.0 的 `engines.node`(`^22.19.0 || ^24.11.0 || >=26.0.0`)与 `bin`
|
||||
(`scripts/rea.mjs`)均未变化,`optionalDependencies.frida` 为 17.23.2。
|
||||
- 计划:**2026-10-19** 随下一版把钉升到 6.6.0 并复跑真机;届时工具数、探针正则与文档一并更新。
|
||||
|
||||
### 修复 / 工程
|
||||
|
||||
- 新增 `scripts/host-compat.mjs`(纯函数 + CLI):semver 预发布排序、`>=floor <ceiling` 判定、发布列表覆盖检查。
|
||||
- `bundle:host-compat` npm script;`verify-branch.ps1` 新增 `engines.dsh` 存在性与覆盖率两项;
|
||||
`validate.yml` 新增「Declared DSH range covers every released host」步骤,清单检查也要求 `engines.dsh`。
|
||||
|
||||
### 验证
|
||||
|
||||
- 单测 **46 条**(新增 4 条:声明可解析、10 个发布版本全覆盖且 3 个未核对版本被排除、反向对照能抓出
|
||||
`>=0.2.0` 这种看似合理却漏掉整个 0.2.0-rc 线的写法、预发布排序符合 semver 而非字符串序):Windows 43 过 + 3 平台跳过。
|
||||
- `node scripts/host-compat.mjs`:10 个发布版本 covered、3 个 correctly outside。
|
||||
- `verify-branch.ps1` 全绿;`audit-manifest.mjs` clean(两份 README 结构仍平行)。
|
||||
|
||||
## 2.0.5 — 2026-10-18
|
||||
|
||||
**市场更新失败已定位并修复:清单里没有 `main`,市场因此在每次更新时判「新构建没有可加载入口」并回滚。**
|
||||
|
||||
### 修复
|
||||
|
||||
- **应用内市场更新到 2.0.4 失败**:`dshmarket` 的更新路径用 `hasLoadableEntry` 判断新构建能不能加载
|
||||
(实现是 `entryArtifactExists`:读 `main`/`exports`,两者都没有就回落 `index.js`)。本包是纯 bundle
|
||||
(只有 `dsh.bundle.patch`),从未声明 `main`;而它挂载的 `@deepseek-ai/dsh-mcp-client` 属于 harness 自身
|
||||
构建、不在 `node_modules` 里,于是该检查要看的 profile / 包内 / workspace 三个位置全空。结果:2.0.1 → 2.0.4
|
||||
的更新连续三次报「更新后缺少入口文件……且未能验证恢复原版本文件」,而回滚其实成功了 —— 旧版同样没有入口,
|
||||
所以回滚后的复检依然为 false。现已声明 `main: ./index.mjs`,并让该文件进入 `files[]`。
|
||||
- **`index.mjs` 是真空插件,不是占位**:导出 `name` 与空 `apply()`,不注册行、不起进程、不写状态;真正起作用的
|
||||
行仍由 `cordis.patch.yml` 插入。若 harness 某天真把它当插件加载,行为仍是「什么都不做」,因此不会造出第二条
|
||||
`serverName: rea`。
|
||||
|
||||
### 文档
|
||||
|
||||
- 两份 README 新增「在市场里更新 / Updating from the market」小节:说明该报错来自市场侧的入口判定、与 tarball
|
||||
完整性无关、profile 未受损,以及 2.0.5 起的做法。
|
||||
|
||||
### 验证
|
||||
|
||||
- 用 `dshmarket` 自身的 `hasLoadableEntry` 实测:把本包按 profile 布局模拟安装,修复前 `false`、修复后 `true`;
|
||||
对仓库目录 `entryArtifactExists` 修复后为 `true`。
|
||||
- 单测 42 条(新增 2 条:清单声明的入口必须存在且被 `files[]` 收录;入口可 `import`、导出的 `name` 与包名一致、
|
||||
`apply()` 返回 `undefined`):Windows 39 过 + 3 平台跳过。
|
||||
- `verify-branch.ps1` 新增 `main` 存在性与 `files[]` 收录检查;`validate.yml` 的清单检查与 npm 载荷检查加了同一条守卫。
|
||||
- `npm pack --dry-run`:20 个文件,含 `index.mjs`。
|
||||
|
||||
## 2.0.4 — 2026-10-17
|
||||
|
||||
**两处已核实的缺陷:只读 bundle 目录下 shim 启动即崩;与上游 `rea setup --client dsh` 的同名注册冲突现在会被明确提示。**
|
||||
|
||||
@@ -100,7 +100,7 @@ releases, checksum-verified). Then install the plugin for your profile:
|
||||
```
|
||||
dsh plugin --profile <profile> add dsh-plugin-rea
|
||||
# or pin this release instead of tracking npm latest:
|
||||
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.0
|
||||
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.9
|
||||
```
|
||||
|
||||
> **On an Electron host (the DeepSeek Harness desktop app) the profile is owned by the application.**
|
||||
@@ -125,6 +125,46 @@ provisioning (the harness itself is unaffected: `failOnStartupError: false` is d
|
||||
`node scripts/setup.mjs` once; from then on the shim is in place, and every later missing state is picked up
|
||||
by the shim itself, which provisions in the background.
|
||||
|
||||
### Which DSH versions
|
||||
|
||||
`package.json` declares `engines.dsh: ">=0.1.7-rc.2 <0.3.0-0"`, and that declaration is the answer the plugin market
|
||||
reads: with it the card says **compatible**; without it the market can only say *unknown*. The range is not a guess —
|
||||
it is the dsh release list this plugin is checked against, and `node scripts/host-compat.mjs` fails the build when the
|
||||
two disagree:
|
||||
|
||||
| Host | Covered |
|
||||
|---|---|
|
||||
| `0.1.7-rc.2` … `0.2.1-rc.2` — every release in between, alpha and rc | yes |
|
||||
| `0.3.0` and later | no — a new minor line is a host nobody updated this row for yet |
|
||||
|
||||
What was actually compared across that range is the row provider: `packages/mcp/mcp-client` at `dsh-v0.1.7-rc.2` and at
|
||||
`dsh-v0.2.1-rc.2` exposes the same config surface the shipped row uses (`serverName`, `transport`, `command`, `args`,
|
||||
`env`, `failOnStartupError`), and `dsh.bundle.patch` exists at both tags. A host outside the range can still work; it is
|
||||
simply not claimed.
|
||||
|
||||
### A REA you already have is used as-is
|
||||
|
||||
2.x exists to supply what a machine is missing. If REA is already installed — `rea` on PATH, the 1.x setup — the row
|
||||
runs **that**, exactly as the 1.x line did, and nothing is provisioned:
|
||||
|
||||
| What the machine has | What the row runs |
|
||||
|---|---|
|
||||
| `rea` on PATH, at least as new as the provisioned build | your REA (`rea mcp`), with your Node and your PATH |
|
||||
| `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) |
|
||||
| no `rea` on PATH | the provisioned bundle, provisioning itself on first use |
|
||||
|
||||
- `REA_BUNDLE_LAUNCH=auto|system|bundle` overrides the decision (`auto` is the default); `REA_BUNDLE_SYSTEM_REA=<path>`
|
||||
names a binary that is not on PATH.
|
||||
- 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
|
||||
runs once per start and only when a `rea` actually exists.
|
||||
- `--shim-status` prints the decision — `launch.mode`, `launch.reason`, `launch.systemRea` — and its `launch.rejected`
|
||||
list says why a candidate was skipped (`"exit 7"`, `"inside the bundle"`, `"timeout"`).
|
||||
- A delegated child carries `REA_BUNDLE_DELEGATED=1`, so a wrapper that calls this shim cannot make it delegate to
|
||||
itself forever.
|
||||
- **On Windows a forced kill of the shim does not reap the delegated REA.** There is no job object here: Task Manager
|
||||
or a supervisor terminating the shim leaves your `rea` running with the row's pipes (a console Ctrl+C reaches the
|
||||
whole process group and does stop it). On POSIX the child is in the same group and dies with the terminal.
|
||||
|
||||
## Configure — nothing
|
||||
|
||||
The shipped row carries **no machine path**:
|
||||
@@ -193,9 +233,9 @@ Which one? Upstream's is the configuration-only route: it expects Node on REA's
|
||||
zero-dependency route: it fetches Node, REA, JDK and Ghidra on first run. The patch shape and the
|
||||
`mcp__rea__*` surface are the same either way.
|
||||
|
||||
The shim reports the conflict on startup when it can see that file — a custom `DSH_HOME` is hidden
|
||||
from an MCP child, so it falls back to `~/.dsh` there — and `node bin/rea-shim.mjs --shim-status`
|
||||
lists it under `duplicateRegistration`.
|
||||
The shim reports the conflict on startup — `setup.mjs` records the DSH home it ran under in
|
||||
`mirror.json`, and the mirrored copy reads that when the harness has stripped `DSH_*` from its
|
||||
environment; `node bin/rea-shim.mjs --shim-status` lists it under `duplicateRegistration`.
|
||||
|
||||
## Verify
|
||||
|
||||
@@ -262,6 +302,27 @@ macOS/Linux.
|
||||
|
||||
## Updating — three independent things
|
||||
|
||||
### Updating from the market
|
||||
|
||||
Update it where you installed it. Updating **to 2.0.5 or later** is routine; updating an installed 2.0.1–2.0.4
|
||||
*through the in-app market* failed with this, three times on a healthy profile:
|
||||
|
||||
> dsh-plugin-rea 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件
|
||||
|
||||
That message is not about a broken tarball. On every update the market asks whether the new build's
|
||||
`package.json` points at an entry file that exists (`hasLoadableEntry` in `dshmarket`), and rolls the build back
|
||||
when the answer is false. A bundle may legitimately own no entry — but only if a package it *mounts* has one, and
|
||||
the package this bundle mounts (`@deepseek-ai/dsh-mcp-client`) ships inside the harness build rather than in
|
||||
`node_modules`, so every location that test probes comes back empty. The 1.x and 2.0.x manifests also declared
|
||||
no `main` at all, so the answer was false even though the profile was working; the rollback was then reported as
|
||||
unverifiable for the same reason. Nothing in the profile was actually damaged — the update simply did not apply.
|
||||
|
||||
2.0.5 declares `main: ./index.mjs`. That file is a real, deliberately empty Cordis plugin: it registers nothing,
|
||||
spawns nothing, touches no state — the working row is still inserted by `cordis.patch.yml` — and it exists so the
|
||||
question "can this package load?" has an honest answer, and so `import 'dsh-plugin-rea'` resolves. On an older
|
||||
version, update to `>=2.0.5`; if the market refuses before that version exists, install `[email protected]`
|
||||
(Desktop: through the plugin manager) instead of updating in place.
|
||||
|
||||
### Upgrading from 1.x
|
||||
|
||||
Install 2.x, then run provisioning **once** before restarting dsh:
|
||||
@@ -279,6 +340,12 @@ back on the next start.
|
||||
|
||||
- **This plugin** never updates itself. Installed from a market? Update it there. Installed from a `file:`
|
||||
checkout? Overwrite the directory and reinstall.
|
||||
- **The row runs a COPY of this plugin's launcher.** `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` is a mirror
|
||||
made by setup, so a market upgrade replaces the package but not the file the row executes — until setup runs
|
||||
again. From **2.0.8** the mirror notices that the installed plugin ships a different launcher and re-execs it
|
||||
(one hop, guarded by `REA_BUNDLE_SHIM_REEXEC`), so upgrades carry. On an older version the mirror stays behind
|
||||
silently; `--shim-status` now reports `mirror.stale`, and `check-updates.ps1` says to re-run setup. If you are
|
||||
upgrading from 2.0.0–2.0.7, run `node scripts/setup.mjs` once after the upgrade.
|
||||
- **Upgrading from `2.0.0-bundle.1`**: that release mirrored the shim and `setup.mjs` but not the manifest,
|
||||
and recorded nothing about the plugin checkout, so a bundle it provisioned cannot repair itself — its
|
||||
background provisioning dies on the missing manifest, invisibly, and its own lock suppresses retries for
|
||||
|
||||
+64
-3
@@ -94,7 +94,7 @@ profile 安装插件:
|
||||
```
|
||||
dsh plugin --profile <profile> add dsh-plugin-rea
|
||||
# 或钉住这个版本,而不是跟着 npm latest 走:
|
||||
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.0
|
||||
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.9
|
||||
```
|
||||
|
||||
> **Electron 宿主(DeepSeek Harness 桌面版)的 profile 由应用独占。** 那种机器上 `dsh plugin --profile desktop …`
|
||||
@@ -116,6 +116,43 @@ node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
|
||||
任何东西能去启动后台供给(harness 本身不受影响:`failOnStartupError: false` 是刻意的)。先手动跑一次
|
||||
`node scripts/setup.mjs`;此后 shim 就位,之后每一次「状态缺失」都由它自己发现并在后台补供给。
|
||||
|
||||
### 支持哪些 DSH 版本
|
||||
|
||||
`package.json` 里声明了 `engines.dsh: ">=0.1.7-rc.2 <0.3.0-0"`,这就是插件市场读的那句话:有它,卡片显示
|
||||
**兼容**;没有它,市场只能说*未声明*。这个范围不是猜的——它对应本插件实际核对过的 dsh 发布列表,
|
||||
`node scripts/host-compat.mjs` 会在两者不一致时让构建失败:
|
||||
|
||||
| 宿主版本 | 是否覆盖 |
|
||||
|---|---|
|
||||
| `0.1.7-rc.2` … `0.2.1-rc.2`(中间的 alpha 与 rc 全部在内) | 是 |
|
||||
| `0.3.0` 及以后 | 否 —— 新的小版本线是还没为它核对过这一行的宿主 |
|
||||
|
||||
真正被比对过的是「行提供方」:`packages/mcp/mcp-client` 在 `dsh-v0.1.7-rc.2` 与 `dsh-v0.2.1-rc.2` 两个 tag 上,
|
||||
暴露的配置面与发布的行完全一致(`serverName`、`transport`、`command`、`args`、`env`、`failOnStartupError`),
|
||||
并且两个 tag 都有 `dsh.bundle.patch`。范围之外的宿主可能照样能用,只是没有替它声明。
|
||||
|
||||
### 机器上已有 REA 就直接用它
|
||||
|
||||
2.x 存在的意义是补上机器缺的东西。如果 REA 已经装好——`rea` 在 PATH 上,也就是 1.x 的形态——这一行就**直接跑它**,
|
||||
和 1.x 一模一样,不再做任何供给:
|
||||
|
||||
| 机器上的情况 | 这一行跑什么 |
|
||||
|---|---|
|
||||
| `rea` 在 PATH 上,且不比供给的版本旧 | 你自己那份(`rea mcp`),用你的 Node 和你的 PATH |
|
||||
| `rea` 在 PATH 上,但比供给的版本旧 | 供给出来的 bundle —— 旧的全局 CLI 会把工具数往回退(6.4.0 是 170 个,6.5.0 是 207 个) |
|
||||
| PATH 上没有 `rea` | 供给出来的 bundle,首次使用时自行供给 |
|
||||
|
||||
- `REA_BUNDLE_LAUNCH=auto|system|bundle` 覆盖这个判断(默认 `auto`);`REA_BUNDLE_SYSTEM_REA=<path>` 用来点名一个
|
||||
不在 PATH 上的可执行文件。
|
||||
- 探测用的是 CLI 自己的 `--version`,上限 8 秒:Windows 上的 `.cmd` 垫片要 ~2.4 秒才答得出来,所以这个检查每次启动
|
||||
只做一次,而且只在真的存在 `rea` 时才做。
|
||||
- `--shim-status` 会把判断结果打出来 —— `launch.mode`、`launch.reason`、`launch.systemRea`,`launch.rejected` 里
|
||||
写的是每个候选被跳过的原因(`"exit 7"`、`"inside the bundle"`、`"timeout"`)。
|
||||
- 被委派的子进程带着 `REA_BUNDLE_DELEGATED=1`,所以一个调用本 shim 的 wrapper 不会让它无限委派给自己。
|
||||
- **Windows 上强制结束 shim 不会带走被委派的 REA**:这里没有 job object,任务管理器或 supervisor 终止 shim 时,
|
||||
你自己的 `rea` 会继续占着这一行的管道(控制台 Ctrl+C 会发给整个进程组,那种情况下会一起停)。POSIX 上子进程
|
||||
在同一进程组,随终端一起退出。
|
||||
|
||||
## 配置 —— 不需要
|
||||
|
||||
发布的那一行**不含任何机器路径**:
|
||||
@@ -182,8 +219,8 @@ duplicate server name 报错并加载失败,最终留下哪一行取决于加
|
||||
自己准备。本包是零依赖路线:首次运行自动取 Node、REA、JDK、Ghidra。两者的 patch 形态与
|
||||
`mcp__rea__*` 工具面相同。
|
||||
|
||||
shim 在能看到该文件时会在启动时提示冲突 —— MCP 子进程里拿不到自定义 `DSH_HOME`,那种情况下它会
|
||||
退回 `~/.dsh`;`node bin/rea-shim.mjs --shim-status` 会把冲突列在 `duplicateRegistration` 下。
|
||||
shim 会在启动时提示冲突 —— `setup.mjs` 会把运行时的 DSH home 记进 `mirror.json`,镜像副本在 harness 抹掉
|
||||
`DSH_*` 之后就用这条记录去找那份文件;`node bin/rea-shim.mjs --shim-status` 会把冲突列在 `duplicateRegistration` 下。
|
||||
|
||||
## 验证
|
||||
|
||||
@@ -246,6 +283,25 @@ shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.5.0}
|
||||
|
||||
## 更新 —— 三件互相独立的事
|
||||
|
||||
### 在市场里更新
|
||||
|
||||
在哪儿装的就在哪儿更新。更新到 **2.0.5 及以上**是常规操作;而把已装的 2.0.1–2.0.4 通过应用内市场**更新到
|
||||
2.0.5**,在健康的 profile 上连续失败了三次:
|
||||
|
||||
> dsh-plugin-rea 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件
|
||||
|
||||
这条消息与 tarball 是否完整无关。市场在每次更新时都会问一句:新构建的 `package.json` 是否指向一个真实存在的
|
||||
入口文件(`dshmarket` 里的 `hasLoadableEntry`);答案是「否」,就把这次更新判为坏构建并回滚。bundle 本来允许
|
||||
自己没有入口 —— 前提是它**挂载**的某个包有入口;而本包挂载的 `@deepseek-ai/dsh-mcp-client` 属于 harness 自身
|
||||
构建,不在 `node_modules` 里,于是这项检查要看的每个位置都是空的。1.x 与 2.0.x 的清单又根本没有声明 `main`,
|
||||
所以答案必然是「否」,哪怕 profile 一直是好的;紧接着的回滚也因此被报成「无法验证」。实际上 profile 没有被
|
||||
破坏,只是这次更新没有生效。
|
||||
|
||||
2.0.5 声明了 `main: ./index.mjs`。那是一个真实存在、刻意留空的 Cordis 插件:不注册任何行、不启动任何进程、
|
||||
不碰任何状态(真正起作用的行仍由 `cordis.patch.yml` 插入),它的作用只是让「这个包能不能加载」有一个诚实的
|
||||
答案,并让 `import 'dsh-plugin-rea'` 能解析。还停在旧版本时,更新到 `>=2.0.5` 即可;若市场在 2.0.5 之前就拒绝,
|
||||
就直接安装 `[email protected]`(桌面端:在插件管理器里装),不要原地更新。
|
||||
|
||||
### 从 1.x 升级
|
||||
|
||||
装上 2.x 后,**先跑一次供给**,再重启 dsh:
|
||||
@@ -261,6 +317,11 @@ node scripts/setup.mjs # 或:powershell -File scripts\rea-bundle-boot
|
||||
工具就回来了。
|
||||
|
||||
- **本插件**从不自更新。从市场装的就在市场更新;从 `file:` 检出的就覆盖目录重装。
|
||||
- **行跑的是本插件启动器的「镜像副本」**:`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 是 setup 复制过去的,
|
||||
所以市场升级只换包、不换行执行的那个文件 —— 直到再跑一次 setup。从 **2.0.8** 起,镜像会发现自己落后于已安装的
|
||||
插件,并转发执行插件里的那一份(一跳,带 `REA_BUNDLE_SHIM_REEXEC` 守卫),升级因此能生效。更早的版本里镜像会
|
||||
静默地停在旧代码;`--shim-status` 现在会报 `mirror.stale`,`check-updates.ps1` 也会提示重跑 setup。**从
|
||||
2.0.0–2.0.7 升级的话,升级后跑一次 `node scripts/setup.mjs`。**
|
||||
- **从 `2.0.0-bundle.1` 升级**:那一版镜像了 shim 与 `setup.mjs`,却漏了 manifest,也没有留下插件 checkout
|
||||
的记录,所以它供给出来的 bundle **无法自愈** —— 后台供给会因缺 manifest 而崩溃且完全不可见,而它自己写下的
|
||||
lock 又会让重试被抑制六小时。请**从插件目录**重跑一次 `node scripts/setup.mjs`:它会重新镜像三个文件,
|
||||
|
||||
+724
-20
@@ -14,8 +14,9 @@
|
||||
* `failOnStartupError: false`, so the harness stays up
|
||||
* and the message is the operator's remediation.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
@@ -25,6 +26,19 @@ const EX_CONFIG = 78;
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PLUGIN_ROOT = path.resolve(HERE, "..");
|
||||
|
||||
/**
|
||||
* JSON from disk, tolerating the UTF-8 BOM a Windows editor writes.
|
||||
*
|
||||
* `JSON.parse` rejects a leading BOM, and every caller here reads a parse
|
||||
* failure as "no record": a BOM would silently downgrade mirror.json to "no
|
||||
* plugin checkout" and bundle.json to "not provisioned". Measured while testing
|
||||
* this file — a hand-edited mirror.json written by Windows PowerShell behaved
|
||||
* exactly that way.
|
||||
*/
|
||||
function readJson(file) {
|
||||
return JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
|
||||
}
|
||||
|
||||
/** DSH home, by the same rule setup.mjs and the published patch use. */
|
||||
function dshHome() {
|
||||
const fromEnv = process.env.DSH_HOME;
|
||||
@@ -32,6 +46,36 @@ function dshHome() {
|
||||
return path.join(homedir(), ".dsh");
|
||||
}
|
||||
|
||||
/**
|
||||
* The DSH home recorded by setup, or null.
|
||||
*
|
||||
* Only the mirrored copy needs this: the harness strips `DSH_*` from an MCP
|
||||
* child, so with a custom home the environment cannot answer. Read once per
|
||||
* process — there is one bundle root per process and this is a startup query.
|
||||
*/
|
||||
let recordedDshHomeCache = null;
|
||||
function recordedDshHome(root) {
|
||||
if (recordedDshHomeCache !== null) return recordedDshHomeCache;
|
||||
let value = null;
|
||||
try {
|
||||
const record = readJson(path.join(root, "mirror.json"));
|
||||
if (typeof record?.dshHome === "string" && record.dshHome.trim() !== "" && path.isAbsolute(record.dshHome)) {
|
||||
value = path.resolve(record.dshHome);
|
||||
}
|
||||
} catch {
|
||||
value = null;
|
||||
}
|
||||
recordedDshHomeCache = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The home to look in when the environment has been scrubbed. */
|
||||
function dshHomeFor(root) {
|
||||
const fromEnv = process.env.DSH_HOME;
|
||||
if (fromEnv && fromEnv.trim() !== "") return path.resolve(fromEnv);
|
||||
return recordedDshHome(root) ?? path.join(homedir(), ".dsh");
|
||||
}
|
||||
|
||||
/**
|
||||
* Where setup writes the machine-local truth.
|
||||
*
|
||||
@@ -84,7 +128,7 @@ function usableSetup(dir) {
|
||||
*/
|
||||
function provisioningScript(root) {
|
||||
try {
|
||||
const record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
|
||||
const record = readJson(path.join(root, "mirror.json"));
|
||||
const recorded = usableSetup(record && record.pluginRoot);
|
||||
if (recorded) return recorded;
|
||||
} catch {
|
||||
@@ -104,13 +148,16 @@ function provisioningScript(root) {
|
||||
*
|
||||
* The parse is deliberately small: the two files it cares about are block
|
||||
* mappings with one id/serverName per row, and anything it cannot attribute is
|
||||
* ignored — a missed hint is acceptable, a false alarm is not. The mirrored shim
|
||||
* cannot see a custom DSH_HOME (the harness strips DSH_* from an MCP child), so
|
||||
* this falls back to `~/.dsh` like the rest of the bundle; the row has the same
|
||||
* limitation.
|
||||
* ignored — a missed hint is acceptable, a false alarm is not.
|
||||
*
|
||||
* Finding the file is the part that needed care: the harness strips every DSH_*
|
||||
* variable from an MCP child, so a mirrored copy with a custom DSH_HOME used to
|
||||
* read `~/.dsh` — the wrong file, and therefore a conflict it could never report.
|
||||
* `setup.mjs` writes the home it ran under into `mirror.json`, and that record is
|
||||
* what this uses when the environment has nothing to say.
|
||||
*/
|
||||
function foreignReaRegistration() {
|
||||
const patch = path.join(dshHome(), "cordis.patch.yml");
|
||||
function foreignReaRegistration(root) {
|
||||
const patch = path.join(dshHomeFor(root), "cordis.patch.yml");
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(patch, "utf8");
|
||||
@@ -267,13 +314,18 @@ function fail(message, detail) {
|
||||
// copy is fine, but it can only ever provision the manifest it was mirrored
|
||||
// with — and a bundle mirrored before the manifest travelled with it has no
|
||||
// usable copy at all, so the honest answer is then "run the plugin's own script".
|
||||
const setupScript = provisioningScript(bundleRoot());
|
||||
const root = bundleRoot();
|
||||
const setupScript = provisioningScript(root);
|
||||
lines.push(
|
||||
"",
|
||||
" Finish setup with:",
|
||||
setupScript
|
||||
? " node \"" + setupScript + "\""
|
||||
: " node \"<installed plugin directory>/scripts/setup.mjs\"",
|
||||
// `--root` is not decoration: a shim started with REA_BUNDLE_ROOT (or with
|
||||
// DSH_HOME unset in a row's scrubbed environment) resolves a root that
|
||||
// setup's own default need not agree with, and a remediation command that
|
||||
// provisions into another root leaves this row failing exactly as before.
|
||||
? " node \"" + setupScript + "\" --root \"" + root + "\""
|
||||
: " node \"<installed plugin directory>/scripts/setup.mjs\" --root \"" + root + "\"",
|
||||
);
|
||||
if (!setupScript) {
|
||||
lines.push("", " (Run it from the installed plugin directory: the copy in this bundle has no manifest.)");
|
||||
@@ -294,7 +346,7 @@ function readBundleConfig(root) {
|
||||
if (!existsSync(file)) return { ok: false, code: "missing", file };
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(file, "utf8"));
|
||||
parsed = readJson(file);
|
||||
} catch (error) {
|
||||
return { ok: false, code: "malformed", file, detail: String(error && error.message) };
|
||||
}
|
||||
@@ -363,6 +415,576 @@ function childEnvironment(config) {
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch modes. 2.x exists to supply a runtime the machine does not have; when
|
||||
* the machine already has one, the honest thing to do is run it.
|
||||
*
|
||||
* The 1.x line was exactly that: `rea mcp` off PATH, user-managed Node and
|
||||
* Ghidra. So "use the system install" is not a new code path invented here — it
|
||||
* is the 1.x contract, reachable from the 2.x row. `auto` (the default) prefers
|
||||
* the system install and falls back to the provisioned bundle; `bundle` pins the
|
||||
* old behaviour; `system` insists (and fails loudly instead of provisioning
|
||||
* 800 MB behind an operator who asked for their own REA).
|
||||
*/
|
||||
const LAUNCH_MODES = ["auto", "system", "bundle"];
|
||||
|
||||
/**
|
||||
* How long a candidate may take to answer `--version`.
|
||||
*
|
||||
* Measured on a real npm-global install: `rea --version` through its `.cmd`
|
||||
* shim takes ~2.4 s cold (cmd.exe -> node -> the CLI's own module graph), and
|
||||
* 2.8-3.0 s with four probes in flight. A 3 s budget was therefore a coin flip:
|
||||
* half the runs rejected a perfectly good REA and silently fell back to the
|
||||
* bundle. 8 s leaves room for a slow disk and a loaded machine while still
|
||||
* bounding the one-off cost at startup.
|
||||
*/
|
||||
const PROBE_TIMEOUT_MS = 8_000;
|
||||
|
||||
/**
|
||||
* What the environment says about the launch mode.
|
||||
*
|
||||
* An unknown value is reported and treated as `auto`: the row is launched by
|
||||
* the harness with nobody watching stderr, so refusing to start over a typo
|
||||
* would read as "the plugin is broken". `auto` is the mode that still works.
|
||||
*/
|
||||
function launchPreference(env = process.env) {
|
||||
const raw = (env.REA_BUNDLE_LAUNCH ?? "").trim().toLowerCase();
|
||||
if (raw === "") return { value: "auto", invalid: null };
|
||||
return LAUNCH_MODES.includes(raw)
|
||||
? { value: raw, invalid: null }
|
||||
: { value: "auto", invalid: raw };
|
||||
}
|
||||
|
||||
/** True when `child` is `parent` or lives under it (case-insensitive on Windows). */
|
||||
function isInside(child, parent) {
|
||||
const normalise = (value) => (process.platform === "win32" ? value.toLowerCase() : value);
|
||||
const target = normalise(path.resolve(child));
|
||||
const base = normalise(path.resolve(parent));
|
||||
if (target === base) return true;
|
||||
const relative = path.relative(base, target);
|
||||
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executable names a REA install provides on this platform.
|
||||
*
|
||||
* npm's global install writes a shell script plus `.cmd`/`.ps1` shims on
|
||||
* Windows, and only the `.cmd` one is spawnable without a shell. `rea.exe` is
|
||||
* accepted for a native/standalone install.
|
||||
*/
|
||||
function systemReaNames(platform = process.platform) {
|
||||
return platform === "win32" ? ["rea.cmd", "rea.exe", "rea.bat"] : ["rea"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every path worth probing, most explicit first.
|
||||
*
|
||||
* `REA_BUNDLE_SYSTEM_REA` names one binary outright — that is both the test
|
||||
* seam and a real operator need (an install that is not on the harness's PATH,
|
||||
* which on Desktop is not the user's shell PATH).
|
||||
*/
|
||||
function systemReaCandidates({ env = process.env, platform = process.platform } = {}) {
|
||||
const candidates = [];
|
||||
const explicit = (env.REA_BUNDLE_SYSTEM_REA ?? "").trim();
|
||||
if (explicit !== "") candidates.push(path.resolve(explicit));
|
||||
const searchPath = env.PATH ?? env.Path ?? "";
|
||||
for (const raw of searchPath.split(path.delimiter)) {
|
||||
// A quoted PATH entry (`PATH="C:\Program Files\v66";…`) is legal in Windows
|
||||
// environments and `path.join` would treat the quotes as part of the name, so
|
||||
// the install would be invisible. A relative entry is skipped rather than
|
||||
// resolved: its meaning depends on the cwd THIS process happens to have, and
|
||||
// a candidate found that way would be launched by a path that means something
|
||||
// else to whoever reads the status report.
|
||||
const dir = raw.trim().replace(/^"(.*)"$/, "$1");
|
||||
if (dir === "" || !path.isAbsolute(dir)) continue;
|
||||
for (const name of systemReaNames(platform)) candidates.push(path.join(dir, name));
|
||||
}
|
||||
const seen = new Set();
|
||||
return candidates.filter((candidate) => {
|
||||
// Keyed on the platform being RESOLVED, not on the one this process happens
|
||||
// to run: the tests pass `platform` to exercise both shapes on one machine.
|
||||
const key = platform === "win32" ? candidate.toLowerCase() : candidate;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a process and its children.
|
||||
*
|
||||
* `spawnSync`'s timeout kills only the process it started — for a `.cmd` shim
|
||||
* that is cmd.exe, and the `node` running inside it keeps the inherited stdio
|
||||
* handles open and keeps working. Taskkill's `/T` is the one portable-enough way
|
||||
* to get the tree on Windows; POSIX uses the process itself (the candidate is a
|
||||
* shebang script there, so there is no intermediate shell).
|
||||
*/
|
||||
function killTree(pid, platform = process.platform) {
|
||||
if (typeof pid !== "number" || pid <= 0) return;
|
||||
try {
|
||||
if (platform === "win32") {
|
||||
const taskkill = path.join(process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows", "System32", "taskkill.exe");
|
||||
spawnSync(taskkill, ["/pid", String(pid), "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
timeout: 5_000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
} catch {
|
||||
/* best effort: a failed cleanup must not change the verdict */
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the path is a regular file (a directory named `rea.cmd` is not). */
|
||||
function isFileSync(file) {
|
||||
try {
|
||||
return statSync(file).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A command line that survives `cmd /c` on Windows: quoted, quotes doubled. */
|
||||
function cmdQuote(value) {
|
||||
return `"${String(value).replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell used to run a `.cmd`/`.bat` candidate.
|
||||
*
|
||||
* Absolute on purpose: `spawn(comspec())` is resolved against the CHILD's PATH,
|
||||
* and an MCP row inherits a scrubbed environment. A machine with a working REA
|
||||
* but a PATH that does not include System32 would then be judged "no REA",
|
||||
* which is precisely the wrong answer for the feature that exists to find it.
|
||||
*/
|
||||
function comspec() {
|
||||
const fromEnv = process.env.ComSpec ?? process.env.COMSPEC;
|
||||
// A ComSpec that names nothing is a real (if unusual) state, and trusting it
|
||||
// would judge every `.cmd` REA "spawn-failed" — i.e. report the machine as
|
||||
// having no REA because its shell pointer is broken. Fall back to the path
|
||||
// Windows itself would use.
|
||||
if (typeof fromEnv === "string" && fromEnv.trim() !== "" && isFileSync(fromEnv)) return fromEnv;
|
||||
const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows";
|
||||
return path.join(systemRoot, "System32", "cmd.exe");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask a candidate whether it is a working REA.
|
||||
*
|
||||
* A file existing on PATH is not the same as a working install: npx leftovers,
|
||||
* half-removed globals and a shim whose Node is gone all sit on PATH and all
|
||||
* fail at once. One bounded `--version` separates them, and a slow or hanging
|
||||
* answer counts as "not usable" — the fallback (the bundle) still works, while
|
||||
* delegating to a broken binary would take the tools down with it.
|
||||
*
|
||||
* Two things here are not incidental:
|
||||
*
|
||||
* * The probe runs with `REA_BUNDLE_DELEGATED=1`. A candidate that is itself a
|
||||
* wrapper around this shim would otherwise be probed by a nested shim that
|
||||
* probes the wrapper — measured: a process chain that keeps respawning
|
||||
* itself. The flag makes the nested run take the bundle path and return.
|
||||
* * It is asynchronous, not `spawnSync` with a timeout. `spawnSync` reaps the
|
||||
* child it started, and its timeout kills only that child: for a `.cmd` shim
|
||||
* that means cmd.exe dies while the REA inside it keeps running and holds
|
||||
* the pipes. Killing the TREE is only possible while the parent is alive,
|
||||
* which means owning the wait ourselves.
|
||||
*/
|
||||
function probeRea(file, { platform = process.platform, timeout = PROBE_TIMEOUT_MS } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
// `existsSync` says nothing about the shape: a DIRECTORY named `rea.cmd`
|
||||
// exists, fails to spawn, and would be reported as a broken install instead
|
||||
// of as a name collision. Only a regular file is a candidate.
|
||||
if (!isFileSync(file)) {
|
||||
resolve({ ok: false, reason: "not a file" });
|
||||
return;
|
||||
}
|
||||
const useShell = platform === "win32" && /\.(cmd|bat)$/i.test(file);
|
||||
// `REA_BUNDLE_AUTO_PROVISION=0` is part of the same guard: if the candidate
|
||||
// turns out to be a wrapper around THIS shim, the nested run must not start
|
||||
// an 800 MB provisioning job just because a probe asked it for a version.
|
||||
const env = { ...process.env, REA_BUNDLE_DELEGATED: "1", REA_BUNDLE_AUTO_PROVISION: "0" };
|
||||
// `cmd /d /s /c "<line>"`: Windows only. Two details are load-bearing.
|
||||
// `/s` strips the first and last quote, so the whole line is quoted; and the
|
||||
// arguments are passed VERBATIM, because Node otherwise backslash-escapes the
|
||||
// quotes it sees and cmd.exe does not understand `\"` — every candidate then
|
||||
// answers "exit 1", which reads as "no REA on this machine".
|
||||
const child = useShell
|
||||
? spawn(comspec(), ["/d", "/s", "/c", `"${[cmdQuote(file), "--version"].join(" ")}"`], {
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: true,
|
||||
})
|
||||
: spawn(file, ["--version"], { env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
||||
let output = "";
|
||||
let settled = false;
|
||||
let timer = null;
|
||||
const finish = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
resolve(result);
|
||||
};
|
||||
timer = setTimeout(() => {
|
||||
killTree(child.pid, platform);
|
||||
finish({ ok: false, reason: "timeout" });
|
||||
}, timeout);
|
||||
child.stdout.on("data", (chunk) => { output += String(chunk); });
|
||||
child.stderr.on("data", (chunk) => { output += String(chunk); });
|
||||
child.on("error", () => finish({ ok: false, reason: "spawn-failed" }));
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
finish({ ok: false, reason: `exit ${String(code)}` });
|
||||
return;
|
||||
}
|
||||
// A REA install prints its version. Anything else (a shim that swallowed
|
||||
// the flag, an unrelated program that shares the name) is not a usable CLI.
|
||||
const version = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/.exec(output.trim())?.[1] ?? null;
|
||||
finish(version === null
|
||||
? { ok: false, reason: "no version in --version output" }
|
||||
: { ok: true, version });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The system REA this machine already has, or why it was rejected.
|
||||
*
|
||||
* Candidates inside this bundle or this checkout are skipped: a REA that the
|
||||
* provisioner installed is the `bundle` mode, and delegating to it would be a
|
||||
* loop through a path the bundle already covers.
|
||||
*/
|
||||
async function detectSystemRea({ env = process.env, platform = process.platform, root, pluginRoot = PLUGIN_ROOT } = {}) {
|
||||
const rejected = [];
|
||||
let profiled = 0;
|
||||
const explicit = (env.REA_BUNDLE_SYSTEM_REA ?? "").trim();
|
||||
// Probing costs a process start per candidate, and a row has to answer before
|
||||
// its tools exist. The first existing entry is what the shell would run, so a
|
||||
// couple of fallbacks is plenty; a machine whose first three `rea`s are all
|
||||
// broken is a repair job, not something this decision should keep paying for.
|
||||
const MAX_PROBES = 3;
|
||||
for (const candidate of systemReaCandidates({ env, platform })) {
|
||||
if (isInside(candidate, root) || isInside(candidate, pluginRoot)) {
|
||||
// A REA the provisioner installed is `bundle` mode; delegating to it would
|
||||
// be a loop back into the thing this decision is choosing between.
|
||||
if (rejected.length < 8) rejected.push({ file: candidate, reason: "inside the bundle" });
|
||||
continue;
|
||||
}
|
||||
// The candidate list is PATH x every executable name: one missing name per
|
||||
// directory is normal and is not a finding. Only a regular file that EXISTS
|
||||
// and then fails the probe is worth reporting.
|
||||
try {
|
||||
if (!statSync(candidate).isFile()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (profiled >= MAX_PROBES) {
|
||||
if (rejected.length < 8) rejected.push({ file: candidate, reason: "not probed (budget)" });
|
||||
continue;
|
||||
}
|
||||
profiled += 1;
|
||||
const probe = await probeRea(candidate, { platform });
|
||||
if (probe.ok) return { found: true, file: candidate, version: probe.version };
|
||||
if (rejected.length < 8) rejected.push({ file: candidate, reason: probe.reason });
|
||||
}
|
||||
// Say what actually happened, not what a flag says: an explicit path that was
|
||||
// skipped because it lives inside the bundle is a different answer from one
|
||||
// that does not exist.
|
||||
const explicitPath = explicit === "" ? null : path.resolve(explicit);
|
||||
const explicitRejection = explicitPath === null
|
||||
? null
|
||||
: (rejected.find(entry => entry.file === explicitPath) ?? null);
|
||||
let reason;
|
||||
if (explicitPath !== null && explicitRejection !== null) {
|
||||
reason = `REA_BUNDLE_SYSTEM_REA was rejected (${explicitRejection.reason}): ${explicitPath}`;
|
||||
} else if (explicitPath !== null && !isFileSync(explicitPath)) {
|
||||
reason = existsSync(explicitPath)
|
||||
? `REA_BUNDLE_SYSTEM_REA is not a file: ${explicitPath}`
|
||||
: `REA_BUNDLE_SYSTEM_REA does not exist: ${explicitPath}`;
|
||||
} else {
|
||||
reason = profiled === 0 ? "no rea on PATH" : "no usable rea on PATH";
|
||||
}
|
||||
return { found: false, reason, rejected };
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric dotted comparison (6.4.0 vs 6.5.0). null when either side is not a
|
||||
* plain x.y.z — a prerelease or a vendor tag is not something this decision
|
||||
* should pretend to order.
|
||||
*/
|
||||
function compareDotted(a, b) {
|
||||
const parse = (value) => {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(value ?? "").trim());
|
||||
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
||||
};
|
||||
const left = parse(a);
|
||||
const right = parse(b);
|
||||
if (left === null || right === null) return null;
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (left[index] !== right[index]) return left[index] < right[index] ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which REA this invocation should run.
|
||||
*
|
||||
* `auto` prefers the system install, but not blindly: a global REA older than
|
||||
* the one this bundle provisioned would silently take the tool count back
|
||||
* (6.4.0 ships 170 tools, 6.5.0 ships 207), so the newer of the two wins and
|
||||
* the older is only used when asked for by name. `bundle` and `system` are
|
||||
* honoured literally — `system` fails loudly rather than provisioning 800 MB
|
||||
* behind an operator who asked for their own REA.
|
||||
*/
|
||||
async function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false, env = process.env, platform = process.platform } = {}) {
|
||||
const preference = launchPreference(env);
|
||||
if (preference.value === "bundle") {
|
||||
return { preference: preference.value, invalid: preference.invalid, mode: "bundle", reason: "pinned by REA_BUNDLE_LAUNCH=bundle" };
|
||||
}
|
||||
// A delegated child that ends up back in this shim (a wrapper script named
|
||||
// `rea` that calls us, a PATH that resolves to a launcher) would recurse
|
||||
// forever: the probe cannot break it, because the grandchild starts a fresh
|
||||
// one. One flag, written into the child's environment, ends the loop — the
|
||||
// nested run takes the bundle path, which either works or fails loudly.
|
||||
if ((env.REA_BUNDLE_DELEGATED ?? "") === "1") {
|
||||
return {
|
||||
preference: preference.value,
|
||||
invalid: preference.invalid,
|
||||
mode: "bundle",
|
||||
reason: "REA_BUNDLE_DELEGATED=1: already running under a delegated system REA, refusing to delegate again",
|
||||
};
|
||||
}
|
||||
const system = await detectSystemRea({ env, platform, root });
|
||||
if (system.found) {
|
||||
const order = bundleVersion === null ? null : compareDotted(system.version, bundleVersion);
|
||||
const pinned = preference.value === "system";
|
||||
// No provisioned build at all: there is nothing to compare against, and the
|
||||
// machine's own REA is exactly what this mode exists to use.
|
||||
if (!bundleProvisioned || pinned || (order !== null && order >= 0)) {
|
||||
return {
|
||||
preference: preference.value,
|
||||
invalid: preference.invalid,
|
||||
mode: "system",
|
||||
reason: pinned
|
||||
? `pinned by REA_BUNDLE_LAUNCH=system (system ${system.version}, bundle ${bundleVersion ?? "none"})`
|
||||
: !bundleProvisioned
|
||||
? `a working REA is on PATH (${system.version}) and this machine has no provisioned bundle`
|
||||
: `the REA on PATH (${system.version}) is at least as new as the provisioned one (${bundleVersion})`,
|
||||
system,
|
||||
};
|
||||
}
|
||||
return {
|
||||
preference: preference.value,
|
||||
invalid: preference.invalid,
|
||||
mode: "bundle",
|
||||
reason: order === null
|
||||
? `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`
|
||||
: `the REA on PATH (${system.version}) is older than the provisioned one (${bundleVersion}); set REA_BUNDLE_LAUNCH=system to use it anyway`,
|
||||
system,
|
||||
};
|
||||
}
|
||||
if (preference.value === "system") {
|
||||
return {
|
||||
preference: preference.value,
|
||||
invalid: preference.invalid,
|
||||
mode: "system",
|
||||
reason: `REA_BUNDLE_LAUNCH=system, but no usable REA was found (${system.reason})`,
|
||||
system,
|
||||
unusable: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
preference: preference.value,
|
||||
invalid: preference.invalid,
|
||||
mode: "bundle",
|
||||
reason: `no usable system REA (${system.reason})`,
|
||||
system,
|
||||
};
|
||||
}
|
||||
|
||||
/** Launch the system REA exactly the way the 1.x row did. */
|
||||
function launchSystemRea(system, argv) {
|
||||
const args = argv.length > 0 ? argv : ["mcp"];
|
||||
const useShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(system.file);
|
||||
// `REA_BUNDLE_DELEGATED` marks the child as "already the system REA". Without
|
||||
// it a wrapper on PATH that calls this shim would delegate to itself forever.
|
||||
const env = { ...process.env, REA_BUNDLE_DELEGATED: "1" };
|
||||
// Only the PATH is quoted. Quoting the arguments too would hand them to a
|
||||
// `.cmd`'s `%*` WITH their quotes (`"mcp"` instead of `mcp`) — measured — which
|
||||
// is a different command line than the 1.x row produced. The row passes `mcp`
|
||||
// and nothing else, so plain tokens are the contract here; an argument that
|
||||
// needs shell quoting is not something this path supports (cmd would also
|
||||
// expand `%NAME%` inside it).
|
||||
const child = useShell
|
||||
? spawn(comspec(), ["/d", "/s", "/c", `"${[cmdQuote(system.file), ...args].join(" ")}"`], {
|
||||
env,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: true,
|
||||
})
|
||||
: spawn(system.file, args, { env, stdio: "inherit", windowsHide: true });
|
||||
// No signal forwarding on Windows, deliberately. `child.kill()` there is
|
||||
// TerminateProcess on the cmd.exe WRAPPER — the REA inside it survives, still
|
||||
// holding the inherited pipes — and a console Ctrl+C is delivered to the whole
|
||||
// process group anyway, so the child already has it. A hard kill of this shim
|
||||
// (what a supervisor or Task Manager does) runs no JavaScript at all, so the
|
||||
// wrapper must not pretend otherwise: on Windows the delegated REA outlives a
|
||||
// forcibly killed shim. On POSIX the child is in this process's group and gets
|
||||
// the terminal's signal directly; `process.exit` below then ends the shim.
|
||||
child.on("error", (error) => {
|
||||
process.stderr.write(`rea-shim: could not start ${system.file}\n ${String(error && error.message)}\n`);
|
||||
process.exit(EX_CONFIG);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
|
||||
process.exit(128 + (signals[signal] ?? 0));
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mirror problem.
|
||||
*
|
||||
* The published row launches `<bundle>/bin/rea-shim.mjs` — a COPY made by
|
||||
* `setup.mjs`. Upgrading the plugin from the market replaces the package but runs
|
||||
* no setup, so the copy keeps executing the code it was made from: every fix in
|
||||
* a new plugin version reaches a user only if they happen to re-run setup. That
|
||||
* is invisible (the old copy keeps working) and it is exactly the wrong
|
||||
* direction for a safety fix.
|
||||
*
|
||||
* The repair has to live in the copy, because the copy is what runs. So the
|
||||
* copy compares itself with the plugin's own `bin/rea-shim.mjs`, recorded in
|
||||
* `mirror.json` by setup, and re-execs that file when the two differ. One hop,
|
||||
* guarded by an environment flag, and only from mirror to checkout — the
|
||||
* checkout copy has no `mirror.json` and would find itself anyway.
|
||||
*
|
||||
* A plugin version older than 2.0.8 cannot do this for its own upgrades: there
|
||||
* is nothing in it to run. `--shim-status` reports `mirror.stale` so the
|
||||
* condition is at least visible, and `check-updates.ps1` says to re-run setup.
|
||||
*/
|
||||
function sha256(file) {
|
||||
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
||||
}
|
||||
|
||||
/** Where the installed plugin lives according to setup's record, or null. */
|
||||
function pluginCheckout(root) {
|
||||
try {
|
||||
const record = readJson(path.join(root, "mirror.json"));
|
||||
const dir = record?.pluginRoot;
|
||||
if (typeof dir !== "string" || dir.trim() === "") return null;
|
||||
const shim = path.join(dir, "bin", "rea-shim.mjs");
|
||||
return isFileSync(shim) ? shim : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this copy is behind the plugin it was mirrored from.
|
||||
*
|
||||
* `self` is this file, `source` the checkout's. A checkout copy (it ships
|
||||
* `cordis.patch.yml`) is never stale against itself; a mirror with no record, or
|
||||
* whose recorded checkout is gone, is not re-pointed anywhere.
|
||||
*/
|
||||
function describeMirror(root, { self = fileURLToPath(import.meta.url) } = {}) {
|
||||
let record = null;
|
||||
try {
|
||||
record = readJson(path.join(root, "mirror.json"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const source = pluginCheckout(root);
|
||||
let stale = false;
|
||||
let reason = "no usable plugin checkout in mirror.json";
|
||||
if (source !== null) {
|
||||
if (path.resolve(source) === path.resolve(self)) {
|
||||
reason = "this copy is the plugin checkout";
|
||||
} else {
|
||||
try {
|
||||
stale = sha256(source) !== sha256(self);
|
||||
reason = stale ? "the plugin ships a different launcher than this mirrored copy" : "this copy matches the plugin";
|
||||
} catch {
|
||||
reason = "could not compare the two launchers";
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
pluginRoot: typeof record?.pluginRoot === "string" ? record.pluginRoot : null,
|
||||
mirroredAt: typeof record?.mirroredAt === "string" ? record.mirroredAt : null,
|
||||
stale,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the checkout's launcher instead of this copy, once.
|
||||
*
|
||||
* The promise resolves ONLY when the re-exec did not take over, and the caller
|
||||
* must then continue with its own launch path. While the child is alive the
|
||||
* promise stays pending and the child's own exit becomes this process's exit —
|
||||
* settling it at the `spawn` event was wrong: the event fires before the child
|
||||
* has done anything, so a later "it actually failed" could no longer be seen, and
|
||||
* the two handlers disagreed about who owns the exit code. That mistake made the
|
||||
* file-vanishes-mid-flight case exit 0 silently.
|
||||
*
|
||||
* The first version of this printed "continuing with the mirrored copy" on
|
||||
* `error` and returned: nothing continued, no `exit` event followed a failed
|
||||
* spawn, the loop drained, and the row saw exit 0 with no tools and no message.
|
||||
* A fallback that is announced but not performed is worse than none, because it
|
||||
* hides the failure it was written for.
|
||||
*/
|
||||
function reexecFromCheckout(source, argv) {
|
||||
const child = spawn(process.execPath, [source, ...argv], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, REA_BUNDLE_SHIM_REEXEC: "1" },
|
||||
windowsHide: true,
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
let started = false;
|
||||
child.once("spawn", () => {
|
||||
started = true;
|
||||
});
|
||||
child.once("error", () => {
|
||||
if (started) return;
|
||||
resolve(false);
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
if (!started) {
|
||||
// Exit without a spawn event: the child never ran. Let the caller fall
|
||||
// back rather than exiting 0 on its behalf.
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (signal) {
|
||||
const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
|
||||
process.exit(128 + (signals[signal] ?? 0));
|
||||
}
|
||||
// A non-zero exit is normally the child's own answer and must be passed
|
||||
// through untouched. One case is not the child's answer: an upgrade in
|
||||
// flight replaced the file BETWEEN the check and node reading it, so node
|
||||
// starts fine and dies with MODULE_NOT_FOUND. Passing that on turns a
|
||||
// millisecond race into a dead row; if the source is gone by now, fall back
|
||||
// to this copy instead. The window is small, the cost of covering it is one
|
||||
// stat, and the alternative is a row that reports failure for a file that
|
||||
// still exists a directory up.
|
||||
if ((code ?? 0) !== 0 && !isFileSync(source)) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage text, also what a bare invocation prints.
|
||||
*
|
||||
@@ -375,19 +997,23 @@ function childEnvironment(config) {
|
||||
*/
|
||||
function usage() {
|
||||
return [
|
||||
"rea-shim: launches the vendored REA MCP server for a DSH MCP row.",
|
||||
"rea-shim: launches a REA MCP server for a DSH MCP row.",
|
||||
"",
|
||||
" node bin/rea-shim.mjs [rea arguments...] (normal use: 'mcp')",
|
||||
" node bin/rea-shim.mjs --shim-status provisioning status, no launch",
|
||||
" node bin/rea-shim.mjs --shim-status launch decision and provisioning status, no launch",
|
||||
" node bin/rea-shim.mjs --shim-print-env the environment it would export",
|
||||
"",
|
||||
` bundle root: ${bundleRoot()}`,
|
||||
` plugin root: ${PLUGIN_ROOT}`,
|
||||
"",
|
||||
" REA_BUNDLE_LAUNCH=auto|system|bundle (default auto: use the REA already",
|
||||
" on PATH when it answers --version, otherwise the provisioned bundle)",
|
||||
" REA_BUNDLE_SYSTEM_REA=<path> probe this binary instead of PATH",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function main() {
|
||||
async function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
if (argv.includes("--shim-help") || argv.length === 0) {
|
||||
@@ -396,11 +1022,45 @@ function main() {
|
||||
}
|
||||
|
||||
const root = bundleRoot();
|
||||
// Self-healing before anything else: a mirrored copy that predates the plugin
|
||||
// it was made from would otherwise keep running that older code forever, and a
|
||||
// market upgrade never re-runs setup. One hop, guarded, and never for the
|
||||
// status query — there the staleness is reported instead.
|
||||
const mirror = describeMirror(root);
|
||||
// A CHECKOUT copy is the source, not a mirror: hopping from it into whatever
|
||||
// the bundle's record names would silently run a different plugin — a developer
|
||||
// testing a local edit would be testing the installed one instead. The
|
||||
// discriminator is the one bundleRoot() already uses: only the checkout ships
|
||||
// cordis.patch.yml.
|
||||
const isCheckout = existsSync(path.join(PLUGIN_ROOT, "cordis.patch.yml"));
|
||||
if (!isCheckout && mirror !== null && mirror.stale && process.env.REA_BUNDLE_SHIM_REEXEC !== "1" && !argv.includes("--shim-status")) {
|
||||
const source = pluginCheckout(root);
|
||||
if (source !== null) {
|
||||
// It resolves only when the re-exec did not take over; while the child
|
||||
// runs, this process is parked and the child's exit is the exit.
|
||||
await reexecFromCheckout(source, argv);
|
||||
// The re-exec did not take over: a spawn failure, a child that exited
|
||||
// before its spawn event, or one that started and found its own file gone
|
||||
// (see the exit handler). Say so and run this copy for real — the point of
|
||||
// the fallback.
|
||||
process.stderr.write(`rea-shim: could not run ${source}; running the mirrored copy instead.\n`);
|
||||
}
|
||||
}
|
||||
const loaded = readBundleConfig(root);
|
||||
// Decide what to run before asking whether the bundle is provisioned: a
|
||||
// machine with its own working REA must not be sent into an ~800 MB
|
||||
// provisioning run just because this bundle has no state yet.
|
||||
const bundleVersion = loaded.ok ? (loaded.config.versions?.reaAgents ?? null) : null;
|
||||
const launch = await resolveLaunch({ root, bundleVersion, bundleProvisioned: loaded.ok });
|
||||
if (launch.invalid !== null) {
|
||||
process.stderr.write(
|
||||
`rea-shim: REA_BUNDLE_LAUNCH="${launch.invalid}" is not one of ${LAUNCH_MODES.join("/")}; using auto.\n`,
|
||||
);
|
||||
}
|
||||
// Upstream can add a second row for the same serverName; the shim cannot stop
|
||||
// DSH from loading both rows, but saying so is better than doubled tools with
|
||||
// no stated cause. The status report carries the same fact for diagnostics.
|
||||
const duplicate = foreignReaRegistration();
|
||||
const duplicate = foreignReaRegistration(root);
|
||||
if (duplicate && !argv.includes("--shim-status")) {
|
||||
process.stderr.write(
|
||||
`\n dsh-plugin-rea (bundle): duplicate REA registration.\n` +
|
||||
@@ -417,7 +1077,22 @@ function main() {
|
||||
provisioned: loaded.ok,
|
||||
reason: loaded.ok ? undefined : loaded.code,
|
||||
autoProvision: process.env.REA_BUNDLE_AUTO_PROVISION ?? "(default)",
|
||||
launch: {
|
||||
preference: launch.preference,
|
||||
mode: launch.mode,
|
||||
reason: launch.reason,
|
||||
// Reported, not implied: `mode: "system"` with `unusable: true` is a
|
||||
// request that cannot be met, and a caller reading only the mode would
|
||||
// draw the opposite conclusion.
|
||||
unusable: launch.unusable === true,
|
||||
systemRea: launch.system?.found ? { file: launch.system.file, version: launch.system.version } : null,
|
||||
},
|
||||
};
|
||||
if (launch.system?.rejected?.length) report.launch.rejected = launch.system.rejected;
|
||||
// A mirrored copy that lags the plugin keeps working, which is exactly why
|
||||
// nobody notices it: the row runs old code until the mirror is refreshed.
|
||||
// Reported here so `--shim-status` answers "am I running what I installed?".
|
||||
if (mirror !== null) report.mirror = mirror;
|
||||
if (duplicate) report.duplicateRegistration = duplicate;
|
||||
if (loaded.ok) {
|
||||
report.paths = loaded.config.paths ?? {};
|
||||
@@ -425,7 +1100,33 @@ function main() {
|
||||
report.rea = resolveRea(loaded.config, root);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
|
||||
return loaded.ok ? 0 : EX_CONFIG;
|
||||
// The row works either way round — a system launch needs no bundle, a bundle
|
||||
// launch needs a provisioned one — but a PINNED system launch with nothing to
|
||||
// launch is a failure whether or not the bundle happens to be provisioned,
|
||||
// and reporting 0 there is how a broken row looks healthy until it starts.
|
||||
const usable = launch.mode === "system" ? launch.unusable !== true : loaded.ok;
|
||||
return usable ? 0 : EX_CONFIG;
|
||||
}
|
||||
|
||||
if (launch.mode === "system") {
|
||||
if (launch.unusable) {
|
||||
process.stderr.write(
|
||||
`\n dsh-plugin-rea (bundle): REA_BUNDLE_LAUNCH=system was requested, but no usable REA was found.\n` +
|
||||
` ${launch.reason}\n\n` +
|
||||
` Install REA (\`npm install -g rea-agents\`), point REA_BUNDLE_SYSTEM_REA at it,\n` +
|
||||
` or unset REA_BUNDLE_LAUNCH to use the provisioned bundle instead.\n`,
|
||||
);
|
||||
return EX_CONFIG;
|
||||
}
|
||||
if (argv.includes("--shim-print-env")) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ launch: "system", rea: launch.system.file, version: launch.system.version }, null, 2) + "\n",
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
// The 1.x contract, from the 2.x row: the machine's own REA, its own Node,
|
||||
// its own PATH. Nothing is provisioned and no bundle state is required.
|
||||
return launchSystemRea(launch.system, argv);
|
||||
}
|
||||
|
||||
if (!loaded.ok) {
|
||||
@@ -457,7 +1158,7 @@ function main() {
|
||||
} else if (provisioning.reason === "spawn-failed" || provisioning.reason === "unwritable") {
|
||||
process.stderr.write(
|
||||
`\n dsh-plugin-rea (bundle): provisioning could not be started (${provisioning.reason}).\n` +
|
||||
` Run it by hand:\n node "${path.join(root, "scripts", "setup.mjs")}"\n`,
|
||||
` Run it by hand:\n node "${path.join(root, "scripts", "setup.mjs")}" --root "${root}"\n`,
|
||||
);
|
||||
}
|
||||
return fail(message, loaded.detail);
|
||||
@@ -504,5 +1205,8 @@ function main() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = main();
|
||||
// The launch decision awaits a probe, so `main` is async; the status it returns
|
||||
// is this process's exit code, and a launch path resolves to null while the
|
||||
// child (already wired to `process.exit`) keeps the loop alive.
|
||||
const status = await main();
|
||||
if (typeof status === "number") process.exit(status);
|
||||
+33
-5
@@ -73,9 +73,13 @@ function Test-NodeEngines([string]$node, [string]$engines) {
|
||||
# bundle.json:把全局路径当默认是 1.x 的答案,在 2.x 机器上会让这一节指向错误的
|
||||
# 对象("全局包丢了"),并给出错误的升级动作(npm i -g)。
|
||||
$bundleRootEarly = $env:REA_BUNDLE_ROOT
|
||||
# Resolve the home unconditionally: the profile section below needs it whether or
|
||||
# not REA_BUNDLE_ROOT was set, and computing it only inside the fallback branch
|
||||
# left it $null — `Join-Path $null 'profiles'` throws, so the section degraded to
|
||||
# "no profile" on exactly the machines that use the override.
|
||||
$homeEarly = $env:DSH_HOME
|
||||
if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
|
||||
if (-not $bundleRootEarly) {
|
||||
$homeEarly = $env:DSH_HOME
|
||||
if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
|
||||
$bundleRootEarly = Join-Path $homeEarly 'plugins\rea-bundle'
|
||||
}
|
||||
$bundleState = $null
|
||||
@@ -106,8 +110,12 @@ $reaFromBundle = [bool]($bundleReaPackageDir -and $ReaPackageDir -eq $bundleReaP
|
||||
if (-not $ProfileDir) {
|
||||
if ($env:DSH_PROFILE_DIR) { $ProfileDir = $env:DSH_PROFILE_DIR }
|
||||
else {
|
||||
$profiles = @(Get-ChildItem (Join-Path $env:USERPROFILE '.dsh\profiles') -Directory -ErrorAction SilentlyContinue)
|
||||
$ProfileDir = if ($profiles.Count -gt 0) { $profiles[0].FullName } else { Join-Path $env:USERPROFILE '.dsh\profiles\desktop' }
|
||||
# Same home rule the bundle uses everywhere else: DSH_HOME, then ~/.dsh.
|
||||
# Hardcoding $env:USERPROFILE here was a second algorithm in the same script,
|
||||
# so on a custom DSH_HOME the profile section reported "no profile found"
|
||||
# while the rest of the run had already resolved that home correctly.
|
||||
$profiles = @(Get-ChildItem (Join-Path $homeEarly 'profiles') -Directory -ErrorAction SilentlyContinue)
|
||||
$ProfileDir = if ($profiles.Count -gt 0) { $profiles[0].FullName } else { Join-Path $homeEarly 'profiles\desktop' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +348,27 @@ if (-not (Test-Path $manifestPath)) {
|
||||
Warn ("TOFU(无厂商校验和,取首次实测值): " + (@($state.unverifiedChecksums) -join ', '))
|
||||
}
|
||||
$mirrored = Join-Path $bundleRoot 'bin\rea-shim.mjs'
|
||||
if (Test-Path $mirrored) { Ok 'MCP 行指向的 shim 已就位' }
|
||||
if (Test-Path $mirrored) {
|
||||
Ok 'MCP 行指向的 shim 已就位'
|
||||
# The row launches this COPY, so a plugin upgrade that does not re-run
|
||||
# setup leaves the old launcher in place — visible only as "the new
|
||||
# version behaves like the old one". Compare it with the checkout the
|
||||
# mirror record names.
|
||||
$mirrorRecord = Join-Path $bundleRoot 'mirror.json'
|
||||
if (Test-Path $mirrorRecord) {
|
||||
try {
|
||||
$record = Get-Content $mirrorRecord -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$pluginShim = if ($record.pluginRoot) { Join-Path $record.pluginRoot 'bin\rea-shim.mjs' } else { $null }
|
||||
if ($pluginShim -and (Test-Path $pluginShim)) {
|
||||
$same = (Get-FileHash $mirrored -Algorithm SHA256).Hash -eq (Get-FileHash $pluginShim -Algorithm SHA256).Hash
|
||||
if ($same) { Ok '镜像 shim 与插件一致(行跑的就是这个版本)' }
|
||||
else { Warn "镜像 shim 落后于插件($($record.pluginRoot))—— 行仍在跑旧副本;重跑 node scripts/setup.mjs 让它换成新版本" }
|
||||
} else {
|
||||
Warn "mirror.json 指向的插件目录已不存在($($record.pluginRoot))—— 无法比对镜像 shim 是否最新"
|
||||
}
|
||||
} catch { Warn "mirror.json 解析失败: $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
else { Bad "缺少 MCP 行指向的 shim: $mirrored —— 重跑 setup --reuse" }
|
||||
# 镜像副本的 setup 用它自己所在的位置推 plugin root,所以 manifest 必须
|
||||
# 一起镜像。旧版镜像缺这个文件:auto-provision 会以 ENOENT 崩掉且不可见,
|
||||
|
||||
+24
-1
@@ -83,7 +83,10 @@ through `failOnStartupError: false` without breaking the harness.
|
||||
```
|
||||
dsh starts
|
||||
└─ MCP row: node <bundle root>/bin/rea-shim.mjs (setup mirrors it there)
|
||||
├─ bundle.json present & components verify → exec REA (normal path)
|
||||
├─ mirrored copy differs from the installed plugin → re-exec that copy, once
|
||||
├─ a working `rea` on PATH is at least as new as the bundle
|
||||
│ → exec it (the 1.x contract)
|
||||
├─ bundle.json present & components verify → exec the provisioned REA
|
||||
├─ missing, auto-provision allowed → start setup detached, print
|
||||
│ remediation, exit non-zero
|
||||
└─ missing, auto-provision refused → print exact command, exit
|
||||
@@ -129,6 +132,26 @@ Two notes on stages that exist because their absence was a bug:
|
||||
harness's own dialect, `args[0]` became `{"[object Object]": "..."}` and the
|
||||
row could not launch at all. Any future expression with a ternary needs the
|
||||
quotes; `audit-manifest.mjs` rejects the bare form.
|
||||
- **Launch mode (2.0.7).** 2.x exists to supply a runtime a machine lacks, so a
|
||||
machine that already has one should not be provisioned. Before touching bundle
|
||||
state the shim asks whether `rea` is on PATH and answers `--version`; if it
|
||||
does, and its version is not older than `bundle.json`'s `versions.reaAgents`,
|
||||
the row execs that install exactly as the 1.x row did. The probe is asynchronous
|
||||
and bounded (8 s: a `.cmd` shim needs ~2.4 s), runs with
|
||||
`REA_BUNDLE_DELEGATED=1` and `REA_BUNDLE_AUTO_PROVISION=0` so a candidate that
|
||||
wraps this shim cannot recurse or start an 800 MB run, and kills its whole tree
|
||||
on timeout. `REA_BUNDLE_LAUNCH=auto|system|bundle` overrides the choice; an
|
||||
unorderable pair (a prerelease on either side) keeps the bundle.
|
||||
- **Self-healing mirror (2.0.8).** The row launches a COPY, and a market upgrade
|
||||
runs no setup, so without this the copy keeps executing the code it was made
|
||||
from — invisibly, because it still works. The copy therefore compares its own
|
||||
hash with `<mirror.json.pluginRoot>/bin/rea-shim.mjs` and re-execs that file
|
||||
when they differ: one hop, guarded by `REA_BUNDLE_SHIM_REEXEC`, and only from a
|
||||
mirror (a checkout ships `cordis.patch.yml`, so it never hops into another
|
||||
copy). `mirror.json` carries `shimSha256`, `pluginVersion` and `dshHome` —
|
||||
the last one because the harness strips `DSH_*` from an MCP child, so the
|
||||
duplicate-registration check would otherwise read `~/.dsh` on a machine with a
|
||||
custom home. `--shim-status` reports `mirror.stale` instead of hopping.
|
||||
- **3 (fetch).** Route order is deliberate and environment-dependent: direct
|
||||
first, then the system proxy, and on Windows the .NET route ahead of both
|
||||
because it follows the GitHub release-asset redirect that the Node routes
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Package entry — the smallest thing that makes this bundle a *loadable* package.
|
||||
*
|
||||
* Everything this plugin DOES lives in `cordis.patch.yml`: it inserts one row of
|
||||
* `@deepseek-ai/dsh-mcp-client` that launches `bin/rea-shim.mjs`, and that row is
|
||||
* what the harness loads. This file registers nothing, starts nothing and
|
||||
* provisions nothing.
|
||||
*
|
||||
* It exists because a package that declares no `main`/`exports` is judged by its
|
||||
* consumers as "has no entry file", whether or not the loader ever needs one:
|
||||
*
|
||||
* - The market's update path (`hasLoadableEntry` in dshmarket) asks that
|
||||
* question on every update and, when the answer is false, treats the new
|
||||
* build as broken and rolls back. A bundle with no entry of its own is
|
||||
* *supposed* to survive the test through the packages it mounts — but the
|
||||
* package this one mounts (`@deepseek-ai/dsh-mcp-client`) is part of the
|
||||
* harness build, not of `node_modules`, so every location that test probes
|
||||
* comes back empty. Measured on 2026-10-18: updating an installed, working
|
||||
* 2.0.1 to 2.0.4 failed three times with "updated build has no loadable
|
||||
* entry", and the rollback was then reported as unverifiable for the same
|
||||
* reason.
|
||||
* - `npm pack` metadata and `import 'dsh-plugin-rea'` both resolve `main`;
|
||||
* absent, both fall back to `index.js`, which this repository never shipped.
|
||||
*
|
||||
* So this is a real module with a real Cordis plugin shape rather than a stub
|
||||
* for a checker: if the harness ever does load this package as a plugin, it
|
||||
* loads cleanly and does nothing.
|
||||
*/
|
||||
|
||||
/** Cordis plugin name. Also the package name, which is what the loader expects. */
|
||||
export const name = 'dsh-plugin-rea'
|
||||
|
||||
/**
|
||||
* Intentionally empty.
|
||||
*
|
||||
* The row that does the work is inserted by the bundle patch, not by this
|
||||
* function: registering the MCP client from here would create a second
|
||||
* `serverName: rea` entry, which the harness refuses as a duplicate (the same
|
||||
* collision `bin/rea-shim.mjs` warns about for upstream's `rea setup --client
|
||||
* dsh`). Doing nothing is the correct behaviour, and the function present keeps
|
||||
* this a valid Cordis plugin instead of an arbitrary module.
|
||||
*/
|
||||
export function apply() {}
|
||||
+7
-3
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "dsh-plugin-rea",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.9",
|
||||
"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。",
|
||||
"type": "module",
|
||||
"main": "./index.mjs",
|
||||
"license": "MIT",
|
||||
"author": "czcdr",
|
||||
"homepage": "https://github.com/czcdr/dsh-plugin-rea",
|
||||
@@ -33,6 +34,7 @@
|
||||
},
|
||||
"files": [
|
||||
"cordis.patch.yml",
|
||||
"index.mjs",
|
||||
"bin/",
|
||||
"scripts/",
|
||||
"manifest/",
|
||||
@@ -56,10 +58,12 @@
|
||||
"bundle:status": "node ./bin/rea-shim.mjs --shim-status",
|
||||
"bundle:dry-run": "node ./scripts/setup.mjs --reuse --dry-run",
|
||||
"bundle:audit": "node ./scripts/audit-manifest.mjs",
|
||||
"bundle:test": "node --test ./scripts/setup.test.mjs"
|
||||
"bundle:test": "node --test ./scripts/setup.test.mjs",
|
||||
"bundle:host-compat": "node ./scripts/host-compat.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.19.0 || ^24.11.0 || >=26.0.0"
|
||||
"node": "^22.19.0 || ^24.11.0 || >=26.0.0",
|
||||
"dsh": ">=0.1.7-rc.2 <0.3.0-0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* host-compat — does the range this package declares cover the DSH releases it
|
||||
* claims to support?
|
||||
*
|
||||
* `package.json` carries `engines.dsh`, and that single string is what the
|
||||
* plugin market reads to say "compatible" instead of "unknown" (the market's
|
||||
* `deriveHostCompatibility` treats `engines.dsh` as a host declaration and
|
||||
* checks it against the running host version). A declaration is therefore load
|
||||
* bearing twice over: it decides whether the market HIDES this plugin from users
|
||||
* on a matching host, and it decides whether users on a host we never tested are
|
||||
* told it works.
|
||||
*
|
||||
* The two ways it goes wrong are silent. A typo makes the range unparseable, and
|
||||
* the market then reports `unknown` — a plugin that looks undeclared rather than
|
||||
* mis-declared. A floor that drifted (say `>=0.2.0`) silently drops hosts the
|
||||
* README still promises. Neither shows up as a test failure unless something
|
||||
* compares the STRING to the release list, which is what this file does.
|
||||
*
|
||||
* `HOST_RELEASES` is the dsh release line this package is built against, newest
|
||||
* first, as published on github.com/deepseek-ai/deepseek-harness. It is a
|
||||
* literal list rather than a GitHub query on purpose: a check that needs the
|
||||
* network fails on a plane and passes on a total outage, which is the wrong
|
||||
* shape for a gate. When a release is added upstream, add it here too — that
|
||||
* edit is the reminder to re-check the row against the new host.
|
||||
*
|
||||
* SEMANTICS: this file evaluates the declaration the way `includePrerelease:
|
||||
* true` does, and that choice is not stylistic. Both consumers of `engines.dsh`
|
||||
* in this ecosystem pass that option — dshmarket's discovery
|
||||
* (`satisfiesRange(host, declared, { includePrerelease: true })` in
|
||||
* `deriveHostCompatibility`) and the host's own peer gate — so a plain semver
|
||||
* reading, where `0.2.0-rc.2` does NOT satisfy `>=0.1.7-rc.2 <0.3.0-0` (a
|
||||
* prerelease must be named by a comparator on its own tuple), would disagree
|
||||
* with the verdict users actually see. The tests pin the option's effect.
|
||||
*
|
||||
* Usage: `node scripts/host-compat.mjs` (exit 0 = every listed release is
|
||||
* covered by the declared range; exit 1 = at least one is not).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const REPO_ROOT = path.join(HERE, '..')
|
||||
|
||||
/**
|
||||
* Every dsh release this bundle is checked against, newest first.
|
||||
*
|
||||
* Taken from the release list of github.com/deepseek-ai/deepseek-harness. The
|
||||
* floor is the oldest release the plugin has been adapted to; everything above
|
||||
* it up to the declared ceiling must keep working, so a release that is missing
|
||||
* here is a release nobody checked.
|
||||
*/
|
||||
export const HOST_RELEASES = [
|
||||
'0.2.1-rc.2',
|
||||
'0.2.1-rc.1',
|
||||
'0.2.1-alpha.5',
|
||||
'0.2.1-alpha.4',
|
||||
'0.2.1-alpha.3',
|
||||
'0.2.1-alpha.2',
|
||||
'0.2.1-alpha.1',
|
||||
'0.2.0-rc.2',
|
||||
'0.2.0-rc.1',
|
||||
'0.1.7-rc.2',
|
||||
]
|
||||
|
||||
/** Hosts that must fall OUTSIDE the declaration, for the other direction. */
|
||||
export const OUTSIDE_HOSTS = [
|
||||
'0.3.0',
|
||||
// Prereleases of the NEXT line are the case a plain `<0.3.0` ceiling lets
|
||||
// through: with `includePrerelease` they sort below `0.3.0` itself. The
|
||||
// declared ceiling is `<0.3.0-0`, semver's idiom for "nothing from 0.3.0",
|
||||
// and these entries are what proves it does what it says.
|
||||
'0.3.0-rc.1',
|
||||
'0.3.0-alpha.1',
|
||||
'0.1.7-rc.1',
|
||||
'0.1.7-alpha.2',
|
||||
]
|
||||
|
||||
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
|
||||
|
||||
/**
|
||||
* Split a version into comparable parts.
|
||||
*
|
||||
* Build metadata (`+sha`) is dropped, as semver says it must be for precedence
|
||||
* and as node-semver does for comparison: a host that reports
|
||||
* `0.2.0-rc.2+build.7` is running 0.2.0-rc.2. Everything else is deliberately
|
||||
* strict — a leading `v` or a zero-padded identifier is rejected rather than
|
||||
* guessed at, because this gate exists to catch a declaration nobody checked and
|
||||
* a guess would put the guess in charge.
|
||||
* @param {string} version - a plain semver string, no range operators.
|
||||
* @returns {{ parts: number[], pre: (string|number)[] } | null} null when unparseable.
|
||||
*/
|
||||
export function parseVersion(version) {
|
||||
const cleaned = String(version ?? '').trim().replace(/\+.*$/, '')
|
||||
const match = SEMVER.exec(cleaned)
|
||||
if (match === null) return null
|
||||
const raw = match[4] === undefined ? [] : match[4].split('.')
|
||||
// semver forbids a zero-padded numeric prerelease identifier (`0.2.1-01`);
|
||||
// accepting one would make this gate read a typo as a version.
|
||||
if (raw.some(part => /^\d+$/.test(part) && part.length > 1 && part.startsWith('0'))) return null
|
||||
const pre = raw.map(part => (/^\d+$/.test(part) ? Number(part) : part))
|
||||
return { parts: [Number(match[1]), Number(match[2]), Number(match[3])], pre }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two versions the way semver does, prerelease included.
|
||||
*
|
||||
* A prerelease sorts BELOW its release (`0.2.0-rc.2 < 0.2.0`), and among
|
||||
* prereleases an identifier that is numeric sorts below one that is not, which
|
||||
* is why `0.2.1-alpha.5 < 0.2.1-rc.1` — the whole reason this file cannot lean
|
||||
* on a numeric comparison.
|
||||
* @returns -1, 0 or 1; NaN-free because unparseable input throws.
|
||||
*/
|
||||
export function compareVersions(a, b) {
|
||||
const left = parseVersion(a)
|
||||
const right = parseVersion(b)
|
||||
if (left === null) throw new Error(`not a version: ${a}`)
|
||||
if (right === null) throw new Error(`not a version: ${b}`)
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (left.parts[index] !== right.parts[index]) return left.parts[index] < right.parts[index] ? -1 : 1
|
||||
}
|
||||
if (left.pre.length === 0 && right.pre.length === 0) return 0
|
||||
if (left.pre.length === 0) return 1
|
||||
if (right.pre.length === 0) return -1
|
||||
for (let index = 0; index < Math.max(left.pre.length, right.pre.length); index += 1) {
|
||||
const l = left.pre[index]
|
||||
const r = right.pre[index]
|
||||
if (l === undefined) return -1
|
||||
if (r === undefined) return 1
|
||||
if (l === r) continue
|
||||
const lNum = typeof l === 'number'
|
||||
const rNum = typeof r === 'number'
|
||||
if (lNum && rNum) return l < r ? -1 : 1
|
||||
// Numeric identifiers always have lower precedence than alphanumeric ones.
|
||||
if (lNum !== rNum) return lNum ? -1 : 1
|
||||
return String(l) < String(r) ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate one `>=floor <ceiling` declaration (the only shape this package
|
||||
* ships, and the shape the market's `engines.dsh` is compared with).
|
||||
*
|
||||
* Deliberately narrow: a wider parser here could disagree with the market's
|
||||
* semver implementation on a range the manifest would then be trusted for. If a
|
||||
* future declaration needs `||` or `^`, this function has to grow with it.
|
||||
* @param {string} version - host version to test.
|
||||
* @param {string} range - the declared range.
|
||||
* @returns {boolean} whether the host is inside the declaration.
|
||||
*/
|
||||
export function satisfiesDeclaredRange(version, range) {
|
||||
const match = /^>=\s*(\S+)\s+<\s*(\S+)$/.exec(range.trim())
|
||||
if (match === null) throw new Error(`unsupported host range: ${range}`)
|
||||
return compareVersions(version, match[1]) >= 0 && compareVersions(version, match[2]) < 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The host range this repository declares, `engines.dsh`.
|
||||
* @param {string} [root] - repository root, for tests.
|
||||
* @returns {string | null} the declared range, or null when absent.
|
||||
*/
|
||||
export function declaredHostRange(root = REPO_ROOT) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'))
|
||||
const value = pkg?.engines?.dsh
|
||||
return typeof value === 'string' ? value : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every release covered by the declaration, and every version that must not be.
|
||||
* @param {string} range - declared host range.
|
||||
* @returns {{ covered: string[], uncovered: string[], leaked: string[] }} findings.
|
||||
*/
|
||||
export function checkHostCoverage(range) {
|
||||
const covered = []
|
||||
const uncovered = []
|
||||
for (const version of HOST_RELEASES) {
|
||||
if (satisfiesDeclaredRange(version, range)) covered.push(version)
|
||||
else uncovered.push(version)
|
||||
}
|
||||
const leaked = OUTSIDE_HOSTS.filter(version => satisfiesDeclaredRange(version, range))
|
||||
return { covered, uncovered, leaked }
|
||||
}
|
||||
|
||||
function main() {
|
||||
const range = declaredHostRange()
|
||||
if (range === null) {
|
||||
console.error('FAIL: package.json declares no engines.dsh — the market would report this plugin as "unknown"')
|
||||
process.exit(1)
|
||||
}
|
||||
let result
|
||||
try {
|
||||
result = checkHostCoverage(range)
|
||||
} catch (error) {
|
||||
console.error(`FAIL: ${error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`engines.dsh = ${range}`)
|
||||
for (const version of HOST_RELEASES) {
|
||||
const inside = satisfiesDeclaredRange(version, range)
|
||||
console.log(` ${inside ? 'ok ' : 'MISS'} ${version}`)
|
||||
}
|
||||
for (const version of OUTSIDE_HOSTS) {
|
||||
const inside = satisfiesDeclaredRange(version, range)
|
||||
console.log(` ${inside ? 'LEAK' : 'ok '} ${version} (must stay outside)`)
|
||||
}
|
||||
if (result.uncovered.length > 0) {
|
||||
console.error(`FAIL: the declaration does not cover ${result.uncovered.join(', ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (result.leaked.length > 0) {
|
||||
console.error(`FAIL: the declaration also admits ${result.leaked.join(', ')}, which is not a tested host`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`host range ok: ${HOST_RELEASES.length} releases covered, ${OUTSIDE_HOSTS.length} correctly outside`)
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main()
|
||||
@@ -30,6 +30,11 @@ function Write-Step([string]$Message) { Write-Host "[bootstrap] $Message" }
|
||||
|
||||
# --- where things go -------------------------------------------------------
|
||||
|
||||
# Same precedence as setup.mjs, the shim and check-updates.ps1: explicit override,
|
||||
# then DSH_HOME, then ~/.dsh. An override this script alone ignored would put
|
||||
# ~800 MB into <DSH_HOME>/plugins/rea-bundle while every reader looked in
|
||||
# REA_BUNDLE_ROOT, saw an empty bundle, and started a second run.
|
||||
if (-not $Root -or $Root.Trim() -eq '') { $Root = $env:REA_BUNDLE_ROOT }
|
||||
if (-not $Root -or $Root.Trim() -eq '') {
|
||||
$home2 = $env:DSH_HOME
|
||||
if (-not $home2 -or $home2.Trim() -eq '') { $home2 = Join-Path $HOME '.dsh' }
|
||||
|
||||
+48
-4
@@ -954,6 +954,21 @@ function reuseExisting(name, root) {
|
||||
|
||||
/* ------------------------------------------------------------------- manifest */
|
||||
|
||||
/**
|
||||
* JSON from disk, tolerating the UTF-8 BOM a Windows editor writes.
|
||||
*
|
||||
* `JSON.parse` rejects a leading BOM, and every caller in this file treats a
|
||||
* parse failure as "no record". A BOM-ed mirror.json therefore meant "this is
|
||||
* not a mirrored run", so setup went on to re-mirror the previous manifest;
|
||||
* a BOM-ed bundle.json meant "nothing is provisioned". `bin/rea-shim.mjs`
|
||||
* carries the same helper — the two must agree, or the same file means two
|
||||
* different things depending on which one read it (a hand-edited record with
|
||||
* PowerShell's BOM is exactly the case that produced this).
|
||||
*/
|
||||
function readJson(file) {
|
||||
return JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this run's plugin files actually live.
|
||||
*
|
||||
@@ -973,7 +988,7 @@ function sourceRoot() {
|
||||
const self = path.resolve(PLUGIN_ROOT);
|
||||
if (!samePath(self, options.root)) return self;
|
||||
try {
|
||||
const record = JSON.parse(readFileSync(path.join(options.root, "mirror.json"), "utf8"));
|
||||
const record = readJson(path.join(options.root, "mirror.json"));
|
||||
const recorded = record?.pluginRoot;
|
||||
if (typeof recorded === "string" && recorded.trim() !== "") {
|
||||
const candidate = path.resolve(recorded);
|
||||
@@ -987,7 +1002,7 @@ function sourceRoot() {
|
||||
|
||||
function loadManifest() {
|
||||
const file = path.join(sourceRoot(), "manifest", "bundle.manifest.json");
|
||||
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
||||
const parsed = readJson(file);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -1002,7 +1017,7 @@ function loadManifest() {
|
||||
function readBundleJson(file) {
|
||||
if (!existsSync(file)) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
||||
const parsed = readJson(file);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
||||
} catch (error) {
|
||||
log(` ${path.basename(file)} is not readable JSON (${String(error && error.message)}); ignoring it for this run`);
|
||||
@@ -1553,7 +1568,11 @@ if (!options.dryRun) {
|
||||
const shim = spawnSync(process.execPath, [mirroredShim, "--version"], {
|
||||
encoding: "utf8",
|
||||
timeout: 120000,
|
||||
env: { ...process.env, REA_BUNDLE_ROOT: options.root },
|
||||
// Pin the bundle path: this step exists to confirm the shim relays the REA
|
||||
// THIS run provisioned. Under the default `auto` mode a machine that also
|
||||
// has a newer global REA answers with its own version, and the step would
|
||||
// report success for something this run never verified.
|
||||
env: { ...process.env, REA_BUNDLE_ROOT: options.root, REA_BUNDLE_LAUNCH: "bundle" },
|
||||
});
|
||||
const output = `${shim.stdout ?? ""}${shim.stderr ?? ""}`.trim();
|
||||
const ok = shim.status === 0 && /\d+\.\d+\.\d+/.test(output);
|
||||
@@ -1632,6 +1651,31 @@ function mirrorLauncher(context) {
|
||||
*/
|
||||
function writeMirrorRecord(context, source) {
|
||||
const record = { schema: 1, pluginRoot: source, mirroredAt: new Date().toISOString() };
|
||||
// Provenance for the self-healing check: the mirrored shim compares itself
|
||||
// with `<pluginRoot>/bin/rea-shim.mjs` and re-execs that one when the plugin
|
||||
// has moved on. These two fields are what `--shim-status` shows when the
|
||||
// comparison cannot be made (missing checkout, unreadable file).
|
||||
try {
|
||||
record.shimSha256 = createHash("sha256")
|
||||
.update(readFileSync(path.join(context.root, "bin", "rea-shim.mjs")))
|
||||
.digest("hex");
|
||||
} catch {
|
||||
/* the copy is missing or unreadable; the shim reports the comparison itself */
|
||||
}
|
||||
try {
|
||||
record.pluginVersion = readJson(path.join(source, "package.json")).version ?? null;
|
||||
} catch {
|
||||
record.pluginVersion = null;
|
||||
}
|
||||
// The MCP child the harness starts has every DSH_* variable stripped, so the
|
||||
// mirrored shim cannot re-derive a CUSTOM home from its environment — it would
|
||||
// read `~/.dsh` and miss a duplicate `serverName: rea` living in the real one.
|
||||
// Record the home setup ran under; the shim prefers the environment, then this.
|
||||
try {
|
||||
record.dshHome = dshHome();
|
||||
} catch {
|
||||
record.dshHome = null;
|
||||
}
|
||||
try {
|
||||
mkdirSync(context.root, { recursive: true });
|
||||
writeFileSync(path.join(context.root, "mirror.json"), JSON.stringify(record, null, 2) + "\n");
|
||||
|
||||
+725
-4
@@ -18,7 +18,8 @@ import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { HOST_RELEASES, OUTSIDE_HOSTS, checkHostCoverage, declaredHostRange, compareVersions, satisfiesDeclaredRange } from "./host-compat.mjs";
|
||||
|
||||
/* ------------------------------------------------- probe token substitution */
|
||||
|
||||
@@ -212,7 +213,10 @@ const SHIM_SOURCE = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."
|
||||
function shimBundleRoot(shimFile, env) {
|
||||
const run = spawnSync(process.execPath, [shimFile, "--shim-status"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, REA_BUNDLE_AUTO_PROVISION: "0", ...env },
|
||||
// These tests assert bundle-root resolution, not the launch decision. Pinning
|
||||
// the mode keeps them independent of whether THIS machine has a REA on PATH
|
||||
// (and keeps them from paying for a probe on every spawn).
|
||||
env: { ...process.env, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle", ...env },
|
||||
});
|
||||
// --shim-status prints its report even when no bundle exists yet (exit 78).
|
||||
return JSON.parse(run.stdout).bundleRoot;
|
||||
@@ -820,6 +824,10 @@ function runShim(bundle, args, extraEnv = {}) {
|
||||
...process.env,
|
||||
DSH_HOME: path.join(path.dirname(bundle), "dsh-home"),
|
||||
REA_BUNDLE_ROOT: bundle,
|
||||
// The bundle path is what these tests assert. Without this pin the shim
|
||||
// would take its `auto` branch and, on a machine that happens to have a REA
|
||||
// on PATH, delegate to it instead of exercising the code under test.
|
||||
REA_BUNDLE_LAUNCH: "bundle",
|
||||
...extraEnv,
|
||||
};
|
||||
delete env.REA_BUNDLE_AUTO_PROVISION;
|
||||
@@ -943,7 +951,7 @@ test("a second REA registration is reported instead of silently duplicated", ()
|
||||
|
||||
const status = spawnSync(process.execPath, [path.join(bundle, "bin", "rea-shim.mjs"), "--shim-status"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, DSH_HOME: home, REA_BUNDLE_ROOT: bundle },
|
||||
env: { ...process.env, DSH_HOME: home, REA_BUNDLE_ROOT: bundle, REA_BUNDLE_LAUNCH: "bundle" },
|
||||
});
|
||||
assert.equal(JSON.parse(status.stdout).duplicateRegistration.id, "rea-mcp");
|
||||
} finally {
|
||||
@@ -1122,7 +1130,7 @@ test("the fallback vendored-REA path matches where setup installs it", () => {
|
||||
const run = spawnSync(process.execPath, [path.join(REPO_ROOT, "bin", "rea-shim.mjs"), "--shim-status"], {
|
||||
encoding: "utf8",
|
||||
timeout: 60000,
|
||||
env: { ...process.env, REA_BUNDLE_ROOT: bundle, REA_BUNDLE_AUTO_PROVISION: "0" },
|
||||
env: { ...process.env, REA_BUNDLE_ROOT: bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
||||
});
|
||||
const report = JSON.parse(run.stdout);
|
||||
assert.equal(report.rea.ok, true, JSON.stringify(report.rea));
|
||||
@@ -1168,3 +1176,716 @@ test("--json releases the lock on success and marks it failed on failure", () =>
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/* --------------------------------------------------------- the package entry */
|
||||
|
||||
// The market judges an update by asking whether the new build's package.json
|
||||
// points at an entry file that exists (`hasLoadableEntry` -> `entryArtifactExists`
|
||||
// in dshmarket). This package declared neither main nor exports, and the package
|
||||
// it mounts (`@deepseek-ai/dsh-mcp-client`) ships inside the harness build rather
|
||||
// than in node_modules, so every location that test probes came back empty:
|
||||
// updating a healthy 2.0.1 profile to 2.0.4 failed with "updated build has no
|
||||
// loadable entry", and the rollback was then reported unverifiable for the same
|
||||
// reason (measured 2026-10-18). These two tests are that regression.
|
||||
test("package.json declares an entry file that is actually shipped", () => {
|
||||
const pkg = JSON.parse(readFileSync(path.join(REPO_ROOT, "package.json"), "utf8"));
|
||||
assert.equal(typeof pkg.main, "string", "package.json must declare main; without it consumers probe index.js, which does not exist");
|
||||
assert.equal(existsSync(path.join(REPO_ROOT, pkg.main)), true, `${pkg.main} is declared but missing`);
|
||||
// `main` alone is not enough: npm pack ships only what files[] lists, and a
|
||||
// tarball without the entry reproduces the very failure this fixes.
|
||||
const shipped = String(pkg.main).replace(/^\.\//, "");
|
||||
assert.equal(pkg.files.includes(shipped), true, `files[] must ship ${shipped}`);
|
||||
});
|
||||
|
||||
test("the entry loads as a cordis plugin and does nothing", async () => {
|
||||
const pkg = JSON.parse(readFileSync(path.join(REPO_ROOT, "package.json"), "utf8"));
|
||||
const mod = await import(pathToFileURL(path.join(REPO_ROOT, pkg.main)).href);
|
||||
assert.equal(mod.name, pkg.name, "the loader resolves the plugin by this name");
|
||||
assert.equal(typeof mod.apply, "function", "a cordis plugin needs apply()");
|
||||
// Loading and applying must not register a row, spawn anything or touch state:
|
||||
// registering the MCP client here would add a second `serverName: rea` entry,
|
||||
// which the harness refuses. Returning undefined is the whole contract.
|
||||
assert.equal(mod.apply(), undefined);
|
||||
});
|
||||
|
||||
/* --------------------------------------------------------- declared DSH hosts */
|
||||
|
||||
// The market reads `engines.dsh` to answer "is this plugin compatible with the
|
||||
// host I am running" — and hides the plugin from hosts that mismatch. Two silent
|
||||
// failures follow from a declaration nobody checks: a typo leaves the range
|
||||
// unparseable and the market falls back to "undeclared", while a drifted floor
|
||||
// drops hosts the README still promises. Both look exactly like a plugin that
|
||||
// simply has nothing to say, so the string is pinned against the release list.
|
||||
test("the manifest declares a DSH host range the market can read", () => {
|
||||
const range = declaredHostRange(REPO_ROOT);
|
||||
assert.equal(typeof range, "string", "engines.dsh must be declared; without it the market reports the plugin as unknown");
|
||||
// The string is pinned, not just parsed: the ceiling decides which hosts are
|
||||
// admitted, and `<0.3.0` vs `<0.3.0-0` is exactly the difference between
|
||||
// "no 0.3.0" and "0.3.0-alpha.1 is fine" once prereleases are included.
|
||||
assert.equal(range, ">=0.1.7-rc.2 <0.3.0-0");
|
||||
assert.throws(() => satisfiesDeclaredRange("0.2.0-rc.2", "not a range"), "the gate must reject an unparseable range");
|
||||
assert.doesNotThrow(() => satisfiesDeclaredRange("0.2.0-rc.2", range));
|
||||
});
|
||||
|
||||
test("the release list this gate checks is the published one", () => {
|
||||
// Pinned literally: deleting an entry from the list would otherwise shrink the
|
||||
// coverage claim silently, and every test below would still pass.
|
||||
assert.deepEqual(HOST_RELEASES, [
|
||||
"0.2.1-rc.2",
|
||||
"0.2.1-rc.1",
|
||||
"0.2.1-alpha.5",
|
||||
"0.2.1-alpha.4",
|
||||
"0.2.1-alpha.3",
|
||||
"0.2.1-alpha.2",
|
||||
"0.2.1-alpha.1",
|
||||
"0.2.0-rc.2",
|
||||
"0.2.0-rc.1",
|
||||
"0.1.7-rc.2",
|
||||
]);
|
||||
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"]);
|
||||
});
|
||||
|
||||
test("every released DSH host is inside the declared range, and nothing untested is", () => {
|
||||
const range = declaredHostRange(REPO_ROOT);
|
||||
const { covered, uncovered, leaked } = checkHostCoverage(range);
|
||||
assert.deepEqual(uncovered, [], `these releases fall outside ${range}`);
|
||||
assert.deepEqual(leaked, [], `the declaration also admits ${leaked.join(", ")}, which nobody tested`);
|
||||
assert.deepEqual(covered, HOST_RELEASES, "every release in the list must be covered");
|
||||
});
|
||||
|
||||
test("the host-range gate rejects a plausible but wrong declaration", () => {
|
||||
// Negative control: a gate that always passes proves nothing. `>=0.2.0` is the
|
||||
// shape someone writes after testing only on their own machine — and because a
|
||||
// prerelease sorts BELOW its release, it drops the whole 0.2.0-rc line as well
|
||||
// as 0.1.7. That is the trap this test exists for: the typo looks like it names
|
||||
// the running host.
|
||||
const drifted = checkHostCoverage(">=0.2.0 <0.3.0-0");
|
||||
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");
|
||||
// The OTHER half of the gate needs its own control: a floor low enough to admit
|
||||
// a host nobody audited must be reported as a leak, not silently accepted.
|
||||
const loose = checkHostCoverage(">=0.1.0 <0.3.0-0");
|
||||
assert.deepEqual(loose.leaked, ["0.1.7-rc.1", "0.1.7-alpha.2"], "a low floor must be seen admitting the earlier prereleases");
|
||||
// And the ceiling is load bearing: the next minor line is a host nobody audited,
|
||||
// including its prereleases, which `<0.3.0` would have let through.
|
||||
assert.equal(satisfiesDeclaredRange("0.3.0", ">=0.1.7-rc.2 <0.3.0-0"), false);
|
||||
assert.equal(satisfiesDeclaredRange("0.3.0-rc.1", ">=0.1.7-rc.2 <0.3.0-0"), false);
|
||||
});
|
||||
|
||||
test("prerelease ordering matches semver, not string order", () => {
|
||||
assert.equal(compareVersions("0.2.1-alpha.5", "0.2.1-rc.1"), -1);
|
||||
assert.equal(compareVersions("0.2.1-rc.2", "0.2.1"), -1);
|
||||
assert.equal(compareVersions("0.2.0-rc.2", "0.2.0-rc.1"), 1);
|
||||
// String comparison would sort "alpha.10" before "alpha.5"; semver does not.
|
||||
assert.equal(compareVersions("0.2.1-alpha.10", "0.2.1-alpha.5"), 1);
|
||||
});
|
||||
|
||||
test("the evaluator reads the two tolerances semver does, and refuses the rest", () => {
|
||||
// Build metadata carries no precedence: a host reporting it is that version.
|
||||
assert.equal(satisfiesDeclaredRange("0.2.0-rc.2+build.7", ">=0.1.7-rc.2 <0.3.0-0"), true);
|
||||
// Everything else is refused rather than guessed at — a gate that accepts `v`
|
||||
// prefixes and zero-padded identifiers would also accept a typo.
|
||||
assert.throws(() => satisfiesDeclaredRange("v0.2.0-rc.2", ">=0.1.7-rc.2 <0.3.0-0"));
|
||||
assert.throws(() => satisfiesDeclaredRange("0.2.1-01", ">=0.1.7-rc.2 <0.3.0-0"));
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------- the launch decision */
|
||||
|
||||
// 2.x supplies a runtime the machine may not have; when it HAS one, the row must
|
||||
// use it — the 1.x contract. These tests own that decision, so every one of them
|
||||
// isolates PATH: without it the suite would silently depend on whatever `rea`
|
||||
// the machine running it happens to have, which is the one thing a test of a
|
||||
// machine-dependent decision must not do.
|
||||
|
||||
function writeFakeRea(dir, { version = "6.9.0", exitForVersion = 0, runExit = 0, record = null } = {}) {
|
||||
const file = path.join(dir, process.platform === "win32" ? "rea.cmd" : "rea");
|
||||
if (process.platform === "win32") {
|
||||
const lines = ["@echo off", 'if "%1"=="--version" (', ` echo ${version}`, ` exit /b ${exitForVersion}`, ")"];
|
||||
if (record !== null) lines.push(`echo %* > "${record}"`);
|
||||
lines.push(`exit /b ${runExit}`);
|
||||
writeFileSync(file, lines.join("\r\n") + "\r\n");
|
||||
} else {
|
||||
const lines = ["#!/bin/sh", `if [ "$1" = "--version" ]; then echo ${version}; exit ${exitForVersion}; fi`];
|
||||
if (record !== null) lines.push(`echo "$*" > "${record}"`);
|
||||
lines.push(`exit ${runExit}`);
|
||||
writeFileSync(file, lines.join("\n") + "\n");
|
||||
chmodSync(file, 0o755);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function runShimIsolated(env, args, shim = path.join(REPO_ROOT, "bin", "rea-shim.mjs")) {
|
||||
const merged = { ...process.env, ...env };
|
||||
// Windows keeps PATH and Path as separate variables (both are "in" the
|
||||
// environment), and a child that reads Path while the test set PATH would see
|
||||
// the machine's real search path instead of the fixture's.
|
||||
if (process.platform === "win32" && Object.prototype.hasOwnProperty.call(env, "PATH")) delete merged.Path;
|
||||
return spawnSync(process.execPath, [shim, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 60000,
|
||||
env: merged,
|
||||
});
|
||||
}
|
||||
|
||||
function launchFixture() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-launch-"));
|
||||
const emptyPath = path.join(root, "empty-path");
|
||||
mkdirSync(emptyPath, { recursive: true });
|
||||
return {
|
||||
root,
|
||||
emptyPath,
|
||||
bundleRoot: path.join(root, "bundle"),
|
||||
bin: path.join(root, "bin"),
|
||||
record: path.join(root, "record.txt"),
|
||||
clean() {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("a machine with its own REA runs the 1.x way, and no bundle is required", () => {
|
||||
const f = launchFixture();
|
||||
mkdirSync(f.bin, { recursive: true });
|
||||
const rea = writeFakeRea(f.bin, { version: "6.9.0", record: f.record });
|
||||
try {
|
||||
const env = {
|
||||
PATH: f.emptyPath,
|
||||
REA_BUNDLE_SYSTEM_REA: rea,
|
||||
REA_BUNDLE_ROOT: f.bundleRoot,
|
||||
REA_BUNDLE_AUTO_PROVISION: "0",
|
||||
REA_BUNDLE_LAUNCH: "",
|
||||
};
|
||||
const status = runShimIsolated(env, ["--shim-status"]);
|
||||
const report = JSON.parse(status.stdout);
|
||||
assert.equal(status.status, 0, status.stderr);
|
||||
assert.equal(report.launch.mode, "system", JSON.stringify(report.launch));
|
||||
assert.equal(report.launch.systemRea.version, "6.9.0");
|
||||
// Unprovisioned and still healthy: the row does not depend on the bundle.
|
||||
assert.equal(report.provisioned, false);
|
||||
assert.equal(existsSync(path.join(f.bundleRoot, "bundle.json")), false, "no provisioning may be started");
|
||||
|
||||
const run = runShimIsolated(env, ["mcp"]);
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.equal(readFileSync(f.record, "utf8").trim(), "mcp", "the system REA must receive the row's arguments");
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("REA_BUNDLE_LAUNCH=bundle keeps the bundle path even with a REA on PATH", () => {
|
||||
const f = launchFixture();
|
||||
mkdirSync(f.bin, { recursive: true });
|
||||
const rea = writeFakeRea(f.bin);
|
||||
try {
|
||||
const run = runShimIsolated(
|
||||
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
||||
["--shim-status"],
|
||||
);
|
||||
const report = JSON.parse(run.stdout);
|
||||
assert.equal(report.launch.mode, "bundle");
|
||||
assert.equal(report.launch.reason, "pinned by REA_BUNDLE_LAUNCH=bundle");
|
||||
assert.equal(run.status, 78, "an unpinned bundle that is not provisioned still reports EX_CONFIG");
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("an older system REA does not displace a newer provisioned one unless pinned", () => {
|
||||
const f = launchFixture();
|
||||
mkdirSync(f.bin, { recursive: true });
|
||||
mkdirSync(f.bundleRoot, { recursive: true });
|
||||
const rea = writeFakeRea(f.bin, { version: "6.4.0" });
|
||||
try {
|
||||
writeFileSync(
|
||||
path.join(f.bundleRoot, "bundle.json"),
|
||||
JSON.stringify({ schema: 1, paths: { node: process.execPath }, versions: { reaAgents: "6.5.0" } }),
|
||||
);
|
||||
const base = { PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0" };
|
||||
const auto = JSON.parse(runShimIsolated({ ...base, REA_BUNDLE_LAUNCH: "" }, ["--shim-status"]).stdout);
|
||||
assert.equal(auto.launch.mode, "bundle");
|
||||
assert.match(auto.launch.reason, /older than the provisioned one/);
|
||||
const pinned = JSON.parse(runShimIsolated({ ...base, REA_BUNDLE_LAUNCH: "system" }, ["--shim-status"]).stdout);
|
||||
assert.equal(pinned.launch.mode, "system");
|
||||
assert.match(pinned.launch.reason, /pinned by REA_BUNDLE_LAUNCH=system/);
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("a candidate that cannot answer --version is rejected, not launched", () => {
|
||||
const f = launchFixture();
|
||||
mkdirSync(f.bin, { recursive: true });
|
||||
const rea = writeFakeRea(f.bin, { exitForVersion: 7 });
|
||||
try {
|
||||
const run = runShimIsolated(
|
||||
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
|
||||
["--shim-status"],
|
||||
);
|
||||
const report = JSON.parse(run.stdout);
|
||||
assert.equal(report.launch.mode, "bundle");
|
||||
assert.equal(report.launch.systemRea, null);
|
||||
assert.deepEqual(report.launch.rejected.map(r => r.reason), ["exit 7"]);
|
||||
assert.equal(run.status, 78);
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("REA_BUNDLE_LAUNCH=system refuses instead of provisioning", () => {
|
||||
const f = launchFixture();
|
||||
const missing = path.join(f.bin, "no-such-rea");
|
||||
try {
|
||||
const run = runShimIsolated(
|
||||
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: missing, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "1", REA_BUNDLE_LAUNCH: "system" },
|
||||
["mcp"],
|
||||
);
|
||||
assert.equal(run.status, 78, run.stdout);
|
||||
assert.match(run.stderr, /REA_BUNDLE_LAUNCH=system was requested/);
|
||||
assert.equal(existsSync(f.bundleRoot), false, "a pinned system launch must not create a bundle");
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("an unknown REA_BUNDLE_LAUNCH value is reported and treated as auto", () => {
|
||||
const f = launchFixture();
|
||||
mkdirSync(f.bin, { recursive: true });
|
||||
const rea = writeFakeRea(f.bin, { version: "6.9.0" });
|
||||
try {
|
||||
const run = runShimIsolated(
|
||||
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "nonsense" },
|
||||
["--shim-status"],
|
||||
);
|
||||
assert.match(run.stderr, /REA_BUNDLE_LAUNCH="nonsense" is not one of/);
|
||||
assert.equal(JSON.parse(run.stdout).launch.mode, "system");
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("the system REA is found through PATH itself, not only through the override", () => {
|
||||
const f = launchFixture();
|
||||
mkdirSync(f.bin, { recursive: true });
|
||||
writeFakeRea(f.bin, { version: "6.9.0" });
|
||||
try {
|
||||
const run = runShimIsolated(
|
||||
{ PATH: f.bin, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
|
||||
["--shim-status"],
|
||||
);
|
||||
const report = JSON.parse(run.stdout);
|
||||
assert.equal(report.launch.mode, "system", JSON.stringify(report.launch));
|
||||
assert.equal(report.launch.systemRea.version, "6.9.0");
|
||||
assert.equal(run.status, 0);
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("a REA installed under a path with a space is probed and launched", () => {
|
||||
const f = launchFixture();
|
||||
const spaced = path.join(f.root, "rea space");
|
||||
mkdirSync(spaced, { recursive: true });
|
||||
const rea = writeFakeRea(spaced, { version: "6.9.0", record: f.record });
|
||||
try {
|
||||
const env = { PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" };
|
||||
const status = JSON.parse(runShimIsolated(env, ["--shim-status"]).stdout);
|
||||
assert.equal(status.launch.mode, "system", JSON.stringify(status.launch));
|
||||
const run = runShimIsolated(env, ["mcp"]);
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.equal(readFileSync(f.record, "utf8").trim(), "mcp");
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("the delegated process's exit status is the shim's exit status", () => {
|
||||
const f = launchFixture();
|
||||
mkdirSync(f.bin, { recursive: true });
|
||||
const rea = writeFakeRea(f.bin, { version: "6.9.0", runExit: 9 });
|
||||
try {
|
||||
const run = runShimIsolated(
|
||||
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
|
||||
["mcp"],
|
||||
);
|
||||
assert.equal(run.status, 9, `the row must see REA's own status, got ${run.status}`);
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("a REA inside the bundle is not treated as a system install", () => {
|
||||
const f = launchFixture();
|
||||
mkdirSync(f.bundleRoot, { recursive: true });
|
||||
const inside = writeFakeRea(f.bundleRoot, { version: "6.9.0" });
|
||||
try {
|
||||
const run = runShimIsolated(
|
||||
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: inside, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
|
||||
["--shim-status"],
|
||||
);
|
||||
const report = JSON.parse(run.stdout);
|
||||
assert.equal(report.launch.mode, "bundle");
|
||||
assert.equal(report.launch.systemRea, null);
|
||||
assert.deepEqual(report.launch.rejected.map(r => r.reason), ["inside the bundle"]);
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("REA_BUNDLE_LAUNCH=bundle never probes the machine", () => {
|
||||
const f = launchFixture();
|
||||
mkdirSync(f.bin, { recursive: true });
|
||||
// Any invocation at all leaves the record behind — including --version, which
|
||||
// is exactly what `auto` would run.
|
||||
const rea = writeFakeRea(f.bin, { version: "6.9.0", record: f.record });
|
||||
try {
|
||||
runShimIsolated(
|
||||
{ PATH: f.bin, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
||||
["--shim-status"],
|
||||
);
|
||||
assert.equal(existsSync(f.record), false, "the pinned bundle path must not pay for a probe");
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("--shim-print-env reports the system launch instead of a child environment", () => {
|
||||
const f = launchFixture();
|
||||
mkdirSync(f.bin, { recursive: true });
|
||||
const rea = writeFakeRea(f.bin, { version: "6.9.0", record: f.record });
|
||||
try {
|
||||
const run = runShimIsolated(
|
||||
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
|
||||
["--shim-print-env"],
|
||||
);
|
||||
const printed = JSON.parse(run.stdout);
|
||||
assert.equal(printed.launch, "system");
|
||||
assert.equal(printed.version, "6.9.0");
|
||||
assert.equal(existsSync(f.record), false, "printing the environment must not start REA");
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("a candidate that wraps this shim is rejected instead of recursing", () => {
|
||||
// The probe asks the candidate for `--version`; a wrapper answers by running
|
||||
// this very shim, which would probe the wrapper again. The probe therefore runs
|
||||
// with REA_BUNDLE_DELEGATED=1, so the nested run takes the bundle path and
|
||||
// returns. Without that, one wrapper on PATH spawns a process chain that keeps
|
||||
// respawning after the parent is killed.
|
||||
const f = launchFixture();
|
||||
mkdirSync(f.bin, { recursive: true });
|
||||
const shim = path.join(REPO_ROOT, "bin", "rea-shim.mjs");
|
||||
const wrapper = path.join(f.bin, process.platform === "win32" ? "rea.cmd" : "rea");
|
||||
if (process.platform === "win32") {
|
||||
writeFileSync(wrapper, `@echo off\r\n"${process.execPath}" "${shim}" %*\r\n`);
|
||||
} else {
|
||||
writeFileSync(wrapper, `#!/bin/sh\nexec "${process.execPath}" "${shim}" "$@"\n`);
|
||||
chmodSync(wrapper, 0o755);
|
||||
}
|
||||
try {
|
||||
const started = Date.now();
|
||||
const run = runShimIsolated(
|
||||
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: wrapper, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
|
||||
["--shim-status"],
|
||||
);
|
||||
const elapsed = Date.now() - started;
|
||||
const report = JSON.parse(run.stdout);
|
||||
assert.equal(report.launch.mode, "bundle");
|
||||
assert.deepEqual(report.launch.rejected.map(r => r.reason), ["exit 78"]);
|
||||
assert.ok(elapsed < 20000, `the probe must not recurse; it took ${elapsed} ms`);
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("a ComSpec that points at nothing falls back to the real shell", () => {
|
||||
// Trusting ComSpec blindly would judge every .cmd REA "spawn-failed" — i.e.
|
||||
// report the machine as having no REA because its shell pointer is broken.
|
||||
const f = launchFixture();
|
||||
mkdirSync(f.bin, { recursive: true });
|
||||
const rea = writeFakeRea(f.bin, { version: "6.9.0" });
|
||||
try {
|
||||
const run = runShimIsolated(
|
||||
{
|
||||
PATH: f.emptyPath,
|
||||
ComSpec: path.join(f.root, "no-such-cmd.exe"),
|
||||
REA_BUNDLE_SYSTEM_REA: rea,
|
||||
REA_BUNDLE_ROOT: f.bundleRoot,
|
||||
REA_BUNDLE_AUTO_PROVISION: "0",
|
||||
REA_BUNDLE_LAUNCH: "",
|
||||
},
|
||||
["--shim-status"],
|
||||
);
|
||||
const report = JSON.parse(run.stdout);
|
||||
if (process.platform === "win32") {
|
||||
assert.equal(report.launch.mode, "system", JSON.stringify(report.launch));
|
||||
assert.equal(report.launch.systemRea.version, "6.9.0");
|
||||
} else {
|
||||
// POSIX never consults ComSpec; the assertion above would be incidental.
|
||||
assert.equal(report.launch.mode, "system");
|
||||
}
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
/* --------------------------------------------------- the mirrored launcher */
|
||||
|
||||
// The row launches `<bundle>/bin/rea-shim.mjs`, a COPY. A market upgrade replaces
|
||||
// the package and runs no setup, so without this the copy keeps running the code
|
||||
// it was made from — every fix silently stops at the old mirror. These tests pin
|
||||
// the repair: the copy re-execs the plugin's launcher when they differ, once.
|
||||
|
||||
function mirrorFixture() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-mirror-"));
|
||||
const bundle = path.join(root, "bundle");
|
||||
const checkout = path.join(root, "checkout");
|
||||
mkdirSync(path.join(bundle, "bin"), { recursive: true });
|
||||
mkdirSync(path.join(checkout, "bin"), { recursive: true });
|
||||
copyFileSync(path.join(REPO_ROOT, "bin", "rea-shim.mjs"), path.join(bundle, "bin", "rea-shim.mjs"));
|
||||
writeFileSync(path.join(bundle, "mirror.json"), JSON.stringify({ schema: 1, pluginRoot: checkout, mirroredAt: "2026-10-18T00:00:00Z" }));
|
||||
return { root, bundle, checkout, clean() { rmSync(root, { recursive: true, force: true }); } };
|
||||
}
|
||||
|
||||
function checkoutStub(checkout, marker) {
|
||||
const file = path.join(checkout, "bin", "rea-shim.mjs");
|
||||
writeFileSync(
|
||||
file,
|
||||
'import { writeFileSync } from "node:fs";\n' +
|
||||
`writeFileSync(${JSON.stringify(marker)}, "checkout");\n` +
|
||||
"process.exit(7);\n",
|
||||
);
|
||||
return file;
|
||||
}
|
||||
|
||||
test("a mirrored launcher re-execs the plugin's launcher when they differ", () => {
|
||||
const f = mirrorFixture();
|
||||
const marker = path.join(f.root, "checkout-ran");
|
||||
checkoutStub(f.checkout, marker);
|
||||
try {
|
||||
// The mirrored copy is what the row launches, and the checkout guard means a
|
||||
// checkout shim refuses to hop at all — so the fixture must be exercised as
|
||||
// the mirror it is meant to be.
|
||||
const run = runShimIsolated(
|
||||
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
||||
["mcp"],
|
||||
path.join(f.bundle, "bin", "rea-shim.mjs"),
|
||||
);
|
||||
assert.equal(run.status, 7, `expected the checkout's status, got ${run.status}: ${run.stderr}`);
|
||||
assert.equal(existsSync(marker), true, "the checkout launcher must have run");
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("the re-exec happens once, and a pinned guard stops it", () => {
|
||||
const f = mirrorFixture();
|
||||
const marker = path.join(f.root, "checkout-ran");
|
||||
checkoutStub(f.checkout, marker);
|
||||
try {
|
||||
const run = runShimIsolated(
|
||||
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle", REA_BUNDLE_SHIM_REEXEC: "1" },
|
||||
["mcp"],
|
||||
path.join(f.bundle, "bin", "rea-shim.mjs"),
|
||||
);
|
||||
// The guard is set, so the mirrored copy runs its own logic: bundle pinned and
|
||||
// nothing provisioned means EX_CONFIG, and the checkout never starts.
|
||||
assert.equal(run.status, 78, run.stderr);
|
||||
assert.equal(existsSync(marker), false, "the guard must prevent a second hop");
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("--shim-status reports a stale mirror instead of re-execing it", () => {
|
||||
const f = mirrorFixture();
|
||||
const marker = path.join(f.root, "checkout-ran");
|
||||
checkoutStub(f.checkout, marker);
|
||||
try {
|
||||
const run = runShimIsolated(
|
||||
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
||||
["--shim-status"],
|
||||
path.join(f.bundle, "bin", "rea-shim.mjs"),
|
||||
);
|
||||
const report = JSON.parse(run.stdout);
|
||||
assert.equal(report.mirror.stale, true, JSON.stringify(report.mirror));
|
||||
assert.match(report.mirror.reason, /different launcher/);
|
||||
assert.equal(existsSync(marker), false, "status is a query; it must not start anything");
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("a checkout shim does not hop into the installed plugin", () => {
|
||||
const f = mirrorFixture();
|
||||
const marker = path.join(f.root, "checkout-ran");
|
||||
checkoutStub(f.checkout, marker);
|
||||
try {
|
||||
// The repository's own shim is a CHECKOUT (it ships cordis.patch.yml). A
|
||||
// developer running it must keep testing the edit in front of them, not a
|
||||
// different plugin that the bundle's record happens to name.
|
||||
const run = runShimIsolated(
|
||||
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
||||
["mcp"],
|
||||
);
|
||||
assert.equal(run.status, 78, run.stderr);
|
||||
assert.equal(existsSync(marker), false, "a checkout must never hop into another copy");
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("a re-exec whose file vanishes mid-flight falls back to the mirrored copy", () => {
|
||||
// The upgrade race the spawn handshake cannot see: the file existed when the
|
||||
// mirror compared hashes, node started, and the file was replaced before node
|
||||
// read it — node exits 1 with MODULE_NOT_FOUND. Passing that through turns a
|
||||
// millisecond race into a dead row, so a non-zero exit with the source gone
|
||||
// must fall back instead.
|
||||
const f = mirrorFixture();
|
||||
const marker = path.join(f.root, "checkout-ran");
|
||||
const file = path.join(f.checkout, "bin", "rea-shim.mjs");
|
||||
writeFileSync(
|
||||
file,
|
||||
'import { rmSync, writeFileSync } from "node:fs";\n' +
|
||||
`writeFileSync(${JSON.stringify(marker)}, "checkout");\n` +
|
||||
`rmSync(${JSON.stringify(file)});\n` +
|
||||
"process.exit(3);\n",
|
||||
);
|
||||
try {
|
||||
const run = runShimIsolated(
|
||||
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
||||
["mcp"],
|
||||
path.join(f.bundle, "bin", "rea-shim.mjs"),
|
||||
);
|
||||
assert.equal(existsSync(marker), true, "the checkout launcher did run");
|
||||
assert.equal(run.status, 78, `expected the local copy's status, got ${run.status}: ${run.stderr}`);
|
||||
assert.match(run.stderr, /running the mirrored copy instead/);
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("an identical mirror is not stale and is not re-exec'd", () => {
|
||||
const f = mirrorFixture();
|
||||
const same = path.join(f.checkout, "bin", "rea-shim.mjs");
|
||||
copyFileSync(path.join(f.bundle, "bin", "rea-shim.mjs"), same);
|
||||
try {
|
||||
const run = runShimIsolated(
|
||||
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
||||
["--shim-status"],
|
||||
path.join(f.bundle, "bin", "rea-shim.mjs"),
|
||||
);
|
||||
const report = JSON.parse(run.stdout);
|
||||
assert.equal(report.mirror.stale, false, JSON.stringify(report.mirror));
|
||||
assert.equal(report.mirror.reason, "this copy matches the plugin");
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
test("a mirrored shim finds the real DSH home when the environment is scrubbed", () => {
|
||||
// The harness strips DSH_* from an MCP child, so with a custom DSH_HOME the
|
||||
// old code read ~/.dsh — the wrong file — and could never report upstream's
|
||||
// duplicate `serverName: rea`. setup records the home; the shim must use it.
|
||||
const f = mirrorFixture();
|
||||
const home = path.join(f.root, "custom-home");
|
||||
mkdirSync(home, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(home, "cordis.patch.yml"),
|
||||
"- insert:\n - id: rea-mcp\n name: '@deepseek-ai/dsh-mcp-client'\n config:\n serverName: rea\n",
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(f.bundle, "mirror.json"),
|
||||
JSON.stringify({ schema: 1, pluginRoot: f.checkout, mirroredAt: "2026-10-18T00:00:00Z", dshHome: home }),
|
||||
);
|
||||
try {
|
||||
const run = runShimIsolated(
|
||||
// DSH_HOME="" models the scrubbed environment: the variable exists, empty.
|
||||
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle", DSH_HOME: "" },
|
||||
["--shim-status"],
|
||||
);
|
||||
const report = JSON.parse(run.stdout);
|
||||
assert.equal(report.duplicateRegistration?.id, "rea-mcp", run.stdout);
|
||||
assert.equal(report.duplicateRegistration.patch, path.join(home, "cordis.patch.yml"));
|
||||
} finally {
|
||||
f.clean();
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------ records a Windows editor has BOM-ed */
|
||||
|
||||
// `bin/rea-shim.mjs` strips the UTF-8 BOM that PowerShell and Notepad write;
|
||||
// setup.mjs kept parsing raw. Both programs read the same two records, so a BOM
|
||||
// used to mean two different things: the shim found the checkout, setup did not.
|
||||
// A mirrored setup then re-mirrored the previous manifest and provisioned from
|
||||
// it, and a BOM-ed bundle.json read as "nothing is provisioned". These tests pin
|
||||
// setup's side of the contract, both files.
|
||||
|
||||
/** A checkout with a manifest whose node probe accepts any node, so `--only node` is deterministic. */
|
||||
function bomFixtureCheckout(root) {
|
||||
const checkout = path.join(root, "checkout");
|
||||
mkdirSync(path.join(checkout, "scripts"), { recursive: true });
|
||||
mkdirSync(path.join(checkout, "manifest"), { recursive: true });
|
||||
copyFileSync(SETUP_SOURCE, path.join(checkout, "scripts", "setup.mjs"));
|
||||
const manifest = JSON.parse(readFileSync(MANIFEST_SOURCE, "utf8"));
|
||||
manifest.components.node.probe.expect.regex = "^v";
|
||||
writeFileSync(path.join(checkout, "manifest", "bundle.manifest.json"), JSON.stringify(manifest, null, 2));
|
||||
writeFileSync(path.join(checkout, "package.json"), JSON.stringify({ name: "dsh-plugin-rea", version: "0.0.0-bom" }));
|
||||
return checkout;
|
||||
}
|
||||
|
||||
test("a BOM-ed mirror.json still points a mirrored setup at the real checkout", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-bom-mirror-"));
|
||||
try {
|
||||
const checkout = bomFixtureCheckout(root);
|
||||
const bundle = path.join(root, "bundle");
|
||||
mkdirSync(path.join(bundle, "scripts"), { recursive: true });
|
||||
copyFileSync(SETUP_SOURCE, path.join(bundle, "scripts", "setup.mjs"));
|
||||
// Exactly what `Out-File` / `Set-Content` leaves behind, and what a human
|
||||
// editing the record by hand produces on Windows.
|
||||
writeFileSync(
|
||||
path.join(bundle, "mirror.json"),
|
||||
"\uFEFF" + JSON.stringify({ schema: 1, pluginRoot: checkout, mirroredAt: "2026-10-18T00:00:00Z" }),
|
||||
);
|
||||
// The bundle has no manifest of its own: only the record can supply one.
|
||||
assert.equal(existsSync(path.join(bundle, "manifest", "bundle.manifest.json")), false);
|
||||
|
||||
const run = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(bundle, "scripts", "setup.mjs"), "--dry-run", "--only", "node", "--root", bundle],
|
||||
{ encoding: "utf8", timeout: 120000 },
|
||||
);
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
// loadManifest() runs before any component: reaching the plan proves the
|
||||
// record was parsed. A BOM-blind parse fell through to the bundle and died
|
||||
// on a missing manifest.
|
||||
assert.match(run.stdout, /\[plan\] node/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a BOM-ed bundle.json is read instead of treated as unprovisioned", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-bom-bundle-"));
|
||||
try {
|
||||
const checkout = bomFixtureCheckout(root);
|
||||
const bundle = path.join(root, "bundle");
|
||||
mkdirSync(bundle, { recursive: true });
|
||||
const args = [path.join(checkout, "scripts", "setup.mjs"), "--dry-run", "--only", "node", "--root", bundle];
|
||||
|
||||
// Control: with no record at all the component is planned, so the assertion
|
||||
// below cannot pass for a reason unrelated to reading the file.
|
||||
const fresh = spawnSync(process.execPath, args, { encoding: "utf8", timeout: 120000 });
|
||||
assert.equal(fresh.status, 0, fresh.stderr);
|
||||
assert.match(fresh.stdout, /\[plan\] node/);
|
||||
|
||||
writeFileSync(
|
||||
path.join(bundle, "bundle.json"),
|
||||
"\uFEFF" + JSON.stringify({ schema: 1, components: { node: { path: process.execPath, version: "v-bom-test" } } }, null, 2),
|
||||
);
|
||||
const run = spawnSync(process.execPath, args, { encoding: "utf8", timeout: 120000 });
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.match(run.stdout, /already provisioned \(v-bom-test\)/, run.stdout);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -12,11 +12,36 @@ function Check($name, [bool]$ok, $detail) {
|
||||
}
|
||||
|
||||
Write-Host "`n=== 1. JS syntax ===" -ForegroundColor Cyan
|
||||
foreach ($f in @('bin\rea-shim.mjs','scripts\setup.mjs','scripts\audit-manifest.mjs','scripts\setup.test.mjs')) {
|
||||
foreach ($f in @('index.mjs','bin\rea-shim.mjs','scripts\setup.mjs','scripts\audit-manifest.mjs','scripts\setup.test.mjs','scripts\host-compat.mjs')) {
|
||||
$out = & node --check $f 2>&1
|
||||
Check $f ($LASTEXITCODE -eq 0) ($out -join ' ')
|
||||
}
|
||||
|
||||
Write-Host "`n=== 1b. both JSON readers tolerate a BOM ===" -ForegroundColor Cyan
|
||||
# bin/rea-shim.mjs and scripts/setup.mjs read the same mirror.json and bundle.json.
|
||||
# A BOM strip on one side only makes one file mean "there is a checkout" to the
|
||||
# shim and "there is none" to setup, and the mirrored setup then re-mirrors the
|
||||
# previous manifest. A bare JSON.parse(readFileSync(...)) is that divergence.
|
||||
$jsBom = Join-Path $env:TEMP 'rea-verify-bom.cjs'
|
||||
@'
|
||||
const fs = require("node:fs");
|
||||
const blind = [];
|
||||
for (const file of ["bin/rea-shim.mjs", "scripts/setup.mjs"]) {
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
for (const m of text.matchAll(/JSON\.parse\(\s*readFileSync\(/g)) {
|
||||
// readJson() itself is the strip; anything else must not parse a file raw.
|
||||
if (!/replace\(/.test(text.slice(m.index, m.index + 200))) {
|
||||
blind.push(file + "@" + text.slice(0, m.index).split("\n").length);
|
||||
}
|
||||
}
|
||||
if (!text.includes("replace(/^\\uFEFF/")) blind.push(file + ": no U+FEFF strip");
|
||||
}
|
||||
console.log("bom-blind reads: " + JSON.stringify(blind));
|
||||
if (blind.length) process.exit(1);
|
||||
'@ | Set-Content $jsBom -Encoding utf8
|
||||
& node $jsBom 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||
Check 'no BOM-blind JSON read' ($LASTEXITCODE -eq 0) 'see above'
|
||||
|
||||
Write-Host "`n=== 2. PowerShell parse + BOM ===" -ForegroundColor Cyan
|
||||
# This script checks itself too: it is edited as often as the others, and an editor
|
||||
# that drops the BOM would otherwise go unnoticed until someone adds CJK text here.
|
||||
@@ -73,10 +98,37 @@ Write-Host "`n=== 6. package payload lists what the bundle needs ===" -Foregroun
|
||||
# -Encoding UTF8 is required: the package description contains CJK text, and
|
||||
# PowerShell 5.1 reads a BOM-less file as ANSI, which breaks ConvertFrom-Json.
|
||||
$pkg = Get-Content (Join-Path $root 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
foreach ($entry in @('bin/', 'scripts/', 'manifest/', 'cordis.patch.yml')) {
|
||||
foreach ($entry in @('index.mjs', 'bin/', 'scripts/', 'manifest/', 'cordis.patch.yml')) {
|
||||
Check "files[] includes $entry" (@($pkg.files) -contains $entry) ''
|
||||
}
|
||||
foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test')) {
|
||||
# The market's update path asks whether package.json's main/exports points at a
|
||||
# file that exists, and rolls the update back when it does not (dshmarket's
|
||||
# hasLoadableEntry). This package declared neither until 2.0.5, so every update of
|
||||
# a healthy profile failed with "updated build has no loadable entry". Asserting
|
||||
# the pointer resolves keeps that from returning silently.
|
||||
Check 'package.json declares main' (-not [string]::IsNullOrWhiteSpace($pkg.main)) ''
|
||||
Check 'main resolves to a shipped file' ($pkg.main -and (Test-Path (Join-Path $root $pkg.main))) "main=$($pkg.main)"
|
||||
# The market decides "compatible" vs "unknown" from engines.dsh, and hides the
|
||||
# plugin from hosts whose version does not match. An absent or unparseable
|
||||
# declaration therefore reads as "nothing to say" rather than as a defect, which
|
||||
# is why the coverage check runs here as well as in CI.
|
||||
Check 'package.json declares engines.dsh' (-not [string]::IsNullOrWhiteSpace($pkg.engines.dsh)) ''
|
||||
$compat = & node scripts\host-compat.mjs 2>&1
|
||||
Check 'engines.dsh covers every released DSH host' ($LASTEXITCODE -eq 0) (($compat | Select-Object -Last 2) -join ' ')
|
||||
# Both READMEs quote the range verbatim, and a stale quote is invisible: JSON,
|
||||
# host-compat and the audit all agree while the sentence a human reads names a
|
||||
# different ceiling. That is exactly how `<0.3.0` survived in the docs after the
|
||||
# declaration moved to `<0.3.0-0`, so the comparison lives here now.
|
||||
foreach ($doc in @('README.md', 'README.zh-CN.md')) {
|
||||
$text = Get-Content (Join-Path $root $doc) -Raw -Encoding UTF8
|
||||
Check "$doc quotes the declared engines.dsh" $text.Contains([string]$pkg.engines.dsh) "expected $($pkg.engines.dsh)"
|
||||
# The pin example is copy-pasteable, so it must name a ref that will exist: the
|
||||
# version this tree publishes. It has drifted twice (v2.0.0 long after 2.0.5,
|
||||
# then v2.0.8 while the tree was 2.0.9), and both times every other signal was
|
||||
# green because nothing compared the two.
|
||||
Check "$doc pins the current version" $text.Contains("dsh-plugin-rea#v$($pkg.version)") "expected dsh-plugin-rea#v$($pkg.version)"
|
||||
}
|
||||
foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test', 'bundle:host-compat')) {
|
||||
Check "scripts.$script exists" ($pkg.scripts.PSObject.Properties.Name -contains $script) ''
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user