# dsh-plugin-backup [English](README.md) | 中文 DeepSeek Harness 的备份插件:把 `$DSH_HOME` 按分类打成可校验、可恢复、可下载的归档, 同名备份可推送到 GitHub。全部能力同时开放给 **Web 设置页**、**模型工具面** 与 **CLI 会话** (三者共用同一套引擎与同一把单飞锁)。 - 备份命名:`dsh-backup--` - 分类:`settings`(DSH 设置)/ `skills`(技能)/ `plugins`(插件)/ `sessions`(会话)/ `storages`(存储) - 每个备份:**下载 / 验证 / 恢复 / 删除** - 配置:**备份路径**、**备份时间间隔**、分类开关、保留份数、创建后立即验证、单类归档体积上限、GitHub 同步 - 零构建、零第三方运行时依赖:宿主半是纯 ESM,浏览器半是可直接手写下发的 CJS 工厂 ## 安装 ```powershell # 本地目录安装(开发) dsh plugin --profile web add file:C:/path/to/dsh-plugin-backup # 或从 GitHub 安装 dsh plugin --profile web add github:shiinakaze/dsh-plugin-backup ``` 桌面端自管 `desktop` profile:通过其插件管理器安装(或 `dsh plugin --profile desktop add …`),然后重启应用。 `dsh plugin add` 会同时完成两件事:把包写进 profile 的 `dependencies`,并把包名追加到 `dsh.profile.bundles`(构成顺序里的一层)。安装后重载或重启 Web 界面,打开 **设置 → 数据备份**。 > **运行时支持范围**:本插件面向 `@deepseek-ai/dsh` 的 0.1.7 与 0.2.0 两条线—— > `peerDependencies` 中 `dsh-tools` 与 `dsh-typert-protocol` 声明为 > `^0.1.7-rc.2 || ^0.2.0-rc.1`,`engines.dsh` 同步该窗口。0.2.0 的启动链会在装载前做 > 兼容性预检,**对 peer 不满足的 bundle 直接拒绝安装/挂载**,因此精确锁某一个 rc > 会被直接拒装。两个分支不是修饰:包管理器按 npm 的预发布规则解析,只有当某个比较符 > 钉住完全相同的版本元组时才放行预发布版,所以写成单条 > `>=0.1.7-rc.2 <0.3.0` 会静默排除 `0.2.0-rc.1` 与 `0.2.0-rc.2`,在 0.2.0 线的主机上 > 让 `npm install` 报 `ERESOLVE`。窗口必须与插件实际使用的 API 保持同步。 > `file:` 安装是**快照**:profile 里存的是包的副本,改动源码后需要 > `dsh plugin --profile web remove ` 再 `add` 一次才会生效。 > 本插件的实测记录见 [docs/verify.md](docs/verify.md)。 > 该包是 DSH 插件而非普通依赖:宿主半的 `inject` 需要 schema 中的 `tools` 服务, > 浏览器半通过 `package.json` 的 `dsh.client` 声明被发现。缺少 `$DSH_HOME` 时会退回 > `~/.dsh`;非 Web profile(没有 `webServer` 服务)时仅“下载”不可用,其余功能与工具照常。 ## 界面 设置 → **数据备份**: | 区块 | 内容 | |---|---| | 备份设置 | 备份路径(留空 = `C:\Users\<用户名>\dsh_backups`,其他平台 `~/dsh_backups`;支持 `~` 与 `${DSH_HOME}`)、备份间隔(关闭 / 15 分钟 / 每小时 / 6 小时 / 每天 / 每周)、保留份数、创建后立即验证 | | 分类备份 | 五个分类的**备份选择**:勾选哪些就备份哪些;「全选 / 全不选」一键切换;按钮显示当前选择(如「立即备份(3 个分类)」),未选任何分类时按钮禁用并给出提示 | | 已有备份 | 每行显示 ID、创建时间、文件数、体积、分类徽标、验证结论;操作为 **下载**(下拉选某一分类或 manifest)、**验证**、**仅预览恢复**、**恢复**、**删除**(恢复与删除需二次点击确认,删除按钮为危险色) | | GitHub 同步 | 启用开关、仓库 `owner/repo`、分支、子目录、令牌环境变量、令牌是否已配置,以及 **推送到远端 / 从远端拉取** | 「立即备份」只备份**当前勾选**的分类;点「保存设置」后,该选择同时成为定时备份的默认分类。 设置写入插件的用户层文件 `$DSH_HOME/dsh-plugin-backup/settings.json`(`0600`), 部署基线仍在 profile 的 `cordis.patch.yml` 里,二者做浅层合并(用户层优先)。 ## 目录布局 ``` <备份路径>/ dsh-backup-20260930-141205/ manifest.json # schema 版本、主机信息、逐类 files/bytes/sha256/归档名、上次验证结论 settings.tar.gz # 每个分类一个归档(gzip 压缩的 USTAR) skills.tar.gz plugins.tar.gz ``` `manifest.json` 最后写入且原子落盘,所以“目录里有 manifest”即代表这份备份完整。 创建期间所有中间文件以 `.part` 结尾,崩溃残留会在下次运行开始时清理或被忽略。 ## 分类与排除 | key | 来源(相对 `$DSH_HOME`) | 默认 | |---|---|---| | `settings` | `settings.yaml*`、`cordis.patch.yml*`、`.credentials.yaml*`、`.anonymous-user-id`、`dsh-skill-hub.json`、`skills-market-sync.json` | 开 | | `skills` | `skills-management/**` | 开 | | `plugins` | `profiles/*/package.json`、`pnpm-lock.yaml`、`pnpm-workspace.yaml`、`cordis.patch.yml*`,以及 `plugins/**` | 开 | | `sessions` | `sessions/**` | 关 | | `storages` | `storages/**` | 关 | 全局排除:任意层级的 `node_modules`、`.git*`、符号链接;**备份根目录自身**也会被排除, 因此把备份放在 `$DSH_HOME` 内不会自包含递归。 ### 真实体量(本机实测,`settings` + `plugins` + `skills`) | 分类 | 文件数 | 原始 | 归档 | 耗时 | |---|---|---|---|---| | `settings` | 5 | 2.0 KB | 1.3 KB | < 0.1 s | | `plugins` | 39 | 1.24 MB | 1.12 MB | 0.2 s | | `skills` | 29 356 | 342.5 MB | 144.8 MB | ≈ 50 s | 创建与校验都是流式的(恒定内存,实测 RSS 57 MB → 158 MB),不会把一整个分类读进内存。 因此默认单类上限设为 **512 MiB**;若你的技能树更大,请在设置里调高 `maxArchiveBytes`, 或把 `skills` 排除后改为手工备份。保留 20 份、且开启 `skills` 时约占用 3 GB 磁盘。 ## GitHub 同步 1. 在设置页勾选“启用同步”,填写 `owner/repo`(也可写完整 URL)、分支、子目录。 2. 令牌来源(按优先级): - 环境变量:默认 `DSH_BACKUP_GITHUB_TOKEN`(可在设置里改名字); - 状态文件:`$DSH_HOME/dsh-plugin-backup/github-token.json`,内容 `{"token": "…"}`,权限 `0600`。 令牌**只**从这两处读取,永不写入配置、日志或工具输出。 3. **推送到远端**:逐文件调用 Contents API,归档先推、`manifest.json` 后推;远端已有同名且 内容一致的备份会跳过。**从远端拉取**:只下载本地缺失的备份,并按 `manifest.json` 的 sha256 校验每个归档,校验不过就丢弃该备份并报告原因。 远端结构:`<子目录>/<备份ID>/{manifest.json,<分类>.tar.gz}`。 ## 模型工具 | 工具 | 参数 | 说明 | |---|---|---| | `backup_run` | `categories?`、`label?` | 立即创建一份备份 | | `backup_list` | `limit?` | 列出已有备份(最新在前) | | `backup_verify` | `id` | 重算 sha256 并重读 tar 结构,逐类报告差异 | | `backup_restore` | `id`、`categories?`、`dryRun?`、`force?` | 恢复;`dryRun` 只列计划 | | `backup_delete` | `id`、`force?` | 删除;仍有更旧备份时最新一份受保护 | | `backup_sync` | `direction`、`ids?` | GitHub 推送 / 拉取 | 典型用法:改动前 `backup_run`,出问题后 `backup_verify` 再 `backup_restore`; `backup_restore` 会先跑一次校验,校验不过会拒绝(除非 `force`)。 ## 安全边界 | 场景 | 行为 | |---|---| | 路径穿越 | 备份 ID 必须匹配 `^dsh-backup-\d{8}-\d{6}$`;归档内条目拒绝绝对路径、盘符路径、`..` | | 归档损坏 | `verify` 报出具体类别与期望/实际 sha256;`restore` 默认拒绝,`force` 时降级为“跳过该分类并报告” | | 覆盖不可逆 | 恢复前把将被覆盖的文件就地留一份 `.pre-restore` | | 并发 | 单飞锁(`<备份路径>/.lock`,30 分钟视为过期);冲突返回 `busy`,绝不并发写 | | 误删 | 仍有更旧备份时,最新一份需要 `force` 才能删除;只剩一份时可直接删除 | | 体积失控 | 单类归档超过 `maxArchiveBytes` 即中止本次运行并删除半成品 | | 令牌泄露 | 令牌只读环境变量 / `0600` 文件;所有输出只出现仓库路径 | | 下载链接 | 单次使用的下载票据(24 字节随机、120 秒过期、消费即失效),由 `GET /dsh-plugin-backup/download?token=…` 提供 | 下载路由不做登录校验(票据本身是一次性凭证),因此请保持 Web 服务仅监听回环地址, 不要额外暴露到不可信网络。 ## 开发 > **要开发/移植别的插件?** 请直接看 DSH 插件开发手册: > [docs/plugin-development-guide.md](docs/plugin-development-guide.md) —— Desktop 0.2.0 > 运行时解剖、peer 兼容性预检规则、六步适配/验证流程(命令均实测)、宿主/Typert/浏览器 > 两面契约速查、0.1.7→0.2.0 API 差异表,以及跨版本坑位清单。 ```powershell npm test # 50 项:tar/manifest/engine/插件装配/浏览器半/打包契约 node --check index.js node --check client.js ``` 测试不需要 DSH 运行时,但会尽量使用**真实的官方包**做契约校验: - `@deepseek-ai/dsh-typert-loader` 的 `validateTypertManifest` 校验宿主描述符; - 真实 `TypertRegistry` 注册 9 个端点; - 真实 `dsh-tools` 的 `defineTool` 编译 6 个工具 schema; - 真实 `@deepseek-ai/cordis` 根上下文挂载插件,并驱动下载路由读写真实归档; - 浏览器半通过模块装载器假实现 + 迷你 hooks 运行时渲染,并逐字段比对宿主/客户端描述符。 ```powershell node test/local/measure-real-home.local.mjs settings plugins skills node test/local/remount.local.mjs ``` 上面的脚本会对**真实** `$DSH_HOME` 做一次备份、校验、dry-run 恢复并清理,用于测量体积与耗时; `remount.local.mjs` 复现 profile 重组时的重复挂载,用于确认服务、工具与下载路由不会泄漏或失效。 ### 结构与职责 ``` index.js 宿主插件:Config schema、apply、下载路由 typert.js 宿主 Typert 描述符 + BackupService(远端方法实现) lib/tools.js 6 个 backup_* 工具 client.js 浏览器半:设置页 + Remote 装载 lib/core/{tar,manifest,plan,store,settings,github,engine,util}.js test/*.test.mjs 离线测试;test/local/ 为测量脚本 ``` ## 故障排查 | 现象 | 处理 | |---|---| | 设置页没有“数据备份” | 确认包已加入 `dsh.profile.bundles`;重载页面;查看启动日志是否有该插件的 FAILED fiber | | 页面提示“备份路径当前不可用” | 路径不可写或被同名文件占用;改成可用目录(默认 `C:\Users\<用户名>\dsh_backups`)后保存 | | 备份报 `busy` | 有另一次运行持锁(定时备份 / 另一会话);等待或删除 `<备份路径>/.lock` | | 报 `too-large` | 调高 `maxArchiveBytes`,或先关闭 `skills` 分类 | | 恢复被拒(corrupt) | 先 `backup_verify` 看具体类别;归档确实损坏时换一份更新的备份 | | GitHub 报 `github-auth` | 令牌未配置或权限不足(需要目标仓库的 `contents: write`) | | 下载无反应 | 票据 120 秒过期;重新点“下载” | | 页面报 `method "backup/remove" conflicts with its namespace service` | 0.1.1 及更早的构建问题:客户端载体的命名空间服务自带 `remove` 成员。0.1.2 起删除端点线上名为 `deleteBackup`;若自行扩展本插件,请避开那组保留名 | | 页面报 `cannot get property "…" without inject` | 0.1.2 及更早的构建问题:宿主/客户端直接读了 Cordis 服务属性而没有声明注入。0.1.3 起改为 `ctx.get(...)` 与 `ctx.inject(...)` 子 fiber;升级后重启 `dsh web` 即可 | | **点「保存设置」后设置页整片变空白**(刷新页面才恢复) | 0.1.3 及更早的构建问题:保存动作把宿主返回的 `Preferences`(无 `rows`/`total`/`state`)当成了整页快照使用,渲染时抛错,而槽位错误边界会把这个分区**永久除名**。0.1.4 起改为保存后重读快照,并加了载荷归一化与本节区错误边界;升级后刷新页面即可 | | 桌面端安装被拒:*「Plugin dsh-plugin-backup@… is incompatible with dsh 0.2.0-rc.2: peerDependencies …」* | 0.1.4 及更早的打包问题:dsh 依赖被精确锁在 `0.1.7-rc.2`,而 0.2.0 启动链的兼容性预检会直接拒绝 peer 不满足的 bundle。移除后重新 `add` 即可;临时绕过可用 `dsh plugin allow-version` 授予一次性豁免 | | 0.2.0-rc 主机上 `npm install` 报 `ERESOLVE … peerOptional @deepseek-ai/dsh-tools@">=0.1.7-rc.2 <0.3.0"` | 仅 0.2.0 线的打包问题:那条窗口看似同时覆盖两条线,但 npm 只在某个比较符钉住完全相同版本元组时才放行预发布版,于是 `0.2.0-rc.1`/`0.2.0-rc.2` 被静默排除,冲突被报在宿主自带的副本上。1.0.0 起改为 `^0.1.7-rc.2 \|\| ^0.2.0-rc.1`,同时满足 npm 规则与运行时预检;升级即可,或改用一直容忍旧范围的 `pnpm` / `dsh plugin add` | ## 设计说明 - **为什么默认备份到用户目录**:`C:\Users\<用户名>\dsh_backups`(其他平台 `~/dsh_backups`) 与 DSH home 解耦,重置或迁移 `$DSH_HOME` 不会连带丢掉备份,也便于用户直接用资源管理器打开。 - **为什么设置放在插件自己的 `settings.json`**:备份路径/间隔是运行期偏好,直接改写 profile 的 YAML 补丁风险更高;插件用户层 + 部署基线两层合并更可诊断。 - **为什么归档按分类拆分**:可只恢复/只下载/只推送某一类;`sessions` 默认关闭,因为它体积最大。 - **为什么手写 USTAR 与 Typert 描述符**:插件不引入任何第三方运行时依赖,安装后不需要构建; 描述符与官方生成物同构,由官方 loader/registry 校验。 - **远端方法名避开载体保留字**:客户端载体的命名空间服务自带 `remove`、`has`、`install`、 `installDirect`、`installScoped`、`assertMethodAvailable` 以及 `ctx`/`name`/`namespace`/`methods`/ `empty`/`invokeRemote` 等成员,同名远端方法会在挂载时被拒绝 (`method "backup/remove" conflicts with its namespace service`)。因此本插件的删除端点在线上 叫 `deleteBackup`;`test/plugin.test.mjs` 里有一条断言锁死这组保留名。 - **服务一律用 `ctx.get(...)` 读,不用属性读**:Cordis 的 `ctx` 是代理,`ctx.webServer` / `ctx.tools` / `ctx.remote.backup` 这类**属性读**只在“该名字已注入到本 fiber”时才合法,否则会在 激活之后抛 `cannot get property "…" without inject`。本插件因此: - `inject` 里不声明可选服务,改用 `ctx.inject([...], cb)` 的**子 fiber** 去等待。 `webServer` 是典型:真实启动里它比本插件晚约 0.8 秒才可用,早先在 `apply` 里直接读会让 下载路由静默失效(页面因此整体读不到状态);工具注册表同理,只在可用时挂载。 - 其余场合统一 `ctx.get(name)`(免注入读取),例如工具注册表与已挂载的 `remote.backup`。 `test/plugin.test.mjs` 与 `test/client.test.mjs` 都用**真实 Cordis 上下文**跑激活, 所以这两类错误会被测试直接抓住。 ## 许可 MIT