dsh-instruction-memory 1.4.3 → 1.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,58 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.4.5] — 2026-10-29
11
+
12
+ 对 1.4.4 发布物与最新桌面端 / 网页端复核(桌面 `0.2.1-rc.3`,网页 `0.2.1-rc.4` 与
13
+ `0.2.2-alpha.2`)后修复的缺陷。
14
+
15
+ ### 修复
16
+
17
+ - **发布包自依赖**:1.4.4 的 `dependencies` 里混入了 `dsh-instruction-memory@^1.4.3`,
18
+ pnpm 安装时会多拉一份嵌套的 1.4.3(`pnpm-lock.yaml` 同时出现 1.4.3 与 1.4.4)。
19
+ 已删除整个 `dependencies`(本插件没有运行时依赖),并新增发布前与 `verify.mjs` 断言,
20
+ 禁止包依赖自身。
21
+ - **Windows 上折叠路径不可用**:快照把家目录折叠成 `~`,Windows 资源管理器 / CMD 不会展开它。
22
+ 现在 Windows 折叠为 `%USERPROFILE%`(资源管理器、CMD、PowerShell 都会展开),macOS/Linux
23
+ 仍为 `~`;新增 `expandDisplayPath()` 作为 `displayPath()` 的逆函数,README 与代码注释同步。
24
+ - **L3 验证脚本假阴性**:`compat-1016-live-check.mjs` 直接用折叠后的 `storage.path` 与绝对
25
+ `DSH_HOME` 比较,1.4.4 之后 `store-inside-home` / `store-on-disk` 必然 FAIL。现在先展开
26
+ 平台占位符再判定;隔离宿主复验 **21/21 PASS**。
27
+ - **全新 profile 安装失败**:`install.mjs` 在 `profiles/web/node_modules` 不存在时
28
+ `symlinkSync` 报 `ENOENT`。现在先 `mkdirSync(dirname(link), { recursive: true })`,
29
+ 并新增“没有 node_modules 的全新 profile”回归测试。
30
+
31
+ ### 兼容区间
32
+
33
+ - 尾部收窄为 `>=0.2.0-0 <0.3.0-0`:1.4.4 写的 `<0.4.0-0` 仍会放行未发布的 `0.3.0`,
34
+ 与“新 minor 必须显式加一行”的承诺不符。`0.3.0` 与 `0.3.0-rc.1` 现在在默认与
35
+ `includePrerelease` 两种语义下都被拒绝。
36
+ - 更正 1.4.4 说明中的版本错误:npm `alpha` 标签当时是 `0.2.2-alpha.2`,不是 `alpha.5`;
37
+ `0.2.2-alpha.2` 已在网页隔离宿主实机启动验证(路由 200、section 注册、设置页渲染)。
38
+
39
+ ## [1.4.4] — 2026-10-29
40
+
41
+ 最新版审计(桌面端 0.2.1-rc.3、网页端同一宿主,并对照 npm `next` 上的 0.2.1-rc.4)发现的缺陷修复。
42
+
43
+ ### 修复
44
+
45
+ - **注入字数上限真的会越界**:形象区块与条目区块各自预留"另有 N …"尾注空间,但都按 `footerFor(omitted + 1)` 预留、按 `footerFor(omitted)` 渲染——被省略的数量跨过一位数时就少留字符。实测 1.4.3:12 个形象 × 292 字符、上限 800,渲染出 **801** 字符。现在两侧都按"最坏情况(全部省略)"预留,形象区块还会先让出条目尾注所需的字符。
46
+ - **条目被形象挤空时尾注消失**:形象占满预算后条目区块文本为空,于是"另有 N 条指令未包含"永远不会出现,模型在毫无提示的情况下丢掉用户指令。现在会显式追加该尾注。
47
+ - **`injection.available` 恒为 `true`**:现在反映提示词区块是否真的注册成功,失败(`sectionError`)时为 `false`,与 `injection.error` 一致。
48
+ - **快照泄露绝对路径**:`storage.backupPath` / `storage.historyDir` / `storage.schemaFile` 之前直接给绝对路径,现在与 `storage.path` 一样经 `displayPath()` 把家目录折叠成 `~`(真实路径只用于读写,不对外暴露账号名)。
49
+ - **`install.mjs` 不在 npm 包里**:`files` 白名单缺它,而 README 让用户跑 `node install.mjs`。已加入。
50
+
51
+ ### 兼容区间
52
+
53
+ - 尾部 `|| >=0.2.0-0` **没有上界**:`0.3.0`、`1.0.0`、`9.9.9` 都被判兼容。现收窄为 `>=0.2.0-0 <0.4.0-0`(仍会放行 `0.3.0`,1.4.5 进一步收窄为 `<0.3.0-0`)。
54
+ - 新增 `>=0.2.2-alpha.0 <0.3.0-0`:semver 只在同一 tuple 上匹配预发布号,`0.2.0` 那段够不到 `0.2.2-alpha.N`(npm `alpha` 标签当时为 `0.2.2-alpha.2`;原文误写为 `alpha.5`,1.4.5 更正)。该线在 1.4.4 时只做过契约比对;1.4.5 已实机启动验证。
55
+ - 三处声明(`peerDependencies` / `dsh.engines.dsh` / `dsh.compatibility.dsh`)仍由 `host-range-test.mjs` 生成并校验一致。
56
+
57
+ ### 说明(未改动,但需要知道)
58
+
59
+ - 设置页依赖宿主的 `slots` 服务;`@deepseek-ai/cordis` 没有"可选注入",只能声明硬依赖。宿主不提供该服务时插件不装载设置页,只打印一行说明——平台要求,已写入 README。
60
+ - 插件 HTTP 路由在宿主网页端**没有鉴权**(README 早已写明)。桌面端同端口下 `/` 返回 401 而插件路由 200,这一层插件自己修不了,只能靠宿主或反向代理。
61
+
10
62
  ## [1.4.3] — 2026-10-27
