dsh-plugin-upgrader:给 DSH 插件页补上一键升级和版本历史

前言

DeepSeek Harness(DSH)的「插件」页可以安装、卸载和查看已安装列表,但缺少两个常见需求:一次把全部插件升到最新,以及退回某个旧版本。dsh-plugin-upgrader 在插件页顶部增加一个「版本」标签,把这两件事补齐。

功能

这个插件提供以下能力:

  • 一键升级:一次把全部可升级插件升到最新版,显示进度、逐项结果和失败原因。
  • 版本历史 / 回滚:展开任意插件即可看到已发布的全部版本(含发布时间与引擎要求),点一下切到那个版本。
  • 多选升级:勾选任意几个插件,只升级选中的这些。
  • 看得见的进度:升级时直接显示 pnpm 的原始输出(含换源重试、lockfile 校验),每步都有已用时计时和不确定态进度条,卡住时可以一键取消。
  • 一键跳 GitHub:每行的 GH 按钮打开插件仓库主页;点版本号直接进该版本的 GitHub 发布页。

界面位置

插件页顶部标签栏新增一个「版本」标签,与「已安装」「发现」并列:

  • 顶部统计:可升级数量 / 已安装数量 / 每个插件的版本数
  • 「一键升级」升全部可升级插件;下面一条选择栏负责「升级选中」
  • 「检查更新」强制刷新 registry 缓存(默认缓存 5 分钟)
  • 搜索框按包名过滤
  • 每个插件一行,展开「历史 (n)」后是该插件的完整版本列表
  • GH 打开仓库主页(没有仓库的插件不显示该按钮)
  • 版本列表里每个版本号本身就是链接,点开直接进 GitHub 的发布页:
  • 该版本打过 tag → releases/tag/v<version>
  • 该版本没打 tag,或 GitHub 列表读不到(限流 / 离线)→ 退回仓库的发布列表 releases

安装

在「插件」页的发现标签里搜索 dsh-plugin-upgrader,点安装即可。桌面版请用它自带的插件管理界面安装,命令行改动不了正在运行的桌面 profile。

用 dsh 命令行(把 web 换成你自己的 profile 名):

dsh plugin --profile web add dsh-plugin-upgrader

从源码安装:

git clone https://github.com/zuwenyin/dsh-plugin-upgrader.git
cd dsh-plugin-upgrader
dsh plugin --profile web add "$PWD"

装好后打开「插件」页,顶部会多出「版本」标签。不需要重新构建,也不用重启。

工作原理

浏览器端读不到 profile 的依赖清单,也无法可靠直连 npm(CORS、私有源、token),因此所有包操作都放在宿主端:

路由 作用
GET /api/plugin-upgrader/overview 已安装插件 + 各插件已发布版本(registry packument,5 分钟缓存,10 秒超时)
POST /api/plugin-upgrader/job 提交一批升级 / 切换步骤,立即返回 job id
GET /api/plugin-upgrader/job?id=… 轮询进度、当前阶段、已用时、逐项结果与实时日志
POST /api/plugin-upgrader/cancel 中断当前步骤(含「先卸载再安装」的第二次安装)

所有路由仅限本机回环访问:批量操作会安装软件包,配对设备或跨站页面一律拒绝。

版本切换的实现方式

pluginManager.installBundle 对已安装的包名会以 already-installed 拒绝,所以换版本必须是 remove + install(官方「更新」走的也是同一条路)。旧版本号会回传给界面,用于提示这是降级。

enabled 状态保留

卸载会丢掉插件原有的启用 / 禁用状态,因此每一步在安装完成后会按操作前的状态重新 setBundleEnabled,禁用中的插件升级后仍然是禁用状态。

为什么升级有时候要好几分钟

不是这个插件慢,是 pnpm 慢。pnpm 11 在 install 前会拿锁文件里的每个条目去 registry 重新核对一次(Verifying lockfile against supply-chain policies),国内镜像(registry.npmmirror.com)对不少包会 ETIMEDOUT,于是走 10 秒 → 1 分钟的重试阶梯。一次「升级」如果目标是已安装的包,还是 remove + install 两条 pnpm 命令,两段各等一次。

