dsh-instruction-memory 1.4.2 → 1.4.4
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 +88 -0
- package/README.md +26 -7
- package/install.mjs +186 -0
- package/lib/client.js +10 -1
- package/lib/index.js +598 -94
- package/package.json +8 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,94 @@
|
|
|
7
7
|
> 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
|
|
8
8
|
> 这些历史 tag **不应回填**——npm 上的版本号已被占用。
|
|
9
9
|
|
|
10
|
+
## [1.4.4] — 2026-10-29
|
|
11
|
+
|
|
12
|
+
最新版审计(桌面端 0.2.1-rc.3、网页端同一宿主,并对照 npm `next` 上的 0.2.1-rc.4)发现的缺陷修复。
|
|
13
|
+
|
|
14
|
+
### 修复
|
|
15
|
+
|
|
16
|
+
- **注入字数上限真的会越界**:形象区块与条目区块各自预留"另有 N …"尾注空间,但都按 `footerFor(omitted + 1)` 预留、按 `footerFor(omitted)` 渲染——被省略的数量跨过一位数时就少留字符。实测 1.4.3:12 个形象 × 292 字符、上限 800,渲染出 **801** 字符。现在两侧都按"最坏情况(全部省略)"预留,形象区块还会先让出条目尾注所需的字符。
|
|
17
|
+
- **条目被形象挤空时尾注消失**:形象占满预算后条目区块文本为空,于是"另有 N 条指令未包含"永远不会出现,模型在毫无提示的情况下丢掉用户指令。现在会显式追加该尾注。
|
|
18
|
+
- **`injection.available` 恒为 `true`**:现在反映提示词区块是否真的注册成功,失败(`sectionError`)时为 `false`,与 `injection.error` 一致。
|
|
19
|
+
- **快照泄露绝对路径**:`storage.backupPath` / `storage.historyDir` / `storage.schemaFile` 之前直接给绝对路径,现在与 `storage.path` 一样经 `displayPath()` 把家目录折叠成 `~`(真实路径只用于读写,不对外暴露账号名)。
|
|
20
|
+
- **`install.mjs` 不在 npm 包里**:`files` 白名单缺它,而 README 让用户跑 `node install.mjs`。已加入。
|
|
21
|
+
|
|
22
|
+
### 兼容区间
|
|
23
|
+
|
|
24
|
+
- 尾部 `|| >=0.2.0-0` **没有上界**:`0.3.0`、`1.0.0`、`9.9.9` 都被判兼容。现收窄为 `>=0.2.0-0 <0.4.0-0`。
|
|
25
|
+
- 新增 `>=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.5`)。该线**只做过契约比对,尚未实机启动**。
|
|
26
|
+
- 三处声明(`peerDependencies` / `dsh.engines.dsh` / `dsh.compatibility.dsh`)仍由 `host-range-test.mjs` 生成并校验一致。
|
|
27
|
+
|
|
28
|
+
### 说明(未改动,但需要知道)
|
|
29
|
+
|
|
30
|
+
- 设置页依赖宿主的 `slots` 服务;`@deepseek-ai/cordis` 没有"可选注入",只能声明硬依赖。宿主不提供该服务时插件不装载设置页,只打印一行说明——平台要求,已写入 README。
|
|
31
|
+
- 插件 HTTP 路由在宿主网页端**没有鉴权**(README 早已写明)。桌面端同端口下 `/` 返回 401 而插件路由 200,这一层插件自己修不了,只能靠宿主或反向代理。
|
|
32
|
+
|
|
33
|
+
## [1.4.3] — 2026-10-27
|
|
34
|
+
|
|
35
|
+
技术方案评审团(3 名专家独立评审 + 逐条复现)列出的缺陷**全部修复**,并补上对应的回归断言。
|
|
36
|
+
其中两条是**数据安全级**的:都会让用户以为"已保存"而实际丢数据。
|
|
37
|
+
|
|
38
|
+
### 修复(数据安全)
|
|
39
|
+
|
|
40
|
+
- **跨进程静默覆盖(P0)**:两个 DSH 进程共享同一 `$DSH_HOME` 时,后保存的进程会把**自己那份
|
|
41
|
+
过期内存**整份写回磁盘,另一个窗口刚保存的条目静默消失,调用方还收到"已保存"。原因是写盘时
|
|
42
|
+
只把 `rev` 递增到 `max(自己, 磁盘)+1`(看起来单调,内容却是旧的)。现在写盘前先读磁盘 `rev`,
|
|
43
|
+
发现磁盘更新即**拒绝写入**并提示"另一个 DSH 进程刚保存过,请重试"。
|
|
44
|
+
- **`.bak` 被不可用的主文件覆盖(P0)**:备份保护只判断"主文件字节数不为 0",于是一份**非空但
|
|
45
|
+
解析不了**的 `memory.json`(截断、手工改坏、空白)会把唯一完好的 `.bak` 覆盖掉——恰好在最需要
|
|
46
|
+
备份的时刻销毁它。现在只有**解析得出合法存储**(或主文件缺失/为空)才覆盖 `.bak`。
|
|
47
|
+
|
|
48
|
+
### 修复(预算与注入)
|
|
49
|
+
|
|
50
|
+
- **用户形象绕过注入字数上限(P1)**:形象段此前完全独立于预算,十几个填满的形象能在指令预算
|
|
51
|
+
还没被考虑之前注入约 5 万字符。现在形象与指令**共用同一个上限**:形象优先占用,指令拿余量,
|
|
52
|
+
超出部分按顺序截断并说明;形象自身仍有 4000 字符硬上限。`text.length ≤ budget` 与
|
|
53
|
+
`profileChars ≤ min(4000, budget)` 在 3000 组随机存储形状上零越界。
|
|
54
|
+
- **用量条只计指令、不计形象(P1)**:宿主按 `entryChars + profileChars` 裁剪,面板却只显示
|
|
55
|
+
`entryChars`,于是宿主已经在截断、面板仍显示"很空"。现在两者都计入(老宿主缺 `entryChars`
|
|
56
|
+
时不重复累加)。
|
|
57
|
+
- **代理对截断(P2)**:按 UTF-16 长度截断会切在代理对中间,产生半个 emoji/CJK 扩展 B 字符。
|
|
58
|
+
新增 `cutText()`,截断时若末位是高代理项则少切一位;`cleanText()` 与所有截断点统一走它。
|
|
59
|
+
|
|
60
|
+
### 修复(请求门禁)
|
|
61
|
+
|
|
62
|
+
- **端口不参与 `Origin` 比对(P1)**:`127.0.0.1:1` 这类同主机不同端口的页面被当成同源。
|
|
63
|
+
现在要求 scheme + 主机名 + 端口一致(默认端口 80/443 归一为无端口)。
|
|
64
|
+
- **`same-site` 被放行(P1)**:回环绑定下同站判定不看端口,等于放行任意本地端口上的页面改写
|
|
65
|
+
长期指令。现在回环(受保护)时 `cross-site` 与 `same-site` 都拒绝;非回环绑定只拒绝
|
|
66
|
+
`cross-site`(反向代理可能合法改写端口)。
|
|
67
|
+
- **FQDN 尾点绕过(P2)**:`localhost.` 与 `localhost` 被当作两个主机名。`hostnameOf()` 统一
|
|
68
|
+
去尾点。
|
|
69
|
+
- **重复 `Host` 头(P2)**:多个 `Host` 头取第一个即可被前置代理注入绕过,改为直接拒绝。
|
|
70
|
+
- **`content-type` 用子串匹配(P2)**:`text/plain; x=application/json` 这类可绕过,改为精确
|
|
71
|
+
解析 media type 并拒绝参数夹带。
|
|
72
|
+
- **SSE 连接无上限(P2)**:订阅数不设限可被本机进程耗尽。新增连接上限,超出即以 503 拒绝。
|
|
73
|
+
|
|
74
|
+
### 修复(其他)
|
|
75
|
+
|
|
76
|
+
- **`{enabled:true, disabledUntil:0}` 被拒(P2)**:两者都表示"未暂停",却被当成矛盾组合拒绝。
|
|
77
|
+
现在只有**真实暂停**(未来时刻)与总开关同传才算矛盾。
|
|
78
|
+
- **`install.mjs --remove` 半卸载(P2)**:链接路径是真实目录(pnpm 物化的副本)时拒绝删除,
|
|
79
|
+
但拒绝之后仍然把 `dependencies` 与 `dsh.profile.bundles` 里的条目删掉了——插件还在磁盘上,
|
|
80
|
+
profile 却报告它不存在。现在拒绝即**原样退出**,不碰 `package.json`。
|
|
81
|
+
- **`install.mjs` 卸载时 `unlinkSync` 抛错(P2)**:链接被占用时抛出未捕获异常,看起来像崩溃。
|
|
82
|
+
现在捕获并提示"关闭 DSH 后重试",退出码非 0。
|
|
83
|
+
- **版本历史去重键含 `rev`(P2)**:同一份内容在不同 `rev` 下被重复记为两份。去重键改为内容哈希。
|
|
84
|
+
- **集合复制的命名(P2)**:派生集合的名字没做去重,可能出现两个同名集合。
|
|
85
|
+
- **临时文件与 0600 权限(P2)**:临时文件改为 `0600` 创建,避免短暂以默认权限暴露存储内容。
|
|
86
|
+
|
|
87
|
+
### 测试
|
|
88
|
+
|
|
89
|
+
- `install.mjs` 补断言:真实子进程跑安装 / 卸载 / 幂等 / **拒绝半卸载**(链接是真实目录时不得
|
|
90
|
+
改 `package.json`)。
|
|
91
|
+
- 跨进程与备份保护补断言:真实两个 handler 实例模拟"另一个进程刚保存",以及三种不可用主文件
|
|
92
|
+
(缺失 / 空 / 非空但不可解析)下 `.bak` 的处置。
|
|
93
|
+
- 请求门禁(端口 / `same-site` / 尾点 / 重复 `Host` / MIME 精确匹配)、静音解析、用量条与预算
|
|
94
|
+
上限均补断言;预算不变量另用 3000 组随机存储形状做模糊验证(0 违例)。
|
|
95
|
+
- `npm test` 全套通过:**928 项断言,0 失败**。`lint.mjs` 增加管道子进程不可用时的
|
|
96
|
+
`stdio: inherit` 回退,使语法门禁在受限沙箱内也能给出真实结论(此前会被误报为全部解析失败)。
|
|
97
|
+
|
|
10
98
|
## [1.4.2] — 2026-10-24
|
|
11
99
|
|
|
12
100
|
适配性复核(宿主 `0.2.1-rc.3`,桌面端 + 网页端)。**没有任何运行时代码改动**——这一版把复核证据
|
package/README.md
CHANGED
|
@@ -77,8 +77,11 @@ dsh plugin --profile web remove dsh-instruction-memory
|
|
|
77
77
|
(多个会作为同一画像的不同侧面综合)。启用后,形象段会以「## 关于用户(用户形象)」为抬头注入在
|
|
78
78
|
指令块**之前**,抬头明确把它限定为"仅供调整回答侧重":AI 应在**保证回答正确、完整、客观且扣题**的
|
|
79
79
|
前提下据此调整解释深浅、详略、举例与建议方向,**不得为了迎合而改变事实、省略风险提示或偏离问题本身**,
|
|
80
|
-
无关时直接忽略。形象不是让 AI
|
|
81
|
-
|
|
80
|
+
无关时直接忽略。形象不是让 AI 扮演的角色。形象段与指令**共用同一个注入字数上限**:
|
|
81
|
+
形象优先占用,指令只拿剩下的余量(早期版本让形象完全绕过预算,十几个填满的形象能在指令预算还没被
|
|
82
|
+
考虑之前就塞进约 5 万字符)。形象自身另有 4000 字符的硬上限,超出部分按顺序截断并说明。
|
|
83
|
+
页面上的用量条**同时计入**形象与指令,所以它显示的比例就是宿主真正用来裁剪的那个数字。
|
|
84
|
+
总开关关闭时形象与指令一起停用。
|
|
82
85
|
|
|
83
86
|
总开关关闭时,该提示词段落(指令与用户形象)**整个注销**,系统提示词与未安装本插件时逐字节一致;
|
|
84
87
|
总开关开启但没有任何生效指令、也没有启用形象时,段落同样不出现。
|
|
@@ -131,6 +134,10 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DS
|
|
|
131
134
|
> 记忆看起来凭空消失,而真实数据其实好好躺在 `.bak` 里。改为复制后主文件在原子替换成功
|
|
132
135
|
> 前始终待在原位;同时启动时若发现 `memory.json` 缺失而 `.bak` 可用,会**从备份恢复并写回**
|
|
133
136
|
> 主路径,而不是从零开始。
|
|
137
|
+
>
|
|
138
|
+
> 覆盖 `.bak` 之前会先确认**主文件确实可用**:只有解析得出合法存储(或主文件为空/缺失)才覆盖。
|
|
139
|
+
> 早期版本只判断"字节数不为 0",于是一份被截断的 `memory.json`(非空但解析不了)会把唯一完好的
|
|
140
|
+
> `.bak` 覆盖掉——恰好在最需要它的时刻销毁了它。现在"非空但不可解析"的主文件**不会**动 `.bak`。
|
|
134
141
|
|
|
135
142
|
### 多实例与多窗口(并发保护)
|
|
136
143
|
|
|
@@ -144,6 +151,11 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DS
|
|
|
144
151
|
请求都带上它看到的 `rev`(`baseRev`);若与服务端当前 `rev` 不一致(另一个窗口/进程刚
|
|
145
152
|
提交过),服务端拒绝本次修改并返回**最新快照**,面板自动刷新并提示重试——防止旧窗口
|
|
146
153
|
把别的窗口刚删除的条目"复活"回去。不带 `baseRev` 的旧客户端保持原有行为。
|
|
154
|
+
- **内存与磁盘的修订号比对**:写盘前先读一次磁盘上的 `rev`。若磁盘已经被另一个进程推到更高的
|
|
155
|
+
`rev`,说明本进程内存里的那份快照已经过期,此时**拒绝写入并提示"另一个 DSH 进程刚保存过"**,
|
|
156
|
+
而不是继续写。早期版本只把 `rev` 递增到 `max(自己, 磁盘)+1`,看起来修订号单调,实际上是把
|
|
157
|
+
过期内存整份盖回磁盘——另一个窗口刚存的东西会**静默消失**,调用方还收到"已保存"。
|
|
158
|
+
`baseRev` 是"同一进程内"的乐观锁,这一条补的是"跨进程"的那一半。
|
|
147
159
|
|
|
148
160
|
### 外部改动自动刷新(SSE)
|
|
149
161
|
|
|
@@ -203,6 +215,11 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DS
|
|
|
203
215
|
> "没活干还在动"的副作用。现在文件由**第一个真正需要它的动作**创建:一次保存、一次导入,
|
|
204
216
|
> 或设置页读取 `state`(面板打开时就会读)。
|
|
205
217
|
|
|
218
|
+
> **快照里的路径是折叠过的**(1.4.4 起):`storage.path` / `backupPath` / `historyDir` / `schemaFile` 都把家目录折成 `~`,只用于显示;读写、备份、监听仍用绝对路径。
|
|
219
|
+
> `injection.available` 也不再恒为 `true`——提示词区块注册失败时它是 `false`。
|
|
220
|
+
> 设置页依赖宿主的 `slots` 服务:`@deepseek-ai/cordis` 没有"可选注入",所以这是硬依赖,
|
|
221
|
+
> 宿主不提供时插件不装载设置页(只打印一行说明)。
|
|
222
|
+
|
|
206
223
|
在这之前:`storage.path` 仍会显示路径,但 `storage.onDisk` 为 `false`,设置页显示
|
|
207
224
|
「尚未写入磁盘」(该文案本就存在),`state.injection.registered === false`,`pulls === 0`。
|
|
208
225
|
挂载日志相应为 `from=first run (no store on disk yet) onDisk=no`。
|
|
@@ -533,13 +550,15 @@ DNS 重绑定恰好推翻了它**:`evil.com` 解析到 `127.0.0.1` 之后,
|
|
|
533
550
|
|
|
534
551
|
- 绑定到**任何非回环地址**(`--host 0.0.0.0`、`::` 或具体 IP,即主动对外发布)时 Host 校验**自动关闭**——那种情况下无法预知合法 Host,强行限制只会打断所有正常客户端;`Origin` 校验仍然生效。
|
|
535
552
|
- 无 `Host` 的 HTTP/1.1 请求由 Node 的 HTTP 解析器先以 400 拒掉,根本到不了路由。
|
|
536
|
-
-
|
|
553
|
+
- **端口参与 `Origin` 比对**(早期版本忽略端口,于是 `127.0.0.1:1` 这种同主机不同端口的页面也算同源):比对前先把默认端口归一(`http:80` / `https:443` 视同未写端口),其余情况要求 scheme、主机名、端口三者一致。`Host` 末尾的 FQDN 点(`localhost.`)会被忽略,避免同一台机器的两种写法被当成跨源。
|
|
537
554
|
|
|
538
555
|
此外路由还读取 **`Sec-Fetch-Site`**(Fetch Metadata):现代浏览器给每个请求都打上它,
|
|
539
556
|
即使某些技巧(如 `no-cors` fetch)不带 `Origin`,`evil.com` 发来的请求仍会被标记为
|
|
540
|
-
`cross-site`——命中即 **403
|
|
541
|
-
|
|
542
|
-
|
|
557
|
+
`cross-site`——命中即 **403**。在回环绑定(受保护)时 **`cross-site` 与 `same-site` 都拒绝**:
|
|
558
|
+
"同站"判定不看端口,而回环上的任何其他端口都是别人跑的本地服务,放行 `same-site` 等于让任意
|
|
559
|
+
本地页面改写你的长期指令。`none`(用户直接导航)放行;curl、Node 与 2020 年前的老浏览器根本
|
|
560
|
+
不发这个头,也不受影响(它们由 Host 与 content-type 校验兜底)。非回环绑定(主动对外发布)时
|
|
561
|
+
只拒绝 `cross-site`,因为反向代理可能合法地改写端口。
|
|
543
562
|
|
|
544
563
|
### 经反向代理对外发布(自行承担风险)
|
|
545
564
|
|
|
@@ -579,7 +598,7 @@ GUI 与路由本就同主机不同端口),`none`(用户直接导航)放
|
|
|
579
598
|
同一串范围(三处逐字节一致,由测试锁定):
|
|
580
599
|
|
|
581
600
|
```
|
|
582
|
-
>=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.4.0-0
|
|
583
602
|
```
|
|
584
603
|
|
|
585
604
|
**验证范围与证据等级**——三者不可混为一谈,下面每一行都标了它到底证明了什么:
|
package/install.mjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
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 } 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
|
+
symlinkSync(SRC, link, 'junction')
|
|
144
|
+
console.log('junction created: ' + link)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Roll the previous backup forward instead of keeping the first one forever:
|
|
148
|
+
// a profile package.json broken by a later run is exactly what the user needs
|
|
149
|
+
// the backup for, and a backup frozen at the first install cannot restore it.
|
|
150
|
+
const lastBackupPath = backupPath + '.last'
|
|
151
|
+
if (existsSync(backupPath)) {
|
|
152
|
+
copyFileSync(backupPath, lastBackupPath)
|
|
153
|
+
console.log('rotated previous backup -> ' + lastBackupPath)
|
|
154
|
+
}
|
|
155
|
+
copyFileSync(pkgPath, backupPath)
|
|
156
|
+
console.log('backed up profile package.json -> ' + backupPath)
|
|
157
|
+
|
|
158
|
+
const pkg = readPkg()
|
|
159
|
+
let changed = false
|
|
160
|
+
|
|
161
|
+
// Step 3: a link: dependency, so the market lists it under 「已安装」.
|
|
162
|
+
// package.json only — the target is the junction from step 1, so no
|
|
163
|
+
// reinstall of the package itself is needed.
|
|
164
|
+
const spec = 'link:' + SRC.replaceAll('\\', '/')
|
|
165
|
+
pkg.dependencies = pkg.dependencies || {}
|
|
166
|
+
if (pkg.dependencies[NAME] === spec) {
|
|
167
|
+
console.log('already a profile dependency: ' + spec)
|
|
168
|
+
} else {
|
|
169
|
+
pkg.dependencies[NAME] = spec
|
|
170
|
+
changed = true
|
|
171
|
+
console.log('declared as a profile dependency: ' + spec)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const bundles = bundlesOf(pkg)
|
|
175
|
+
if (bundles.includes(NAME)) {
|
|
176
|
+
console.log('already in dsh.profile.bundles')
|
|
177
|
+
} else {
|
|
178
|
+
bundles.push(NAME)
|
|
179
|
+
changed = true
|
|
180
|
+
console.log('added to dsh.profile.bundles (position ' + bundles.length + ')')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (changed) writePkg(pkg)
|
|
184
|
+
|
|
185
|
+
console.log('\nINSTALLED. Restart DSH to mount the plugin.')
|
|
186
|
+
console.log('Roll back with: node install.mjs --remove')
|
package/lib/client.js
CHANGED
|
@@ -1224,9 +1224,18 @@ window.__ModuleLoader__.load({
|
|
|
1224
1224
|
// `entryChars` is the budgeted half. Older hosts publish neither field,
|
|
1225
1225
|
// and there `chars` is already instructions-only, so the fallback is
|
|
1226
1226
|
// correct on both.
|
|
1227
|
-
const
|
|
1227
|
+
const entryUsed = typeof inj.entryChars === 'number' && inj.entryChars >= 0
|
|
1228
1228
|
? inj.entryChars
|
|
1229
1229
|
: (typeof inj.chars === 'number' && inj.chars >= 0 ? inj.chars : 0)
|
|
1230
|
+
// The Host caps `entryChars + profileChars` against the same `budget`, so
|
|
1231
|
+
// measuring `entryChars` alone made the meter understate usage by the
|
|
1232
|
+
// whole persona block and read green while the Host was truncating.
|
|
1233
|
+
// Counted only alongside `entryChars`: without it the fallback above is
|
|
1234
|
+
// already the whole section, and adding profiles again would double-count.
|
|
1235
|
+
const profileUsed = typeof inj.entryChars === 'number' && inj.entryChars >= 0
|
|
1236
|
+
&& typeof inj.profileChars === 'number' && inj.profileChars >= 0
|
|
1237
|
+
? inj.profileChars : 0
|
|
1238
|
+
const used = entryUsed + profileUsed
|
|
1230
1239
|
const countFlagged = (list, wantTruncated) =>
|
|
1231
1240
|
Array.isArray(list)
|
|
1232
1241
|
? list.filter((item) => item !== null && typeof item === 'object'
|