11
63
 
12
64
  技术方案评审团(3 名专家独立评审 + 逐条复现)列出的缺陷**全部修复**,并补上对应的回归断言。
@@ -63,10 +115,14 @@
63
115
 
64
116
  ### 测试
65
117
 
66
- - 新增 12 项 `install.mjs` 断言(真实子进程跑安装/卸载/幂等/拒绝半卸载)。
67
- - 新增 4 项跨进程与备份保护断言(真实双 handler 实例 + 三种不可用主文件)。
68
- - 新增 4 项静音断言与 2 项用量条断言;`npm test` 全套通过(`lint` 在本机沙箱内因禁止管道子进程
69
- 而误报,改用逐文件 `node --check` 等价校验)。
118
+ - `install.mjs` 补断言:真实子进程跑安装 / 卸载 / 幂等 / **拒绝半卸载**(链接是真实目录时不得
119
+ 改 `package.json`)。
120
+ - 跨进程与备份保护补断言:真实两个 handler 实例模拟"另一个进程刚保存",以及三种不可用主文件
121
+ (缺失 / 空 / 非空但不可解析)下 `.bak` 的处置。
122
+ - 请求门禁(端口 / `same-site` / 尾点 / 重复 `Host` / MIME 精确匹配)、静音解析、用量条与预算
123
+ 上限均补断言;预算不变量另用 3000 组随机存储形状做模糊验证(0 违例)。
124
+ - `npm test` 全套通过:**928 项断言,0 失败**。`lint.mjs` 增加管道子进程不可用时的
125
+ `stdio: inherit` 回退,使语法门禁在受限沙箱内也能给出真实结论(此前会被误报为全部解析失败)。
70
126
 
71
127
  ## [1.4.2] — 2026-10-24
72
128
 
package/README.md CHANGED
@@ -99,7 +99,7 @@ dsh plugin --profile web remove dsh-instruction-memory
99
99
 