旧版本在这里什么都不显示,进度条只在「整步完成」时动一格,所以看起来就是死住了。现在宿主端订阅了 plugin-manager/install-log 和 plugin-manager/install-state,把 pnpm 的原始输出、当前阶段、重试次数实时转进 job 日志,界面同时显示已用时和不确定态进度条:慢的时候你能看见它在重试,而不是盯着一个不动的 0%。

顺带修掉的两个坑:

  • 心跳改成定时器(2 秒),不再按「完成一步」写。单步超过 15 分钟以前会被误判成「job 已放弃」,于是一个健康的任务被作废,还能再并进来一批。
  • 「先卸载再安装」的第二次安装以前用 requestId: undefined,导致那一段无法取消;现在每次安装都有自己的 id,取消在任何阶段都有效。

同一时刻只跑一批

宿主端一次只允许一个 job。重复提交会拿到 409 busy(带正在跑的那个 job id),界面据此接管那一批的进度显示,而不是弹一条看不懂的错误:

  • 新开一个插件页标签、或在进度跑着时再点「一键升级」,都会看到正在进行的那一批
  • overview 里带 active 字段,标签页一挂载就能接上正在跑的任务
  • job 每 2 秒刷一次心跳时间戳,宿主重启后遗留的「僵尸 job」会按心跳超时自动作废,不会永久占着位置让之后所有升级都撞 busy

主进程重启会让正在跑的 job 中断,界面会把这种情况显示为失败而不是永远转圈。

开发与发布

本项目用 pnpm 管理依赖(package.json 里钉了 packageManager,CI 也用 pnpm):

pnpm install       # 安装依赖(会自动跑 prepare → 构建一次)
pnpm run dev       # 监听 src/,宿主端和浏览器端改完即编译(Ctrl+C 停止)
pnpm run build     # tsc 编译宿主端 + esbuild 打包 client → lib/
pnpm run typecheck # tsc 检查 src 和 test
pnpm test          # node:test 单元测试(.ts 直接运行)
pnpm run check     # 以上全部(typecheck + build + test)

宿主端在 src/index.ts(webServer 前缀路由 /api/plugin-upgrader),浏览器端在 src/client/index.ts(注册 plugins.tab 槽位)。

