ygc-dsh-plugin-autoupdate
Verifiedygc-dsh-plugin-autoupdate · v0.9.0 · MIT · Web UI
DSH 插件自动更新器:定时检查已装插件的新版本并按策略升级;把自己的插件特征(清单+本地副本+本机汇总)同步到共享插件库自己那一格,本机标识与会话同步插件共用,并汇集他人清单供人工选用。
Install
dsh plugin add ygc-dsh-plugin-autoupdate Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Creators
Readme
插件自动更新(dsh-plugin-autoupdate)
给 DSH 的已装插件做自动升级,规则按你的要求做:
- 定时检查:间隔自己设(最短 5 分钟,默认 360 分钟)
- 逐个插件定策略:某个插件可以「发布后立即更新」,也可以「发布后等 N 小时」(默认 2 小时)
- 更新完是否自动重启:独立开关,可设「只在没有会话跑的时候重启」和「重启前等待秒数」
打开位置
侧栏图标「插件自动更新」→ 主面板。面板分五块:总开关与频率、默认策略、插件列表、重启设置、最近记录。
怎么判断「等够时间了」
用上游的发布时间,不是本地发现时间。
假如你把某插件设成「等 2 小时」,而它 3 小时前就发布了 —— 打开 DSH 立刻就会装,不会再压你 2 小时。 反过来,如果版本刚发布 10 分钟,就算 DSH 全程开着,也要等满 2 小时才装。
发布时间从 npm registry 的 packument time[version] 取;取不到才退回「本次检查时间」。
本地路径(file:)和 Git 来源的包不参与检查 —— 它们没有「发布」这个概念。
更新怎么执行
- 先把 profile 的
package.json/pnpm-lock.yaml/pnpm-workspace.yaml快照到~/.dsh/storages/plugin-autoupdate/backups/<时间戳>/(保留最近 20 份) - 在
pnpm-workspace.yaml的minimumReleaseAgeExclude里登记包名@版本—— 否则 pnpm 的「发布年龄」策略会把刚落地的版本挡回去 - 走宿主自己的
ctx.pluginManager.installBundle(spec, { saveExact: true }) installBundle不可用或没生效 → 退回用桌面宿主自带的 pnpm 执行add- 装完记一条历史;如果是
restart-required,标记「需要重启」
插件不自己改 cordis.patch.yml,不卸载任何插件,不碰 ~/.dsh/workbuddy-vault。
括号里的「不卸载插件」是 0.7.x 及更早的边界。0.8.0 起新增了受控卸载, 见下面「卸载插件」一节:只卸 profile 自装且宿主判定 removable 的包,走宿主官方接口。
自动重启怎么做的
两条通道,官方优先。
通道一:宿主优雅重启(首选)
宿主若暴露 desktopRuntime.requestRestart()(ctx.get('desktopRuntime') 或
ctx.get('desktopActions')),插件直接调它。桌面 shell 自己 app.relaunch() 并无确认退出,
把这次退出当作预期内的退出,因此不会弹「启动失败」恢复框、不写 crash report。
通道二:进程树之外的助手(兜底)
拿不到官方通道时才走这条。插件不自杀——它把重启助手送到调用树之外,由助手负责:
- 等
restartDelaySeconds(默认 0 秒) - 等旧 Electron main 自己退出(默认最多 15 秒,
exitWaitMs可调)——它自己退了就一步强制动作都不做 - 仍没退出,先
taskkill /PID <main>(不带/F,等于发 WM_CLOSE,走app.quit()正常收尾); 仍不领情才/T /F - 用
process.execPath重新拉起,Windows 上带--updated(窗口会自己置前台) - 删掉跨进程锁,下一次重启才能正常排期
全程写 ~/.dsh/storages/plugin-autoupdate/restart.log。
0.9.0 修的两条「重启之后 DSH 就没了」(实测复现 + 回归测试)
故障一:助手是宿主的子进程,被自己的 /T 一起杀掉。
0.8.0 用 spawn(detached: true) 拉助手,助手仍然是宿主的子进程。随后的
taskkill /PID <宿主> /T /F 会顺着 ParentProcessId 把助手一起结束 ——
日志停在「兜底结束旧宿主 pid=…」,relaunched 永远不出现。
实测确认:taskkill /T 连 detached 子进程一起杀。
现在两条路把助手送走:
| 通道 | 父进程 | 说明 |
|---|---|---|
WMI Win32_Process.Create(首选) |
WmiPrvSE.exe |
压根不在调用树里,/T 走不到 |
| 两跳孤儿(WMI 不可用时) | 已退出的第一跳 | 第一跳拉起第二跳后立刻 exit(0),/T 顺树找不到它 |
故障二:结束的是宿主(Host),不是 Electron main。
插件跑在 Host 里,process.pid 是 Host 的 pid:
DeepSeek Harness.exe (main, 13872)
├─ gpu / utility / renderer
└─ DeepSeek Harness.exe (Host, 8036) ← 插件在这里
只杀 Host,main 与 renderer 都还活着;新实例一启动就 requestSingleInstanceLock()
失败并立刻退出 —— 用户看到「重启之后 DSH 不见了」。
现在 lib/pidtree.js 按「同一可执行文件往上爬到根」解析出 main,把它登记成目标 pid。
仅在 process.versions.electron 有值时启用(普通 node 环境里爬 node.exe 会爬到无关父进程);
解析不出就只等不杀,绝不回落到 process.pid / process.ppid。
另外两个坑(都有回归测试):
ELECTRON_RUN_AS_NODE必须显式设。 助手是用DeepSeek Harness.exe <helper.js>起的,不带这个变量 Electron 会把它当成一个完整 App 启动 —— 结果是又开一个 DSH 窗口、 助手永远不执行。test/restart-electron-env.e2e.mjs用真 Electron 验证,…negative.mjs证明该断言不是空的。- WMI 不继承调用方环境。 实测:父进程设的变量在 WMI 子进程里是空的。
所以 launcher 用
.cmd逐行set白名单(PATH/DSH_HOME/DSH_PROFILE/DSH_DESKTOP_NODE_EXECUTABLE/TEMP…),payload 也带一份; 白名单之外的变量(可能含令牌)不落盘。
并发:面板连点或自动更新撞车时,0.8.0 会拉起多个助手互相打架(日志里 9 秒内 3 条
scheduled restart)。现在有进程内冷却(60 秒)+ 跨进程锁文件,第二次请求直接返回
「已有一个重启在执行中,本次跳过」。
为什么绝不按映像名杀进程(0.8.0 修复的实机故障)
Electron 的 main / gpu-process / renderer / utility,以及以 ELECTRON_RUN_AS_NODE
跑起来的宿主,映像名完全相同(DeepSeek Harness.exe)。0.7.x 在「清掉同名残留进程」
这一步用 tasklist /FI "IMAGENAME eq ..." 取全部同名 pid 再逐个强制结束,实际后果是:
- 把自己的 renderer 一起打死 →
Desktop renderer exited: launch-failed崩溃日志 - 用户看到的是 DSH 无预警消失、随后自动重开,完全不像「更新完成后的正常重启」
0.8.0 起助手只认两个明确的 pid:自己(process.pid,永不动)与
调用方登记的旧宿主(mainPid)。这条边界由 test/restart-safety.test.mjs
以源码契约钉死(tasklist 不得出现 IMAGENAME、taskkill 必须带 /PID 且不得带 /IM)。
「只在没有会话正在跑的时候重启」默认开着:判定依据是最近 90 秒内有没有会话事件。 有会话时重启会被推迟,等空闲了再执行。手动卸载与手动重启不受这条约束。
状态文件
~/.dsh/storages/plugin-autoupdate.json
{
"enabled": true, // 总开关
"intervalMinutes": 360, // 检查间隔
"defaultPolicy": "delayed", // immediate | delayed
"defaultDelayHours": 2,
"autoRestart": false,
"restartOnlyWhenIdle": true,
"restartDelaySeconds": 10,
"perPlugin": { // 单包覆盖
"dsh-connect-workbuddy": { "policy": "immediate", "delayHours": 2 }
},
"lastCheck": {}, // 上次检查的快照(面板「最新」列用它)
"pending": {}, // 待装的新版本(含上游发布时间)
"history": [] // 最近 100 条
}
插件识别规则
profile/package.json 里 dsh.profile.bundles 列出的就是插件全集,再按 dependencies 的来源分三类:
| 来源写法 | 类别 | 是否参与更新 |
|---|---|---|
1.2.3 / ^1.2.3 |
registry |
是 |
file: / link: / workspace: |
local |
否(没有「发布」概念) |
git+ / github: / http(s):// |
git |
否 |
不在 dependencies 里(随 DSH 安装提供) |
installation |
否(版本跟随宿主) |
HTTP API
面板和外部脚本都走 /api/local/plugin-autoupdate/:
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | /state |
面板快照 |
| POST | /settings |
改总开关 / 间隔 / 默认策略 / 重启设置 |
| POST | /plugin-policy |
改单个插件策略(policy: 'inherit' 恢复跟随默认) |
| POST | /check |
立即检查(只查不装) |
| POST | /query |
查单个包 |
| POST | /update |
立即更新指定包(忽略延迟) |
| POST | /update-all |
更新所有已到点的包 |
| POST | /restart |
立即重启 |
| GET | /backups |
列出清单备份 |
| POST | /restore |
用某份备份回滚清单 |
| GET | /removable |
哪些插件能卸(透传宿主 removable / availability) |
| POST | /uninstall |
卸载一个插件(body: { name, confirm: true }) |
| GET | /diagnose |
看插件识别结果(排查「为什么某个包没被检查」) |
卸载插件(0.8.0 新增)
桌面 profile 下一个插件同时躺在三处状态里,漏掉任何一处都会留下 「卸了但行为还在」或「UI 里没了却仍在加载」的半成品:
package.json的dependenciesdsh.profile.bundles(不在这里就不会被加载)cordis.patch.yml里针对它的补丁行
所以卸载只走宿主官方 pluginManager.removeBundle(name):它一次做完
「摘选 bundle → pnpm remove → 重新发布并 reload 加载树」。本插件绝不自己去改
这三个文件(自己改最容易漏,而且和宿主内存状态不同步)。
什么能卸、什么不能
| 情形 | 结果 |
|---|---|
availability === 'profile' 且 removable === true |
可卸 |
availability === 'installation'(随 DSH 安装提供) |
拒绝,说明原因 |
removable !== true(正在运行 / 宿主只读) |
拒绝,回显宿主的 readOnlyReason |
本插件自身 ygc-dsh-plugin-autoupdate |
拒绝,避免把自动更新器自己拆了 |
@deepseek-ai/dsh-plugin-manager |
拒绝,卸掉后整个插件体系都加载不了 |
宿主没提供 pluginManager |
拒绝,明确告知改用 DSH 自带插件管理 |
三条硬约束
- 必须显式确认:
POST /uninstall没有confirm: true一律不执行; 面板上的按钮会先弹二次确认,取消就什么都不做。 - 不可卸载时按钮置灰并说明原因:信息未知(拿不到宿主清单)时也置灰—— 宁可点不了,也不让用户点了才发现不行。
- 卸载后自动重启生效:模块已经加载进宿主内存,不重启不会真的消失。 走的是与自动更新同一条重启通道(优雅优先),不另写一套杀进程逻辑。
卸载 → 重启用哪条路
与「自动重启怎么做的」完全一致:优先 desktopRuntime.requestRestart(),
拿不到才用分离助手。卸载时不受「只在空闲时重启」约束——
这是用户当前主动发起的操作,不会打断别人的长任务还装作没发生。
插件共享(特征文件夹)
把「我这台机器装了什么插件」沉淀成一份可以给别人看的东西,互相补全。
目录结构
插件库 是共享文件夹,一个人一格,自己的所有东西都锁在自己那一格里。
D:\Ai工作空间\插件库\ ← 共享文件夹,两台机器都能读写
├─ <本机标识>\ ← 我的格子,例 DSH-7e6b1bb7e052
│ ├─ PLUGINS.json 我的插件清单(含副本指纹表)
│ ├─ FEATURE.json 我的身份说明
│ ├─ OTHERS-PLUGINS.json 我的汇总(本机自用)
│ ├─ OTHERS-PLUGINS.md 同一份的人读版
│ └─ local\ 本地 / Git 来源插件的 .tgz 副本
└─ <别人的标识>\ ← 别人写,我只读
├─ PLUGINS.json
├─ FEATURE.json
└─ local\
没有公共分区。 早先版本在插件库下分出「特征 / 汇集」两级子目录,那是错的: 插件库是多人共享的盘,公共目录等于公共广场,所有人的东西往一处堆,谁的产出分不清, 而且「汇集」会成为多方争写的热点。现在改成:
| 原位置 | 现位置 |
|---|---|
插件库\特征\<标识>\ |
插件库\<标识>\ |
插件库\汇集\OTHERS-PLUGINS.* |
插件库\<标识>\OTHERS-PLUGINS.* |
汇集只给本机自己用。 每台机器各自扫一遍插件库下所有人的格子,在自己的格子里 生成自己的汇总——不做全局总表,因为那需要所有机器写同一个文件,必然是冲突源。
本机标识与会话同步插件共用
特征文件夹的目录名就是本机标识,格式 <名称>-<特征值>,例如 DSH-7e6b1bb7e052。
这套规则和会话同步插件(ygc-dsh-sync-backup)逐字一致,并且共用同一份身份:
| 项 | 规则 |
|---|---|
| 身份来源 | 优先读 ~/.dsh/storages/sync-backup.json 的 machineHash |
| 特征值 | 6 字节随机数 → 12 位十六进制,第一次确定后永久固定,永不重算 |
| 名称部分 | 面板可改;改名只换目录名,特征值不变,旧目录不删 |
| 落盘位置 | 固定进 ~/.dsh/storages/plugin-autoupdate.json 的 shareMachineHash |
| 迁移 | 唯一来源是既有 state 里已有的哈希;没有旧值才新生成 |
为什么必须共用:两台机器之间的身份只能有一个。会话同步已经把这一格写成了
machineId,插件共享如果自己再算一份(比如主机名-用户名),同一台机器会在共享盘上
留下两个身份,别人汇总时看到两个「我」,本机自己也不确定该往哪格写。
名称里的非法字符按会话同步的 joinId 规则换成下划线;特征/ 下只写自己那一格,
写别人的目录会被守卫拒绝(WRITE_DENIED)。想强行重算特征值,用
POST /settings { shareResetHash: true }——这是唯一的重算入口,其它情况一律沿用旧值。
清单里写什么
| 插件来源 | 清单里写什么 | 要不要打副本 |
|---|---|---|
registry(能联网更新) |
名称 + 版本 | 不打,对方自己从 registry 拉 |
local(file: 本地路径) |
名称 + 版本 + 来源说明 | 打,local/<name>-<version>.tgz |
git(GitHub 等) |
名称 + 版本 | 打,同上 |
installation(随 DSH 安装提供) |
不写 | 别人本来就有,也没版本号可装 |
副本是 npm 标准 tgz(package/ 前缀),排除 node_modules、.git、__pycache__、*.bak-*。
单包上限 96 MB,单个特征的副本总量上限 512 MB,同名插件只留最近 2 个版本。
link: 装的包在 node_modules 下是符号链接,打包时按 realpath 追到真实目录。
什么时候跑
每次巡检跑一次——巡检 = 自动更新的那一轮定时检查(间隔见「检查频率」)。 顺序是先查更新、再同步共享;更新失败不影响共享同步。面板上也有「立即同步一次」。
每次同步都幂等:
- 清单内容(除
generatedAt/lastSyncAt这类时间字段)没变 → 不重写文件 - 源目录指纹(文件数 + 最大 mtime + 总字节)没变 → 不重压
.tgz,直接复用 - 本机标识第一次同步时固定进 state,之后每轮原样传回,不做任何重算
所以几百 MB 的本地插件不会把每轮巡检拖慢。
拉别人的清单
把别人的 <别人的标识>\ 整个目录直接放进 插件库\(同步盘自动同步也行),然后:
- 面板点「重新读取」,或等下一次巡检;
- 自己格子里的
OTHERS-PLUGINS.json生成合并后的总清单; - 面板「他人插件(本机没有的)」里逐个点「安装」。
不自动安装。 别人的插件清单可能包含你不想要的东西,装机这个动作必须人工点。
合并去重规则:
- 去重键是包名;多本特征都有同一插件时,版本取更高的那份(按语义版本比,不看文件时间)
- 副本跟着最高版本走;
features里记下它出现在哪几本 - 「本机有没有」按 profile 的
dsh.profile.bundles判断,不看node_modules(那里塞满传递依赖) - 没
PLUGINS.json的目录只记一条提示,不报错
装的时候走哪条路
| 对方特征里有 | 用的 spec |
|---|---|
.tgz 副本 |
file:<对方副本的绝对路径>(正斜杠) |
| 只有名称(registry 来源) | <name>@<version> |
| 既没副本又不是 registry | 拒绝,说明原因 |
装完自动把包名补进 profile 的 dsh.profile.bundles——不补的话插件装下来但不会加载。
装之前同样会快照 profile 三个清单文件,出问题用 /restore 回滚。装完必须完全退出 DSH 再重开。
共享相关接口
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | /share |
我的特征现状 + 他人清单(含每项是否已装、有没有副本) |
| POST | /share/sync |
立即同步一次(我的清单 + 副本 + 他人汇集) |
| POST | /share/install |
安装他人清单里的一个插件(body: { name }) |
设置项(走 /settings):shareEnabled、shareLibraryRoot、shareFeatureName、shareResetHash。
shareFeatureName 只改名称部分,特征值不动;shareResetHash: true 是唯一的重算入口,
会让下次同步重新解析特征值(正常情况别用,会让目录漂移、别人看到两个你)。
自检
$node = "C:\DeepSeekHarness\resources\runtime\primary-runtime\dependencies\node\bin\node.exe"
& $node "C:\DSH-Work\plugin-autoupdate\pkg\test\all.mjs"
十六个套件:
| 套件 | 覆盖 |
|---|---|
selftest.mjs |
版本比较(含 prerelease)、状态规范化夹紧、延迟判定、registry packument 解析、本机 profile 识别 |
workspace-merge.test.mjs |
pnpm-workspace.yaml 的 minimumReleaseAgeExclude 合并(同名并成一行、不重复、CRLF 保持、段落连续) |
api-smoke.test.mjs |
用假 ctx 真加载 Host 半,走完全部 API 路由;状态写在临时 HOME,不碰真实 profile |
snapshot-regression.test.mjs |
面板快照结构回归 |
update-guard.test.mjs |
只向上更新、来源判定、发布年龄门槛 |
git-source.test.mjs |
Git 来源的 commit 比较与安装 spec |
machine.test.mjs |
本机标识:继承会话同步的 machineId、固定后不漂移、改名不换特征值、卸载会话同步后旧值仍有效、写边界(别人的格 / ../ 逃逸 / 前缀相近) |
share.test.mjs |
特征文件夹:清单分类(registry 只写名 / 本地打副本 / 宿主自带不写)、tgz 结构、同步幂等与副本复用、他人清单合并去重、安装路径选择、特征名防上跳 |
schedule.test.mjs |
定时排期:更新与共享同步各自独立开关 |
restart-safety.test.mjs |
重启安全契约(源码级):不得按映像名杀进程、必须排除自身、必须先等再兜底、助手须由 WMI/两跳送出进程树、须先礼后兵、须有冷却与锁、launcher 须设 ELECTRON_RUN_AS_NODE |
pidtree.test.mjs |
进程树解析(合成进程表):Host 爬到 main、renderer 爬到同一个 main、起点缺失/父进程缺失/链过深时的边界、只在 Electron 里才爬 |
restart-helper-behavior.test.mjs |
助手行为(真子进程):旧 main 自退时零强制动作、不退出时先请求正常退出再强杀、只针对登记的那个 pid、payload 读后即删 |
restart-relaunch.e2e.mjs |
重启链路(真进程树):助手父进程不是调用方、整树 taskkill /T /F 之后助手仍活着并完成 relaunched |
restart-env.e2e.mjs |
环境传递:DSH_PROFILE / DSH_HOME / PATH 传到新实例、白名单外的变量不落盘、新实例不带 ELECTRON_RUN_AS_NODE |
restart-electron-env.e2e.mjs |
Electron 助手模式(真 Electron exe):launcher 能真的让 Electron 以 Node 模式跑脚本 |
restart-electron-env.negative.mjs |
负向对照:故意不带 ELECTRON_RUN_AS_NODE 时脚本不执行,证明上一条断言不是空的 |
uninstall.test.mjs |
卸载契约:未确认不出手、installation/不可移除的拒绝、拒绝卸自己与管理器、走 removeBundle、失败写 uninstall-failed、成功后请求重启;外加前端按钮 / 二次确认 / canUninstall 契约 |
client-smoke.test.mjs |
在 Node 里用假 document / React 求值 client.js,验证插槽注册、locale 注册、面板首渲染 |
真机取证脚本(不属于 all.mjs,需要手工跑):
# 在真宿主进程树里跑一次解析,确认能指出 Electron main
$env:ELECTRON_RUN_AS_NODE = '1'
& "C:\DeepSeekHarness\DeepSeek Harness.exe" "C:\DSH-Work\plugin-autoupdate\pkg\test\_live-pidtree.mjs"
边界
- 只更新已装插件,不安装新插件;卸载仅限 profile 自装且宿主判定 removable 的包(见「卸载插件」)
- 只在
registry来源上工作;local/git/installation来源只显示、不处理 - 更新失败只记历史,不自动回滚(避免把用户自己手改的清单吞掉);要回滚用
/restore - DSH 关闭期间不检查(进程内定时器的固有限制);重开后会立刻按上游发布时间重新判定
- 共享只同步清单与副本,不自动安装别人的插件;安装必须人工点选
- 只写
插件库\<本机标识>\那一格,别人的格子只读(写入口有守卫) - 不在插件库根下留任何公共文件或公共目录
- 重启只认显式 pid:任何「按映像名清场」的写法都是回归,
restart-safety.test.mjs会红 - 重启助手必须活在进程树之外:
spawn(detached)逃不掉taskkill /T(实测), 任何退回「直接 spawn 助手」的改动都会让 0.9.0 的故障复活 - 要结束的是 Electron main:插件里的
process.pid是 Host, 只杀 Host 等于没重启。目标 pid 只能来自lib/pidtree.js的解析结果或调用方显式登记 - 没有可信 pid 就只等不杀:绝不回落到
process.pid/process.ppid - launcher 必须设
ELECTRON_RUN_AS_NODE=1:否则 Electron 把助手脚本当 App 启动
踩过的坑
不要 export const Config = {}。 cordis 的 resolveConfig 对任何 truthy 的 Config
都会去读 Config['~standard'].validate,空对象直接抛
TypeError: Cannot read properties of undefined (reading 'validate'),
插件激活失败(dsh: warning: 1 entry did not activate)。
配置全走面板 + 状态文件,不需要 patch 里的 config 行,也就不需要 Config 导出。