100
100
  ## 存储
101
101
 
102
- 记忆保存在 **DSH 数据根目录**下(设置页会显示完整绝对路径):
102
+ 记忆保存在 **DSH 数据根目录**下(设置页显示折叠后的路径:Windows 为 `%USERPROFILE%`,macOS/Linux 为 `~`;真实绝对路径只用于读写):
103
103
 
104
104
  ```
105
105
  <DSH_HOME>/instruction-memory/memory.json # DSH_HOME 默认为 ~/.dsh
@@ -215,6 +215,11 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DS
215
215
  > "没活干还在动"的副作用。现在文件由**第一个真正需要它的动作**创建:一次保存、一次导入,
216
216
  > 或设置页读取 `state`(面板打开时就会读)。
217
217
 
218
+ > **快照里的路径是折叠过的**(1.4.4 起):`storage.path` / `backupPath` / `historyDir` / `schemaFile` 都把家目录折叠成**平台可用的占位符**——1.4.5 起 Windows 为 `%USERPROFILE%`(资源管理器、CMD、PowerShell 都会展开;1.4.4 曾用 `~`,但资源管理器不展开它),macOS/Linux 为 `~`——只用于显示;读写、备份、监听仍用绝对路径。
219
+ > `injection.available` 也不再恒为 `true`——提示词区块注册失败时它是 `false`。
220
+ > 设置页依赖宿主的 `slots` 服务:`@deepseek-ai/cordis` 没有"可选注入",所以这是硬依赖,
221
+ > 宿主不提供时插件不装载设置页(只打印一行说明)。
222
+
218
223
  在这之前:`storage.path` 仍会显示路径,但 `storage.onDisk` 为 `false`,设置页显示
219
224
  「尚未写入磁盘」(该文案本就存在),`state.injection.registered === false`,`pulls === 0`。
220
225
  挂载日志相应为 `from=first run (no store on disk yet) onDisk=no`。
@@ -593,15 +598,15 @@ DNS 重绑定恰好推翻了它**:`evil.com` 解析到 `127.0.0.1` 之后,
593
598
  同一串范围(三处逐字节一致,由测试锁定):
594
599
 
595
600
  ```