pnpm run dev 会并行跑两个监听器:tsc --watch 负责 lib/index.js 和 lib/host/**,esbuild 负责 lib/client.js,两者输出互不覆盖。注意 esbuild 打包时会直接剥掉类型而不做检查,所以改浏览器端时顺手跑一次 pnpm run typecheck。

改完代码后必须重新构建(profile 里的 link: 指向本目录,但加载的是 lib/,不是 src/)。pnpm run dev 已经替你重建了产物,但插件本身不会热更新——要让宿主重新加载本插件,无需重启应用:先 pluginManager.setBundleEnabled(name, false) 再 setBundleEnabled(name, true)。

首次手动发一次,之后打 v* tag 由 CI 通过 npm trusted publishing 自动发布:

pnpm publish --access public   # 仅首次

技术细节

项目结构

dsh-plugin-upgrader/
├── src/                  源码(TypeScript)
│   ├── index.ts          宿主端入口:路由注册、job 调度
│   ├── host/             宿主端逻辑(Node,无 DOM)
│   │   ├── overview.ts   组装 overview 载荷
│   │   ├── registry.ts   registry packument 拉取 / 裁剪、GitHub tag 列表
│   │   ├── install.ts    单步安装 + enabled 状态恢复
│   │   ├── bundle.ts     插件元数据(来源分类、多语言字段、图标)
│   │   ├── repo.ts       仓库 URL 归一化
│   │   ├── version.ts    semver 比较、发布页链接
│   │   ├── http.ts       回环校验、JSON 读写
│   │   └── manager.ts    宿主 pluginManager 服务的最小类型契约
│   └── client/           浏览器端(拆成模块,由 esbuild 打成一个文件)
│       ├── index.ts      createPlugin(require):槽位注册入口
│       ├── components/   Tab / PluginRow / VersionLine / JobPanel
│       ├── copy.ts       zh / en 文案
│       ├── styles.ts     作用域 CSS
│       ├── hooks.ts      locale 订阅、overview 拉取
│       ├── api.ts        fetch 封装、job 轮询
│       ├── format.ts     版本号 / 日期 / 外部链接
│       ├── env.ts        window 上的最小类型声明
│       └── types.ts      载荷类型
├── lib/                  构建产物(git 忽略,`pnpm run build` 生成)
│   ├── index.js          宿主端入口(tsc 输出)
│   ├── host/*.js         宿主端模块 + .d.ts
│   └── client.js         浏览器端单文件 bundle(含 __ModuleLoader__ 包装)
├── scripts/build.mjs     构建脚本:tsc 编译宿主端 + esbuild 打包 client(加 --watch 即 dev)
├── test/                 node:test 单元测试(.ts,Node 24 直接剥离类型)
├── locale/               (保留)语言包
├── assets/icon.svg       图标
├── cordis.patch.yml      bundle 注册
└── package.json

浏览器端虽然拆成了多个 .ts 模块,但最终由 esbuild 打成一个 lib/client.js,外面套上 window.__ModuleLoader__.load({ id, factory(require) { … } })。运行时它只能 require('react') 这类由宿主提供的模块,不能 require 自己的其它文件,所以「拆模块」只发生在源码层,产物仍然是单文件。

依赖与配置

  • 版本:1.0.3
  • 许可证:MIT
  • 包管理器:pnpm@11.9.0
  • Node 版本要求:>=20
  • packageManager 字段:pnpm@11.9.0(钉在 package.json 里,CI 也用 pnpm)

package.json 关键字段

{
  "name": "dsh-plugin-upgrader",
  "version": "1.0.3",
  "description": "One-click upgrades, version history and rollback for plugins on the DeepSeek Harness Plugins page",
  "license": "MIT",
  "author": "zuwenyin",
  "packageManager": "pnpm@11.9.0",
  "type": "module",
  "main": "./lib/index.js",
  "types": "./lib/index.d.ts",
  "exports": {
    ".": {
      "types": "./lib/index.d.ts",
      "default": "./lib/index.js"
    },
    "./client": "./lib/client.js",
    "./package.json": "./package.json",
    "./locale/*.json": "./locale/*.json",
    "./icon": "./assets/icon.svg"
  },
  "files": [
    "lib/**/*.js",
    "lib/**/*.d.ts",
    "lib/**/*.map",
    "locale/*.json",
    "assets/icon.svg",
    "cordis.patch.yml",
    "README.md",
    "README.en.md",
    "LICENSE",
    "CHANGELOG.md"
  ],
  "engines": {
    "node": ">=20"
  },
  "dsh": {
    "bundle": {
      "patch": "./cordis.patch.yml"
    },
    "client": {
      "platform": "web",
      "inject": [
        "@deepseek-ai/dsh-client-locale",
        "@deepseek-ai/dsh-client-ui-settings"
      ]
    }
  }
}

适用场景与注意事项

适合需要批量管理 DSH 插件版本、频繁升级或需要回滚的场景。插件以当前 dsh 进程权限运行,安装前应检查源码与许可证。

总结

dsh-plugin-upgrader 为 DSH 插件页补上了一次性升级和版本历史功能,界面集成在「版本」标签中,提供进度可见、多选升级、一键跳 GitHub 等实用能力。其工作原理是通过宿主端路由处理包操作,确保浏览器端无法访问 profile 依赖清单时的可用性。通过订阅 plugin-manager/install-log 和 plugin-manager/install-state,实现了对 pnpm 慢速操作的可见反馈。

  • 目录页:https://www.skillhub.cn/plugins/zuwenyin/dsh-plugin-upgrader
  • GitHub:https://github.com/zuwenyin/dsh-plugin-upgrader
羽毛球分组比赛记分
小程序二维码

欢迎使用《羽毛球分组比赛记分》微信小程序

Xiaoye