dsh-git-sync
已验证@fish-under-sea/dsh-git-sync · v0.3.2 · MIT · Web 界面
DeepSeek Harness 一键 GitHub 同步:把插件清单、插件启用状态、插件配置、本地设置、Skills、Skill 的 refs/脚本、看板/用量账本、设置导航顺序偏好、标题自动刷新设置、壁纸引擎设置(含字体集与吉祥物素材)、免费模型插件配置与用量账本采集进你自己的私有 git 仓库并推送,换机可一键还原;采集前自动 fetch 远端并合并,密钥永不搬运。0.2.5 起不再同步 agent 预设 / 桌宠存档 / 工作区映射,0.2.6 起不再同步归档台账,0.2.7 起纳入 skill-
安装
dsh plugin add @fish-under-sea/dsh-git-sync 用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。
源码
发布到 npm 但没有公开仓库。安装前请检查包内容。
标签
说明文档
解决什么问题
DSH 把所有用户状态收敛在单个 home 目录($DSH_HOME,默认 ~/.dsh)。手动同步要记住一串路径、还要小心别把密钥推上去。本插件把这件事变成「设置 → Git 同步」里的几个按钮——把你的 DSH 环境(已安装的插件、每个插件是否启用、插件配置、本地设置、Skill、看板账本、壁纸引擎设置)采集进你自己的私有 Git 仓库并推送,或在新机器上一键还原。
效果
- 白名单采集:只搬运配置面,密钥文件(
.credentials.yaml)在硬黑名单里,永不搬运。 - 三道安全闸:文件级硬黑名单 → 提交前复查暂存区 → 密钥体检(通用正则 +
.credentials.yaml精确匹配)。 - 远端合并:采集 / 推送之前先
git fetch;远端领先且本机无提交时快进合并;两边都有提交时自动rebase;冲突则回滚并如实报错,绝不留半合并状态。 - 补推语义:推送与否按「本地是否领先远端」判断,而不是「本次是否产生了新提交」——推送失败后再点一次「一键同步」即可重试。
- 单文件容错:单个文件读不到(被锁定、权限不足、或被文件策略拒绝)只会跳过并在日志里列出,不中断整次同步。
- 额外扫描根(0.3.0):白名单条目可以用「根前缀」指向
$DSH_HOME之外的额外目录(当前用于壁纸引擎),仓库里落成同名子目录。 - 零 npm 依赖:只用
node:内置模块。
安装
# 方式一(推荐):从 npm 安装
# --profile 后跟本机实际的 profile 名:桌面版是 desktop,Web 版是 web
dsh plugin --profile <profile> add @fish-under-sea/dsh-git-sync
# 方式二:link 安装(改源码开发时用)—— 仓库源码即安装源,改完重启 DSH 即生效
dsh plugin --profile <profile> add "link:<仓库路径>/packages/dsh-git-sync"
# 方式三:拷贝安装(file:)—— 改完源码必须重新 add 才生效
dsh plugin --profile <profile> add "file:<仓库路径>/packages/dsh-git-sync"
怎么选:日常使用走方式一(npm,版本可追溯、可 update);要改源码时用方式二(link: 建的是目录联接,仓库源码即安装源,改完重启 DSH 即可)。file: 是 pnpm 的目录拷贝——装完之后改 lib/*.js 不会反映到已安装的那份,重启也没用,必须重新 add 一次,只适合一次性试用。
装完重启 DSH(插件行与设置页都是下次启动生效)。
换机提醒:安装会把本机绝对路径写进
profiles/<profile>/package.json("@fish-under-sea/dsh-git-sync": "link:D:/…/plugin")。这个文件会被同步到另一台机器,而那边没有这个目录,dsh plugin install会在这一项上失败。要么两台机器 clone 到同一路径,要么在新机器上先dsh plugin --profile web remove @fish-under-sea/dsh-git-sync再按本机路径add回去。
使用
打开 设置 → Git 同步:
| 按钮 | 作用 |
|---|---|
| 一键同步 | 先 git fetch 远端并自动合并,再采集 → 提交 → 推送,日常用这一个就够 |
| 仅采集并提交 | $DSH_HOME 的白名单内容 → 仓库目录,然后 git add / commit(不推送) |
| 从仓库还原到本机 | 仓库目录 → $DSH_HOME,覆盖前备份到仓库的 _backup/ |
| 密钥体检 | 扫描将上传的文本文件 + 用 .credentials.yaml 里的真实密钥值做精确匹配 |
| 刷新 | 重新读取仓库状态 |
状态卡怎么看
| 卡片 | 含义 |
|---|---|
| 待同步 | 本机 ↔ 仓库的内容差异(新增 / 变更 / 仓库多出)。为 0 表示两边一致 |
| 仓库内文件 | 仓库里已同步的载荷规模(文件数 · 体积)。这不是待办量 |
| 工作区 | 仓库的 git 工作区是否干净、是否领先远端 |
| 分支 / 远端 | 当前分支与 origin URL |
| 上次运行 | 上一次动作、时间与成败 |
「待同步」与「工作区」要一起看:待同步 = 0 且工作区干净 ⇒ 本机配置已完整提交并推送到远端,没有什么可同步的。早先版本把「仓库内文件数」标成了「待同步文件」,会让人误以为有几十个文件排队等着上传——已改。
关键语义
- 「一键同步」会补推之前没推上去的提交。 推送与否按「本地是否领先远端」判断,而不是「本次是否产生了新提交」。推送失败(TLS 拦截、断网、凭据过期)后不用做别的,再点一次「一键同步」即可——曾经有个隐蔽的 bug:旧代码只在本次产生新提交时才推送,于是推送失败后再点一键同步会因为「本机已无变更」而永不重试,还回报成功。已修,并加了回归测试。
- 「仅采集并提交」是「先看后推」的闸口。 它只提交、不推送,方便你先看差异再决定要不要送上 GitHub。(
pushgit动作仍在 API 上保留,作为应急通道。) - 跨机同步不再需要手工救火。 采集与推送之前会先
git fetch远端:远端领先且本机没有提交时快进合并;两边都有提交时自动rebase(保持线性历史);遇到冲突则回滚到操作前并如实报错,绝不留半合并状态。此前只会 push,后果是远端一有新提交就永远卡在! [rejected] main -> main (fetch first);更隐蔽的是本机没有提交时@{u}引用陈旧,会谎报「本地与远端完全一致」而什么都不做。
配置
仓库目录在面板里直接改、点「保存设置」即可。它存在 $DSH_HOME/dsh-git-sync/config.json——故意放在同步范围之外,所以每台机器可以指向自己的克隆路径,不会被互相覆盖。
同步范围(白名单)
白名单列出相对 $DSH_HOME 的路径。<profile> 按目录动态枚举。
| 路径 | 含义 |
|---|---|
settings.yaml |
0.1.x 口径的全局设置;0.2.0 起 home 下的它已被 patch layer 改名 settings.yaml.imported,该条目只在从 0.1.x 老机器迁移时有用 |
skin-center-active.json |
皮肤中心当前激活项 |
skills/ |
Skill 目录($DSH_HOME/skills) |
skill-refs/ |
Skill 的 refs 判据、可运行脚本与测试样本(0.2.7 起纳入) |
AGENTS.md |
用户级全局指令 |
task-board/ledger-v2.json |
看板账本 |
task-board/scheduler-v2.json |
看板调度器状态 |
dsh-usage/ |
用量账本 |
dsh-settings-nav-order/state.json |
设置导航顺序偏好(详见下方专节) |
dsh-session-title-refresh/config.json |
标题自动刷新的用户设置:用哪个模型起标题(provider / model)与全部调参(首轮轮次、间隔、窗口、输出预算、超时、目标字数、是否跳过子代理会话)。同目录的 history.json 是本机运行记录,不进白名单 |
our-free-model/settings.json |
免费模型插件(dsh-our-free-model)的用户设置:开关、探测间隔、转发、出口、渠道网关、默认 maxTokens(详见下方专节) |
our-free-model/catalog.json |
免费模型目录快照(模型 id 列表 + 时间戳),无密钥 |
our-free-model/availability.json |
免费模型可用性快照(出口 IP + 各模型探测结果),无密钥 |
our-free-model/stats.json |
免费模型用量统计账本(详见下方专节,含跨机覆盖说明) |
profiles/<profile>/package.json |
装了什么插件 + bundle 层顺序 |
profiles/<profile>/cordis.patch.yml |
每个插件是否启用(disabled: 行)+ 配置覆盖 |
profiles/<profile>/cordis.patch.yml.bak-plugin-manager |
插件管理器写的配置备份(精确整路径放行,其它 *.bak* 仍一律拒绝) |
profiles/<profile>/pnpm-lock.yaml |
精确版本,保证可复现 |
profiles/<profile>/pnpm-workspace.yaml |
pnpm 配置 |
wallpaper-engine/config.json |
壁纸引擎全部设置(额外扫描根,详见下方专节) |
wallpaper-engine/glass-presets/ |
用户保存的玻璃预设(目录级收录,按额外扫描根) |
wallpaper-engine/fontsets/ |
字体集(目录级收录,按额外扫描根)。这是 config.json 引用的配置,不是派生物——详见下方专节 |
wallpaper-engine/mascot/ |
自定义吉祥物图(目录级收录,按额外扫描根)。config.json 的 mascotImage 指向这里 |
<profile>按目录动态枚举:本机profiles/下每个 profile 目录都会被覆盖,桌面版是desktop、Web 版是web。写死 profile 名会漏掉「装了什么插件 / 每个插件是否启用 / 精确版本」这最要紧的三样——0.2.0 桌面版踩过这个坑:profile 改名后白名单一条都命中不了,仓库里只剩 0.1.x 的profiles/web快照。
设置导航顺序
设置菜单的顺序与隐藏项真正生效的地方是浏览器 localStorage(键 dsh-settings-nav-order/v1),本插件在宿主进程里够不着它。dsh-settings-nav-order 的宿主半区因此把它镜像成 $DSH_HOME/dsh-settings-nav-order/state.json——用户每次保存时用自己那条同源路由写入,本插件只负责按相对路径搬运。少了这一条,换机后设置菜单的顺序与隐藏项就复原不了(剩下的都能复原)。
标题自动刷新设置
dsh-session-title-refresh 的用户设置落成 $DSH_HOME/dsh-session-title-refresh/config.json——里面是用哪个模型起标题(provider / model)与整套调参(首轮轮次、间隔、窗口、输出预算、超时、目标字数、是否跳过子代理会话)。少了这一条,换机后要把标题模型和这套参数在原界面上重设一遍。
同目录的 history.json 是本机运行记录(每次刷新的成败与会话 id),只增不减、跨机互相覆盖且没有复原价值,所以只点名 config.json,不写整目录。
额外扫描根(0.3.0 新增)
白名单的口径是「相对 $DSH_HOME 的路径」,但 dsh-plugin-wallpaper-engine(壁纸引擎)把全部设置与素材放在 ~/.dsh-wallpaper-engine——那是 $DSH_HOME 的同级目录,普通白名单条目无论怎么写都够不到它。
0.3.0 引入额外扫描根:白名单条目可以用「根前缀」指向额外根,仓库里落成同名子目录。代码里是一张 EXTRA_ROOTS 表,当前只有 wallpaper-engine 一项,可被环境变量 DSH_WE_DATA_DIR 覆盖,未设时落到 ~/.dsh-wallpaper-engine。
本版收录四条:
wallpaper-engine/config.json—— 壁纸引擎全部设置(外观、扩展、播放、壁纸库的隐藏与轮播);wallpaper-engine/glass-presets/—— 用户保存的玻璃预设,按目录收录,以后新存的自动跟着走;wallpaper-engine/fontsets/—— 字体集,按目录收录,见下方「配置与素材分离」;wallpaper-engine/mascot/—— 自定义吉祥物图,按目录收录,见下方「配置与素材分离」。
之所以「加扫描根」而不是「把文件搬进 home」:壁纸引擎的默认数据目录是跨插件读契约(皮肤中心靠 <该目录>/config.json 的 settings.id 预判「壁纸在台」),搬走会让皮肤侧首帧先闪一下。加扫描根完全不动生产路径。
明确不收录:壁纸引擎的 cache/(约 2.8 GB 派生缓存)、ffmpeg/ 二进制、bin/、diag/、avatars/(当前为空)。
实现要点:0.3.0 同时把原来散在五处(采集 / 还原 / 差异比较 / 密钥体检 / 面板统计)的白名单循环收敛成唯一入口
entriesOf(base, side)——否则「新增一种扫描口径只在其中一两处生效」是必然结局;本项目已经因为「多层防御各自为政」踩过两次(见isBakAllowed与.gitignore的注释),所以额外扫描根这件事必须只有一个落点。
配置与素材分离:字体集与吉祥物(0.3.2 新增)
壁纸引擎有两处**「设置文件里只留一个指针,真身在旁边另存」**的设计,只同步 config.json 会得到「设置指着一个不存在的文件」:
字体集 fontsets/<id>.json。config.json 的根字段 fontSetId 就是活动字体集的 id;而字体键(themeColors / themeSize / themeWeight / themeFamily / globalFamily / componentFonts)已从 settings 键集里退出,只住在这个文件里。插件分两层:
| 层 | 位置 | 内容 |
|---|---|---|
| 随包层 | 插件内 lib/fontsets/ |
出厂预设(本机只有 compact.json) |
| 用户层 | <数据目录>/fontsets/ |
导入、覆盖、以及一次性迁移的产物 |
用户层优先。本机的 default 正是「六个内联字体键」那次迁移的产物(FONTSET_MIGRATED_ID),随包层没有 default,所以用户层那份是该 id 的唯一真源 —— 不同步它,换机后设置过去了、字体值没过去,打开就是默认外观。
吉祥物 mascot/<文件名>。config.json 里写着 mascotImage: "<文件名>" 与 mascotImageBox,图本身不在 config.json 里。只搬 config.json 就会指向一张不存在的图。
两条都按目录收录,所以以后导入 / 新建的字体集、换过的吉祥物自动跟着走。
免费模型插件(our-free-model)
dsh-our-free-model 的配置目录 $DSH_HOME/our-free-model/ 在 home 之内,所以按普通相对路径点名,不需要额外扫描根。收录四份:
our-free-model/settings.json—— 用户设置:启用开关、探测间隔、转发、出口、渠道网关、默认 maxTokens;our-free-model/catalog.json—— 模型目录快照(模型 id 列表 + 时间戳);our-free-model/availability.json—— 可用性快照(出口 IP + 各模型探测结果);our-free-model/stats.json—— 用量统计账本。
只排除一份:
eac-user.json—— 含真实登录 token 与 GitHub 登录名,属凭据。与.credentials.yaml同理:不走 git,需要时自行拷贝。它同时进了NEVER_COPY,所以即使白名单哪天被误改成'our-free-model'整目录,这道闸仍然拦得住。
stats.json的跨机语义(收录它是有代价的,这个代价是知道的):它是本机累计量(days/models/requests/failedRequests/samples),两台机器各自累加后经仓库互相覆盖,最后同步的那台会盖掉另一台的计数。「两边都完整」用当前实现做不到,只能保证「有一份跟着仓库走」。要两边都准,得把它改成合并式账本(按天 / 按模型取并集或取最大值)——尚未实现。
settings.json里的forward.key/egress.url/chanGateway.relay.key目前是空串。将来真填了密钥,由「密钥体检」如实报出并拦下提交,而不是放宽体检规则。
Skill 位置
DSH 会从多个根目录读 Skill:<项目根>/.dsh/skills、<项目根>/.agents/skills、<DSH_HOME>/skills、~/.agents/skills。本插件只覆盖 <DSH_HOME>/skills(即 ~/.dsh/skills)——所以 Skill 必须放在用户级目录才会被同步。放在项目级 .dsh/skills 的不在同步范围内。
永不搬运
路径的任一段命中即拒绝:
- 精确文件名:
.credentials.yaml/credentials.yaml/credentials.json/.env/node_modules/.pnpm/.git/session_projcache/.anonymous-user-id/.dshw-size.json/.dshw-usage.json/cordis.yml/workspace-local-paths.json - 后缀规则:
*.bak*/*.pem/*.key/.credentials* - 唯一例外:
profiles/<profile>/cordis.patch.yml.bak-plugin-manager(插件管理器写的配置备份,换机复原时有用)按精确整路径放行,其它*.bak*仍一律拒绝
不在范围内:sessions/、attachments/(0.2.0 起永久排除)、.agent-presets/、pet.json、storages/workspace.json(0.2.5 起撤下)、dsh-session-archive/(0.2.6 起撤下)、node_modules、密钥文件、派生物(cordis.yml、storages/session_projcache)、本机状态(.anonymous-user-id 等)。
0.2.5 撤下三条,理由各自独立:
.agent-presets/—— 不用自定义 agent 预设;pet.json—— 桌宠插件没启用,纯死文件;storages/workspace.json—— 里面是机器相关的绝对路径,搬到另一台机器本来也要手工改(见下方「兼容与边界」),同步它只会带来「换机后工作区指向不存在的位置」的噪声。
0.2.6 再撤一条:
dsh-session-archive/——@linxin666/dsh-session-archive的归档台账与运行状态(archive-ledger.json+state.json)。它是纯本机状态:记「哪些会话何时被归档」,而会话本身永久不跨机同步(sessions/、attachments/已排除),台账换机后没有意义;自动归档的策略在profiles/<profile>/cordis.patch.yml里、那份是同步的,新机器会自己重新记账。此前它还制造了一个假象:配置仓的.gitignore恰好也排除了它,于是「复制进仓库却永远不提交」,而面板显示待同步 0。
撤下的内容仍留在本仓历史里(git log -- <路径> 可取回);想恢复同步,把对应的行加回 WHITE_LIST 即可。
侧记(2026-10-06 实测的一次教训):白名单里任何被配置仓
.gitignore排除的条目,都会变成「本地复制、永不提交、面板却显示已同步」的假象。改动白名单时顺手核对一遍仓库的.gitignore是值得的。
同步范围 0.2.0 起收缩为「配置面」:会话记录与附件不再上传。sessions/ 是 zstd 二进制,只增不减、git 无法 diff 也无法行级合并,两台机器同时改必然冲突;attachments/ 同理且属本机隐私数据。配置面换机后靠这一份清单 + 各自的会话副本复原即可。已提交到仓库的旧会话文件不会被删除(只停新增),历史保留可查。
安全设计(三道闸)
新机器请不必动 API 设置:
DEEPSEEK_API_KEY、BAILIAN_API_KEY、BAILIAN_HE_API_KEY等密钥不在同步范围内,新机器上直接配即可。
- 文件级硬黑名单:路径任一段命中即拒绝。这是唯一不依赖内容判断的闸,
.credentials.yaml因此在结构上就不可能被搬运。 - 提交前复查暂存区:
git add后检查git diff --cached --name-only,一旦出现密钥类文件名 →git reset并中止提交。 - 密钥体检:读
.credentials.yaml提取真实密钥值做精确匹配,叠加通用正则(sk-…、GitHub token、AWS key、私钥块、Bearer、api_key:赋值)。
三道闸里前两道硬拦、第三道只告警。正则无法穷尽密钥形态,体检是辅助手段而非主闸——主闸是第一道的文件级拒绝。
其他约束:不做 force push、不重写历史;git 以非交互方式运行(GIT_TERMINAL_PROMPT=0),不会弹凭据窗口卡住宿主;API 路由只允许同源请求(拒绝 sec-fetch-site: cross-site)。
兼容与边界
- 工作区映射不再同步(0.2.5 起):
storages/workspace.json里是绝对路径(如D:\Fish-code\DSH),换机后本来就该由那台机器自己生成;本插件既不采集它、也不在还原时覆盖它。 - 会话是 zstd 压缩二进制,git 无法 diff / 行级合并。两台机器同时改同一会话会二进制冲突,本插件不做内容级合并。
- 仓库只增不减:会话是追加型数据,删本地不会缩小仓库。
- 装完插件后需要重启 DSH 才能在设置页看到它。
- 依赖
git在 PATH 中。若你的机器用 HTTPS 拦截式加速器(如 Watt Toolkit / SteamTools),git 可能报 TLS 错误——解决办法见仓库的AGENT-GUIDE.md§9.1。 - 本机没有 git 身份也能同步:提交前会探一次
user.name/user.email,没有就临时用 origin 的 GitHub 主人名提交(详见下方「三个已修的坑」)。想固定成你自己的身份,在仓库里执行git config user.name/user.email即可。 - 主机半边的
ctx.webServer路由是 loopback + 同源保护,没有额外的鉴权层。 - 只覆盖用户级 Skill(
$DSH_HOME/skills)。项目级.dsh/skills、.agents/skills与~/.agents/skills不在同步范围内。 - 刻意不同步:
skin-center/(导入到壁纸库的壁纸工程——体积上百 MB,且能从本机 Wallpaper Engine 重新导入;其.cache/we-tokens.json里全是本机 Steam 绝对路径)、*.bak*。至于task-board/ledger-v2.lock这类锁文件:它们只是不在白名单里,所以同样不会被搬运,并非另有一条「锁文件规则」。
三个已修的坑
本机没有 git 身份会让整次同步失败。 全新机器上 user.name / user.email 都没有时,git commit 直接拒绝(Author identity unknown … unable to auto-detect email address)—— 而全新机器恰恰是「换机复原」最需要同步成功的一刻。0.2.3 及以前把这句报错笼统报成「推送失败」,面板上只显示「推送不行」,完全看不出真正原因。现在:提交前先探一次身份(git var GIT_AUTHOR_IDENT,与 git commit 同一套解析),没有就按 origin 的 GitHub 主人名提交(配成 <主人>@users.noreply.github.com,与仓库既有提交一致),拿不到 origin 就退到「本机登录名 @ 主机名」,并在面板日志里写明用了谁、怎么固定下来。配了身份的机器完全不受影响(连 -c 都不注入,作者就是你配置里的那个)。
Windows 只读目标会让覆盖失败。 copyFileSync 会把源文件的只读属性带到目标,而 Windows 的 CopyFileW 在目标已存在且带 ReadOnly 时直接返回 ERROR_ACCESS_DENIED。两者叠加的结果是「第一次采集成功,之后每次都失败」。处理:拷前清掉目标只读位;仍失败则删掉目标重来;拷后保持可写。(实测:修复前仓库里有 108 个只读文件,修复后为 0,采集 0 跳过。)
单个文件失败的不该炸掉整次同步。 插件跑在 DSH 宿主进程内,而会话与附件正在被该进程写入,出现 EBUSY / EPERM 是常态。现在每个文件独立 try/catch,失败记入 skipped 并在面板日志里列出,其余文件照常同步。
开发与测试
- 零 npm 依赖:只用
node:内置模块。宿主 loader 在插件未导出Configschema 时会把config原样透传(if (!runtime.Config) return config),所以这里不引入 schemastery。 - Web 半边用
react.createElement手写,只require('react'),不依赖任何@deepseek-ai/*客户端包。 - 宿主导出:
name/inject = ['webServer']/apply(ctx, config)。 - Web 半导出:
inject = ['slots']/apply(ctx),向settings.section注册一页。
测试:同步引擎 120 个用例全部通过(node test/test-sync-engine.mjs);设置页客户端 37 个用例全部通过(node test/test-client.mjs)。
本包没有配置 scripts.test;测试入口为六个文件:
node test/test-sync-engine.mjs # 同步引擎:白名单 / 采集 / 还原 / 差异 / 拒绝闸
node test/test-client.mjs # Web 半边渲染与文案断言
node test/client.test.mjs # Web 半边装载冒烟(node:test)
node test/commit-identity.test.mjs # 没有 git 身份时的提交回退(真实临时仓库 + 真实提交)
node test/test-push-retry.mjs # 补推语义(真实远端)
node test/test-remote-merge.mjs # 远端分叉时的快进 / rebase(真实远端)
直接
node <测试文件>即可,不要用node --test test/:测试运行器会派生子进程并捕获管道输出,在受限沙箱里会以EPERM失败。后三个用例(
commit-identity.test.mjs/test-push-retry.mjs/test-remote-merge.mjs)会 spawn 子进程调git,在受限沙箱里以spawn EPERM失败——这是环境限制,不是代码问题;请在正常终端里跑它们。
与聚合包的关系
本包是
@fish-under-sea/dsh-fish聚合包的成员之一;单独安装只影响这一项。
许可
MIT © Fish-under-sea