596
- >=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.0-0
601
+ >=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.2-alpha.0 <0.3.0-0 || >=0.2.0-0 <0.3.0-0
597
602
  ```
598
603
 
599
604
  **验证范围与证据等级**——三者不可混为一谈,下面每一行都标了它到底证明了什么:
600
605
 
601
606
  | 等级 | 证明什么 | 覆盖 |
602
607
  | --- | --- | --- |
603
- | ① **真实启动** | 在该宿主版本上真的把 DSH 跑起来:插件装载、注入真的进入**组装后的提示**、插件 HTTP 路由可应答、客户端设置页模块被下发(Windows / Node 22) | `0.1.3-alpha.2` · `0.1.5-alpha.1` · `0.1.5-alpha.2` · `0.1.5-rc.1` · `0.1.5-rc.2` · `0.1.5-rc.3` · **`0.2.0-rc.1`** ⁽*⁾ · **`0.2.0-rc.2`** ⁽**⁾ · `0.2.1-alpha.2` ⁽***⁾ · **`0.2.1-alpha.3`** ⁽***⁾ · **`0.2.1-alpha.4`** ⁽***⁾ · **`0.2.1-rc.2`** · **`0.2.1-rc.3`** ⁽⁴⁾ |
604
- | ② **契约核验** | 抓该版本**真实发布的源码**,逐个核验本插件实际用到的每个符号:`systemPrompt.section()`、`assemble()` 是否求值**函数型** `text`、`FILE_REFERENCE=900` / `TOOL_BASH=1000` 仍在(决定 order 950 是否空档)、重名注册是否抛错、`webServer.register()` / `get host()`、`ctx.inject()` / `ctx.effect()`、`dsh.client.inject` 两包在该版本是否存在且声明了 `dsh.client` | 声明范围接住的**全部 18 个已发布版本**(`0.1.3-alpha.2` → `0.2.1-alpha.4`) |
608
+ | ① **真实启动** | 在该宿主版本上真的把 DSH 跑起来:插件装载、注入真的进入**组装后的提示**、插件 HTTP 路由可应答、客户端设置页模块被下发(Windows / Node 22) | `0.1.3-alpha.2` · `0.1.5-alpha.1` · `0.1.5-alpha.2` · `0.1.5-rc.1` · `0.1.5-rc.2` · `0.1.5-rc.3` · **`0.2.0-rc.1`** ⁽*⁾ · **`0.2.0-rc.2`** ⁽**⁾ · `0.2.1-alpha.2` ⁽***⁾ · **`0.2.1-alpha.3`** ⁽***⁾ · **`0.2.1-alpha.4`** ⁽***⁾ · **`0.2.1-rc.2`** · **`0.2.1-rc.3`** ⁽⁴⁾ · **`0.2.1-rc.4`** ⁽⁵⁾ · **`0.2.2-alpha.2`** ⁽⁵⁾ |
609
+ | ② **契约核验** | 抓该版本**真实发布的源码**,逐个核验本插件实际用到的每个符号:`systemPrompt.section()`、`assemble()` 是否求值**函数型** `text`、`FILE_REFERENCE=900` / `TOOL_BASH=1000` 仍在(决定 order 950 是否空档)、重名注册是否抛错、`webServer.register()` / `get host()`、`ctx.inject()` / `ctx.effect()`、`dsh.client.inject` 两包在该版本是否存在且声明了 `dsh.client` | 截至 2026-10-16 审计时,声明范围接住的 **18 个已发布版本**(`0.1.3-alpha.2` → `0.2.1-alpha.4`);后续新增版本按发布补充 |
605
610
  | ③ **真实执行** | 把插件**真的挂载**进该版本的 `@deepseek-ai/cordis`,跑装载 / 路由注册 / 卸载共 9 项断言 | cordis `4.0.2` 与 `4.0.4`(各 9/9) |
606
611
 
607
612
  ⁽*⁾ **`0.2.0-rc.1` 的①档有一条要说明**:插件装载、注入进入组装后的提示、HTTP 路由可应答三项**均已实测**(路由 `HTTP 200`;`injection.registered=true`、`chars=437`、**`pulls=38`**——即 0.2.0-rc.1 的 agent loop 真的求值了 38 次函数型 `text`)。第四项"客户端模块被下发"**没有直接抓取**:首页需要鉴权(`/` 返回 `401`),我无法取到 `window.__DSH_BOOT__`。这一项的支撑是间接但强的两条:① `dsh-client-modules` 在合成阶段对 `dsh.client` 声明做硬校验,不合格会抛 `ClientPackageCompositionError` 直接导致启动失败——宿主正常启动即说明声明通过了 0.2.0-rc.1 的校验;② 本插件用到的三个客户端契约包(`-slots` / `-renderer` / `-settings`)相对升级前真正在跑的 `0.1.7-rc.2` **逐字节未变**。**未做人工视觉确认**(没有打开设置页看)。
@@ -612,11 +617,13 @@ DNS 重绑定恰好推翻了它**:`evil.com` 解析到 `127.0.0.1` 之后,
612
617
 
613
618
  ⁽⁴⁾ **`0.2.1-rc.2` / `0.2.1-rc.3` 的①档**(2026-10-24,插件 1.4.2):`rc.3` 上隔离 `DSH_HOME` + 真实 `dsh web` 宿主驱动 **21 项 HTTP 断言 21/21 PASS**,桌面端活体 `pulls=5052`、p50 0.72 ms;并且**首次直接抓到了客户端模块的下发证据**——boot HTML 的预加载清单含 `dsh-instruction-memory/client.js`,该合并 bundle 返回 `200 text/javascript`(2,204,270 字节),正文含 Client 半源码与「指令记忆」文案。因此上一条 ⁽*⁾ 里"无法直接抓到 `window.__DSH_BOOT__`"的限制**已对 rc.3 解除**。`rc.2` 未单独起宿主,靠它与 `rc.3` 的逐文件字节比对(仅 `package.json` 与一行构建号不同)承接结论。证据:`dsh-memory-verification/out/compat-1016-live-latest-rc3.json`、`…-rc3b.json`。仍**未做设置页人工视觉确认**。
614
619
 
615
- 此外 `host-range-test.mjs` 的固定矩阵覆盖 `0.1.6-alpha.2`、`0.1.7-rc.2`、`0.2.0-rc.1`、`0.2.0-rc.2` 与 `0.2.1-alpha.1`~`0.2.1-alpha.4`(回归守卫把最新验证过的宿主逐名钉住),并会**读取本机实际安装的宿主版本**做实时断言。
620
+ ⁽⁵⁾ **`0.2.1-rc.4` / `0.2.2-alpha.2` 的①档**(2026-10-29,插件 1.4.4 → 1.4.5):隔离 `DSH_HOME` + 真实 `dsh web` 宿主,两个版本各跑 21 项 HTTP 断言;两端均 **21/21 PASS**(1.4.4 首次跑出的 2 项 FAIL 是验证脚本未展开折叠路径造成的假阴性,1.4.5 修正脚本后复验通过)。客户端 bundle 实际下发:rc.4 `2,211,701` 字节、alpha.2 `2,370,024` 字节,正文均含 Client 半源码与「指令记忆」文案。**首次做了设置页人工视觉确认**——两个宿主上「指令记忆」栏目均完整渲染。桌面端 `0.2.1-rc.3` 活体 `pulls=653`、p50 0.95 ms、`problems=[]`。仍**未跑真实 Agent 回合**(隔离宿主无模型凭证,`pulls=0`)。
621
+
622
+ 此外 `host-range-test.mjs` 的固定矩阵覆盖 `0.1.6-alpha.2`、`0.1.7-rc.2`、`0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-alpha.1`~`0.2.1-alpha.4`、`0.2.1-rc.2`~`0.2.1-rc.4` 与 `0.2.2-alpha.2`(回归守卫把最新验证过的宿主逐名钉住),并会**读取本机实际安装的宿主版本**做实时断言。
616
623
 
617
624
  **关于 1.0.10 新增的 `ctx.inject`。** 这是新版唯一的硬依赖增量:`inject` 去掉了 `webServer`,设置页路由改为非阻塞的 `ctx.inject(['webServer'], …)` 子 fiber(原因见上)。为此逐个核验了 **cordis 全部 8 个已发布版本**(`4.0.1-rc.1` → `4.0.5-alpha.2`)——**每一个都具备 `ctx.inject`**(其中 4.0.2 / 4.0.4 另做了真实挂载执行,4.0.5-alpha.1 与 4.0.5-alpha.2 为源码符号核验),因此声明范围内不存在因它而失效的宿主版本。
618
625
 
619
- **关于声明范围是否"超范围承诺"。** 已对全部 **33 个**已发布宿主版本核验(2026-10-05 穷举 30 个;新增版本随发布补充——`0.2.1-alpha.2` 于 2026-10-10、`0.2.1-alpha.3` / `0.2.1-alpha.4` 于 2026-10-16,npm 官方 registry):范围内 **18 个**版本的 API 面**全部齐备,超范围承诺 0 项**,且默认与 `includePrerelease` 两种语义在这 18 个版本上零分歧。范围外的 15 个版本(`0.0.1-rc.1` → `0.1.2-rc.1`)API 面**同样齐备**,但它们**从未真实启动验证过**,所以下限维持不动、不向外放宽——不把没验证过的东西写进声明。
626
+ **关于声明范围是否"超范围承诺"。** 截至 2026-10-16 已对当时全部 **33 个**已发布宿主版本核验(2026-10-05 穷举 30 个;后续版本随发布补充——`0.2.1-alpha.2` 于 2026-10-10、`0.2.1-alpha.3` / `0.2.1-alpha.4` 于 2026-10-16,npm 官方 registry):当时范围内 **18 个**版本的 API 面**全部齐备,超范围承诺 0 项**,且默认与 `includePrerelease` 两种语义在这 18 个版本上零分歧。范围外的 15 个版本(`0.0.1-rc.1` → `0.1.2-rc.1`)API 面**同样齐备**,但它们**从未真实启动验证过**,所以下限维持不动、不向外放宽——不把没验证过的东西写进声明。
620
627
 
621
628
  **关于 `0.2.1-alpha.1`(2026-10-03 发布,1.0.16 起覆盖)。** 它是前述"未来 minor 预发布版本在默认语义下接不住"的**第一个真实发生案例**:1.0.15 的范围在宿主门禁与市场判定(均用 `includePrerelease`)下放行它,但在 npm/pnpm 默认 peer 解析下拒绝(ERESOLVE);其耦合面(9 个契约点)经最近 10 版本矩阵核验全部齐备,cordis 为 `4.0.5-alpha.1`。1.0.16 新增 `>=0.2.1-alpha.0 <0.3.0-0` 枚举段后两种语义一致放行。**该版本仅有契约层证据,未做真机启动实测。**
622
629
 
@@ -638,7 +645,7 @@ major.minor.patch 且自身带预发布标签的比较符"。这条规则是**
638
645
  `0.1.8-rc.1` 这类版本 tuple 与所有比较符都不同,在 **npm 默认语义**下**永远匹配不到**——与
639
646
  `^0.1.0` 无法匹配 `0.1.8-rc.1` 是同一个限制。正式版(`0.1.8`、`0.2.0`、`1.0.0` 等)不受影响。
640
647
  这个限制不再只是假设:**`0.2.1-alpha.1`(2026-10-03 发布)就是第一个真实撞上它的版本**,
641
- 1.0.16 已通过新增 `0.2.1` 枚举段闭合;下一个未声明 tuple(如 `0.2.2-rc.1`、`0.3.0-rc.1`)
648
+ 1.0.16 已通过新增 `0.2.1` 枚举段闭合;下一个未声明 tuple(如 `0.2.3-rc.1`、`0.3.0-rc.1`)
642
649
  仍会重演同一现象,需要发一版加一行——host-range 的实时守卫会在本机升级到该宿主时
643
650
  直接让测试失败并给出可直接套用的修复行。
644
651
 
package/install.mjs ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Install / uninstall dsh-instruction-memory into the web profile.
3
+ *
4
+ * node install.mjs # install
5
+ * node install.mjs --remove # roll back completely
6
+ *
7
+ * Three idempotent steps:
8
+ * 1. make the package resolvable from the profile's node_modules (junction)
9
+ * 2. register it in `dsh.profile.bundles`, so the boot composes its
10
+ * cordis.patch.yml host row
11
+ * 3. declare it in the profile's `dependencies` as `link:<this checkout>`.
12
+ * A junction plus a bundle entry is enough for the HOST to load the
13
+ * plugin, but NOT enough to be seen by the market: dshmarket's installed
14
+ * list is built from the profile's `dependencies` only
15
+ * (`readInstalled` in dshmarket/src/profile.ts), so a plugin installed
16
+ * by steps 1-2 alone runs while showing up nowhere under 「已安装」.
17
+ *
18
+ * The profile package.json is backed up before the first modification.
19
+ */
20
+ import { existsSync, readFileSync, writeFileSync, symlinkSync, lstatSync, unlinkSync, copyFileSync, mkdirSync } from 'node:fs'
21
+ import { homedir } from 'node:os'
22
+ import { dirname, join, resolve } from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+
25
+ // Profile location follows the same precedence as @deepseek-ai/dsh-home-paths
26
+ // (and this plugin's own store resolution): explicit $DSH_HOME, else ~/.dsh.
27
+ // A relative override is resolved ONCE, for the same reason the plugin does it:
28
+ // every path this script prints and writes must be absolute.
29
+ const DSH_HOME = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim() !== ''
30
+ ? resolve(process.env.DSH_HOME.trim())
31
+ : join(homedir(), '.dsh')
32
+ const PROF = join(DSH_HOME, 'profiles', 'web')
33
+ // This script lives in the package root, so the source is wherever it runs from.
34
+ const SRC = dirname(fileURLToPath(import.meta.url))
35
+ const NAME = 'dsh-instruction-memory'
36
+
37
+ const remove = process.argv.includes('--remove')
38
+ const link = join(PROF, 'node_modules', NAME)
39
+ const pkgPath = join(PROF, 'package.json')
40
+ const backupPath = pkgPath + '.dsh-instruction-memory.bak'
41
+
42
+ function readPkg() {
43
+ return JSON.parse(readFileSync(pkgPath, 'utf8'))
44
+ }
45
+
46
+ function writePkg(pkg) {
47
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
48
+ }
49
+
50
+ function bundlesOf(pkg) {
51
+ pkg.dsh = pkg.dsh || {}
52
+ pkg.dsh.profile = pkg.dsh.profile || {}
53
+ pkg.dsh.profile.bundles = pkg.dsh.profile.bundles || []
54
+ return pkg.dsh.profile.bundles
55
+ }
56
+
57
+ if (remove) {
58
+ let touched = false
59
+ // `link` is normally a junction, and unlinking one removes only the link —
60
+ // the real package stays where it is. It can also be a REAL directory (pnpm
61
+ // materialises `link:` dependencies into node_modules as a copy), and there
62
+ // the old `rmSync(link, {recursive: true})` fallback deleted the installed
63
+ // copy itself: an uninstall that silently destroys the thing it was told to
64
+ // detach. Refuse that case and say what to do instead.
65
+ let linkKind = null
66
+ try {
67
+ linkKind = lstatSync(link).isSymbolicLink() ? 'link' : 'directory'
68
+ } catch {
69
+ linkKind = null
70
+ }
71
+ if (linkKind === 'link') {
72
+ // A junction can still refuse to go away (EBUSY/EPERM while DSH holds it
73
+ // open). The old call let that throw as an unhandled rejection, which read
74
+ // as a crash rather than "nothing was removed".
75
+ try {
76
+ unlinkSync(link)
77
+ console.log('removed junction: ' + link)
78
+ touched = true
79
+ } catch (err) {
80
+ console.error('could not remove ' + link + ': ' + (err && err.code ? err.code : String(err)))
81
+ console.error(' close DSH (or any process using the plugin) and run this again.')
82
+ process.exitCode = 1
83
+ }
84
+ } else if (linkKind === 'directory') {
85
+ console.error('refusing to delete ' + link)
86
+ console.error(' it is a real directory (a pnpm-materialised copy), not a junction;')
87
+ console.error(' recursive deletion would remove the installed plugin itself.')
88
+ console.error(' delete it yourself once you have confirmed that is what you want.')
89
+ // Stop HERE: continuing would strip the dependency and the bundle entry
90
+ // while the plugin is still on disk, leaving a profile that reports the
91
+ // plugin as absent and an install that cannot find it either.
92
+ console.log('\nNOT UNINSTALLED — see the refusal above.')
93
+ process.exit(1)
94
+ }
95
+ if (process.exitCode) {
96
+ // The junction could not be removed; do not report the plugin as detached.
97
+ console.log('\nNOT UNINSTALLED — see the refusal above.')
98
+ process.exit(process.exitCode)
99
+ }
100
+ const pkg = readPkg()
101
+ let changed = false
102
+ if (pkg.dependencies !== undefined && Object.hasOwn(pkg.dependencies, NAME)) {
103
+ delete pkg.dependencies[NAME]
104
+ changed = true
105
+ console.log('removed from dependencies')
106
+ }
107
+ const bundles = pkg.dsh?.profile?.bundles
108
+ const index = Array.isArray(bundles) ? bundles.indexOf(NAME) : -1
109
+ if (index >= 0) {
110
+ bundles.splice(index, 1)
111
+ changed = true
112
+ console.log('removed from dsh.profile.bundles')
113
+ }
114
+ if (changed) {
115
+ writePkg(pkg)
116
+ touched = true
117
+ }
118
+ if (existsSync(backupPath)) console.log('backup kept at: ' + backupPath)
119
+ if (process.exitCode) {
120
+ console.log('\nNOT UNINSTALLED — see the refusal above.')
121
+ } else {
122
+ console.log(touched ? '\nUNINSTALLED — restart DSH to unload the plugin.' : '\nnothing to remove')
123
+ }
124
+ // Honour the failure flag the refused-deletion branch may have set: a bare
125
+ // process.exit(0) would report success for an uninstall that did nothing.
126
+ process.exit(process.exitCode ?? 0)
127
+ }
128
+
129
+ // ---- install ----
130
+ if (!existsSync(SRC)) {
131
+ console.error('source package not found: ' + SRC)
132
+ process.exit(1)
133
+ }
134
+
135
+ if (!existsSync(join(PROF, 'package.json'))) {
136
+ console.error('web profile not found at ' + PROF + '(DSH 尚未初始化该 profile?)')
137
+ process.exit(1)
138
+ }
139
+
140
+ if (existsSync(link)) {
141
+ console.log('junction already present: ' + link)
142
+ } else {
143
+ // A freshly initialised profile has no node_modules directory yet; symlink()
144
+ // does not create the parent, so this used to fail with ENOENT on exactly
145
+ // the "new profile" case this script exists to support.
146
+ mkdirSync(dirname(link), { recursive: true })
147
+ symlinkSync(SRC, link, 'junction')
148
+ console.log('junction created: ' + link)
149
+ }
150
+
151
+ // Roll the previous backup forward instead of keeping the first one forever:
152
+ // a profile package.json broken by a later run is exactly what the user needs
153
+ // the backup for, and a backup frozen at the first install cannot restore it.
154
+ const lastBackupPath = backupPath + '.last'
155
+ if (existsSync(backupPath)) {
156
+ copyFileSync(backupPath, lastBackupPath)
157
+ console.log('rotated previous backup -> ' + lastBackupPath)
158
+ }
159
+ copyFileSync(pkgPath, backupPath)
160
+ console.log('backed up profile package.json -> ' + backupPath)
161
+
162
+ const pkg = readPkg()
163
+ let changed = false
164
+
165
+ // Step 3: a link: dependency, so the market lists it under 「已安装」.
166
+ // package.json only — the target is the junction from step 1, so no
167
+ // reinstall of the package itself is needed.
168
+ const spec = 'link:' + SRC.replaceAll('\\', '/')
169
+ pkg.dependencies = pkg.dependencies || {}
170
+ if (pkg.dependencies[NAME] === spec) {
171
+ console.log('already a profile dependency: ' + spec)
172
+ } else {
173
+ pkg.dependencies[NAME] = spec
174
+ changed = true
175
+ console.log('declared as a profile dependency: ' + spec)
176
+ }
177
+
178
+ const bundles = bundlesOf(pkg)
179
+ if (bundles.includes(NAME)) {
180
+ console.log('already in dsh.profile.bundles')
181
+ } else {
182
+ bundles.push(NAME)
183
+ changed = true
184
+ console.log('added to dsh.profile.bundles (position ' + bundles.length + ')')
185
+ }
186
+
187
+ if (changed) writePkg(pkg)
188
+
189
+ console.log('\nINSTALLED. Restart DSH to mount the plugin.')
190
+ console.log('Roll back with: node install.mjs --remove')