Skip to content

ygc-dsh-plugin-autoupdate

Verified

ygc-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 来源的包不参与检查 —— 它们没有「发布」这个概念。

更新怎么执行

  1. 先把 profile 的 package.json / pnpm-lock.yaml / pnpm-workspace.yaml 快照到 ~/.dsh/storages/plugin-autoupdate/backups/<时间戳>/(保留最近 20 份)
  2. 在 pnpm-workspace.yaml 的 minimumReleaseAgeExclude 里登记 包名@版本 —— 否则 pnpm 的「发布年龄」策略会把刚落地的版本挡回去
  3. 走宿主自己的 ctx.pluginManager.installBundle(spec, { saveExact: true })
  4. installBundle 不可用或没生效 → 退回用桌面宿主自带的 pnpm 执行 add
  5. 装完记一条历史;如果是 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。

通道二:进程树之外的助手(兜底)

拿不到官方通道时才走这条。插件不自杀——它把重启助手送到调用树之外,由助手负责:

  1. 等 restartDelaySeconds(默认 0 秒)
  2. 等旧 Electron main 自己退出(默认最多 15 秒,exitWaitMs 可调)——它自己退了就一步强制动作都不做
  3. 仍没退出,先 taskkill /PID <main>(不带 /F,等于发 WM_CLOSE,走 app.quit() 正常收尾); 仍不领情才 /T /F
  4. 用 process.execPath 重新拉起,Windows 上带 --updated(窗口会自己置前台)
  5. 删掉跨进程锁,下一次重启才能正常排期

全程写 ~/.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 里没了却仍在加载」的半成品:

  1. package.json 的 dependencies
  2. dsh.profile.bundles(不在这里就不会被加载)
  3. 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 自带插件管理

三条硬约束

  1. 必须显式确认:POST /uninstall 没有 confirm: true 一律不执行; 面板上的按钮会先弹二次确认,取消就什么都不做。
  2. 不可卸载时按钮置灰并说明原因:信息未知(拿不到宿主清单)时也置灰—— 宁可点不了,也不让用户点了才发现不行。
  3. 卸载后自动重启生效:模块已经加载进宿主内存,不重启不会真的消失。 走的是与自动更新同一条重启通道(优雅优先),不另写一套杀进程逻辑。

卸载 → 重启用哪条路

与「自动重启怎么做的」完全一致:优先 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 的本地插件不会把每轮巡检拖慢。

拉别人的清单

把别人的 <别人的标识>\ 整个目录直接放进 插件库\(同步盘自动同步也行),然后:

  1. 面板点「重新读取」,或等下一次巡检;
  2. 自己格子里的 OTHERS-PLUGINS.json 生成合并后的总清单;
  3. 面板「他人插件(本机没有的)」里逐个点「安装」。

不自动安装。 别人的插件清单可能包含你不想要的东西,装机这个动作必须人工点。

合并去重规则:

  • 去重键是包名;多本特征都有同一插件时,版本取更高的那份(按语义版本比,不看文件时间)
  • 副本跟着最高版本走;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 导出。