dsh-plugin-rea 2.0.4 → 2.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/README.md +38 -0
- package/README.zh-CN.md +34 -0
- package/index.mjs +43 -0
- package/package.json +7 -3
- package/scripts/host-compat.mjs +199 -0
- package/scripts/setup.test.mjs +77 -1
- package/scripts/verify-branch.ps1 +17 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,73 @@
|
|
|
2
2
|
|
|
3
3
|
本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
|
|
4
4
|
|
|
5
|
+
## 2.0.6 — 2026-10-18
|
|
6
|
+
|
|
7
|
+
**声明宿主版本:`engines.dsh` 覆盖 `0.1.7-rc.2` … `0.2.1-rc.2` 全部发布版本,市场卡片因此显示「兼容」而不是「未声明」。**
|
|
8
|
+
|
|
9
|
+
### 兼容声明
|
|
10
|
+
|
|
11
|
+
- **`engines.dsh: ">=0.1.7-rc.2 <0.3.0"`**:市场侧的 `deriveHostCompatibility` 只认两类声明 —— `engines.dsh`
|
|
12
|
+
与指向 `@deepseek-ai/dsh*` 的 `peerDependencies`;两者都没有就是 `unknown`(卡片「兼容性未知」)。
|
|
13
|
+
这里只声明前者,刻意不声明 peer:`@deepseek-ai/dsh-mcp-client` 在 npm 上只有 `0.0.1-rc.1`,
|
|
14
|
+
而宿主随包内置的是 0.2.x,声明 peer 会让 pnpm 解析出一个与宿主不一致的版本,反而制造假冲突。
|
|
15
|
+
- **范围来自核对,不是估计**:`scripts/host-compat.mjs` 内置 dsh 发布列表(0.1.7-rc.2 至 0.2.1-rc.2 共 10 个),
|
|
16
|
+
逐个验证是否被声明覆盖,并反向确认 `0.3.0` 与 `0.1.7-rc.1`、`0.1.7-alpha.2` 落在范围之外。
|
|
17
|
+
- **跨版本比对过的东西**:`packages/mcp/mcp-client` 在 `dsh-v0.1.7-rc.2` 与 `dsh-v0.2.1-rc.2` 两个 tag 上,
|
|
18
|
+
配置面与发布的行一致(`serverName`/`transport`/`command`/`args`/`env`/`failOnStartupError` 均存在且同名),
|
|
19
|
+
且两个 tag 都有 `dsh.bundle.patch`。范围声明的是「核对过的宿主」,不是「应该能用」。
|
|
20
|
+
|
|
21
|
+
### 上游(只更新仓库,暂不带入)
|
|
22
|
+
|
|
23
|
+
- 上游已发布 **rea-agents 6.6.0**(GitHub release `rea-agents-6.6.0`,2026-10-18);npm `latest` 仍是 **6.5.0**,
|
|
24
|
+
所以本包的钉(`manifest/bundle.manifest.json` 的 `pins.reaAgents` 与 `components.reaAgents.sources.npm.version`)
|
|
25
|
+
**保持 6.5.0 不动**。已核 6.6.0 的 `engines.node`(`^22.19.0 || ^24.11.0 || >=26.0.0`)与 `bin`
|
|
26
|
+
(`scripts/rea.mjs`)均未变化,`optionalDependencies.frida` 为 17.23.2。
|
|
27
|
+
- 计划:**2026-10-19** 随下一版把钉升到 6.6.0 并复跑真机;届时工具数、探针正则与文档一并更新。
|
|
28
|
+
|
|
29
|
+
### 修复 / 工程
|
|
30
|
+
|
|
31
|
+
- 新增 `scripts/host-compat.mjs`(纯函数 + CLI):semver 预发布排序、`>=floor <ceiling` 判定、发布列表覆盖检查。
|
|
32
|
+
- `bundle:host-compat` npm script;`verify-branch.ps1` 新增 `engines.dsh` 存在性与覆盖率两项;
|
|
33
|
+
`validate.yml` 新增「Declared DSH range covers every released host」步骤,清单检查也要求 `engines.dsh`。
|
|
34
|
+
|
|
35
|
+
### 验证
|
|
36
|
+
|
|
37
|
+
- 单测 **46 条**(新增 4 条:声明可解析、10 个发布版本全覆盖且 3 个未核对版本被排除、反向对照能抓出
|
|
38
|
+
`>=0.2.0` 这种看似合理却漏掉整个 0.2.0-rc 线的写法、预发布排序符合 semver 而非字符串序):Windows 43 过 + 3 平台跳过。
|
|
39
|
+
- `node scripts/host-compat.mjs`:10 个发布版本 covered、3 个 correctly outside。
|
|
40
|
+
- `verify-branch.ps1` 全绿;`audit-manifest.mjs` clean(两份 README 结构仍平行)。
|
|
41
|
+
|
|
42
|
+
## 2.0.5 — 2026-10-18
|
|
43
|
+
|
|
44
|
+
**市场更新失败已定位并修复:清单里没有 `main`,市场因此在每次更新时判「新构建没有可加载入口」并回滚。**
|
|
45
|
+
|
|
46
|
+
### 修复
|
|
47
|
+
|
|
48
|
+
- **应用内市场更新到 2.0.4 失败**:`dshmarket` 的更新路径用 `hasLoadableEntry` 判断新构建能不能加载
|
|
49
|
+
(实现是 `entryArtifactExists`:读 `main`/`exports`,两者都没有就回落 `index.js`)。本包是纯 bundle
|
|
50
|
+
(只有 `dsh.bundle.patch`),从未声明 `main`;而它挂载的 `@deepseek-ai/dsh-mcp-client` 属于 harness 自身
|
|
51
|
+
构建、不在 `node_modules` 里,于是该检查要看的 profile / 包内 / workspace 三个位置全空。结果:2.0.1 → 2.0.4
|
|
52
|
+
的更新连续三次报「更新后缺少入口文件……且未能验证恢复原版本文件」,而回滚其实成功了 —— 旧版同样没有入口,
|
|
53
|
+
所以回滚后的复检依然为 false。现已声明 `main: ./index.mjs`,并让该文件进入 `files[]`。
|
|
54
|
+
- **`index.mjs` 是真空插件,不是占位**:导出 `name` 与空 `apply()`,不注册行、不起进程、不写状态;真正起作用的
|
|
55
|
+
行仍由 `cordis.patch.yml` 插入。若 harness 某天真把它当插件加载,行为仍是「什么都不做」,因此不会造出第二条
|
|
56
|
+
`serverName: rea`。
|
|
57
|
+
|
|
58
|
+
### 文档
|
|
59
|
+
|
|
60
|
+
- 两份 README 新增「在市场里更新 / Updating from the market」小节:说明该报错来自市场侧的入口判定、与 tarball
|
|
61
|
+
完整性无关、profile 未受损,以及 2.0.5 起的做法。
|
|
62
|
+
|
|
63
|
+
### 验证
|
|
64
|
+
|
|
65
|
+
- 用 `dshmarket` 自身的 `hasLoadableEntry` 实测:把本包按 profile 布局模拟安装,修复前 `false`、修复后 `true`;
|
|
66
|
+
对仓库目录 `entryArtifactExists` 修复后为 `true`。
|
|
67
|
+
- 单测 42 条(新增 2 条:清单声明的入口必须存在且被 `files[]` 收录;入口可 `import`、导出的 `name` 与包名一致、
|
|
68
|
+
`apply()` 返回 `undefined`):Windows 39 过 + 3 平台跳过。
|
|
69
|
+
- `verify-branch.ps1` 新增 `main` 存在性与 `files[]` 收录检查;`validate.yml` 的清单检查与 npm 载荷检查加了同一条守卫。
|
|
70
|
+
- `npm pack --dry-run`:20 个文件,含 `index.mjs`。
|
|
71
|
+
|
|
5
72
|
## 2.0.4 — 2026-10-17
|
|
6
73
|
|
|
7
74
|
**两处已核实的缺陷:只读 bundle 目录下 shim 启动即崩;与上游 `rea setup --client dsh` 的同名注册冲突现在会被明确提示。**
|
package/README.md
CHANGED
|
@@ -125,6 +125,23 @@ provisioning (the harness itself is unaffected: `failOnStartupError: false` is d
|
|
|
125
125
|
`node scripts/setup.mjs` once; from then on the shim is in place, and every later missing state is picked up
|
|
126
126
|
by the shim itself, which provisions in the background.
|
|
127
127
|
|
|
128
|
+
### Which DSH versions
|
|
129
|
+
|
|
130
|
+
`package.json` declares `engines.dsh: ">=0.1.7-rc.2 <0.3.0"`, and that declaration is the answer the plugin market
|
|
131
|
+
reads: with it the card says **compatible**; without it the market can only say *unknown*. The range is not a guess —
|
|
132
|
+
it is the dsh release list this plugin is checked against, and `node scripts/host-compat.mjs` fails the build when the
|
|
133
|
+
two disagree:
|
|
134
|
+
|
|
135
|
+
| Host | Covered |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `0.1.7-rc.2` … `0.2.1-rc.2` — every release in between, alpha and rc | yes |
|
|
138
|
+
| `0.3.0` and later | no — a new minor line is a host nobody updated this row for yet |
|
|
139
|
+
|
|
140
|
+
What was actually compared across that range is the row provider: `packages/mcp/mcp-client` at `dsh-v0.1.7-rc.2` and at
|
|
141
|
+
`dsh-v0.2.1-rc.2` exposes the same config surface the shipped row uses (`serverName`, `transport`, `command`, `args`,
|
|
142
|
+
`env`, `failOnStartupError`), and `dsh.bundle.patch` exists at both tags. A host outside the range can still work; it is
|
|
143
|
+
simply not claimed.
|
|
144
|
+
|
|
128
145
|
## Configure — nothing
|
|
129
146
|
|
|
130
147
|
The shipped row carries **no machine path**:
|
|
@@ -262,6 +279,27 @@ macOS/Linux.
|
|
|
262
279
|
|
|
263
280
|
## Updating — three independent things
|
|
264
281
|
|
|
282
|
+
### Updating from the market
|
|
283
|
+
|
|
284
|
+
Update it where you installed it. Updating **to 2.0.5 or later** is routine; updating an installed 2.0.1–2.0.4
|
|
285
|
+
*through the in-app market* failed with this, three times on a healthy profile:
|
|
286
|
+
|
|
287
|
+
> dsh-plugin-rea 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件
|
|
288
|
+
|
|
289
|
+
That message is not about a broken tarball. On every update the market asks whether the new build's
|
|
290
|
+
`package.json` points at an entry file that exists (`hasLoadableEntry` in `dshmarket`), and rolls the build back
|
|
291
|
+
when the answer is false. A bundle may legitimately own no entry — but only if a package it *mounts* has one, and
|
|
292
|
+
the package this bundle mounts (`@deepseek-ai/dsh-mcp-client`) ships inside the harness build rather than in
|
|
293
|
+
`node_modules`, so every location that test probes comes back empty. The 1.x and 2.0.x manifests also declared
|
|
294
|
+
no `main` at all, so the answer was false even though the profile was working; the rollback was then reported as
|
|
295
|
+
unverifiable for the same reason. Nothing in the profile was actually damaged — the update simply did not apply.
|
|
296
|
+
|
|
297
|
+
2.0.5 declares `main: ./index.mjs`. That file is a real, deliberately empty Cordis plugin: it registers nothing,
|
|
298
|
+
spawns nothing, touches no state — the working row is still inserted by `cordis.patch.yml` — and it exists so the
|
|
299
|
+
question "can this package load?" has an honest answer, and so `import 'dsh-plugin-rea'` resolves. On an older
|
|
300
|
+
version, update to `>=2.0.5`; if the market refuses before that version exists, install `dsh-plugin-rea@2.0.5`
|
|
301
|
+
(Desktop: through the plugin manager) instead of updating in place.
|
|
302
|
+
|
|
265
303
|
### Upgrading from 1.x
|
|
266
304
|
|
|
267
305
|
Install 2.x, then run provisioning **once** before restarting dsh:
|
package/README.zh-CN.md
CHANGED
|
@@ -116,6 +116,21 @@ node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
|
|
|
116
116
|
任何东西能去启动后台供给(harness 本身不受影响:`failOnStartupError: false` 是刻意的)。先手动跑一次
|
|
117
117
|
`node scripts/setup.mjs`;此后 shim 就位,之后每一次「状态缺失」都由它自己发现并在后台补供给。
|
|
118
118
|
|
|
119
|
+
### 支持哪些 DSH 版本
|
|
120
|
+
|
|
121
|
+
`package.json` 里声明了 `engines.dsh: ">=0.1.7-rc.2 <0.3.0"`,这就是插件市场读的那句话:有它,卡片显示
|
|
122
|
+
**兼容**;没有它,市场只能说*未声明*。这个范围不是猜的——它对应本插件实际核对过的 dsh 发布列表,
|
|
123
|
+
`node scripts/host-compat.mjs` 会在两者不一致时让构建失败:
|
|
124
|
+
|
|
125
|
+
| 宿主版本 | 是否覆盖 |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `0.1.7-rc.2` … `0.2.1-rc.2`(中间的 alpha 与 rc 全部在内) | 是 |
|
|
128
|
+
| `0.3.0` 及以后 | 否 —— 新的小版本线是还没为它核对过这一行的宿主 |
|
|
129
|
+
|
|
130
|
+
真正被比对过的是「行提供方」:`packages/mcp/mcp-client` 在 `dsh-v0.1.7-rc.2` 与 `dsh-v0.2.1-rc.2` 两个 tag 上,
|
|
131
|
+
暴露的配置面与发布的行完全一致(`serverName`、`transport`、`command`、`args`、`env`、`failOnStartupError`),
|
|
132
|
+
并且两个 tag 都有 `dsh.bundle.patch`。范围之外的宿主可能照样能用,只是没有替它声明。
|
|
133
|
+
|
|
119
134
|
## 配置 —— 不需要
|
|
120
135
|
|
|
121
136
|
发布的那一行**不含任何机器路径**:
|
|
@@ -246,6 +261,25 @@ shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.5.0}
|
|
|
246
261
|
|
|
247
262
|
## 更新 —— 三件互相独立的事
|
|
248
263
|
|
|
264
|
+
### 在市场里更新
|
|
265
|
+
|
|
266
|
+
在哪儿装的就在哪儿更新。更新到 **2.0.5 及以上**是常规操作;而把已装的 2.0.1–2.0.4 通过应用内市场**更新到
|
|
267
|
+
2.0.4**,在健康的 profile 上连续失败了三次:
|
|
268
|
+
|
|
269
|
+
> dsh-plugin-rea 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件
|
|
270
|
+
|
|
271
|
+
这条消息与 tarball 是否完整无关。市场在每次更新时都会问一句:新构建的 `package.json` 是否指向一个真实存在的
|
|
272
|
+
入口文件(`dshmarket` 里的 `hasLoadableEntry`);答案是「否」,就把这次更新判为坏构建并回滚。bundle 本来允许
|
|
273
|
+
自己没有入口 —— 前提是它**挂载**的某个包有入口;而本包挂载的 `@deepseek-ai/dsh-mcp-client` 属于 harness 自身
|
|
274
|
+
构建,不在 `node_modules` 里,于是这项检查要看的每个位置都是空的。1.x 与 2.0.x 的清单又根本没有声明 `main`,
|
|
275
|
+
所以答案必然是「否」,哪怕 profile 一直是好的;紧接着的回滚也因此被报成「无法验证」。实际上 profile 没有被
|
|
276
|
+
破坏,只是这次更新没有生效。
|
|
277
|
+
|
|
278
|
+
2.0.5 声明了 `main: ./index.mjs`。那是一个真实存在、刻意留空的 Cordis 插件:不注册任何行、不启动任何进程、
|
|
279
|
+
不碰任何状态(真正起作用的行仍由 `cordis.patch.yml` 插入),它的作用只是让「这个包能不能加载」有一个诚实的
|
|
280
|
+
答案,并让 `import 'dsh-plugin-rea'` 能解析。还停在旧版本时,更新到 `>=2.0.5` 即可;若市场在 2.0.5 之前就拒绝,
|
|
281
|
+
就直接安装 `dsh-plugin-rea@2.0.5`(桌面端:在插件管理器里装),不要原地更新。
|
|
282
|
+
|
|
249
283
|
### 从 1.x 升级
|
|
250
284
|
|
|
251
285
|
装上 2.x 后,**先跑一次供给**,再重启 dsh:
|
package/index.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package entry — the smallest thing that makes this bundle a *loadable* package.
|
|
3
|
+
*
|
|
4
|
+
* Everything this plugin DOES lives in `cordis.patch.yml`: it inserts one row of
|
|
5
|
+
* `@deepseek-ai/dsh-mcp-client` that launches `bin/rea-shim.mjs`, and that row is
|
|
6
|
+
* what the harness loads. This file registers nothing, starts nothing and
|
|
7
|
+
* provisions nothing.
|
|
8
|
+
*
|
|
9
|
+
* It exists because a package that declares no `main`/`exports` is judged by its
|
|
10
|
+
* consumers as "has no entry file", whether or not the loader ever needs one:
|
|
11
|
+
*
|
|
12
|
+
* - The market's update path (`hasLoadableEntry` in dshmarket) asks that
|
|
13
|
+
* question on every update and, when the answer is false, treats the new
|
|
14
|
+
* build as broken and rolls back. A bundle with no entry of its own is
|
|
15
|
+
* *supposed* to survive the test through the packages it mounts — but the
|
|
16
|
+
* package this one mounts (`@deepseek-ai/dsh-mcp-client`) is part of the
|
|
17
|
+
* harness build, not of `node_modules`, so every location that test probes
|
|
18
|
+
* comes back empty. Measured on 2026-10-18: updating an installed, working
|
|
19
|
+
* 2.0.1 to 2.0.4 failed three times with "updated build has no loadable
|
|
20
|
+
* entry", and the rollback was then reported as unverifiable for the same
|
|
21
|
+
* reason.
|
|
22
|
+
* - `npm pack` metadata and `import 'dsh-plugin-rea'` both resolve `main`;
|
|
23
|
+
* absent, both fall back to `index.js`, which this repository never shipped.
|
|
24
|
+
*
|
|
25
|
+
* So this is a real module with a real Cordis plugin shape rather than a stub
|
|
26
|
+
* for a checker: if the harness ever does load this package as a plugin, it
|
|
27
|
+
* loads cleanly and does nothing.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Cordis plugin name. Also the package name, which is what the loader expects. */
|
|
31
|
+
export const name = 'dsh-plugin-rea'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Intentionally empty.
|
|
35
|
+
*
|
|
36
|
+
* The row that does the work is inserted by the bundle patch, not by this
|
|
37
|
+
* function: registering the MCP client from here would create a second
|
|
38
|
+
* `serverName: rea` entry, which the harness refuses as a duplicate (the same
|
|
39
|
+
* collision `bin/rea-shim.mjs` warns about for upstream's `rea setup --client
|
|
40
|
+
* dsh`). Doing nothing is the correct behaviour, and the function present keeps
|
|
41
|
+
* this a valid Cordis plugin instead of an arbitrary module.
|
|
42
|
+
*/
|
|
43
|
+
export function apply() {}
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-rea",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.6",
|
|
4
4
|
"description": "One-plugin bundle: provisions Node/REA/JDK/Ghidra for you, then registers the upstream REA MCP server as mcp__rea__* tools. · 一体包:自动准备 Node/REA/JDK/Ghidra,并把上游 REA 的 MCP 服务接入 DeepSeek Harness。",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"main": "./index.mjs",
|
|
6
7
|
"license": "MIT",
|
|
7
8
|
"author": "czcdr",
|
|
8
9
|
"homepage": "https://github.com/czcdr/dsh-plugin-rea",
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
},
|
|
34
35
|
"files": [
|
|
35
36
|
"cordis.patch.yml",
|
|
37
|
+
"index.mjs",
|
|
36
38
|
"bin/",
|
|
37
39
|
"scripts/",
|
|
38
40
|
"manifest/",
|
|
@@ -56,10 +58,12 @@
|
|
|
56
58
|
"bundle:status": "node ./bin/rea-shim.mjs --shim-status",
|
|
57
59
|
"bundle:dry-run": "node ./scripts/setup.mjs --reuse --dry-run",
|
|
58
60
|
"bundle:audit": "node ./scripts/audit-manifest.mjs",
|
|
59
|
-
"bundle:test": "node --test ./scripts/setup.test.mjs"
|
|
61
|
+
"bundle:test": "node --test ./scripts/setup.test.mjs",
|
|
62
|
+
"bundle:host-compat": "node ./scripts/host-compat.mjs"
|
|
60
63
|
},
|
|
61
64
|
"engines": {
|
|
62
|
-
"node": "^22.19.0 || ^24.11.0 || >=26.0.0"
|
|
65
|
+
"node": "^22.19.0 || ^24.11.0 || >=26.0.0",
|
|
66
|
+
"dsh": ">=0.1.7-rc.2 <0.3.0"
|
|
63
67
|
},
|
|
64
68
|
"publishConfig": {
|
|
65
69
|
"registry": "https://registry.npmjs.org/"
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* host-compat — does the range this package declares cover the DSH releases it
|
|
4
|
+
* claims to support?
|
|
5
|
+
*
|
|
6
|
+
* `package.json` carries `engines.dsh`, and that single string is what the
|
|
7
|
+
* plugin market reads to say "compatible" instead of "unknown" (the market's
|
|
8
|
+
* `deriveHostCompatibility` treats `engines.dsh` as a host declaration and
|
|
9
|
+
* checks it against the running host version). A declaration is therefore load
|
|
10
|
+
* bearing twice over: it decides whether the market HIDES this plugin from users
|
|
11
|
+
* on a matching host, and it decides whether users on a host we never tested are
|
|
12
|
+
* told it works.
|
|
13
|
+
*
|
|
14
|
+
* The two ways it goes wrong are silent. A typo makes the range unparseable, and
|
|
15
|
+
* the market then reports `unknown` — a plugin that looks undeclared rather than
|
|
16
|
+
* mis-declared. A floor that drifted (say `>=0.2.0`) silently drops hosts the
|
|
17
|
+
* README still promises. Neither shows up as a test failure unless something
|
|
18
|
+
* compares the STRING to the release list, which is what this file does.
|
|
19
|
+
*
|
|
20
|
+
* `HOST_RELEASES` is the dsh release line this package is built against, newest
|
|
21
|
+
* first, as published on github.com/deepseek-ai/deepseek-harness. It is a
|
|
22
|
+
* literal list rather than a GitHub query on purpose: a check that needs the
|
|
23
|
+
* network fails on a plane and passes on a total outage, which is the wrong
|
|
24
|
+
* shape for a gate. When a release is added upstream, add it here too — that
|
|
25
|
+
* edit is the reminder to re-check the row against the new host.
|
|
26
|
+
*
|
|
27
|
+
* Usage: `node scripts/host-compat.mjs` (exit 0 = every listed release is
|
|
28
|
+
* covered by the declared range; exit 1 = at least one is not).
|
|
29
|
+
*/
|
|
30
|
+
import { readFileSync } from 'node:fs'
|
|
31
|
+
import path from 'node:path'
|
|
32
|
+
import process from 'node:process'
|
|
33
|
+
import { fileURLToPath } from 'node:url'
|
|
34
|
+
|
|
35
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
36
|
+
const REPO_ROOT = path.join(HERE, '..')
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Every dsh release this bundle is checked against, newest first.
|
|
40
|
+
*
|
|
41
|
+
* Taken from the release list of github.com/deepseek-ai/deepseek-harness. The
|
|
42
|
+
* floor is the oldest release the plugin has been adapted to; everything above
|
|
43
|
+
* it up to the declared ceiling must keep working, so a release that is missing
|
|
44
|
+
* here is a release nobody checked.
|
|
45
|
+
*/
|
|
46
|
+
export const HOST_RELEASES = [
|
|
47
|
+
'0.2.1-rc.2',
|
|
48
|
+
'0.2.1-rc.1',
|
|
49
|
+
'0.2.1-alpha.5',
|
|
50
|
+
'0.2.1-alpha.4',
|
|
51
|
+
'0.2.1-alpha.3',
|
|
52
|
+
'0.2.1-alpha.2',
|
|
53
|
+
'0.2.1-alpha.1',
|
|
54
|
+
'0.2.0-rc.2',
|
|
55
|
+
'0.2.0-rc.1',
|
|
56
|
+
'0.1.7-rc.2',
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
/** Hosts that must fall OUTSIDE the declaration, for the other direction. */
|
|
60
|
+
export const OUTSIDE_HOSTS = [
|
|
61
|
+
'0.3.0',
|
|
62
|
+
'0.1.7-rc.1',
|
|
63
|
+
'0.1.7-alpha.2',
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Split a version into comparable parts.
|
|
70
|
+
* @param {string} version - a plain semver string, no range operators.
|
|
71
|
+
* @returns {{ parts: number[], pre: (string|number)[] } | null} null when unparseable.
|
|
72
|
+
*/
|
|
73
|
+
export function parseVersion(version) {
|
|
74
|
+
const match = SEMVER.exec(version.trim())
|
|
75
|
+
if (match === null) return null
|
|
76
|
+
const pre = match[4] === undefined
|
|
77
|
+
? []
|
|
78
|
+
: match[4].split('.').map(part => (/^\d+$/.test(part) ? Number(part) : part))
|
|
79
|
+
return { parts: [Number(match[1]), Number(match[2]), Number(match[3])], pre }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compare two versions the way semver does, prerelease included.
|
|
84
|
+
*
|
|
85
|
+
* A prerelease sorts BELOW its release (`0.2.0-rc.2 < 0.2.0`), and among
|
|
86
|
+
* prereleases an identifier that is numeric sorts below one that is not, which
|
|
87
|
+
* is why `0.2.1-alpha.5 < 0.2.1-rc.1` — the whole reason this file cannot lean
|
|
88
|
+
* on a numeric comparison.
|
|
89
|
+
* @returns -1, 0 or 1; NaN-free because unparseable input throws.
|
|
90
|
+
*/
|
|
91
|
+
export function compareVersions(a, b) {
|
|
92
|
+
const left = parseVersion(a)
|
|
93
|
+
const right = parseVersion(b)
|
|
94
|
+
if (left === null) throw new Error(`not a version: ${a}`)
|
|
95
|
+
if (right === null) throw new Error(`not a version: ${b}`)
|
|
96
|
+
for (let index = 0; index < 3; index += 1) {
|
|
97
|
+
if (left.parts[index] !== right.parts[index]) return left.parts[index] < right.parts[index] ? -1 : 1
|
|
98
|
+
}
|
|
99
|
+
if (left.pre.length === 0 && right.pre.length === 0) return 0
|
|
100
|
+
if (left.pre.length === 0) return 1
|
|
101
|
+
if (right.pre.length === 0) return -1
|
|
102
|
+
for (let index = 0; index < Math.max(left.pre.length, right.pre.length); index += 1) {
|
|
103
|
+
const l = left.pre[index]
|
|
104
|
+
const r = right.pre[index]
|
|
105
|
+
if (l === undefined) return -1
|
|
106
|
+
if (r === undefined) return 1
|
|
107
|
+
if (l === r) continue
|
|
108
|
+
const lNum = typeof l === 'number'
|
|
109
|
+
const rNum = typeof r === 'number'
|
|
110
|
+
if (lNum && rNum) return l < r ? -1 : 1
|
|
111
|
+
// Numeric identifiers always have lower precedence than alphanumeric ones.
|
|
112
|
+
if (lNum !== rNum) return lNum ? -1 : 1
|
|
113
|
+
return String(l) < String(r) ? -1 : 1
|
|
114
|
+
}
|
|
115
|
+
return 0
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Evaluate one `>=floor <ceiling` declaration (the only shape this package
|
|
120
|
+
* ships, and the shape the market's `engines.dsh` is compared with).
|
|
121
|
+
*
|
|
122
|
+
* Deliberately narrow: a wider parser here could disagree with the market's
|
|
123
|
+
* semver implementation on a range the manifest would then be trusted for. If a
|
|
124
|
+
* future declaration needs `||` or `^`, this function has to grow with it.
|
|
125
|
+
* @param {string} version - host version to test.
|
|
126
|
+
* @param {string} range - the declared range.
|
|
127
|
+
* @returns {boolean} whether the host is inside the declaration.
|
|
128
|
+
*/
|
|
129
|
+
export function satisfiesDeclaredRange(version, range) {
|
|
130
|
+
const match = /^>=\s*(\S+)\s+<\s*(\S+)$/.exec(range.trim())
|
|
131
|
+
if (match === null) throw new Error(`unsupported host range: ${range}`)
|
|
132
|
+
return compareVersions(version, match[1]) >= 0 && compareVersions(version, match[2]) < 0
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The host range this repository declares, `engines.dsh`.
|
|
137
|
+
* @param {string} [root] - repository root, for tests.
|
|
138
|
+
* @returns {string | null} the declared range, or null when absent.
|
|
139
|
+
*/
|
|
140
|
+
export function declaredHostRange(root = REPO_ROOT) {
|
|
141
|
+
try {
|
|
142
|
+
const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'))
|
|
143
|
+
const value = pkg?.engines?.dsh
|
|
144
|
+
return typeof value === 'string' ? value : null
|
|
145
|
+
} catch {
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Every release covered by the declaration, and every version that must not be.
|
|
152
|
+
* @param {string} range - declared host range.
|
|
153
|
+
* @returns {{ covered: string[], uncovered: string[], leaked: string[] }} findings.
|
|
154
|
+
*/
|
|
155
|
+
export function checkHostCoverage(range) {
|
|
156
|
+
const covered = []
|
|
157
|
+
const uncovered = []
|
|
158
|
+
for (const version of HOST_RELEASES) {
|
|
159
|
+
if (satisfiesDeclaredRange(version, range)) covered.push(version)
|
|
160
|
+
else uncovered.push(version)
|
|
161
|
+
}
|
|
162
|
+
const leaked = OUTSIDE_HOSTS.filter(version => satisfiesDeclaredRange(version, range))
|
|
163
|
+
return { covered, uncovered, leaked }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function main() {
|
|
167
|
+
const range = declaredHostRange()
|
|
168
|
+
if (range === null) {
|
|
169
|
+
console.error('FAIL: package.json declares no engines.dsh — the market would report this plugin as "unknown"')
|
|
170
|
+
process.exit(1)
|
|
171
|
+
}
|
|
172
|
+
let result
|
|
173
|
+
try {
|
|
174
|
+
result = checkHostCoverage(range)
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error(`FAIL: ${error.message}`)
|
|
177
|
+
process.exit(1)
|
|
178
|
+
}
|
|
179
|
+
console.log(`engines.dsh = ${range}`)
|
|
180
|
+
for (const version of HOST_RELEASES) {
|
|
181
|
+
const inside = satisfiesDeclaredRange(version, range)
|
|
182
|
+
console.log(` ${inside ? 'ok ' : 'MISS'} ${version}`)
|
|
183
|
+
}
|
|
184
|
+
for (const version of OUTSIDE_HOSTS) {
|
|
185
|
+
const inside = satisfiesDeclaredRange(version, range)
|
|
186
|
+
console.log(` ${inside ? 'LEAK' : 'ok '} ${version} (must stay outside)`)
|
|
187
|
+
}
|
|
188
|
+
if (result.uncovered.length > 0) {
|
|
189
|
+
console.error(`FAIL: the declaration does not cover ${result.uncovered.join(', ')}`)
|
|
190
|
+
process.exit(1)
|
|
191
|
+
}
|
|
192
|
+
if (result.leaked.length > 0) {
|
|
193
|
+
console.error(`FAIL: the declaration also admits ${result.leaked.join(', ')}, which is not a tested host`)
|
|
194
|
+
process.exit(1)
|
|
195
|
+
}
|
|
196
|
+
console.log(`host range ok: ${HOST_RELEASES.length} releases covered, ${OUTSIDE_HOSTS.length} correctly outside`)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main()
|
package/scripts/setup.test.mjs
CHANGED
|
@@ -18,7 +18,8 @@ import { createServer } from "node:http";
|
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
19
19
|
import path from "node:path";
|
|
20
20
|
import test from "node:test";
|
|
21
|
-
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
22
|
+
import { HOST_RELEASES, OUTSIDE_HOSTS, checkHostCoverage, declaredHostRange, compareVersions, satisfiesDeclaredRange } from "./host-compat.mjs";
|
|
22
23
|
|
|
23
24
|
/* ------------------------------------------------- probe token substitution */
|
|
24
25
|
|
|
@@ -1168,3 +1169,78 @@ test("--json releases the lock on success and marks it failed on failure", () =>
|
|
|
1168
1169
|
rmSync(root, { recursive: true, force: true });
|
|
1169
1170
|
}
|
|
1170
1171
|
});
|
|
1172
|
+
|
|
1173
|
+
/* --------------------------------------------------------- the package entry */
|
|
1174
|
+
|
|
1175
|
+
// The market judges an update by asking whether the new build's package.json
|
|
1176
|
+
// points at an entry file that exists (`hasLoadableEntry` -> `entryArtifactExists`
|
|
1177
|
+
// in dshmarket). This package declared neither main nor exports, and the package
|
|
1178
|
+
// it mounts (`@deepseek-ai/dsh-mcp-client`) ships inside the harness build rather
|
|
1179
|
+
// than in node_modules, so every location that test probes came back empty:
|
|
1180
|
+
// updating a healthy 2.0.1 profile to 2.0.4 failed with "updated build has no
|
|
1181
|
+
// loadable entry", and the rollback was then reported unverifiable for the same
|
|
1182
|
+
// reason (measured 2026-10-18). These two tests are that regression.
|
|
1183
|
+
test("package.json declares an entry file that is actually shipped", () => {
|
|
1184
|
+
const pkg = JSON.parse(readFileSync(path.join(REPO_ROOT, "package.json"), "utf8"));
|
|
1185
|
+
assert.equal(typeof pkg.main, "string", "package.json must declare main; without it consumers probe index.js, which does not exist");
|
|
1186
|
+
assert.equal(existsSync(path.join(REPO_ROOT, pkg.main)), true, `${pkg.main} is declared but missing`);
|
|
1187
|
+
// `main` alone is not enough: npm pack ships only what files[] lists, and a
|
|
1188
|
+
// tarball without the entry reproduces the very failure this fixes.
|
|
1189
|
+
const shipped = String(pkg.main).replace(/^\.\//, "");
|
|
1190
|
+
assert.equal(pkg.files.includes(shipped), true, `files[] must ship ${shipped}`);
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
test("the entry loads as a cordis plugin and does nothing", async () => {
|
|
1194
|
+
const pkg = JSON.parse(readFileSync(path.join(REPO_ROOT, "package.json"), "utf8"));
|
|
1195
|
+
const mod = await import(pathToFileURL(path.join(REPO_ROOT, pkg.main)).href);
|
|
1196
|
+
assert.equal(mod.name, pkg.name, "the loader resolves the plugin by this name");
|
|
1197
|
+
assert.equal(typeof mod.apply, "function", "a cordis plugin needs apply()");
|
|
1198
|
+
// Loading and applying must not register a row, spawn anything or touch state:
|
|
1199
|
+
// registering the MCP client here would add a second `serverName: rea` entry,
|
|
1200
|
+
// which the harness refuses. Returning undefined is the whole contract.
|
|
1201
|
+
assert.equal(mod.apply(), undefined);
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
/* --------------------------------------------------------- declared DSH hosts */
|
|
1205
|
+
|
|
1206
|
+
// The market reads `engines.dsh` to answer "is this plugin compatible with the
|
|
1207
|
+
// host I am running" — and hides the plugin from hosts that mismatch. Two silent
|
|
1208
|
+
// failures follow from a declaration nobody checks: a typo leaves the range
|
|
1209
|
+
// unparseable and the market falls back to "undeclared", while a drifted floor
|
|
1210
|
+
// drops hosts the README still promises. Both look exactly like a plugin that
|
|
1211
|
+
// simply has nothing to say, so the string is pinned against the release list.
|
|
1212
|
+
test("the manifest declares a DSH host range the market can read", () => {
|
|
1213
|
+
const range = declaredHostRange(REPO_ROOT);
|
|
1214
|
+
assert.equal(typeof range, "string", "engines.dsh must be declared; without it the market reports the plugin as unknown");
|
|
1215
|
+
assert.throws(() => satisfiesDeclaredRange("0.2.0-rc.2", "not a range"), "the gate must reject an unparseable range");
|
|
1216
|
+
assert.doesNotThrow(() => satisfiesDeclaredRange("0.2.0-rc.2", range));
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
test("every released DSH host is inside the declared range, and nothing untested is", () => {
|
|
1220
|
+
const range = declaredHostRange(REPO_ROOT);
|
|
1221
|
+
const { covered, uncovered, leaked } = checkHostCoverage(range);
|
|
1222
|
+
assert.deepEqual(uncovered, [], `these releases fall outside ${range}`);
|
|
1223
|
+
assert.deepEqual(leaked, [], `the declaration also admits ${leaked.join(", ")}, which nobody tested`);
|
|
1224
|
+
assert.equal(covered.length, HOST_RELEASES.length, "every release in the list must be covered");
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
test("the host-range gate rejects a plausible but wrong declaration", () => {
|
|
1228
|
+
// Negative control: a gate that always passes proves nothing. `>=0.2.0` is the
|
|
1229
|
+
// shape someone writes after testing only on their own machine — and because a
|
|
1230
|
+
// prerelease sorts BELOW its release, it drops the whole 0.2.0-rc line as well
|
|
1231
|
+
// as 0.1.7. That is the trap this test exists for: the typo looks like it names
|
|
1232
|
+
// the running host.
|
|
1233
|
+
const drifted = checkHostCoverage(">=0.2.0 <0.3.0");
|
|
1234
|
+
assert.deepEqual(drifted.uncovered, ["0.2.0-rc.2", "0.2.0-rc.1", "0.1.7-rc.2"], "a 0.2.0 floor must be seen dropping the 0.2.0 prereleases");
|
|
1235
|
+
// And the ceiling is load bearing: the next minor line is a host nobody audited.
|
|
1236
|
+
assert.equal(satisfiesDeclaredRange("0.3.0", ">=0.1.7-rc.2 <0.3.0"), false);
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
test("prerelease ordering matches semver, not string order", () => {
|
|
1240
|
+
assert.equal(compareVersions("0.2.1-alpha.5", "0.2.1-rc.1"), -1);
|
|
1241
|
+
assert.equal(compareVersions("0.2.1-rc.2", "0.2.1"), -1);
|
|
1242
|
+
assert.equal(compareVersions("0.2.0-rc.2", "0.2.0-rc.1"), 1);
|
|
1243
|
+
// String comparison would sort "alpha.10" before "alpha.5"; semver does not.
|
|
1244
|
+
assert.equal(compareVersions("0.2.1-alpha.10", "0.2.1-alpha.5"), 1);
|
|
1245
|
+
assert.equal(OUTSIDE_HOSTS.length > 0, true);
|
|
1246
|
+
});
|
|
@@ -12,7 +12,7 @@ function Check($name, [bool]$ok, $detail) {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
Write-Host "`n=== 1. JS syntax ===" -ForegroundColor Cyan
|
|
15
|
-
foreach ($f in @('bin\rea-shim.mjs','scripts\setup.mjs','scripts\audit-manifest.mjs','scripts\setup.test.mjs')) {
|
|
15
|
+
foreach ($f in @('index.mjs','bin\rea-shim.mjs','scripts\setup.mjs','scripts\audit-manifest.mjs','scripts\setup.test.mjs','scripts\host-compat.mjs')) {
|
|
16
16
|
$out = & node --check $f 2>&1
|
|
17
17
|
Check $f ($LASTEXITCODE -eq 0) ($out -join ' ')
|
|
18
18
|
}
|
|
@@ -73,10 +73,24 @@ Write-Host "`n=== 6. package payload lists what the bundle needs ===" -Foregroun
|
|
|
73
73
|
# -Encoding UTF8 is required: the package description contains CJK text, and
|
|
74
74
|
# PowerShell 5.1 reads a BOM-less file as ANSI, which breaks ConvertFrom-Json.
|
|
75
75
|
$pkg = Get-Content (Join-Path $root 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
76
|
-
foreach ($entry in @('bin/', 'scripts/', 'manifest/', 'cordis.patch.yml')) {
|
|
76
|
+
foreach ($entry in @('index.mjs', 'bin/', 'scripts/', 'manifest/', 'cordis.patch.yml')) {
|
|
77
77
|
Check "files[] includes $entry" (@($pkg.files) -contains $entry) ''
|
|
78
78
|
}
|
|
79
|
-
|
|
79
|
+
# The market's update path asks whether package.json's main/exports points at a
|
|
80
|
+
# file that exists, and rolls the update back when it does not (dshmarket's
|
|
81
|
+
# hasLoadableEntry). This package declared neither until 2.0.5, so every update of
|
|
82
|
+
# a healthy profile failed with "updated build has no loadable entry". Asserting
|
|
83
|
+
# the pointer resolves keeps that from returning silently.
|
|
84
|
+
Check 'package.json declares main' (-not [string]::IsNullOrWhiteSpace($pkg.main)) ''
|
|
85
|
+
Check 'main resolves to a shipped file' ($pkg.main -and (Test-Path (Join-Path $root $pkg.main))) "main=$($pkg.main)"
|
|
86
|
+
# The market decides "compatible" vs "unknown" from engines.dsh, and hides the
|
|
87
|
+
# plugin from hosts whose version does not match. An absent or unparseable
|
|
88
|
+
# declaration therefore reads as "nothing to say" rather than as a defect, which
|
|
89
|
+
# is why the coverage check runs here as well as in CI.
|
|
90
|
+
Check 'package.json declares engines.dsh' (-not [string]::IsNullOrWhiteSpace($pkg.engines.dsh)) ''
|
|
91
|
+
$compat = & node scripts\host-compat.mjs 2>&1
|
|
92
|
+
Check 'engines.dsh covers every released DSH host' ($LASTEXITCODE -eq 0) (($compat | Select-Object -Last 2) -join ' ')
|
|
93
|
+
foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test', 'bundle:host-compat')) {
|
|
80
94
|
Check "scripts.$script exists" ($pkg.scripts.PSObject.Properties.Name -contains $script) ''
|
|
81
95
|
}
|
|
82
96
|
|