dsh-plugin-update
> ✨ 本包为任何 DSH 插件提供开箱即用的自我更新能力:宿主侧一段接线,面板侧挂一个现成组件,把「查新版 / 装更新 / 失败兜底 / 更新日志」一次性包掉,为开发者省下大量重复工作量与长期维护成本。直接把本 README 丢给 AI 通读即可集成:第 1–2 节是三步接入法,后续章节是全部 UI 控件(单面板 / 批量面板 / 入口件 / 批量入口件 / HTTP 直挂)与自定义 UI 参数,基本都支持按需覆盖。
> 📦 优秀示例项目:dsh-mattpocock-skills-deck · dsh-opencode-palette · ilife
给 DSH 插件加「检查更新 / 安装更新」能力的 npm 包。宿主侧一段接线,面板侧挂一个现成组件,装不上时给用户一条可复制的手工命令。
要求 Node 22 或更高。运行期依赖只有一个:dsh-log ^0.3.4(0.16.0 起引入,0.16.4 起下限抬到 0.3.4,见第 10 节)。当前版本见上方徽章与 package.json(此处不写死版本号,免得跟着发布漂)。
装上它你会拿到七样东西:
- 六个电话:查状态(只读本地)、查新版(用户点了才联网一次)、装更新(拿凭证提交)、取日志(按版取更新日志,全文或空)、交日志(面板把攒下的日志行交回宿主落盘,0.16.0 起)、查合规版(最新版还在年龄门内时,回「满门限的那个版本是哪个」与它的候选凭证,0.18.6 起)。电话指宿主对外提供的方法。
- 一套落盘:任务状态、安装锁、回滚凭据,按「插件标识 + 使用范围」隔离,多插件互不干扰。
- 面板要的派生取值:电话名与轮询间隔,构建期从本包生成,面板里不写死。
- 一个现成整组件:默认内嵌、可切弹窗,调用者传参指定;轮询、安装门控、中文一句话、待重启横幅、手工命令展示与复制、排队可见开关、跳过与恢复、诊断一键复制、更新日志自动展示全在组件内部消化。
- 一个更新入口件:配置页一行挂上就是「检查更新」按钮(或只给状态点的徽标、或整块内嵌),按钮文案随状态自己变;见第 2.5 节。
- 一个安装按钮控件:任意卡片里原地装好一家插件(缺席可装:未装显示「未安装 → 目标版本」,点下先查后装);判定、文案、诊断与面板同一口径,失败兜底手工命令;见第 2.8 节。
- 一套多目标批量更新:一个插件管 N 个插件的更新(总账 + 明细 + 动作,一家收尾才起下一家,会话落盘可断点续跑,进程级更新中枢让自更新不断链、全局进度可只读查询);见第 2.6 节。
1. 安装
npm install dsh-plugin-update
把本包装成你自己插件的依赖即可(推荐);老式的「把本包 vendor 进插件目录」也照旧可用。
两种形态下,包都按包名在自己的 node_modules 链上找你的插件包——找到它,才能推出「插件装在哪、正在跑的是哪一版」。
包还没公开发布时先用本地路径代替(例如 npm install ../dsh-plugin-update)。
2. 三步接入
下面假设你的插件标识是 my-notes-plugin、电话名前缀是 notes、要查更新的包也是 my-notes-plugin。
三处名字都换成你自己的;prefix 不传即 wf(历史取值),新插件务必传自己的前缀,多插件靠它隔离。
第 1 步:装包
见第 1 节。
第 2 步:宿主侧接线
import { createHostUpdate } from 'dsh-plugin-update'
const update = createHostUpdate(
{ ctx, logCtx },
{
pluginId: 'my-notes-plugin',
prefix: 'notes',
targetPackageName: 'my-notes-plugin',
}
)
// 把处理器按键注册进自己的电话表;电话名从 update.phoneNames 读,不要自己拼字符串。
for (const [name, handler] of Object.entries(update.handlers)) {
registry.set(name, handler)
}
这一步得到六个电话名:notes.updateStatus、notes.updateCheck、notes.updateInstall、notes.updateChangelog(取日志:给版本号,回该版 tarball 里的更新日志全文,取不到回空)、notes.logAppend(收面板侧的日志行,见第 10 节;0.16.0 起新增)、notes.updateCheckFallback(合规版决议:最新版还在年龄门内时装不上,问它「满门限的最新版是哪个」,0.18.6 起新增)。另外还有一通只读全局进度的 notes.updateHubStatus(hubStatus 字段给出全名,见第 2.6 节末尾),与批量宿主那通同语义。
pluginId 必填(非空字符串,不含路径分隔符)。单例复用键强制含插件标识,多插件不串内存状态与锁。
除 pluginId 之外的配置都可选并带默认值,不传即走默认(见第 3 节)。
接线的第二个参数除 { ctx, logCtx } 外还能给 pluginManager(显式交宿主管理器实例)与 readerOverrides。
平时不用管;只有自动解析对不上目标包时(hoisted、多副本、开发态链接)才用得上它的 targetPackageDir:
createHostUpdate(
{ ctx, logCtx, readerOverrides: { targetPackageDir: '/abs/path/to/node_modules/my-notes-plugin' } },
{ pluginId: 'my-notes-plugin', prefix: 'notes', targetPackageName: 'my-notes-plugin' }
)
使用范围(profile)怎么定(0.18.25 起):本包按下面的顺序解析,前一步拿到就不再往后走——① 调用方显式传的 readerOverrides.profileDir / profileName;② DSH 注入的 ctx.profileContext(profile 启动的宿主在插件挂载前提供,含 name 与 dir);③ 第三方 Desktop 的 ctx.desktopProfiles.current({ name, dir });④ 调用方显式传入的 readerOverrides.env.DSH_PROFILE_DIR(名字取 DSH_PROFILE);⑤ 从已定位的目标包目录反推 <使用范围>/node_modules/<目标包>。所以在 DSH 里运行(普通 profile 启动或 Desktop)不会因为目标包不在预期位置而认不出使用范围;只有完全没有 profile 上下文、也没显式覆盖、又反推不到的嵌入方才会拿到 unknown-profile(这时传 readerOverrides.profileDir / profileName)。识别出的名字会用于面板横幅与手工命令里的 --profile。本包不读隐式 process.env;要传环境快照请走 readerOverrides.env。
本包是 ESM("type": "module"):ESM 插件直接 import;CJS 插件用 await import('dsh-plugin-update')。
六个电话的入参与回参
面板与宿主两侧共用这一份契约(… 是你的前缀):
| 电话 | 入参 | 成功回包 | 失败回包 |
|---|---|---|---|
….updateStatus |
{} |
{ ok: true, snapshot, manual, receipt: null } ※ |
{ ok: false, error, errorKind, diag? } |
….updateCheck |
{} |
{ ok: true, snapshot, manual, receipt } ※ |
同上 |
….updateInstall |
{ checkId, requestId, version?, approvedBuilds? } |
{ ok: true, snapshot, manual, receipt: null } ※ |
同上 |
….updateChangelog |
{ version } |
{ ok: true, version, markdown: string \| null, status, cached } ※ |
同上 |
….logAppend |
{ rows: [{ level, event, fields, ts }] } |
{ accepted, dropped }(永不 ok:false、永不抛) |
— |
….updateCheckFallback |
{ kind, blame, targetVersion, windowMs, windowSource, publishedAtMs } |
{ ok: true, snapshot: { verdict, candidate }, receipt } ※ |
同上 |
※ 快照里带着失败任务时,成功回包另可带可选 diag(失败证据小对象,见下一条);无失败任务时形状逐字不变(0.18.7 起)。
※ updateChangelog 成功回包自 0.18.26 起另带 status 与 cached:status ∈ ok | empty | missing | fetch-failed | parse-degraded | cache-hit(这一版为什么有或没有说明——作者没写 / 包里没这个文件 / 取数失败 / 有内容但解析不出结构),cached: true 表示这次回的是宿主进程缓存。老集成方只读 markdown 不受影响;认不出的 status 值一律按老样子显示。命中缓存那一帧的 status 仍是那次取数的原始内容态(不会被改写成 cache-hit),cache-hit 只出现在日志行里。
….updateInstall的version可选:给了就装这个精确版(「装合规版」这条候选路径用),不给仍装快照里的最新版。….updateInstall的approvedBuilds可选(0.18.23起):宿主 pnpm 因「被忽略的构建脚本」失败时,失败详情会点名需要批准的精确包名(见 5.9);调用方决定批准后,把这些包名原样带回来重试一次,宿主先登记批准再跑 pnpm。重试必须换一个新的requestId——同一个requestId重复提交会被当成重复请求直接返回旧结果(含旧的失败快照),不会真的重试;若宿主回stale-approval(名单已过期/不在待批列表),说明批准窗口已过,应重新发起一次检查+安装、且不要带上次的approvedBuilds。形状归一在宿主侧电话入口完成(非字符串、通配、开关样式、路径片段、包@版本一律丢弃,去重保序、最多 32 条),认不出的项不会交给宿主;不传即现状,老调用方一字不动。snapshot恒为第 5.1 节那九个字段(七个核心 +identity+registered);manual是第 5.3 节那条手工命令(能给则给,不能给为null)。receipt查新版与合规版决议都给({ checkId, checkedAt, expiresAt });装更新时把checkId原样带回来,requestId由面板自己生成(同一个编号重复提交直接返回旧结果)。- 失败一律
ok: false:error是原因码(第 5.2 节那十一种,另加check-failed/invalid-release/check-expired/update-busy/install-failed),errorKind认不出时是internal。面板按 17 码给中文文案(十一种见第 5.2 节,另五种与internal见下表,未来码走兜底);分支只认errorKind,error仅回退。 - 失败可能顺带回可选
diag(失败证据小对象:阶段、路由、耗时、人话摘要、版本、宿主、请求与检查编号、源主机、动作提示;0.18.5起另有归因三键causeKind/causePackage/causeUnlockAt;#207起再加一格intentHash——请求编号的短指纹,拿它就能把这次失败的诊断回包、update.install.request行与update.install.fail行对上;共 20 键;缺省即省略,序列化恒在 1KB 内)。旧面板直接忽略它,行为逐字不变;新面板也只按稳定码分支,不拿它做分支。 0.18.7起,快照里带着失败任务时成功回包也可能带这份diag(批量面板与安装按钮靠它认出「该装合规版」);无失败任务时成功回包形状逐字不变。
第 3 步:面板侧接线(构建期派生)
目标只有一句:面板里不要写死电话名与轮询间隔。构建时用包内工具生成一个小文件:
node node_modules/dsh-plugin-update/derive-client-values.mjs --prefix notes --out scripts/generated/updateClient.derived.js
生成的常量直接在面板里用:
// UPD_STATUS / UPD_CHECK / UPD_INSTALL / UPD_POLL 由上面那条命令生成
host.call(UPD_STATUS, {}) // 查状态
host.call(UPD_CHECK, {}) // 查新版
host.call(UPD_INSTALL, { checkId, requestId }) // 装更新
setInterval(readStatus, UPD_POLL) // 轮询
派生工具的自定义项:
--prefix必填,必须与宿主侧createHostUpdate传的prefix一致,否则面板调的电话名与宿主注册的对不上。--out必填,故意不给默认值:默认值会覆盖别人的文件。--dry-run只打印生成内容、不写文件。- 工具用
esbuild打包一次(构建期使用,不引入运行期依赖);找不到 esbuild 时打印安装提示,不静默失败。
以后换前缀或升级本包,重新跑一次这条命令即可。
第 4 步:面板侧挂载整组件(一个挂载点即跑)
前面三步是“自己拼面板”的走法;要整组件,把第 3 步的常量换成下面这一行(框架无关,任何面板直接嵌,样式隔离):
import { mountUpdatePanel } from 'dsh-plugin-update/panel'
// host.call 是你调宿主电话的函数:(phoneName, args) => Promise
const panel = mountUpdatePanel(document.getElementById('update-slot'), {
pluginId: 'my-notes-plugin',
prefix: 'notes', // 与宿主侧一致;电话名从它算出,不写字面量
mode: 'embedded', // 默认内嵌;切弹窗传 'dialog',同一套内核
showOthers: false, // 默认只看自己的排队,他人仅露“正忙”占位
call: (name, args) => host.call(name, args),
})
// 离开时 panel.unmount():只停轮询,安装在宿主侧继续跑;重开面板立刻重查,1 秒内恢复显示。
// 弹窗版想让「关闭」按钮真把弹窗撤掉:传 onCloseRequested(面板点关闭/Esc 时先调它撤 DOM,再停轮询;入口件打开的 dialog 已内置)。
自动检查(autoCheck,默认 true)。挂载后先只读一次本地状态(首刷),拿到快照后若判定需要就自动查一次新版(只读,不安装):面板还装着/验着、或已有动作在飞时不查(再查也只会得到同一结论);传 autoCheck: false 则挂载不自动查,只在用户点「检查更新」或你调 panel.refresh() 时动——关的只是自动检查,面板的只读轮询与用户手动检查照常(自查同样是只读,任何路径都不自动安装)。入口件与安装按钮的同名选项取值不同('mount' / 'never'),语义同向,见第 2.5、2.8 节。挂载日志行 update.panel.mount 的 autoCheck 一格记录这一面面板到底跑没跑:started(自动查已发起)/ recent(首刷已显示在忙——装着/验着或动作在飞,跳过)/ disabled(调用方关了自动检查)/ failed(首刷失败、没拿到状态,没跑成)——排查「面板为什么没有自己查」时看它;安装按钮那个选项只会落成 started / disabled。
组件内部消化的事(调用者不再写):按 panelPollMs 轮询查状态(下限 250 毫秒);安装按钮状态跟随快照的 canInstall,不另写门控规则;装不了的原因按第 5.2 节展示中文一句话;pending-restart 单独横幅加重启指引,不再给安装按钮;手工命令展示与复制;排队位置展示与 showOthers 开关;跳过按版本记(“已跳过 X.Y.Z · 恢复”在同一行,不藏进设置页);失败时旁边的“复制诊断”一键给出脱敏后的自包含文本(稳定码、版本、宿主、队列位置),深挖仍看日志(组件里留着第 6 节第 11 条的过滤口径)。失败若归因指向本插件、且最新版被 24 小时年龄门挡住,旁边另给「装合规版」与「等新版」两个按钮(0.18.6 起;先决议、点名版本确认后才装,名词与整套流程见第 2.6 节)。失败横幅常驻:查/装失败后保留到下一次用户主动查/装的新结论、任务态或版本变化,只读轮询不再洗掉;复制诊断锁定致命那次回包的证据(码、摘要、版本、宿主、编号冻结在失败时刻);失败旁有「知道了」可显式确认回到可装页(只清面板提示,不调电话)。无失败时复制诊断给当前状态快照(版本、宿主、范围、队列),不伪造失败码。04 章平时是路牌(中性行+日志过滤口径,可用 showLogHint: false 隐藏);失败时变为卷宗:真实稳定码、本次查询键(请求/检查编号+失败时刻)、证据冻结声明、凭编号对日志的现成查询。日志事件名(host.call/host.call.fail/update.install.exec)由宿主与面板共用单源,改名即全改。
待重启横幅上有个「重启宿主」入口。宿主没有“重启自己”的电话,所以默认点击只如实提示「请手动重启宿主」;想把重启流程接进来就传 onRestartRequested(面板调它,成败都回执):
mountUpdatePanel(slot, { pluginId: 'my-notes-plugin', prefix: 'notes', call: host.call,
onRestartRequested: async () => { /* 拉起你自己的重启流程 */ } })
面板画成档案五章:01 检查与安装、02 更新日志、03 更新队列、04 错误信息、05 手工命令(五章恒在,缺内容给中性提示,不跳号)。档案头一行是「插件名 + 使用范围 + profile 牌」:使用范围由面板自动向宿主索取(电话入参 includeEnv,宿主只回范围名与宿主种类、不回任何路径),显示的就是更新要落到的那个 profile——web 与 desktop 各装一份,装错范围是严重故障,所以这一栏宁可显示“未知”也不猜。调用方知道得更准时可以用 profileName 显式覆盖。
可选档案卷主题(纸面浅色案卷风,不替换默认):挂载时加 theme: 'archive' 即换肤(右上大印章「待查/可装/安装中/待重启/受阻/已最新」+ 横幅小印章一字 + profile 牌 + 待重启衬线横幅配手绘 SVG 标 + 窄屏印章固定 + 省略号逐字折叠 + 浅深双主题跟随系统),内核 DOM 顺序不动、复制诊断常在;不传即最小可用默认深色样式。运行时用 panel.setTheme('archive' | 'default') 可切。入口件与批量面板是同一个 theme 参数,取值同一套。
主题变量覆盖(第三方换肤):四个 UI 口(单面板、批量面板、入口件、批量入口件)都收 themeTokens,键为语义名,一次传入即换肤,不传即零回归。入口件打开的 dialog 面板同步透传;运行时用 setThemeTokens({...}) 即时换肤,传 undefined 即清掉覆盖:
mountUpdatePanel(el, { pluginId: 'p', prefix: 'notes', call,
theme: 'archive',
themeTokens: { primary: '#c8402a', bg: '#fffdf6', radiusButton: '3px' },
})
键与样式表里的 --dsh-update-* 变量一一对应(括号里是变量名),手写 CSS 变量等价生效:
| 组 | 键(变量) |
|---|---|
| 文字背景边线 | text、textMuted、bg、bgSoft、border、borderStrong、buttonBg |
| 主色焦点 | primary、primaryDeep、focus |
| 状态四组 | okBg / okBorder / okText、warnBg / warnBorder / warnText、badBg / badBorder / badText、busyBg / busyBorder / busyText |
| 批量新版 | newText(未装好红)、newOkText(已装好绿) |
| 字体阴影 | fontSans、fontSerif、fontMono、shadow |
| 圆角 | radiusPanel、radiusButton、radiusBadge |
| 入口件尺寸 | entryFontSize、entryPadding、entryBorderRadius、entryScale(数字,大于 0) |
| 面板字号(#103) | panelFontSize(正文基准,默认 14px)、panelChapterFontSize(章节标题,默认 14px/档案卷 17px,批量标题 15px/档案卷 21px 同键)、panelBannerTitleFontSize(横幅标题,默认继承正文/档案卷 27px、已最新 24px)、panelLogFontSize(日志,默认 13px系)、panelSmallFontSize(小字副行/注脚,默认 11px–12.5px按规则回退) |
三条约束:颜色只收 hex(#rgb/#rrggbb/#rrggbbaa)或英文名单词(含 transparent),rgb()/color-mix() 请自行换算成 hex;其余值走注入检查(分号引号括号等不许过,见 sizing 同口径);未知键与非法值挂载即抛错,不静默吞掉。被覆盖的 token 不再跟随深色模式(内联值固定,调用方自己保证深浅可读,字号类同理:传了即固定值,不跟媒体查询走)。
类型定义随包分发(dsh-plugin-update/panel 的 .d.ts),不用自编译;面板离线可读,与包版本绑定。
整组件 HTTP 版(无 host.call 环境即跑:浏览器面板经 HTTP POST 直达宿主网关,只传三样):
import { mountUpdatePanelHttp } from 'dsh-plugin-update/http'
mountUpdatePanelHttp(document.querySelector('#upd'), {
pluginId: 'my-plugin',
prefix: 'myplug',
baseUrl: 'https://host.local:3000/upd',
showOthers: false,
pollMs: 1000,
})
// 离开时 panel.unmount():只停轮询,安装在宿主侧继续跑。
第 2.5 节:更新入口件(配置页上那一颗按钮)
目标只有一句:让用户不用点开就知道有没有事。 一行挂上:
import { mountUpdateEntry } from 'dsh-plugin-update/entry'
const entry = mountUpdateEntry(document.getElementById('upd-entry'), {
pluginId: 'my-notes-plugin',
prefix: 'notes',
call: (name, args) => host.call(name, args),
})
三个自由度,默认值都选好了:
| 自由度 | 取值 | 默认 | 说明 |
|---|---|---|---|
| 摆什么 | button / badge / inline |
button |
按钮;只给一个状态点;面板本体直接嵌进来 |
| 什么时候查 | mount / never |
mount |
进页面静默查一次(只调 .updateStatus,只读);never 则只在点击时查 |
| 点了做什么 | has-update / always / manual / direct |
has-update |
有新版本才开面板;检查完总是开;交给 onActivate 自己跳;点开即弹窗、不预查 |
| 无新版本摆哪 | button / tooltip / note |
button |
版本即按钮本身;收进悬停;回右侧小字旧样子 |
按钮文案随状态自己变(按宿主语言单语渲染:中文界面纯中文、英文界面纯英文,一次只出现一种语言):检查更新(尚未查询)/ 已是最新 X.Y.Z(无新版本,默认即按钮本身)/ 有新版本 1.1.0 / 正在安装… / 待重启 / 更新失败,请查看详情(英文见集中字典同 key)。
语言跟随(单语):入口件默认跟随 document.documentElement.lang > navigator.languages > 中文兜底;显式覆盖传 locale: 'zh' | 'en' | { getActive(): string; subscribe?(cb): () => void }(对象形态可接宿主语言服务,切换即时重绘,unmount 后停订;无 DOM 环境不抛,落中文)。
无新版本时版本号摆哪(upToDateDisplay,缺省 'button'):button 即按钮本身就是已是最新 X.Y.Z(单按钮不占宽,可点再查,hover 回检查更新暗示动作;无新版本中性弱边,hover 才走主色);tooltip 按钮还是检查更新、版本收进悬停;note 回旧样子(按钮旁右侧小字,只在 openOn: 'has-update' 下点击确认无新版本后出现)。旧版默认即 note 的样子,要回去就显式传 upToDateDisplay: 'note'。想连按钮都不留痕,就用 openOn: 'always'(检查完总是开弹窗,无新版本在弹窗里看“已是最新”)或 openOn: 'direct'(点开即弹窗,连预查都省了,面板挂载即自查;徽标形态仍走回调口径)。
按钮尺寸(跟头行其他控件同高/整体缩放):传 sizing 只改按钮本体,不碰面板;不传即默认外观。scale 是整体缩放(默认 1,如 1.2 放大、0.9 缩小),原来写在容器上的 zoom 可整体搬进来:
mountUpdateEntry(el, { pluginId: 'p', prefix: 'notes', call,
sizing: { fontSize: '12px', padding: '2px 8px', borderRadius: '8px', scale: 1 },
})
等价的手写 CSS 变量(与现有 --dsh-update-* 颜色变量同口径,写在容器或祖先元素上即可):--dsh-update-entry-font-size(默认 13px)、--dsh-update-entry-padding(默认 4px 12px)、--dsh-update-entry-border-radius(默认 6px,archive 主题下 3px)、--dsh-update-entry-scale(默认 1)。badge 圆点与 inline 内嵌不受影响。一点约束:sizing 与手写变量都不接 !important(变量本身已赢特异性);确需 !important 请手写 CSS 覆盖。
一条铁律:检查是只读、安装是写入,两者不许合并成一个动作。 入口件永远只做「查 + 打开面板」,
任何路径都不自动安装;用户必须在面板里明确点「安装」。想让点击交给自己(例如你已有自己的更新页):
mountUpdateEntry(el, { pluginId: 'p', prefix: 'notes', call, variant: 'badge',
onActivate: ({ hasUpdate, latestVersion }) => { /* 自己跳自己的页面 */ } })
批量感知的入口件(一颗按钮看 N 家,点开即批量面板)
一个总管替 N 家管更新时,别再用单入口的 manual 桥接批量面板——徽标只反映总管自己一行,不是七家聚合。换这一颗:
import { mountUpdateBatchEntry } from 'dsh-plugin-update/entry-batch'
const entry = mountUpdateBatchEntry(document.getElementById('upd-entry'), {
prefix: 'life', // 批量电话前缀(七个批量电话从它派生)
call: (name, args) => host.call(name, args),
theme: 'archive', // 与单入口/面板同一套皮肤
})
// 点击先调 `life.batchCheck` 查一次 N 家,再以 dialog 形态开批量面板;徽标即七家聚合。
// 下游删掉 `openOn: 'manual' + onActivate` 桥接即跑,总管单行不再当状态源。
聚合口径与批量面板总账同一份数法(含「忙失败占位翻回可更新」):忙 > 失败 > 待重启 > 可更新 > 待查,
一个收尾事实(0.18.7 起):账本目标没装成、但这家实际已装上「能装的最新」(合规版,见第 2.6 节)的行,按要不要重启进「待重启 / 已最新」,不再报安装失败。
文案按宿主语言单语渲染(中文如左,英文见字典 batch-entry.* 同 key),locale 选项与单入口同形态,切换即时重绘。
三自由度与单入口同取值,缺省不同:openOn 缺省 'always'(查完总是开批量面板;has-update 下有事才开,否则原地给总账一句)。
autoCheck 缺省 'mount'(只调 .batchStatus,只读);铁律同单入口:任何路径都不自动安装。
窗口期说明(必读):mountUpdatePanel 与 mountUpdateBatchPanel 接受 locale 选项(与入口件同形态);本包新增的文案(批量面板的合规版入口、安装按钮的兜底按钮等)已按它单语渲染,早期散落文案仍按基线中文渲染——按面迁移在后续版本继续落地,入口件两条链已全部生效。
第 2.6 节:多目标批量更新(一个插件管 N 个插件的更新)
一个插件替自己和另外几个插件管更新时,别把 N 个面板并排——用户在那块界面上只问三件事:
有没有事 / 是哪几家 / 我要做什么。所以这套东西是「总账 + 明细 + 动作」三层,一行只回答一个问题。
宿主侧:
import { createMultiHostUpdate } from 'dsh-plugin-update/batch'
const multi = createMultiHostUpdate({ ctx, logCtx }, {
prefix: 'life', // 批量电话前缀
selfKey: 'life-pack', // 「自己」:排序时排最后(自更新安全)
targets: [
{ key: 'bill', title: '记账', packageName: 'dsh-bill-ilife', prefix: 'ilife-bill' },
{ key: 'calorie', title: '卡路里', packageName: 'dsh-calorie', prefix: 'ilife-calorie' },
{ key: 'life-pack', title: '爱生活', packageName: 'dsh-life-pack', prefix: 'ilife-life-pack' },
],
// drain: true, // 宿主侧定时推进(默认关:自动装是行为跃迁,显式开)
})
for (const [name, handler] of Object.entries(multi.handlers)) registry.set(name, handler)
七个批量电话(` 即上面的life,同时是账本属主身份):batchStatus/batchCheck/batchInstall/batchResume/batchCancel/batchPrefs/batchPrefsSave;
每个目标的六个单插件电话照旧以**各自前缀**暴露(ilife-bill.updateStatus、ilife-bill.updateCheckFallback等);每行回包里的phoneNames带全这六个名字,合规版兜底与候选安装都按它点名(0.18.7` 起)。
回包形状(成功六项,失败只有三项):
{ ok: true, session, rows, progress, inventory, prefs } // rows 一行一家:key/title/phase/targetVersion/restartRequired/error/snapshot/phoneNames(本行那家的六个电话名)、可选 diag 与 pluginId;#177 起可选 manualSource(frame / carried-over / null)
{ ok: false, error, errorKind } // 跨使用范围混目标会回 cross-scope,不抢锁、不写盘
batchResume 无事可续时回 resumed: false(面板不再报空话成功);batchPrefsSave 只收布尔 checkOnOpen。
面板侧:
import { mountUpdateBatchPanel } from 'dsh-plugin-update/panel-batch'
const panel = mountUpdateBatchPanel(el, {
prefix: 'life',
call: (name, args) => host.call(name, args),
theme: 'archive', // 与单插件面板同一套皮肤(archive = 档案卷)
autoCompliant: true, // 缺省开:年龄门行自动决议(只打决议电话,绝不自动装)
})
// 弹窗版与单面板同口径:传 onCloseRequested(点关闭/Esc 时先调它撤 DOM,再停轮询;入口件打开的 dialog 已内置;不传即只停轮询)。
批量面板 HTTP 版(无 host.call 环境即跑:同一传输内核,七电话走同一映射,取消走 batchCancel):
import { mountUpdateBatchPanelHttp } from 'dsh-plugin-update/http'
mountUpdateBatchPanelHttp(document.querySelector('#batch'), {
batchPrefix: 'life',
baseUrl: 'https://host.local:3000/upd',
pollMs: 1500,
})
// 取消整批走 batchCancel 电话;unmount 只停轮询,宿主侧照跑。
三条硬约束(都在实现里):一行只回答一个问题(这家的下一步是什么,状态词全中文可执行);
行内动作只作用于该行,「全部更新」是宏而不是第二个状态机;待重启与失败常驻横幅,不藏进展开里。
点任意一行展开该家详情:内容是单插件那套五章内核的只读渲染,动作由批量面板自己提供。第 02 章日志由批量面板按行自动取该行自己的 updateChangelog 电话(按行+版本记住结果,取不到即中性提示,不挡安装;autoChangelog: false 可关)。
内核渲染时传 actions: 'none'——五章内容、进度条、「已跳过」提示一字不减,动作按钮一个都不画
(内核里那些按钮带的是 data-action,批量面板只认 data-act;照搬 markup 而不接管行为,
就会得到「可点却没反应」的死按钮——这条缝现在由渲染开关焊死)。详情里的动作行是批量面板自己的:
装 X.Y.Z / 重试、装合规版 / 等新版(0.18.7 起:最新版被年龄门挡住时的两个出口,见下文)、跳过这一版(与单插件面板同语义:按插件+版本记,跳过后那家单独看也不再提醒)、
复制手工命令、复制诊断——全部走与行内同一条 act() 通道、同一份忙守卫。
「复制诊断」需要宿主把失败诊断一并回出来:batchStatus 的每个 row 带可选 diag 与 pluginId;生产形状下宿主还会把目标包名与归因键补进这份 diag,兜底按钮才认得出该给哪一家装合规版。
失败横幅批量复制(0.18.11 起):多家安装失败时,失败名单下方多一行两个批量按钮——复制诊断一次复制全部失败行的诊断文本(按面板顺序、块间空行,已脱敏),复制手工命令一次复制全部失败行的手工命令(有命令的原样一行,缺命令的用 # <插件名>:无手工命令(原因) 注释行占位,整段可安全粘终端)。两枚按钮与失败横幅同生灭,口径与横幅名单完全一致(排队占位、待重启、已装合规版的行不算失败);一条可执行命令都没有时复制手工命令置灰,置灰说明只在整批原因一致时点名原因,原因混杂时给中性一句。
> 自己写渲染时同理:renderUpdatePanelHTML(input) 少传 actions 即默认(画动作行);
> 传 actions: 'none' 就是只读内容——凡是要复用内核 HTML 的地方,都必须显式接管或摘掉它的动作面。
耐久(这是这套东西存在的理由):批量会话按属主落在
<家目录>/update-queue/<使用范围短指纹>/<批量前缀>/batch.json,每一步都写盘;检查知识(只做展示)落同目录 inventory.json,面板偏好落 prefs.json。所以关面板、重载页面、
甚至进程重启都不怕——batchResume 读回来接着推:已完成的不重装(编号恒等、幂等),
没做完的重新查一次再装。这就是「更新自己时 UI 消失、剩下几家永不启动」那个病的正解。
面板打开即按偏好自动查一轮(缺省开,底部可关;60 秒节流 + 在途抑制,入口预查与面板开查一击只查一次),有没做完的一轮自动续一次,显式按钮保留;面板常驻「关掉面板不会中断」一行事实。年龄门行另按 autoCompliant 自动决议(缺省开,只问不装;传 false 就只留手动入口,见下文)。
合规版:最新版被 24 小时年龄门挡住时的出口(0.18.7 起)。npm 的年龄门要求新版本发布满 24 小时(或源上配置的时长)才可安装,于是「最新版」暂时装不上,但满门限的最近那个版本装得上——本包把它叫合规版(不是随手挑的旧版,是唯一那个还没跨过门限之前的最新版本)。
行内因此多两个动作:装合规版(先打决议电话问出候选版本,再点名版本请你确认,点确认才装——永不自动装)、等新版(收起这一轮提示,等最新版自己满门限)。源上暂时没有可装的版本时显示「约 X 后可装」,不画一个点不动的按钮。
装完之后那一行给的是实际安装结果:「已装 1.8.0,不是最新 1.9.0(最新版这次没装上)」,按要不要重启进「待重启 / 已最新」;批量总账里也不再把这些家算作安装失败。批量级确认条会把所有「有候选待确认」的行收在一起,一次确认一批(确认装这 N 个)。
检查的后置条件(0.18.2 起):一次检查(含自动查)之后,面板里不再出现上一次检查之前的数据。成功查出的结论会同步对账进会话——有新版的行置为可装并换上新目标版,已是最新则清掉陈旧目标;检查失败、待重启、正在装的行不受影响。另有一道渲染兜底:会话目标版不比当前版新时,只显示当前版,不画「→ 新版本」(旧版曾把陈旧目标如 0.3.55 配上当前 0.3.60 画成"有新版本",现已修正)。
升级说明:旧版无属主 batch.json(根目录那份)不再被读取、不删除、不接管——没跑完的那批重打一次「全部更新」即可(幂等,已完成的秒过不重装)。单队列不变:四家单插件与批量仍共用同一份 queue.json + 全局锁,一次只装一个。
自更新安全:selfKey 指定的那家默认排到最后。承载更新界面的那个包若第一个被替换掉,
界面与推进它的循环会一起消失——排最后则前面几家早已落盘收尾。启用本节末的「更新中枢」后,
这条排序退成展示与兜底:自更新把界面换掉也不会断链。
跨使用范围如实拒绝:web 与 desktop 各自排队(装的是不同落点),混在一起的目标会回
cross-scope,本包不会替你跨范围抢锁。
批次日志与落点(一批出问题时先看这里):批量更新比单插件多一层「一批」的概念。日志里用三条常驻行回答三个问题——这批什么时候起的(update.batch.entry.request)、哪一家卡在哪一步(update.batch.entry.fail)、这批为什么停(update.batch.summary)。
- 同一批的三条行靠
sessionHash串起来:它是批次编号的 8 位指纹,三条行都带,只说明「同一批」,不带插件名清单——行可以落在不同插件目录,聚合认这一格,不认目录。 - 失败行带
stage与index。stage是失败阶段:check(查询,含装前重查——预查就失败的目标也落这一行)、install(提交安装)、timeout(等安装终态超时)、settle(收尾,装到终态才失败);index是该目标在整批里的 0 基序号(只推进选中的几行时仍按整批序号算;算不出时这一格不出现,不拿 0 冒充第一家)。exitCode只在真拿到退出码时出现。预查抛异常、拿不到稳定码时errorKind记'unknown'(异常原话可能带路径/URL,不进日志)。 - 总账行带
stoppedBecause说明这批为什么停:finished(全部跑完——不代表没失败,失败数看failed)、stopped-after-failure(遇错按策略停)、max-steps(本轮步数预算用完,等下一轮接着推)、empty(没有可推进的目标)、queued(目标进了队列还没轮到,不是失败)、user-cancel(用户取消整批)、error(驱动抛异常,含后台续跑)。值来自最近一次推进的读数,没推进过按finished。这批没跑完就停下时(queued/stopped-after-failure/user-cancel/error)在停下那一刻另记一条同字段行,同一会话同一原因只记一次——所以「为什么停」不会只留在内存里。 - 七个批量电话(
<前缀>.batchStatus等)各自失败时也留一行host.call.fail:method是全名(如life.batchCheck)、kind是短名;同一通电话连续同一种错只记首次(成功即清记忆)——批量电话不设时间窗,靠这条同码去重兜住轮询失败,所以batchStatus每 1.5 秒轮询失败也不会刷屏。状态轮询(updateStatus)另有降级态时间预算:按 (pluginId, method) 首次一条、持续失败每 5 分钟一条心跳(窗口内错误码变化不额外落行,下次心跳带当时原因)、下一次成功清记忆——一小时最多 12 行,600 次轮询失败落在 10 分钟内只 2 行;这是持续失败降级态的预算,是稳态 50 行/天断言的唯一豁免(降级态最多约 288 行/天封顶,正常日不受影响,见 §10)。
落点(认目录就知道这行说的是谁的事):
| 行 | 落点 |
|---|---|
批量级行:入队 update.batch.entry.request、总账 update.batch.summary、七个批量电话失败行 host.call.fail |
批量属主(selfKey 那家)的插件日志目录 |
失败行 update.batch.entry.fail |
失败那家自己的插件日志目录(与属主是谁无关) |
队列行 update.queue.transition、update.store.fail |
当次动作方(被装的那家 / 抢锁的那家)自己的目录 |
批量面板的客户端行(挂载行 mode:'batch'、动作、渲染失败、全局兜底) |
属主插件的日志落点:批量面板默认自建通道,打 <批量前缀>.logAppend,由批量宿主转发给属主的 logAppend |
没指定 selfKey(无属主)时:批量接口不传 selfKey 也能跑,但日志按「不知道落点就不猜」如实退化——① 批量级行(入队行与总账行)一行不发,没有属主就没有落点;② 失败行照落失败那家自己的目录(它按目标取口,不依赖属主);③ 七个批量电话的失败行一行不发;④ 批量面板的客户端行落不下来:宿主没登记 <批量前缀>.logAppend(没有属主、或属主那家没接 logAppend),面板侧的日志投递失败,只在客户端队列的内存计数里留着(不落行、不挡批量主流程);⑤ 队列行照旧落当次动作方的目录。要完整的批次证据,就把 selfKey 传上。
更新中枢:自更新不断链 + 全局进度(进程级)
批量推进默认搬进一个进程级更新中枢:批次不再挂在发起它的那段宿主 fiber 上。界面被卸载、页面关掉、甚至发起方自己就是被替换的那一家,中枢都会按盘上的方案把这一轮推完;新挂载的同类入口(或下次进程启动后装载的批量入口)会接着推剩下的步骤。接入方不用改代码——只要还在用 createMultiHostUpdate({ ctx, logCtx }, …)。
为什么需要它:上一段「自更新安全」靠 selfKey 排最后缓解问题;但承载面板与驱动闭包的 bundle 被宿主卸载时,那段 driveLocked → runBatch 循环会随 fiber 一起销毁,剩下几家停在 pending / ready,宿主侧没有自愈。中枢把「执行席」交给进程级单例:席里封的是查/装/落盘/日志能力与盘上方案,不依赖任何 fiber 是否活着,所以替换完自己后仍有人能接着走。
开关与前提:
- 传了宿主
ctx(真机接入)缺省开;纯宿主脚本/测试(没有ctx)缺省关,显式hub: true可开;hub: false强制回旧路径——旧行为原样保留,作为回退线。 - 必须能识别出使用范围(
profileDir,一般由ctx或readerOverrides.profileDir带出)。识别不出时中枢自动不启用,照旧路径跑。 - 同一使用范围一个中枢;同一进程里本包的多份副本也共用(
globalThis注册表按使用范围分槽),不会出现两个推进者。 - 一次只推一家、单队列,
selfOrder语义与原有七个批量电话及回包形状都一字不变(中枢只增了一通只读电话,见下)。忙时提交立刻回queued、由中枢接着推;同一批次重复提交并入原任务,不会把同一步走两遍。
盘上两份文件(都在这个使用范围的队列目录下):
- 方案
<家目录>/update-queue/<使用范围短指纹>/hub/plans/<批量前缀>.json:这一轮装什么、装到哪(属主、目标清单、selfKey与顺序、失败策略)。它只写意图、不写进度——进度仍在<批量前缀>/batch.json。写不进去不影响本进程推进,只是别人接不了手。 - 租约
<家目录>/update-queue/<使用范围短指纹>/hub/lease.json:驾驶位。同一使用范围同一时刻只认一个推进者;每推进一步续心跳(上限 5 秒一次),心跳陈旧超过 20 秒、或同机 pid 已死才允许接管;接管者把代次 +1,释放只认自己那一代(绝不删别人的驾驶位)。
孤儿接管:装载批量入口时扫一遍自己属主的方案。盘上若还有未收尾、且会话编号对得上的那一轮,就交给中枢接着推——不需要用户重开面板,也不需要再点一次「继续上次未完成的更新」。只动自己属主(批量前缀);已收尾的不重装。
只读电话 <批量前缀>.updateHubStatus(每套集成更新系统都登记:批量宿主在识别得出使用范围时登记(hub: false 也登记,照样从盘上答);单插件宿主始终登记,名字同在前缀派生、句柄见 host.hubStatus;批量侧全名也可从 multi.phoneNames.hubStatus 读到;识别不出使用范围时不登记):
const reply = await host.call('life.updateHubStatus') // args 可不传
// → { ok: true, snapshot }
snapshot 回答的是「这个使用范围里所有批量会话」的全局进度,不只是自己前缀:只读聚合(方案 + 各属主 batch.json + inventory.json + 租约),不写盘、不驱动,中枢在不在、面板开没开都如实回答;坏文件当缺失,永不抛。回包有上限:最多 8 个会话、每个会话最多 20 行,超了用 sessionsTruncated / rowsTruncated 如实说:
{
version: 1, at: 1730000000000,
sessionsTotal: 2, sessionsTruncated: false,
activeInstall: { pluginId: 'dsh-life-pack', startedAt: 1730000000000 }, // 没有在装的就是 null
sessions: [{
sessionId: 'b-…', owner: 'life',
selfKey: 'life-pack', // 方案里没给 selfKey 时为 null
state: 'running', // running / waiting / finished / missing(有方案但账本读不出或对不上)
updatedAt: 1730000000000,
progress: { total: 3, done: 1, failed: 0, skipped: 0, current: 1, pending: 1, finished: false },
rowsTotal: 3, rowsTruncated: false,
rows: [{ key: 'bill', title: '记账', phase: 'installing', targetVersion: '0.3.82',
installedVersion: '0.3.81', latestVersion: '0.3.82', restartRequired: false, error: null }],
}],
}
排序固定:进行中 → 等待 → 记录缺失 → 已收尾;同状态内按更新时间新到旧。快照同一使用范围内「写了中枢方案」与「没写方案的旧批账本」都会收进来(后者 selfKey 为 null,按账本相位判 running/waiting);activeInstall 是中枢驾驶位租约里的「正在装哪一家」,心跳陈旧就不算。单插件更新链路 Phase 1 不迁入中枢,所以单插件的安装既不出现在会话里,也不进 activeInstall。
常驻进度坞(面板侧,可选):挂在自选容器里(右下角浮层、底部条,或 mountAs: 'inline' 内联进自己的页面流),只轮询上面那通只读电话:
import { mountHubProgressDock } from 'dsh-plugin-update/panel-batch'
const dock = mountHubProgressDock(document.getElementById('upd-dock'), {
prefix: 'life', // 由它派生 life.updateHubStatus;也可用 phone 直接点名
call: (name, args) => host.call(name, args), // call 只透传完整电话名,前缀由组件自己拼
title: '更新进度',
mountAs: 'bottom-right', // 'bottom-bar' 贴底通栏;'inline' 由调用方放进页面流
hideWhenIdle: true, // 缺省 true:空闲整块隐藏不占空间;false 留一个空壳
collapsed: false, // 缺省 false;true 起步就折叠成标题+百分比+一行计数
maxRows: 3, // 明细行上限(缺省 3,最多 20),超出折叠成 +K
onOpen: () => openMyBatchPanel(),
})
// 明细一行一个目标:标题、版本箭头(已装 -> 目标)、相位人话、失败码;装完要重启的另汇总一句。
// dock.refresh() / dock.update() 手动刷一次(并发合并、永不 reject;坏回包保持上一次画面,不清屏)。
// document.hidden 时暂停轮询;dock.dispose() 只停轮询、摘监听、移除根节点,不打断中枢。
// prefix 与 phone 都不传会在 mount 时直接抛配置错误(不静默失败)。
迁移与兼容:对现有集成方透明——hub 不传就是「有 ctx 开、没有关」;hub: false 回到旧路径。中枢只在使用范围队列目录下新增 hub/ 的方案与租约,不读、不改旧的无属主 batch.json;运行期依赖与公开子路径都不变(进度坞随 ./panel-batch 子路径一起导出)。自建 HTTP 宿主若走 createMultiHostUpdate 却没传 ctx,要显式 hub: true 才用上中枢。
第 2.7 节:更新日志自动展示(告诉用户更新了什么)
任何插件的升级能力都是两件事:① 给一套面板(上面第 4 步 / 2.5 / 2.6,挂载一行即跑);② 告诉用户这次更新了什么(本节,写 md 即显示,零新增代码)。
import { mountUpdatePanel } from 'dsh-plugin-update/panel'
// 与第 4 步同一行,不用加任何参数:有新版本时第 02 章“更新日志”自动出现
const panel = mountUpdatePanel(document.getElementById('update-slot'), {
pluginId: 'my-notes-plugin',
prefix: 'notes',
call: (name, args) => host.call(name, args),
})
作者侧只写文件,不写代码(发布后用户自动看到):
- 包根放
CHANGELOG.md(文件名全大写,小写视为没有),版本节形如## [1.2.0] - 2026-10-05(最新在前),分类用### Added/Fixed/Changed/Security(亦收中文名新增/修复/变更/弃用预告/移除/安全;面板必显展开;Deprecated/Removed折叠;Unreleased与空节忽略)。分类与条目必须挂在版本节下:裸条目(没有###分类行)会被忽略;全文一个版本节都读不出时面板如实说"读不出来",不说"未提供"。 package.json的files白名单加上CHANGELOG.md(否则发出去的包里没有它,面板永远读不到),发布前跑一次npm publish --dry-run核对。
行为:有新版本时面板按新版号自动取一次该版 tarball 里的全文(复用官方源 + integrity 校验,同一版本只取一次);删了文件或取不到即中性提示,安装永远不受影响。想自己接管就传 autoChangelog: false 退回手动(备好文本后 mountUpdatePanel({ changelogMarkdown }) 或 setChangelogMarkdown 传入,显式文本永不被覆盖)。入口件与批量面板同样自动:前者透传,后者展开行按行取。细节与边界见第 5.8 节。
安全事项必显:### Security 与 Added/Fixed/Changed 同级展开。单类超 200 条时前 200 条展开、其余收进该类内部折叠并标计数。目标版本被撤回(标题含 [YANKED])时,第 02 章顶部加一条警告横幅,安装不受影响。条目开头写 BREAKING: 或 不兼容:(大小写不敏感,中英文冒号皆可)即挂破坏标记,正文不动。同一版本日志只取一次;取不到显示中性提示,安装不受影响。
第 2.8 节:安装按钮(任意卡片里原地装好一家)
更新面板是整块五章,入口件只查不装。当缺的东西住在你自己的功能页里(例如某页签依赖的兄弟插件没装),就用这颗按钮,一行挂上:
import { mountInstallButton } from 'dsh-plugin-update/install-button'
const button = mountInstallButton(document.getElementById('bill-install-slot'), {
pluginId: 'my-pack-bill',
prefix: 'mypack-bill', // 与宿主侧这一家的前缀一致;电话名从它算出
call: (name, args) => host.call(name, args),
})
// 离开时 button.unmount():只停轮询,安装在宿主侧继续跑。
行为与面板里那颗安装按钮同一口径(同一份快照、同一句话):未装显示「未安装 → 目标版本」,按钮是「安装版本 X.Y.Z」;点下后先查后装(一次点击内自动衔接,仍是明确的写意图,绝不自动装);装中置忙,撞上别的安装显示排队位;失败给重试 + 复制诊断;失败若归因指向这张卡片这家、且最新版被 24 小时年龄门挡住,另出「装合规版」(决议出候选后变成「确认装 X.Y.Z」)与「等新版」两个按钮,点确认的前一刻会重新核对候选版本(0.18.7 起,与批量面板同一套决议电话与确认流程);待重启只给重启指引;实在装不了才交出手工命令(manual 为空时该段隐藏)。还没查到远端版本时按钮退回「检查更新」。轮询只在装中进行且有界(约 5 分钟封顶转重查态),平时不 polling。
theme / themeTokens / locale / showOthers / profileName / pollMs 与面板同口径;autoCheck 收 'mount'(默认,挂载即静默查一次状态,只读,绝不自动安装)或 'never'(不自查,等用户点或你调 button.refresh());button.state() 可读当前快照,button.refresh() 手动重查一次。自己渲染时:视图的 busyAct 多一档 'fallback'(决议与等待确认中),另带 fallbackStage('offer' / 'confirm')、fallbackCandidate、fallbackBusy(0.18.7 起)。
升级本包(已经接入过的项目)
宿主种类与安装出口都由本包自己探测和选择,升级依赖即可,宿主侧与面板侧都不用改代码:
- 依赖版本提到
^0.5.2。 - 重新装 / 发一版你自己的插件,让新依赖进当前使用范围(运行时用的是
node_modules里那份)。 - 如果你自己接过一版宿主安装出口,把它删掉——它会挡在本包的路由前面。
两处例外必须动代码(它们把自动探测挡住了):显式传了 readerOverrides.environmentKind;显式传了与真实使用范围不符的 profileDir / profileName。
targetPackageName 建议就是你自己的包名:使用范围目录按「装好的包住在 <范围>/node_modules/<目标包名>」反推(这是最后一步兜底;能拿到上面那几条权威身份时不用反推)。
0.2.0 起目标包按包名解析(清单直解 → 入口反查 → node_modules 步行 → 自锚定兜底),exports 没导出 . 与 ./package.json 的包也能命中。
0.1.x 只能在本包被 vendor 进插件目录时找到目标包;以依赖形态安装时一键升级会永远不可用(面板显示假的 installation-changed)。
3. 配置
const update = createHostUpdate(
{ ctx, logCtx },
{
pluginId: 'my-notes-plugin',
prefix: 'notes',
targetPackageName: 'my-notes-plugin',
confirmationTtlMs: 20 * 60_000, // 只调这两项,其余走默认
panelPollMs: 2000,
}
)
| 配置键 | 默认 | 说明 |
|---|---|---|
pluginId |
必填,无默认 | 非空字符串,不含路径分隔符 |
prefix |
wf |
电话名前缀;电话名 = 前缀 + 点 + 动作名 |
targetPackageName |
dsh-mattpocock-skills-deck |
要检查更新的那个包是谁 |
registryUrl |
https://registry.npmjs.org/ |
官方源 |
homeDir |
现推导 | 环境变量 DSH_HOME 优先,否则家目录下 .dsh |
checkTimeoutMs |
10000 |
联网超时,须为有限大于 0 的数 |
confirmationTtlMs |
600000 |
凭证有效期,须为有限大于 0 的数 |
installTimeoutMs |
900000 |
安装时限,须为有限大于 0 的数 |
panelPollMs |
1000 |
面板轮询间隔,不得小于 250 毫秒 |
releaseChannel |
stable |
版本通道:默认只推稳定版;显式传 prerelease 才收预发布版(如 0.2.0-rc.2),安装仍只装精确版 |
配置只经函数入参注入,不读配置文件。越界直接抛错,不静默取整。
运行版本未知:宿主侧拿不到「正在跑的版本」时,readerOverrides.runningVersion 请传 null(或省略,由宿主清单推导),不要伪造 0.0.0 之类的占位版号——伪造值会进事件 update.check.done.current 并参与新旧比较。未知时快照 runningVersion 为 null:不宣称「有新版」、不判 pending-restart、update.check.done 不再落 current,面板显示「运行版本未知」而不是「已是最新」;安装路径不再拿未知去比大小(首次安装照常可装)。
DSH 运行时版本(可选):readerOverrides.dshVersion 给了就按远端声明的 engines.dsh 提前判不兼容(blockedReason: 'incompatible-dsh');不给就不猜、不误拦。宿主真拒绝时把 error.incompatible[](键是 engines.dsh 或 DSH peer 包名)翻成「要求 ≥X(当前 Y)」拼进失败详情,原文仍在前面。
落盘目录按标识派生:家目录下 updates 加插件标识加使用范围短指纹,三个文件名是 state.json、install.lock、before.json。只读自己的新路径,写也只写自己的(0.18.22 起,#203:旧字面目录恰好是默认标识自己的实时目录,跨标识回退读会把别家正在跑的任务读进来再回写,真机上把 deck 的任务写进了 life-pack/prompt/calorie 的目录)。
标识取历史值 dsh-mattpocock-skills-deck 时,路径与旧版一字不差(升级不丢状态)。
安装锁是自愈的(0.18.11 起):锁位先生成完整内容再原子占位,所以在支持硬链接的盘上不存在「空锁」(不吃硬链接的盘退回「先写内容再放锁位」,仍有极短窗口,但坏锁不再堵死读取);判陈旧只看时间——锁里 startedAt(读不出就看文件 mtime)超过 installTimeoutMs 才回收,否则一律按「别人正在装」回 update-busy。同一使用范围内发现任意陈旧锁时不需要再手工删 install.lock。
4. 安装是怎么执行的
你不需要按宿主分支:包探测当前宿主,选它自己的安装出口。
| 宿主 | 出口 | 说明 |
|---|---|---|
第三方 Desktop(发布 desktopProfiles / desktopPnpm 的那一支) |
桌面服务 desktopPnpm.runPlugin |
装进「当前激活的使用范围」;激活范围与本插件所在范围对不上时宁可不装 |
| 官方桌面版(宿主进程内的插件管理器) | pluginManager.installBundle |
只收 包名@精确版本,不接受任何开关 |
| 普通 DSH 宿主 | 自己起 dsh plugin --profile <名> add … |
参数数组直传;不经 shell、不用 PATH 上的命令名、不按系统分支 |
三种出口都只装精确版本。第三方 Desktop 与普通宿主把官方源写进参数(--registry=),官方桌面版走管理器的 registry 选项。
官方源不可达时诚实失败,不换源。三种出口都不成立(宿主既没有桌面服务、也没有插件管理器、命令行入口也认不出)时同样诚实失败,转第 5.3 节的手工命令。
装不上时宿主原话会写进任务说明 job.message,形如 install-failed: <原话>。三条宿主出口都收 pnpm 的原始输出(抓取上限 64 KiB),再从中挑出错误码行(ERR_PNPM_* / ERR!)与紧邻的签名行(published / cutoff / minimumReleaseAge)拼成原话,挑不到就取末段;detail 仍封顶 300 字、去掉绝对路径(0.18.7 起)。这样面板「复制诊断」里那句人话原因才认得出「是年龄门还是别的毛病」。