dsh-plugin-guide 0.3.24 → 0.3.26

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.
@@ -140,9 +140,16 @@ function isDir(path) {
140
140
  * `{{dshPeerRange}}` placeholder. Keep it identical to the canonical range in
141
141
  * `dsh-plugin-kit/data/peer-range.json`: one clause per prerelease tuple is
142
142
  * required by semver's prerelease rule, and the `-0` floor covers the whole
143
- * 0.1.6 tuple (never the bare `>=0.1.6` form).
143
+ * tuple (never a bare `>=0.2.0` form, which semver's prerelease rule would
144
+ * reject for `0.2.1-alpha.1`).
145
+ *
146
+ * The 0.2 line is load-bearing: `dsh` enforces peer ranges at install time and
147
+ * rejects any plugin whose declared range excludes the running runtime, so a
148
+ * range that stops at 0.1.x makes every scaffolded plugin uninstallable on a
149
+ * 0.2.x harness (`installation rejected: ... is incompatible with dsh
150
+ * 0.2.1-alpha.1`).
144
151
  */
145
- const DSH_PEER_RANGE = ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0 || >=0.1.7-0 <0.2.0";
152
+ const DSH_PEER_RANGE = ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0 || >=0.1.7-0 <0.2.0 || >=0.2.0-0 <0.3.0 || >=0.2.1-0 <0.3.0";
146
153
  /** Resolve the templates root: env override first, then candidate walks. */
147
154
  function resolveTemplatesRoot() {
148
155
  const env = process.env.DSH_PLUGIN_DEV_TEMPLATES;
@@ -1605,6 +1612,22 @@ async function initGit(targetDir) {
1605
1612
 
1606
1613
  //#endregion
1607
1614
  //#region src/cli/commands/verify.ts
1615
+ /**
1616
+ * The verified harness line `verify` installs into its throwaway profile.
1617
+ *
1618
+ * Keep these in lockstep with `.github/workflows/compat.yml`, which installs the
1619
+ * same versions. `dsh` rejects an incompatible plugin/base pair at install time,
1620
+ * so a stale pin turns `verify` into a failure that names the *base bundle*
1621
+ * rather than the plugin under test:
1622
+ *
1623
+ * dsh: installation rejected: Plugin @deepseek-ai/dsh-headless@<old> is
1624
+ * incompatible with dsh <new>
1625
+ *
1626
+ * `tests/verify.test.ts` asserts these match compat.yml, so the two cannot drift
1627
+ * apart silently again. Override per run with `--base` / `--headless`.
1628
+ */
1629
+ const VERIFY_BASE_SPEC = "@deepseek-ai/dsh-base@0.2.1-alpha.1";
1630
+ const VERIFY_HEADLESS_SPEC = "@deepseek-ai/dsh-headless@0.2.1-alpha.1";
1608
1631
  const PROFILE_WORKSPACE = `# scratch profile allowlist (mirrors the repo's compat workflow)
1609
1632
  packages:
1610
1633
  - .
@@ -1794,21 +1817,38 @@ function suggestionsFor(step, result) {
1794
1817
  const base = [`tail of "${step}":\n${tailOf(result)}`];
1795
1818
  if (result.timedOut) base.push(`command timed out; raise --timeout or --smoke-timeout`);
1796
1819
  if (step === "install") {
1797
- base.push("ensure the dsh CLI is @deepseek-ai/dsh@0.1.7-rc.2 (older builds such as the rc.6 line do not satisfy the compat pin)");
1820
+ base.push("ensure the dsh CLI is @deepseek-ai/dsh@0.2.1-alpha.1 (older builds such as the rc.6 line do not satisfy the compat pin)");
1798
1821
  base.push("confirm the profile allowlist matches the repo compat workflow (native builds allowlisted)");
1799
1822
  }
1800
1823
  if (step === "headless") base.push("a hang usually means an injected service stayed pending; the smoke timeout surfaces exactly that");
1801
1824
  return base;
1802
1825
  }
1826
+ /** Error signatures a headless smoke prints only *after* the tree loaded. */
1827
+ const SMOKE_LOADED_SIGNATURES = [
1828
+ "MISSING_CREDENTIAL",
1829
+ "TRANSPORT:",
1830
+ "transport failed",
1831
+ "UNAUTHORIZED",
1832
+ "invalid api key",
1833
+ "API key is invalid"
1834
+ ];
1803
1835
  /**
1804
- * True when a headless smoke run proves the plugin tree loaded: keyless runs
1805
- * print `MISSING_CREDENTIAL` (on stderr), keyed runs print `ok`.
1836
+ * True when a headless smoke run proves the plugin tree loaded. Three accepted
1837
+ * outcomes, all of which require that the tree mounted and the turn dispatched:
1838
+ * a keyless run printing `MISSING_CREDENTIAL`, a keyed run printing `ok`, or a
1839
+ * keyed run whose provider call failed at the transport/auth layer.
1840
+ *
1841
+ * The third case is not a fallback for a broken plugin: a plugin that never
1842
+ * mounted produces no provider traffic at all, so it prints none of these
1843
+ * signatures and still fails this check.
1844
+ *
1806
1845
  * @param stdout - captured stdout.
1807
1846
  * @param stderr - captured stderr.
1808
1847
  */
1809
1848
  function isSmokeOk(stdout, stderr) {
1810
1849
  const text = `${stdout}\n${stderr}`;
1811
- return text.includes("MISSING_CREDENTIAL") || /(^|[^a-z])ok([^a-z]|$)/i.test(text);
1850
+ if (SMOKE_LOADED_SIGNATURES.some((signature) => text.includes(signature))) return true;
1851
+ return /(^|[^a-z])ok([^a-z]|$)/i.test(text);
1812
1852
  }
1813
1853
  /** Resolve the dsh binary: explicit flag/env first, then PATH `dsh`. */
1814
1854
  function resolveDsh(flag) {
@@ -1915,8 +1955,8 @@ async function main(argv, cwd = process.cwd()) {
1915
1955
  dshBin: resolveDsh(flagString(flags, "dsh")),
1916
1956
  pnpmBin: resolvePnpm(flagString(flags, "pnpm")),
1917
1957
  profile: flagString(flags, "profile", "compat") ?? "compat",
1918
- base: flagString(flags, "base", "@deepseek-ai/dsh-base@0.1.7-rc.2") ?? "@deepseek-ai/dsh-base@0.1.7-rc.2",
1919
- headless: flagString(flags, "headless", "@deepseek-ai/dsh-headless@0.1.7-rc.2") ?? "@deepseek-ai/dsh-headless@0.1.7-rc.2",
1958
+ base: flagString(flags, "base", "@deepseek-ai/dsh-base@0.2.1-alpha.1") ?? "@deepseek-ai/dsh-base@0.2.1-alpha.1",
1959
+ headless: flagString(flags, "headless", "@deepseek-ai/dsh-headless@0.2.1-alpha.1") ?? "@deepseek-ai/dsh-headless@0.2.1-alpha.1",
1920
1960
  timeoutMs: timeoutMs(flags, "timeout", "DSH_PLUGIN_DEV_TIMEOUT", DEFAULT_TIMEOUT_MS),
1921
1961
  smokeTimeoutMs: timeoutMs(flags, "smoke-timeout", "DSH_PLUGIN_DEV_SMOKE_TIMEOUT", DEFAULT_SMOKE_TIMEOUT_MS)
1922
1962
  });
@@ -20,7 +20,7 @@
20
20
  | npm `alpha` / `next` | `alpha`=`0.2.1-alpha.1`、`next`=`0.2.0-rc.2` |
21
21
  | 区间内发布线(9 个) | `0.1.6-alpha.2`(09-17) → `0.1.7-alpha.1`(09-22) → `0.1.5-rc.3`(09-22) → `0.1.7-alpha.2`(09-22) → `0.1.7-rc.1`(09-23) → `0.1.7-rc.2`(09-24) → `0.2.0-rc.1`(09-28) → `0.2.0-rc.2`(09-29) → `0.2.1-alpha.1`(10-03) |
22
22
  | Node 门槛 | `^22.19.0 \|\| >=24.0.0`,**区间内未变**(无 `.nvmrc`/`.node-version` 文件) |
23
- | `dsh.version` 字段 | **不存在**。DSH 的包清单只有 `dsh.bundle` / `dsh.profile` / `dsh.client` 三组键,且官方明确"不校验 peer 版本范围"——声明的 peer 范围只是文档意图,**不是加载期门禁** |
23
+ | `dsh.version` 字段 | **不存在**。DSH 的包清单只有 `dsh.bundle` / `dsh.profile` / `dsh.client` 三组键 |
24
24
  | 官方发布说明在哪 | 仓库里**没有 `CHANGELOG.md`**、也没有 `docs/persistence-changes/releases/*` 新条目,所以逐版本说明只在 GitHub **Releases** 的 body 里(`gh release view <tag>` 或 Releases API)。本文件的每一条都对照过 tag `dsh-v0.1.7-alpha.1` / `dsh-v0.2.0-rc.2` / `dsh-v0.2.1-alpha.1` 的发布说明 |
25
25
 
26
26
  **官方 0.1.7-alpha.1 发布说明里点名的四条插件必改**(原文"其他变更"段):Session 日志升级 V4、工作区文件读取统一为 `readBytes`(旧接口必须迁移)、官方 DeepSeek 适配器**只用 Messages API**(移除 Chat Completions 与 `protocol` 选项)、设置改由当前 Profile 的插件配置保存(旧 `settings.yaml` 只尝试导入一次)。同版本还改了:Agent 预设改由插件组合包声明/安装(旧目录预设需迁移)、组合包支持按序多 patch、插件可声明"无需重载的配置字段"、新增 `--dump-config-schema`、插件可用 locale 声明多语言标题描述 + `package.json` 图标。
@@ -29,7 +29,7 @@
29
29
 
30
30
  **npm 陷阱(最贵的一条)**:库包(`dsh-tools`、`dsh-base`、`dsh-headless`、`dsh-session-persistence-jsonl`、`dsh-session`、`dsh-settings`、`dsh-workflow` …)的 `latest` 至今钉在 **`0.0.1-rc.1`**。写 `"@deepseek-ai/dsh-session": "latest"` 会装到远古版本;必须写死版本或钉 `next`/`alpha`。唯一的例外是 CLI 包 `@deepseek-ai/dsh`(`latest`=`0.2.0-rc.2`)。
31
31
 
32
- **跨线 peer 范围**:`^0.1.x` 与 `^0.2.x` 互不覆盖,要同时支持两条线必须写两侧范围(本知识库自身用 `>=0.1.2-rc.1 <0.3.0`)。因为 DSH 不校验范围,真正的兼容手段是**运行时特性探测**。
32
+ **跨线 peer 范围**:`^0.1.x` 与 `^0.2.x` 互不覆盖,要同时支持两条线必须写两侧范围。⚠️ **peer 范围会被安装期强制**(本节早期版本写成"DSH 不校验范围",已更正):`dsh plugin add` 会拿运行时的版本去比插件声明的 peer 范围,不匹配就**拒绝安装**并把声明的范围原样打印出来。跨线支持因此必须**在范围里同时列出两侧的 prerelease 元组**(每个元组一个 clause),否则 0.2.x 宿主装不上——详见 [plugin-dev-guide.md](plugin-dev-guide.md) §7.3 的 `dsh-plugin` 段。范围之外仍要配**运行时特性探测**,因为范围只能表达"能不能装",表达不了"某个 API 在不在"。
33
33
 
34
34
  ## 2. 会话格式 V3 → V4(影响最深,涉及历史日志)
35
35
 
@@ -84,7 +84,7 @@
84
84
 
85
85
  - `dsh.bundle.patch` 现在接受**有序数组**:`{ "patch": ["./base.patch.yml", "./web.patch.yml"] }`,按序作为同一层应用,且每个文件里的相对插件路径**相对该文件所在目录**解析。校验失败信息:`dsh.bundle.patch must be a file path or a list of file paths`。
86
86
  - 层顺序未变:`dsh.profile.bundles` 顺序 → profile 自己的 `cordis.patch.yml` → `$DSH_HOME/cordis.patch.yml` → 各 `--patch`(argv 顺序)。**按 id 覆盖时整段 `config` 被替换,不做深合并。**
87
- - 包清单还可以声明 `dsh.manifestVersion`(格式标识,当前 `1`,与 npm 版本、会话格式版本无关)与 `engines.dsh`(作者声明的兼容范围,**当前安装器/加载器都不强制**)。类型定义见 `@deepseek-ai/dsh-package-manifest`。
87
+ - 包清单还可以声明 `dsh.manifestVersion`(格式标识,当前 `1`,与 npm 版本、会话格式版本无关)与 `engines.dsh`(作者声明的兼容范围,**当前安装器/加载器都不强制**——真正会被强制的是 `@deepseek-ai/dsh-*` 的 **peerDependencies**,安装期即拒绝不匹配者,见 [plugin-dev-guide.md](plugin-dev-guide.md) §7.3)。类型定义见 `@deepseek-ai/dsh-package-manifest`。
88
88
  - 展示元数据契约(Plugin Manager / Settings 在**不激活插件**的前提下读取):包根读 `package.json` 的 `name`/`description`/`icon`,或导出 `./locale/*.json` 的 `meta.title`/`meta.description` 与 `./icon`;图标支持 SVG/PNG/JPEG/WebP,**≤ 256 KiB 且必须落在包内**(绝对路径、URL、指向包外的 symlink 都被拒)。子路径插件**永远不读** `<subpath>/package.json`——标题/描述走 `locale/*.json` 的 `meta.*`,图片走导出的 `<subpath>/icon`。
89
89
  - `dsh.client` 键**未变**:`platform`(web)、`inject`(仅排序,不是 cordis 服务注入)、`immediately`(一阶段预取)、`external`(基线与注入边之外的精确模块请求)。但客户端半侧只挂在**说明符恰为裸包名**的那一行上:把一个包拆成多行的组合包,其 UI 只随根行启停。
90
90
 
@@ -76,7 +76,7 @@ export function apply(ctx: Context) {
76
76
  - **bundle(分发层)**:npm 包,`package.json` 声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`;patch 里是"插入/覆盖插件行"的 YAML 数组。**`patch` 也可以是有序数组**:`{ "patch": ["./base.patch.yml", "./web.patch.yml"] }`,按序作为同一层应用,且每个文件里的相对插件路径**相对该文件所在目录**解析(校验失败信息:`dsh.bundle.patch must be a file path or a list of file paths`)。
77
77
  - **profile(可运行组合)**:`$DSH_HOME/profiles/<name>/`,`package.json` 声明 `"dsh": { "profile": { "bundles": [...] } }`;由 `dsh plugin` / `plugin_manager` 维护,手写不允许。
78
78
  - **client(浏览器半侧)**:`package.json` 声明 `"dsh": { "client": { "platform": "web", "inject": [...], "immediately": true, "external": [...] } }` 并导出编译好的 `./client`。`inject` **只决定激活顺序**(不是 cordis 服务注入),`immediately` 标记一阶段预取,`external` 列出基线之外的精确模块请求。客户端半侧只挂在**说明符恰为裸包名**的那一行上。
79
- - 另有两个可选作者字段:`dsh.manifestVersion`(清单格式标识,当前 `1`,与 npm 版本、会话格式版本无关)与 `engines.dsh`(兼容范围声明,**当前安装器/加载器都不校验**)。类型定义来自 `@deepseek-ai/dsh-package-manifest`。
79
+ - 另有两个可选作者字段:`dsh.manifestVersion`(清单格式标识,当前 `1`,与 npm 版本、会话格式版本无关)与 `engines.dsh`(兼容范围声明,**当前安装器/加载器都不校验**;会被校验的是 `@deepseek-ai/dsh-*` 的 peerDependencies,见 §7.3)。类型定义来自 `@deepseek-ai/dsh-package-manifest`。
80
80
 
81
81
  bundle 最小结构:
82
82
 
@@ -394,6 +394,7 @@ Plugin Manager 与 Settings 会**在不激活插件的前提下**读展示信息
394
394
  - `files` 必须包含 patch、locale、图标与**每个运行时文件**——但**本地目录安装是链接 checkout,`files` 不过滤链接目录**,要直接核对文件;
395
395
  - **子路径插件(`my-plugins/search`)不是包,永远不读 `package.json`**:标题/描述走 `my-plugins/search/locale/*.json` 的 `meta.*`,图片走导出的 `my-plugins/search/icon`。
396
396
  - 可选作者字段:`dsh.manifestVersion`(当前 `1`)与 `engines.dsh`(**当前不校验**,只作声明)。类型来自 `@deepseek-ai/dsh-package-manifest`。
397
+ - **真正会被强制的是 peerDependencies**(见 §7.3):`dsh plugin add` 会用运行时的版本比对插件声明的 `@deepseek-ai/dsh-*` 范围,不匹配即**拒绝安装**。
397
398
 
398
399
  ### 7.1 从 GitHub 安装的 build-script 坑
399
400
 
@@ -420,7 +421,28 @@ bundle 挂一个普通 provider 插件:`inject = ['cmdlineArgs']`,用 `@deep
420
421
  - **cordis 双副本 / 双 Cordis 分裂**:插件构建时若从 `.pnpm` 副本解析 cordis,与 harness 的 vendored 副本是"两个模块",`declare module` 增强合并不了 → 报 `Property 'tools' does not exist on type 'Context'`。构建期把 cordis 解析到 harness 的 `vendor/cordis`;npm 安装路径下 peer 必须与宿主同一身份——**scoped `@deepseek-ai/cordis` 与 unscoped `cordis` 混用同样分裂**(dsh-tools 的类型只增强 scoped 版本)。独立包把 cordis 设为 peerDependency(+ dev),版本对齐宿主。
421
422
  - **官方 `@deepseek-ai/*` 包曾未发布公共 npm**(rc 早期):社区 bundle 的 `dependencies` 留空,靠 profile 的 pnpm 闭包 flat fallback(`$DSH_HOME/profiles/node_modules`)注入;声明了反而解析失败。rc.6 起公开包可用(from-scratch 教程锁 `0.1.0-rc.6`、cordis `4.0.1`),两条时间线的资料都要知道,按当时宿主版本取舍。
422
423
  - **npm `latest` 标签是过期版本**:库包(`dsh-tools`、`dsh-base`、`dsh-headless`、`dsh-session-persistence-jsonl`、`dsh-settings`、`dsh-workflow` …)的 `latest` **至今**钉在 `0.0.1-rc.1`,所以写 `"latest"` 会装到远古版本,必须写死版本或钉 `next`/`alpha`。**2026-10-06 复核**:`@deepseek-ai/dsh` `latest`=`next`=`0.2.0-rc.2`、`alpha`=`0.2.1-alpha.1`;dsh-tools / dsh-base / dsh-headless / dsh-session-persistence-jsonl 的 `latest` 仍 `0.0.1-rc.1`、`next`=`0.2.0-rc.2`、`alpha`=`0.2.1-alpha.1`;`@deepseek-ai/cordis` `latest`=4.0.4 并有新标签 `dsh-0-2-1-alpha-1`=4.0.5-alpha.1;`@deepseek-ai/schemastery` `latest`=3.18.4;`create-dsh-plugin` `latest`=0.2.3;dsh-core、dsh-sdk 仍 404。历史两次复核(2026-08-14 与 09-04)的数值保留在上一条记录里,可见"`latest` 陷阱"从 rc 早期持续到 0.2 世代。**无作用域 `dsh` 包是无关项目 node-dsh**("A shell written in JavaScript")——官方 CLI 包是 `@deepseek-ai/dsh`,别装错。
423
- - **profile 内安装的 `@deepseek-ai/*` 会静默遮蔽运行时自带版本**(解析优先级 `profile > runtime`):把共享实例的 dsh 包写进 **dependencies** 是错的做法——pnpm 会把它装进 profile,盖掉宿主自带的版本,然后核心服务注册失败(实测 `settings service is absent: mount @deepseek-ai/dsh-settings with @deepseek-ai/dsh-config-editor`,桌面端每次崩溃)。**共享实例的 dsh 包只放 `peerDependencies` + `devDependencies`**;独立版本演进或纯工具型包才放 `dependencies`。另注意 **DSH 不校验 peer 版本范围**(官方明文),peer 范围只是声明意图,真正的兼容要靠运行时探测。
424
+ - **profile 内安装的 `@deepseek-ai/*` 会静默遮蔽运行时自带版本**(解析优先级 `profile > runtime`):把共享实例的 dsh 包写进 **dependencies** 是错的做法——pnpm 会把它装进 profile,盖掉宿主自带的版本,然后核心服务注册失败(实测 `settings service is absent: mount @deepseek-ai/dsh-settings with @deepseek-ai/dsh-config-editor`,桌面端每次崩溃)。**共享实例的 dsh 包只放 `peerDependencies` + `devDependencies`**;独立版本演进或纯工具型包才放 `dependencies`。
425
+ - ⚠️ **peer 范围会被安装期强制**(本节早期版本写成"DSH 不校验 peer 版本范围",**已更正**):`dsh plugin add` 用运行时版本比对插件声明的 `@deepseek-ai/dsh-*` 范围,不匹配即**拒绝安装**,打印声明的范围并给出豁免命令。实测原文:
426
+
427
+ ```
428
+ dsh: installation rejected: Plugin dsh-nurse-record-check@0.1.0 is incompatible with
429
+ dsh 0.2.1-alpha.1: peerDependencies {"@deepseek-ai/dsh-tools":">=0.1.2-rc.1 <0.2.0 || …"}.
430
+ To accept this risk explicitly, grant the exact-version exemption for
431
+ dsh-nurse-record-check@0.1.0 on dsh 0.2.1-alpha.1 with `dsh plugin allow-version` or the
432
+ plugin manager, then retry the installation or restart dsh.
433
+ ```
434
+
435
+ 两个后果:① 范围**少列一个 0.2.x 的 prerelease 元组**,插件在 0.2.x 宿主上就装不上(拦在安装期,不是运行期);② 用**旧版本宿主**打的 tarball 用新版本 `verify` 跑,报错会指向 base bundle 而不是你的插件(`dsh-plugin-dev verify` 的 `--base`/`--headless` 默认值因此必须与 compat workflow 同步)。
436
+ - **豁免机制**(被拒后唯一的出路,可用 `dsh plugin --profile <p> --help` 复核):
437
+
438
+ ```sh
439
+ dsh plugin --profile <profile> version-exemptions # 列出已授予的豁免
440
+ dsh plugin --profile <profile> allow-version <pkg@version> \
441
+ --dsh-version <runtime> --accept-risk # 授予精确版本豁免
442
+ dsh plugin --profile <profile> revoke-version <pkg@version> # 撤销
443
+ ```
444
+
445
+ 豁免是**按 `<包@精确版本> × <宿主版本>` 的精确配对**(不是范围),所以宿主升级后要重新授予。`plugin_manager` 工具面里有对应入口,Web 侧边栏与插件管理页也能授予。**给用户写 README 排错节时把这三条命令抄进去**——这是被拒绝安装时用户唯一能自救的手段。
424
446
  - **`file:` 安装载入的是副本**:`dsh plugin add file:../my-plugin` 之后,profile 的 `node_modules` 下是**拷贝**(`nodeLinker: hoisted` + 锁文件只记 `version: file:<path>`),所以**改源码不生效、`update` 是 no-op**——只有 `remove` + `add` 才会重装。调试期要频繁改就用 `--patch` 直挂源码目录,或每次显式重装。
425
447
 
426
448
  **tsconfig 三件套 + 构建陷阱**
@@ -482,7 +504,7 @@ bundle 挂一个普通 provider 插件:`inject = ['cmdlineArgs']`,用 `@deep
482
504
  ## 9.1 版本兼容与迁移(0.2 世代必读)
483
505
 
484
506
  - **先认版本**:`dsh --version`。`0.1.7-alpha.1` 起包含本世代全部破坏性变更(会话格式 V4)。npm 上 `latest`=`0.2.0-rc.2`、`alpha`=`0.2.1-alpha.1`,而 master 最新 tag 是 `0.2.1-alpha.1`——**三个"最新"不是一个东西**。
485
- - **peer 范围写两侧**(如 `>=0.1.2-rc.1 <0.3.0`),但要记住 **DSH 不校验它**;真正的兼容靠运行时探测与发版纪律。
507
+ - **peer 范围要写全两侧的 prerelease 元组**(如 `>=0.1.2-rc.1 <0.2.0 || … || >=0.2.0-0 <0.3.0 || >=0.2.1-0 <0.3.0`)。⚠️ **这个范围会被安装期强制**,不是文档意图:`dsh plugin add` 用运行时版本比对它,不匹配就拒绝安装并打印你声明的范围。所以少一个 0.2.x clause,插件在 0.2.x 宿主上**根本装不上**(拦在安装期,不是运行期)。范围之外仍要靠运行时特性探测——范围只表达"能不能装",表达不了"某个 API 在不在"。
486
508
  - **共享实例的 dsh 包只放 peer + dev**,绝不放 dependencies(否则 profile 内副本会遮蔽运行时版本)。
487
509
  - **升级后必做**:`dsh --profile <name> --dump-config` 看有没有 `patch: entry <id> not found`(上游删行/改名后,你的 override 会**静默失效**);再跑一次真实能力。
488
510
  - **逐项迁移清单**:见 [migration-0.2.md](migration-0.2.md)(含会话格式 V4、settings 重写、invariants 删除、`readBytes`、客户端 slot/Remote 改名、`agent.inject()` 的 source 包装退役、preset 分叉等全部条目与官方原文出处)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-guide",
3
- "version": "0.3.24",
3
+ "version": "0.3.26",
4
4
  "description": "The dsh-plugin-guide knowledge base plus the dsh-plugin-dev CLI toolchain: official docs, Cordis primer, community deep-dives, and battle-tested pitfalls registered as an on-demand agent skill, with a scaffolder, static checker, and pack verifier for building DSH plugins.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -71,6 +71,8 @@
71
71
  "devDependencies": {
72
72
  "@deepseek-ai/dsh-attachment": "0.2.1-alpha.1",
73
73
  "@types/node": "^22.20.1",
74
+ "@types/semver": "^7.8.0",
75
+ "semver": "^7.8.5",
74
76
  "vitest": "^3.2.7"
75
77
  },
76
78
  "peerDependencies": {
@@ -117,6 +119,7 @@
117
119
  "0.1.7-alpha.2",
118
120
  "0.1.7-rc.1",
119
121
  "0.1.7-rc.2",
122
+ "0.2.0-rc.2",
120
123
  "0.2.1-alpha.1"
121
124
  ]
122
125
  },
@@ -5,7 +5,7 @@
5
5
  // 3. the scaffolder produces TS and JS skeletons inside a mkdtemp sandbox
6
6
  // (never touching a real home directory), which are then removed.
7
7
  import { spawnSync } from 'node:child_process'
8
- import { existsSync, mkdtempSync, rmSync, readdirSync } from 'node:fs'
8
+ import { existsSync, mkdtempSync, readFileSync, rmSync, readdirSync } from 'node:fs'
9
9
  import { tmpdir } from 'node:os'
10
10
  import { dirname, join, resolve } from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
@@ -59,6 +59,18 @@ try {
59
59
  check(result.status === 0 && hasEntry && hasPatch, `${lang} scaffold produces entry + cordis.patch.yml (exit ${result.status})`)
60
60
  check(readmeCount === 5, `${lang} scaffold produces 5 README languages (${readmeCount})`)
61
61
  }
62
+
63
+ // 3b. Extension contract for the TS scaffold. `platform: node` makes tsdown
64
+ // force `.mjs`/`.cjs` unless `fixedExtension` is disabled, while the scaffolded
65
+ // package.json declares `lib/index.js` + `lib/index.d.ts`. When those two
66
+ // disagree the build succeeds and `main` resolves to nothing, so the plugin
67
+ // installs and then silently never loads — with no error anywhere. Asserted
68
+ // offline (no install, no build) so the gate stays network-free.
69
+ const tsConfig = readFileSync(join(root, 'templates', 'ts', 'tsdown.config.mjs'), 'utf8')
70
+ check(
71
+ /fixedExtension\s*:\s*false/.test(tsConfig),
72
+ 'ts template disables tsdown fixedExtension so the build emits the declared lib/index.js',
73
+ )
62
74
  } finally {
63
75
  rmSync(sandbox, { recursive: true, force: true })
64
76
  }
@@ -12,4 +12,10 @@ export default defineConfig({
12
12
  clean: true,
13
13
  sourcemap: false,
14
14
  outDir: 'lib',
15
+ // With `platform: node` tsdown forces `.mjs`/`.cjs`; disable that so ESM output
16
+ // follows the package `"type": "module"` and emits `lib/index.js` +
17
+ // `lib/index.d.ts` — the paths this template's package.json declares. Without
18
+ // this flag the build emits `lib/index.mjs`/`lib/index.d.mts`, `main` resolves
19
+ // to nothing, and the plugin silently never loads.
20
+ fixedExtension: false,
15
21
  })