香菜的博客
全部项目

PROJECT · 2026

DSH GitHub 备份:一键把 Harness 自己推上 GitHub

给 DSH 加一个一键备份插件:设置页 + 侧边栏按钮,把 profile、storages 和工作区打成一次 commit 推到 GitHub 私有仓库,全程不用装 git。

运行中 DSHCordisGitHub备份插件开发自动化

为什么做这个

这个 Harness 的「自我」散在磁盘上:~/.dsh/profiles/web 是全部配置和补丁层,~/.dsh/storages 是持久化状态,再加上当前工作区。改崩一次就没了。

而 ~/.dsh 里恰好躺着 .credentials.yaml——所以备份这个动作不能是"打包整个目录上传"那么简单,它得同时是方便的和不会把密钥一起送出去的。

于是做了 @local/dsh-github-backup:一个 DSH profile 插件,设置页里有 Back up now,侧边栏底部有个图标按钮,点一下就是一次提交。

核心决定:不依赖 git

这台机器上没有 git,也没有 gh。装一个当然可以,但那意味着备份功能的可用性绑在另一个软件的安装状态和一堆凭据配置上。

所以直接说 GitHub 的 Git Data API:

blobs (每个文件) → tree (整棵树) → commit (一次提交) → ref (移动分支)

一次备份 = 一次 fast-forward,GitHub 上看到的就是一条正常提交历史,而本地不需要有任何工作副本。用 Node 自带的 fetch 就够了。

结构:一个操作,两个入口

最省事也最容易写歪的地方是:设置页写一套备份逻辑,侧边栏按钮再写一套,然后两边行为慢慢漂移。

这里反过来了——备份只有一份实现,在 Host 半,两个入口都只是调用方。桥是插件自己注册的四条同源 HTTP 路由:

路由用途
GET /github-backup/status设置、token 是否就绪、上次结果、实时进度
POST /github-backup/settings合并设置并落盘
POST /github-backup/token存入或移除 token
POST /github-backup/run执行一次备份

浏览器半是纯 JavaScript(无 JSX、无构建步骤),通过 window.__ModuleLoader__.load 注册,向 settings.section 和 sidebar.footer.action 两个槽位各注册一个组件。两个组件共用一个 useBackupState,所以「一键」和「设置页里点按钮」走的是完全相同的路径。

同一个操作还顺手暴露成了 agent 工具 github_backup_now,于是模型也能主动触发——这是 DSH 的常规做法:一个操作、多个调用方,逻辑只有一份。

文件分工

文件职责
index.jsHost 半:设置读写、凭据、四条路由、agent 工具
github.jsGitHub API 客户端与备份流程
collect.js文件遍历:源根、跳过清单、符号链接处理、体积上限
store.js设置文档 ~/.dsh/github-backup.json
homes.js本地版 dshHomePath / credentialRef,让插件零依赖
client.js浏览器半:设置页 + 侧边栏按钮

Token 去哪了

走 DSH 的凭据服务,写进 ~/.dsh/.credentials.yaml,引用名 GITHUB_BACKUP_TOKEN。

它不在设置文件里、不回传给页面、不写日志。同名的环境变量可以作为只读兜底。实测确认:~/.dsh/github-backup.json 里搜不到 ghp_ 前缀。

备份什么、不备份什么

一次提交里三个前缀:

  • dsh/profiles/ — profile 本身(配置、补丁层、已装插件)
  • dsh/storages/
  • workspace/ — 当前会话工作目录,或设置里的覆盖路径

永远排除:.credentials.yaml、settings.yaml.imported、插件自己的设置文件、node_modules、缓存、构建产物、会话日志,以及超过体积上限的文件(会计数并报告)。

删除只作用在当前范围拥有的前缀里——所以把范围从「全部」收窄成「只备份 profile」时,workspace/ 下的文件不会被误删。

四个真实踩出来的坑

这些 mock 测试一个都测不出来,全是真跑才暴露的。

1. 空仓库的 409

新建的仓库第一次备份必然失败:

GitHub rejected the payload (409): Git Repository is empty.

没有 commit 的仓库,ref 读取和甚至 blob 写入都返回 409——不是你写错了路径,是 Git Data API 不接受一个没有历史的仓库。

兜底方案是先用 contents API 写一个 README 造出首个 commit,再正常推。那个 README 落在主分支根目录,不属于任何受管前缀,所以后续备份不会把它当孤儿删掉。

2. 密钥排除有个缺口

最初自我排除只在配置了排除目录时才生效,而且只看文件名。改成无条件、且对 realpath 判断之后,才挡得住"在 profile 里放一个 junction 指向被排除目录"这种绕法。

3. 本地目录安装 + 裸导入 = 装不上

install_bundle 用 link: 装本地目录,Node 会按真实路径解析裸导入,于是 @deepseek-ai/dsh-home-paths 找不到,插件 import 直接失败。

解法是让插件真的零依赖:把 dshHomePath 和 credentialRef 两个小函数内联成 homes.js。这样无论链接安装、打包安装还是直接拷贝,行为一致。

4. ctx.tools 必须先声明

Error: cannot get property "tools" without inject

改成 ctx.get('tools') + undefined 检查,插件在没有 tools 服务的 profile 里也能安静地不注册。

环境坑:TLS 被中间人拦了

第一次真跑,fetch 报:

UNABLE_TO_VERIFY_LEAF_SIGNATURE

查下来 api.github.com 的证书链签发者是本机装的 SteamTools Certificate——被本地根证书中间人了,Windows 信任它,但 Node 自带的 CA 列表不认。example.com 却正常,所以不是断网。

修法是在启动脚本里加一行,让 Node 参考 Windows 证书存储(而不是关掉校验):

if ($env:NODE_OPTIONS -notmatch 'use-system-ca') {
    $env:NODE_OPTIONS = (@($env:NODE_OPTIONS, '--use-system-ca') | Where-Object { $_ }) -join ' '
}

实测结果

项值
首次172 文件 / 78.3 MB / 59 秒
二次(只改了 2 个文件)只上传 2 个 / 6.6 秒
远端201 条路径,0 处密钥泄露
二次跑no-changes 时就返回空提交,不产生噪音 commit

二次运行只传变动文件,是因为拿远端 tree 的 path → size 做索引比对——不需要把仓库拉下来。

已知限制

  • 只做整树替换语义,不做真正的三方合并;force: false,所以不会覆盖别人的提交,冲突时直接报错。
  • 文件变更判定用 path + size,同尺寸改写同一天不会被识别(这是为了避免每次都下载全仓库)。
  • token 需要有 Contents: Read and write;用 fine-grained token 时只授予目标仓库,不要用 classic 全权限 PAT。

怎么用

插件装进 profile 后,设置页里填 owner/repo、分支、勾选范围,粘贴 token,点 Back up now。之后侧边栏那个按钮就是真正的一键了。