3 Commits
Author SHA1 Message Date
czcdr a6ee476bfd compat: declare the DSH host range the market reads (2.0.6)
engines.dsh = '>=0.1.7-rc.2 <0.3.0'. The market's deriveHostCompatibility reads only engines.dsh (top-level or dsh.engines) and peers on @deepseek-ai/dsh*; with neither it reports 'unknown', which is what the card has shown until now. Peer declarations are deliberately NOT used: @deepseek-ai/dsh-mcp-client is published on npm at 0.0.1-rc.1 while the host ships 0.2.x, so a peer range would resolve to a version the host does not have.

The range is checked, not guessed: scripts/host-compat.mjs carries the dsh release list (0.1.7-rc.2 .. 0.2.1-rc.2) and fails when the declaration stops covering one of them, or starts admitting 0.3.0. The row surface was compared at dsh-v0.1.7-rc.2 and dsh-v0.2.1-rc.2: packages/mcp/mcp-client exposes the same config keys, and dsh.bundle.patch exists at both.

Upstream rea-agents 6.6.0 is released on GitHub but npm latest is still 6.5.0, so the pin stays at 6.5.0; the bump is scheduled for 2026-10-19.
2026-10-18 12:31:27 +08:00
czcdr d0e2ffd7a6 package: declare the entry file the market's update check requires (2.0.5)
dshmarket's update path asks hasLoadableEntry whether the new build has an entry file that exists (main/exports, else index.js) and rolls back when it does not. This package declared neither, and the package it mounts (@deepseek-ai/dsh-mcp-client) ships inside the harness build rather than in node_modules, so every location the check probes was empty: 2.0.1 -> 2.0.4 failed three times with 'updated build has no loadable entry' on a healthy profile, and the rollback was reported unverifiable for the same reason.

index.mjs is a real but deliberately empty cordis plugin: the working row is still inserted by cordis.patch.yml, so loading it must do nothing (a second serverName: rea would be refused). Two tests, verify-branch and the CI manifest/payload checks now pin both the pointer and its presence in files[].
2026-10-18 11:53:24 +08:00
czcdr 27d3f1106c shim: guard the stale-lock removal and name the duplicate REA registration (2.0.4) 2026-10-17 13:03:15 +08:00
10 changed files with 726 additions and 12 deletions

No files matched your search

+16 -2
View File
@@ -25,13 +25,27 @@ 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: JSON files parse
run: |
node -e "
@@ -51,7 +65,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));
+96
View File
@@ -2,6 +2,102 @@
本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
## 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` 的同名注册冲突现在会被明确提示。**
### 修复
- **只读 bundle 根目录 + 陈旧 lock → shim 未捕获 EACCES 崩溃**:2.0.3 把删锁移出了 `if (fresh)`,却没包
try/catch。目录只读(`chmod 555`、系统级安装目录)且锁已陈旧时,`rmSync(lock)` 抛 `EACCES`,shim 以
未捕获异常退出(exit 1 + 堆栈)—— 比原来的静默更吵,但同样起不来。沙箱已复现(WSL `chmod 555`)。
现在删锁失败返回 `unwritable`,走既有的「无法写入,请手动跑 setup」路径(exit 78,行不崩)。
- **与上游 `rea setup --client dsh` 的同名注册冲突**:该命令往 `<DSH_HOME>/cordis.patch.yml` 写入
`id: rea-mcp`、`serverName: rea`,而本包是 `id: mcp-rea`、同一个 `serverName`;DSH 对同名条目只保留
一个(后加载者报 duplicate server name)。已按上游 6.5.0 实测生成该文件确认。shim 现在启动时检测同
文件的 `serverName: rea`(id 不是 `mcp-rea`)并向 stderr 提示「二选一」,`--shim-status` 也会在
`duplicateRegistration` 下报告。
### 文档
- 两份 README 新增「只保留一条 REA 注册」小节:与 `rea setup --client dsh` 二选一,并说明两条路线的
取舍(零依赖供给 vs 上游官方纯配置注册)。
- v1 分支 README 顶部加横幅:指向 2.x(`main`,默认推荐)与上游官方注册。
### 验证
- 单测 40 条:Windows 37 过 + 3 平台跳过;WSL 39 过 + 1 跳过(本轮新增 3 条,其中只读目录一条仅 POSIX)。
- 反向验证:回退删锁的 try/catch → 只读目录测试在 WSL 必红(EACCES/exit 1);回退冲突检测 → 重复注册
测试必红。
- `verify-branch.ps1` 全绿。
## 2.0.3 — 2026-10-16
**修复 11 处已核实的缺陷(每条都先确认存在、并配了"回退即变红"的回归测试);上游 REA 钉子从 6.4.0 升到 6.5.0。**
+72
View File
@@ -82,6 +82,10 @@ Both lines ship the **same patch shape** — one `insert` row of `@deepseek-ai/d
same `mcp__rea__*` surface; they differ in who prepares the runtime. Moving from 1.x to 2.x takes two
commands: see [Upgrading from 1.x](#upgrading-from-1x).
Upstream also registers itself with dsh now (`rea setup --client dsh`); that route is essentially the
1.x model maintained upstream — configuration only, your own Node/REA/Ghidra. This bundle is the
zero-dependency route. Pick one of the three, and never keep two `serverName: rea` rows at once.
## Install
**Nothing to install first.** On a bare Windows x64 machine:
@@ -110,6 +114,10 @@ If Node is already installed, you can skip the bootstrap and run provisioning di
node scripts/setup.mjs --reuse # or --dry-run to see the plan first
```
Node, when you provide it instead of letting setup fetch one, must satisfy REA's engines —
`^22.19.0 || ^24.11.0 || >=26.0.0` (check with `node -v`). A lower Node does not fail at install
time; the MCP row fails later, which reads as silence.
Installing the plugin **first** also works, but it takes two steps. The row resolves to
`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`, and that file is created by setup — so until the first
provisioning run the row exits with `MODULE_NOT_FOUND`, and nothing is there to start a background
@@ -117,6 +125,23 @@ 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"`, 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.
## Configure — nothing
The shipped row carries **no machine path**:
@@ -168,6 +193,27 @@ To pin a different launch command on this machine (for example your own globally
the row from the profile's own `cordis.patch.yml` — that file is machine-local and never published. Only one
row may use `serverName: rea` in a profile.
### One REA registration only
Upstream REA 6.5.0 can register itself with DSH:
```
rea setup --client dsh
```
That writes a row with `id: rea-mcp` and `serverName: rea` into `<DSH_HOME>/cordis.patch.yml`. This
bundle's row is `id: mcp-rea` with the same `serverName`, and DSH rejects the later of two entries
that share a server name — so which REA row survives depends on load order. **Use one, not both.**
Which one? Upstream's is the configuration-only route: it expects Node on REA's engines,
`rea-agents` reachable, and a Ghidra/JDK you provisioned yourself. This bundle is the
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`.
## Verify
Ask for a `mcp__rea__*` call — `binary_session` reports the engine checks
@@ -213,6 +259,11 @@ macOS/Linux.
request, and a `.part` that already holds the whole file is verified and kept instead of re-fetched.
- Some REA tools launch processes, write files and access the network — DSH's permission policy applies.
Analyse only artifacts you are authorized to inspect.
- The MCP child and the engines it launches are ordinary processes running as **you**. DSH's tool
approvals govern what the model asks for, not what that child may read, write or send on its own;
treat the plugin like any third-party code you install.
- On Linux, upstream's process-ownership checks need a **procps-compatible `ps`** on PATH; the
Ghidra/JADX and firmware paths use it. A minimal image without it fails those tools, not the install.
- `scripts/setup.mjs` runs `npm install` for the REA CLI with `--ignore-scripts`: the published tarball
carries no `node_modules`, so a dependency closure must be resolved, and package lifecycle scripts are
skipped on purpose during provisioning. That step is also the **only** one that cannot use this bundle's
@@ -228,6 +279,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:
+64
View File
@@ -77,6 +77,9 @@ patch 里不含任何机器路径**。
`mcp__rea__*` 工具面;区别只在"谁准备运行时"。从 1.x 到 2.x 只要两条命令,见
[从 1.x 升级](#从-1x-升级)。
上游现在也能自己注册进 dsh(`rea setup --client dsh`):那基本就是上游维护的 1.x 模式 —— 只写配置,
Node/REA/Ghidra 由你准备。本包是零依赖路线。三条路选一条,不要同时留两行 `serverName: rea`。
## 安装
**前置什么都不用装。** 在干净的 Windows x64 机器上:
@@ -104,11 +107,30 @@ dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.0
node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
```
如果 Node 由你自备(而不是让 setup 去取),它必须满足 REA 的 engines:
`^22.19.0 || ^24.11.0 || >=26.0.0`(用 `node -v` 核对)。版本偏低不会在安装时失败,而是到 MCP 行
启动时才失败 —— 表现就是"没有反应"。
反过来**先装插件**也可以,但要分两步:发布的行指向 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`,
而那个文件**只有 setup 会创建**——所以在第一次供给之前,该行会以 `MODULE_NOT_FOUND` 退出,也就没有
任何东西能去启动后台供给(harness 本身不受影响:`failOnStartupError: false` 是刻意的)。先手动跑一次
`node scripts/setup.mjs`;此后 shim 就位,之后每一次「状态缺失」都由它自己发现并在后台补供给。
### 支持哪些 DSH 版本
`package.json` 里声明了 `engines.dsh: ">=0.1.7-rc.2 <0.3.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`。范围之外的宿主可能照样能用,只是没有替它声明。
## 配置 —— 不需要
发布的那一行**不含任何机器路径**:
@@ -159,6 +181,25 @@ node bin/rea-shim.mjs --shim-print-env # MCP 子进程将看到的环境变量
`cordis.patch.yml` 按 id 覆盖即可 —— 那个文件是机器本地的,永不发布。一个 profile 里只允许一行使用
`serverName: rea`。
### 只保留一条 REA 注册
上游 REA 6.5.0 起可以自己注册进 DSH:
```
rea setup --client dsh
```
它会把一行 `id: rea-mcp`、`serverName: rea` 写进 `<DSH_HOME>/cordis.patch.yml`。本包的行是
`id: mcp-rea`、同样 `serverName: rea`,而 DSH 对同名条目只保留一个:后加载的那行会以
duplicate server name 报错并加载失败,最终留下哪一行取决于加载顺序。**二选一,不要同时用。**
选哪个?上游那条是纯配置路线:需要 Node 满足 REA engines、`rea-agents` 可达,Ghidra/JDK 由你
自己准备。本包是零依赖路线:首次运行自动取 Node、REA、JDK、Ghidra。两者的 patch 形态与
`mcp__rea__*` 工具面相同。
shim 在能看到该文件时会在启动时提示冲突 —— MCP 子进程里拿不到自定义 `DSH_HOME`,那种情况下它会
退回 `~/.dsh`;`node bin/rea-shim.mjs --shim-status` 会把冲突列在 `duplicateRegistration` 下。
## 验证
随便发一个 `mcp__rea__*` 调用 —— `binary_session` 会报引擎各项检查
@@ -203,6 +244,10 @@ shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.5.0}
同一个地址 `Invoke-WebRequest` 通、Node 却 `ECONNRESET`/超时。setup 自己解析代理
(环境变量优先,Windows 上回退读注册表)并用 CONNECT 隧道,`NO_PROXY` 也照常生效。
- REA 的部分工具会启动进程、写文件、访问网络 —— DSH 的权限策略照常适用。只分析你有权检视的样本。
- MCP 子进程与它拉起的引擎都是以**你的身份**运行的普通进程。DSH 的工具审批管的是模型发起的调用,
管不到这个子进程自己去读、写或发送什么;请把插件当作你安装的第三方代码来对待。
- Linux 上,上游的进程归属检查依赖 PATH 上**兼容 procps 的 `ps`**;Ghidra/JADX 与固件工具会用到它。
极简镜像里缺它只会让这些工具失败,不影响整体安装。
- `scripts/setup.mjs` 为 REA CLI 跑的是带 `--ignore-scripts` 的 `npm install`:官方 tarball 不带
`node_modules`,必须解析依赖闭包;而供给过程中**刻意跳过包的 lifecycle 脚本**。这一步也是**唯一**用不上
本插件自己的路由/代理回退的环节 —— npm 只从环境读 `HTTPS_PROXY`/`HTTP_PROXY` 与
@@ -216,6 +261,25 @@ shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.5.0}
## 更新 —— 三件互相独立的事
### 在市场里更新
在哪儿装的就在哪儿更新。更新到 **2.0.5 及以上**是常规操作;而把已装的 2.0.1–2.0.4 通过应用内市场**更新到
2.0.4**,在健康的 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:
+60 -1
View File
@@ -93,6 +93,43 @@ function provisioningScript(root) {
return usableSetup(PLUGIN_ROOT);
}
/**
* Upstream REA 6.5.0 can add a second registration for the same MCP server:
* `rea setup --client dsh` writes a row with id "rea-mcp" and serverName "rea"
* into `<DSH_HOME>/cordis.patch.yml`, while this bundle ships id "mcp-rea" with
* the same serverName. DSH does not dedupe those rows: it rejects the later entry
* with a duplicate-server-name error, so which REA row survives depends on load
* order. The shim cannot prevent that, but it can name the conflict instead of
* leaving a missing row to be diagnosed from "the tools are gone".
*
* 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.
*/
function foreignReaRegistration() {
const patch = path.join(dshHome(), "cordis.patch.yml");
let text;
try {
text = readFileSync(patch, "utf8");
} catch {
return null;
}
const unquote = (value) => value.replace(/^(['"])(.*)\1$/, "$2").trim();
// A row starts at `- id:`; the block runs to the next row, so a serverName can
// never be attributed to a row that does not contain it.
for (const block of text.split(/\r?\n(?=\s*-\s+id:\s*)/)) {
const id = /^\s*-\s+id:\s*(.+?)\s*$/m.exec(block);
const server = /^\s+serverName:\s*(.+?)\s*$/m.exec(block);
if (!id || !server || unquote(server[1]) !== "rea") continue;
const rowId = unquote(id[1]);
if (rowId !== "mcp-rea") return { patch, id: rowId };
}
return null;
}
/**
* The provisioning lock is a small JSON record: { state, pid, at, exit? }.
* `state: "running"` is written when a run is started (setup's own pid once it
@@ -175,7 +212,16 @@ function maybeAutoProvision(root) {
// *forever*, auto-provisioning never ran again, and main() printed nothing at
// all for that outcome. The lock's own "honour it only while it is fresh"
// comment described behaviour the code did not have.
rmSync(lock, { force: true });
try {
rmSync(lock, { force: true });
} catch {
// A read-only bundle root (system-wide install, chmod 555) cannot drop the
// stale lock either. Returning the same "cannot write here" outcome as the
// exclusive create below keeps the row alive: an uncaught EACCES killed the
// shim with a stack trace, which is louder than the old silence but just as
// unable to start REA.
return { started: false, reason: "unwritable", logPath };
}
}
try {
mkdirSync(path.join(root, "logs"), { recursive: true });
@@ -351,6 +397,18 @@ function main() {
const root = bundleRoot();
const loaded = readBundleConfig(root);
// 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();
if (duplicate && !argv.includes("--shim-status")) {
process.stderr.write(
`\n dsh-plugin-rea (bundle): duplicate REA registration.\n` +
` ${duplicate.patch} also registers serverName "rea" (id "${duplicate.id}").\n` +
` This bundle's row is id "mcp-rea" with the same serverName; DSH rejects the later\n` +
` entry as a duplicate server name, so only one survives. Keep exactly one of them.\n`,
);
}
if (argv.includes("--shim-status")) {
const report = {
@@ -360,6 +418,7 @@ function main() {
reason: loaded.ok ? undefined : loaded.code,
autoProvision: process.env.REA_BUNDLE_AUTO_PROVISION ?? "(default)",
};
if (duplicate) report.duplicateRegistration = duplicate;
if (loaded.ok) {
report.paths = loaded.config.paths ?? {};
report.versions = loaded.config.versions ?? {};
+43
View File
@@ -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
View File
@@ -1,8 +1,9 @@
{
"name": "dsh-plugin-rea",
"version": "2.0.3",
"version": "2.0.6",
"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"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/"
+199
View File
@@ -0,0 +1,199 @@
#!/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.
*
* 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',
'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.
* @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 match = SEMVER.exec(version.trim())
if (match === null) return null
const pre = match[4] === undefined
? []
: match[4].split('.').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()
+152 -3
View File
@@ -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 */
@@ -813,9 +814,17 @@ function makeBundleTree(root, { marker, lock }) {
return bundle;
}
function runShim(bundle, args) {
const env = { ...process.env, REA_BUNDLE_ROOT: bundle };
function runShim(bundle, args, extraEnv = {}) {
// A fresh home keeps the duplicate-registration check from reading this
// machine's real ~/.dsh/cordis.patch.yml and making the suite depend on it.
const env = {
...process.env,
DSH_HOME: path.join(path.dirname(bundle), "dsh-home"),
REA_BUNDLE_ROOT: bundle,
...extraEnv,
};
delete env.REA_BUNDLE_AUTO_PROVISION;
mkdirSync(env.DSH_HOME, { recursive: true });
return spawnSync(process.execPath, [path.join(bundle, "bin", "rea-shim.mjs"), ...args], { encoding: "utf8", timeout: 60000, env });
}
@@ -896,6 +905,71 @@ test("an expired lock is cleared instead of wedging provisioning forever", async
}
});
test("a read-only bundle root reports unwritable instead of crashing on the stale lock", { skip: process.platform === "win32" ? "POSIX directory permissions only" : false }, () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-readonly-"));
try {
const stale = Date.now() - 7 * 60 * 60 * 1000;
const lock = JSON.stringify({ state: "running", pid: 999999, at: stale });
const bundle = makeBundleTree(root, { marker: path.join(root, "setup-ran"), lock });
chmodSync(bundle, 0o555);
try {
const run = runShim(bundle, ["mcp"], { DSH_HOME: path.join(root, "dsh-home") });
assert.equal(run.status, 78, run.stderr);
// Before the fix this was an uncaught EACCES stack trace and exit 1: the
// stale lock could not be removed, and nothing caught the throw.
assert.match(run.stderr, /provisioning could not be started \(unwritable\)/);
assert.doesNotMatch(run.stderr, /EACCES/);
} finally {
chmodSync(bundle, 0o755);
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a second REA registration is reported instead of silently duplicated", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-duplicate-"));
try {
const bundle = makeBundleTree(root, { marker: path.join(root, "setup-ran"), lock: undefined });
const home = path.join(root, "dsh-home");
mkdirSync(home, { recursive: true });
writeFileSync(
path.join(home, "cordis.patch.yml"),
["- insert:", " - id: rea-mcp", " name: '@deepseek-ai/dsh-mcp-client'", " config:", " serverName: rea", " transport: stdio", ""].join("\n"),
);
const run = runShim(bundle, ["mcp"], { DSH_HOME: home });
assert.equal(run.status, 78, run.stderr);
assert.match(run.stderr, /duplicate REA registration/);
assert.match(run.stderr, /id "rea-mcp"/);
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 },
});
assert.equal(JSON.parse(status.stdout).duplicateRegistration.id, "rea-mcp");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("this bundle's own row is not reported as a duplicate", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-own-row-"));
try {
const bundle = makeBundleTree(root, { marker: path.join(root, "setup-ran"), lock: undefined });
const home = path.join(root, "dsh-home");
mkdirSync(home, { recursive: true });
writeFileSync(
path.join(home, "cordis.patch.yml"),
["- insert:", " - id: mcp-rea", " name: '@deepseek-ai/dsh-mcp-client'", " config:", " serverName: rea", ""].join("\n"),
);
const run = runShim(bundle, ["mcp"], { DSH_HOME: home });
assert.equal(run.status, 78, run.stderr);
assert.doesNotMatch(run.stderr, /duplicate REA registration/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
/* ------------------------------- --offline is a promise, and npm is the one step
* that cannot keep it: it is an install, not a download. -------------------- */
@@ -1095,3 +1169,78 @@ 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");
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("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.equal(covered.length, HOST_RELEASES.length, "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");
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");
// And the ceiling is load bearing: the next minor line is a host nobody audited.
assert.equal(satisfiesDeclaredRange("0.3.0", ">=0.1.7-rc.2 <0.3.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);
assert.equal(OUTSIDE_HOSTS.length > 0, true);
});
+17 -3
View File
@@ -12,7 +12,7 @@ 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 ' ')
}
@@ -73,10 +73,24 @@ 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 ' ')
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) ''
}