- ホーム
- プラグイン
- 開発とプラグインツール
- dsh-sync
dsh-sync
huangfuren/dsh-sync
Move an entire dsh installation to another machine and check it item by item. Three layers go across: the declaration layer (settings, credentials, plugin declarations and vendored link: sources), the state layer (sessions, storages, skills, attachments) and, optionally, the workspace project code. Sessions are rewritten at the zstd header frame only, workspace paths are remapped through an explicit path mapping, and at the end 14 checks (I1–I13 plus I6b) report PASS / FAIL / WARN. A LAN route pairs two machines and pulls the bundle over directly; merge mode diffs a bundle against a machine that has already been in use and stops before writing anything while conflicts are undecided.
インストール
dsh plugin --profile web add github:huangfuren/dsh-syncREADME
dsh-sync
中文 | English
把整台机器的 dsh 状态搬到另一台机器 —— 换机后新机不只是"能跑",而是跟旧机一样: 同样的设置、同样的插件、同样的历史会话、同样的工作区列表、同样的 skills 链接。
- 三层一起走:声明层(设置 / 凭据 / 插件源码)→ 状态层(会话 / 工作区记录 / skills / 附件)→ 可选的工作区代码。
- 会话改不坏:zstd 多帧日志只重写首帧里的
cwd,事件区逐字节原样保留。 - 落地就核对:15 项检查(I1–I14 另含 I6b)逐条给 PASS / FAIL / WARN,不是"应该没问题"。
- 能再迁一次:
merge=true先出分歧清单,未决议就停在写盘之前,不静默盖掉目标机这期间的新东西。
两条路:手动搬 bundle(导出 → 拷 → 落地 → 核对),或局域网直传(配对后一条命令拉过去、自动落地并核验,不用 U 盘也不用网盘)。
术语(全文与工具输出统一按此方向):源机 = 旧机 —— 有历史、要搬出去的那台,跑
dshsync_export/dshsync_lan_host,全程只读;目标机 = 新机 —— 刚装好、要变成跟旧机一样的 那台,跑dshsync_apply/dshsync_lan_pull/dshsync_verify。「source」永远不指「正在配置的这台」。方向不靠插件猜:在哪台跑导出,哪台就是源机。搞反的后果与拦截见 文末折叠块「先确认哪台是新机」。
安装
dsh plugin --profile web add github:huangfuren/dsh-sync
把 web 换成你实际使用的 profile(桌面端是 desktop)。新旧两台机器都要装 —— 发送端与落地端
都在这个包里。装完重启 dsh:8 个工具与 3 条同源路由都在启动时注册,不重启既看不见也调不到。
也可以在插件市场(dsh-market)里搜 dsh-sync,从详情页一键安装。
要求:node ≥ 22.19(要有内建 zstd);走局域网直传时两台机器需在同一网段。
快速开始
三步:旧机导出 → 搬过去 → 新机落地并核对。
1. 旧机:导出
dshsync_export(profile="*", includeWorkspaces=true, includeCredentials=true,
credentialPassphrase="<你记得住的口令>", preview=true)
先 preview=true 看一眼(只扫描、不写文件),确认清单后再去掉它真跑。预览会列出工作区候选
(路径 + 体积 + 是否含 git),按名字勾选:
dshsync_export(profile="*", includeWorkspaces=true, workspaces=["dsh-chat","dsh-plugin"])
dshsync_export(profile="*", includeWorkspaces=true, workspaces=["*"]) # 全选
不传 workspaces 就是全选所有源机上存在的候选;includeGit=false 可丢掉 .git 换体积。
产出 dsh-sync-bundle/,目录结构见文末折叠块「bundle 里有什么」。源机全程只读。
2. 搬过去
A. 局域网直传(推荐) —— 不占 U 盘、不过网盘,两台机器在同一网络下走完。
源机导出完成后起一个发送端:
dshsync_lan_host() # bundleDir 缺省取配置里的「输出目录」(= 导出时的落点)
dshsync_lan_host(bundleDir="D:/其它") # 也可以显式指定
它打印 6 位配对码和进度页地址(http://<源机IP>:38473/),然后立即返回 —— 服务在后台跑,
对话不会被卡十几分钟。
新机器上两种发起方式,任选:
- 卡片里点(推荐):设置 → 内置插件 → dsh-sync 迁移 → 局域网设备(展开卡片时已经广播过一次, 没找到再点「扫描」全量找)→ 点选一台 → 开始迁移(填配对码、收到哪、项目代码落到)→ 点「开始迁移」。 之后卡片里直接看进度与结果,完成后还会列出落地后的下一步(最重要的一条是重启 dsh)。
- 对话里说:先
dshsync_lan_discover()列出开着发送端的机器,再dshsync_lan_pull(deviceId="1", code="123456", destDir="...", workspaceRoot="E:/code")。
两条路走的是同一份执行逻辑,负责:配对 → 等你在源机页面上点「接受」→ 逐文件接收并核对 sha256 →
自动 apply → 自动 verify。dshsync_lan_status(jobId="...") 随时查。
中断了直接重跑同一条命令,已收到且哈希一致的文件自动跳过。
B. 手动拷 —— 整个文件夹拷到新电脑(U 盘 / 网盘 / 局域网共享都行)。
用 dsh-localsend 的通道时,目录树走 localsend_smb_push;它的打包器会跳过软链,本插件的
links.json 机制正是为此设计,不受影响。
bundle 自带
apply.ps1/apply.sh,目标机不装 dsh-sync 也能落地声明层与状态层(含整包校验、 备份、回滚),但不解包工作区代码、也不改写会话 —— 改写会话需要 zstd 帧级读写,脚本里没有现成能力。 要全功能请用工具;两者共用同一套映射口径,不会"脚本改一种、工具改另一种"。
3. 新机:落地
bundle 里带了工作区代码时,一个 workspaceRoot 就够了 —— 这是换机推荐的用法:
dshsync_apply(bundleDir="D:/incoming/dsh-sync-bundle",
workspaceRoot="E:/code", # 你在新机器上选的项目根目录
dryRun=true) # 先预演
会话 cwd、工作区路径、skills 软链会自动改到 workspaceRoot/<原名> 下,不用再写映射。
预演会逐个打印「哪个工作区 → 落到哪」「哪些会话的 cwd 会被改成什么」,确认后再 dryRun=false。
只搬 .dsh、没带工作区代码时,路径不一致就给映射,否则会话指向不存在的目录:
dshsync_apply(bundleDir="D:/incoming/dsh-sync-bundle",
mappings=[{"from":"D:/deepseek","to":"E:/code"}],
dryRun=true)
映射规则:按顺序应用、首条命中即返回、命中必须落在路径段边界
(D:/deep 不会碰到 D:/deepseek/...)。不要自创"最长前缀优先" —— 多条规则重叠时会算出
与你书写顺序不同的结果。
目标机的 $DSH_HOME 可以放在任意位置。 与源机不同时,内部软链(指向 $DSH_HOME 里的那些)
会从源前缀重定位到目标前缀、保留相对路径,并报告数量。用环境变量 DSH_HOME 指定,或在插件配置里填 dshHome。
落地之后:
- 完全退出 dsh(桌面版从托盘退出)→ 重开,宿主按新的
package.json重建依赖; - 重开后插件列表若是空的,去插件管理器点一次「重新安装 / 修复」再重启;
- 先别在界面里改设置 —— 免得宿主把内存里的旧配置写回,盖掉刚落地的文件;
- 想确认搬对了:
dshsync_verify(bundleDir="D:/incoming/dsh-sync-bundle"), 14 项检查逐条给结论,详见文末折叠块「核对项」。
FAIL 的含义是:这部分状态 dsh 看不到。不是"可能有点问题",是确定对不上。
再迁一次(第二次及以后)
第一次迁移的目标机是空的,覆盖写怎么写都无所谓。第二次不是 —— 目标机已经用了一段时间, 它自己装过插件、注册过工作区、改过设置,而这些不在源机的快照里,一盖就没了(备份目录里有, 但没人会去翻)。
第二次开始请加 merge=true:它先把 bundle 与本机现状比一遍,只要还有一项没决定就停在写盘之前,
本机一个字节都不会动:
dshsync_apply(bundleDir="D:/incoming/dsh-sync-bundle", workspaceRoot="E:/code", merge=true)
它会列出四类分歧:
| 类别 | 什么情况算分歧 |
|---|---|
plugins | 本机声明里有、bundle 里没有(落地后从声明中消失,源码还躺在 plugins/ 里但没人加载它);或两边都有但版本不同 |
workspaces | 本机注册过、bundle 里没有的工作区记录 |
settings | 两边 settings.yaml 不一样(不做三方合并:锚点、注释、键序在不知道 schema 的情况下并对不了,合坏的结果比任一边都糟) |
sessions | 汇总一条:多少个会话两边都有但日志份数不同(会话可以有几百个,逐条列等于没列) |
然后照着清单填 resolutions 再跑一次(其他参数不变):
dshsync_apply(..., merge=true, resolutions={
plugins: {"desktop/dsh-outline": "keep-target"}, # 留本机装的
workspaces: {"w-new": "keep-target"},
settings: "target", # 保留本机设置
sessions: "append-missing", # 只补本机缺的日志
})
keep-target= 留本机的;take-source= 跟源机走(本机这项就没了)。settings是target/source二选一。sessions统一给一个策略:skip(不动)/append-missing(只补本机缺的那几份日志, 已有的一个字节都不碰,包括本机自己追加的)/prefer-source(以源机为准,会覆盖)/prefer-target(同 skip)。- 没填的项没有默认值 —— 这就是 merge 的意义,宁可让你多看一眼,也不能替你猜。
会话一律是文件级操作:绝不拆帧再重组,否则"历史不可能被破坏"这条保证就不成立了。 目标机还是空的(第一次迁移)不用开 merge,没有东西可冲突,直接覆盖更简单。
工具
| 工具 | 在哪跑 | 说明 |
|---|---|---|
dshsync_export | 源机 | 导出 bundle。源机只读(只复制,从不改配置) |
dshsync_apply | 目标机 | 落地 + 工作区解包 + 路径重映射 + 会话改写 + 软链重建。支持 dryRun;merge=true 用于第二次及以后的迁移(先出分歧清单,未决即停)。每一笔变动都记进落地账本 |
dshsync_undo | 目标机 | 后悔药:按落地账本整体撤销最近一次 apply —— 新建的删、覆盖的从备份还原、软链还原旧指向、分组名改回去。落地之后内容变过的文件自动跳过并逐条报告。支持 dryRun |
dshsync_verify | 目标机 | 逐条核对一致性 |
dshsync_lan_host | 源机 | 起局域网发送端,给出配对码与进度页。bundleDir 缺省取配置的输出目录。action="renew" 换码、action="stop" 关停 |
dshsync_lan_discover | 目标机 | 找局域网里开着的发送端。UDP 广播优先,逐地址扫描兜底。结果带 id 与序号,会被记住 |
dshsync_lan_pull | 目标机 | 配对 → 等确认 → 接收 → 落地 → 核验,一条命令。可用 deviceId 指定(或用卡片里选中的那台)。后台执行,立即返回 jobId |
dshsync_lan_status | 两端 | 查进度、速率、剩余时间,以及落地与核验的最终结果 |
dshsync_remap | 本机 | 迁移完成后目录又动了的补救:就地重映射五个落点(会话首帧 cwd → 分组目录名 → workspace.json → projcache 两层 → 软链目标),不拷任何数据。支持 dryRun |
后悔了?dshsync_undo
apply 把每一笔变动(新建 / 覆盖 / 软链 / 分组改名)记进备份目录里的落地账本(journal.json)。
dshsync_undo 倒序回放:新建的删掉、覆盖的从备份还原、软链还原旧指向、分组名改回去。
安全网是内容指纹:每笔操作记着「apply 写完那一刻」的 sha256,当前内容对不上的文件(典型是
你已经继续用 dsh、会话追加了新帧)绝不碰,逐条报告 —— 撤销永远不会拿旧快照盖掉你新产生的数据。
所以 apply 刚跑完就撤销是干净的全量回退;用过之后再撤销是"部分撤销 + 精确跳过清单"。
缺省撤最近一次(回执定位);更早的备份在存在更新 apply 时拒绝撤销 —— 一层一层退。
dryRun=true 先看。删除备份目录 = 宣布这次迁移定稿,账本随它一起消失。
deviceId 认 id / 1 基序号 / 别名 / IP 四种写法;host 与 deviceId 都不给时,用卡片里选中的那台。
一个都没选过就报错并说清三条路,不会替你挑 —— 把 bundle 传给错的一方不可挽回。唯一的例外是
「只发现一台、且你还没选过」,这时自动选中并在界面上标出来;已经选过就绝不自动改选。
配置(settings 命名空间 sync)
设置在 设置 → 内置插件 → dsh-sync 迁移 这张卡片里改。卡片上半是下面标了「可编辑」的四项, 下半两块:只读的本机状态(会话 / 工作区 / skills 数量、默认输出目录里的 bundle、局域网发送端与任务), 以及局域网设备 + 开始迁移(展开即广播一次 → 点选一台 → 填配对码与两个路径 → 点开始 → 看进度、 结果与「接下来」)。整趟迁移可以在设置页里完成,不用回对话。
| 键 | 默认 | 说明 |
|---|---|---|
dshHome | $DSH_HOME 或 ~/.dsh | harness home |
profile | * | 默认导出的 profile;"*" = 全部。可编辑 |
outDir | 临时目录 dsh-sync-bundle | bundle 输出目录。可编辑 |
includePlugins | true | 是否 vendoring link: 插件源码。可编辑 |
includeState | true | 是否带 sessions / storages / skills(关掉就只是"能用的空环境")。可编辑 |
includeCredentials | false | 是否带 .credentials.yaml(真实密钥)。故意不可编辑,只能按次传参 |
credentialPassphrase | 空 | ≥6 位:把 settings + 凭据加密搬运(推荐)。故意不可编辑,只能按次传参 |
includeWorkspaces | false | 是否把会话的项目目录一起打包。换机自用建议打开 |
includeGit | true | 打包工作区时保留 .git(完整仓库);关掉只为省体积 |
workspaceRoot | 空 | 目标机上的项目根目录,落地时用它决定一切落位 |
后两项(includeCredentials / credentialPassphrase)不在卡片里是有意的:前者一键把明文凭据打进
bundle,后者若进表单就会明文落在 settings.yaml 里。它们仍然可以用工具参数按次传入。
密钥策略(重要)
三条路线,按推荐度排序:
- 加密搬运(推荐):给
credentialPassphrase。settings 与凭据以 AES-256-GCM 密文进 bundle (scrypt 派生密钥,每包随机 salt / iv,带认证标签)。口令错或文件被改都会在解密时直接失败, 不会解出一份错误明文。明文密钥既不丢也不裸奔,换机后不用挨个重填插件 key。 - 不带凭据(默认):
.credentials.yaml不导出;settings 里的明文密钥行会被剔除并告警。 - 明文携带(不推荐):
includeCredentials=true且不给口令 —— bundle 含裸密钥, 只适用于可信信道,用完立刻删。
补充规则:明文密钥判据是 *Env 字段必须是合法引用名(^[A-Za-z_][A-Za-z0-9_]*$),否则视为填了明文;
key/token/secret/password 类字段值较长也会告警。工具只输出脱敏预览(前缀 + 长度),从不回显完整密钥。
已知限制
- 目标机需自重建依赖:
node_modules不随行。落地后要在目标机装一次依赖 (桌面版:退出重开,宿主自动重建;web:pnpm install或dsh plugin add),否则 dsh 起不来 —— verify 的 I9 会把这件事报成 WARN,不会让你以为完事了。 - 目标机 node 需 ≥ 22.15 才有内建 zstd;否则会话不迁移(会明确告警,不静默跳过)。
- 工作区代码默认不进 bundle:不打开
includeWorkspaces时会话能列出但打不开(I8 逐个报出)。 打开后按根打包(默认带.git、排除node_modules),体积会从百 MB 级涨到 GB 级,先preview=true看候选再决定。 落位目录已存在且非空时会拒绝覆盖,要显式overwriteWorkspaces=true。 - 外部软链需要目标机有对应路径:
skills/下指向$DSH_HOME外部的链接,目标机没有那个目录时 链接会重建失败并如实报告(I6b 单独报),不会偷改指向。 - 宿主版本跨
major.minor时插件可能被启动时拒装,需在插件管理器逐个授予精确版本豁免。 版本一个都取不到时(既没有dsh-runtimes也没有dsh命令)如实报「需人工确认」,不猜。 github:依赖在目标机首次add可能因 pnpm ≥10 的构建门被拒;不搬pnpm-lock.yaml(含源机绝对link:路径)。link:依赖已自动改写为指向$DSH_HOME/plugins/<name>的相对路径,换位置也成立; 只有未 vendoring 的link:(源码缺失或includePlugins=false)才保留源机路径,并在报告里点出来。settings.yaml不存在时会静默通过:本机就没有该文件(只有历史.bak),导出不会凭空造一份; 要确认设置是否搬了,看导出报告的homeFiles列表。- 局域网进度页是浏览器页面,不是 DSH 界面里的面板:宿主工具接口没有执行期流式通道
(
execute()只在结束时返回一次),所以进度走本机 HTTP + SSE。 - 传输任务的状态在内存里:插件重载(改配置、升级、重启 DSH)后
jobId就查不到了。已收到的文件 留在盘上,重跑一次dshsync_lan_pull会按哈希续传。传输期间别退出 DSH。 - Windows 首次监听会弹防火墙提示,要选「专用网络」允许;选公用网络或取消,对方就连不上(报错会指到这一条)。
UDP 38472 被拦时
dshsync_lan_discover会自动改成逐地址扫描,只是慢两三秒。
开发与测试
npm run check # node --check 全部源文件
npm test # 247 个测试
npm run verify
测试全部使用合成数据(自建 zstd 多帧文件 + 临时 dshHome),不读真实 $DSH_HOME、不访问外部网络;
局域网那组起真实 HTTP 服务,但只走 127.0.0.1 回环。负向用例证明校验真的会报错
(篡改 bundle → apply 拒绝执行且零写入;破坏 cwd → verify 报 FAIL;未获确认就开流 → 403;
帧里给 ../../.ssh/... → 拒绝)。
License
MIT。实现为原创 JavaScript,零第三方运行时依赖(仅 Node 内置模块)。设计过程中参考过几个公开的 MIT 项目, 参考的是设计结论而非源码,详见 THIRD_PARTY_NOTICES.md。
深入:设计与细节
以下是「为什么要这么设计」与逐条细节。只用不改的人不必读;出问题时对着这里查。
三层结构
分三层,缺一层都不是"一致":
| 层 | 内容 | 缺了会怎样 |
|---|---|---|
| 声明层 | settings.yaml、凭据(可选)、home 级 cordis.patch.yml、AGENTS.md、每个 profile 的 package.json / cordis.patch.yml / cordis.yml / pnpm-workspace.yaml / .dsh-market/state.json(插件停用清单、自建分组、市场区域)、每个 link: 插件的源码 | dsh 起不来 / 插件全丢 / 插件停用与分组状态回到默认 / agent 行为与旧机不同 |
| 状态层 | sessions/、storages/、skills/、attachments/、background/、dsh-market/、llm-deepseek/ | dsh 能跑,但历史会话全空、工作区列表空、skills 空、附件与背景图全没 |
| 工作区层 | 会话真正打开的那些项目目录(includeWorkspaces,默认不带) | 会话能列出来、点开是空目录 |
其中 profiles/<n>/package.json 是「插件情况」的真相:它同时决定装什么(dependencies)与
实际加载什么(dsh.profile.bundles)。它必须带走,否则插件源码躺在 plugins/ 里也没有东西去加载;
而且不能照抄 —— 里面的 link: 是源机绝对路径,导出时会改写成 link:../../plugins/<name>,
指向 bundle 里 vendored 的那份源码,这样目标机换任何位置都成立。
可重建的产物不搬(搬了反而是错的):node_modules(按 package.json 重装)、
dsh-runtimes(随宿主安装包重建)、cache、logs、.anonymous-user-id(机器身份)。
为什么不能直接复制 `~/.dsh`
| 内容 | 能否直接搬 | 原因 |
|---|---|---|
settings.yaml | ⚠️ 可搬但可能含明文密钥 | 配置本应只带引用不带密钥;含明文时默认剔除(或整份加密) |
.credentials.yaml | ⚠️ 可搬但是真实密钥 | 默认不带;带上时应加密传输 |
profiles/*/package.json | ✅ 必须搬,但要改写 | 它决定装什么(dependencies)与加载什么(dsh.profile.bundles);link: 是绝对路径,导出时改写成指向 vendored 副本 |
profiles/*/node_modules | ❌ 绝不能搬 | 含原生二进制(pty.node / conpty.node),绑 OS / 架构 / Node ABI |
sessions/ | ✅ 可以搬,但要改写 | 目录名编码了工作区路径,权威 cwd 在会话首帧 header 里,可改写 |
storages/workspace.json | ✅ 可以搬,但要改写 | 工作区记录不会自行重建;不改写则历史会话全变孤立 |
skills/ | ✅ 可以搬,按清单重建 | 多为指向 $DSH_HOME 外部的软链,不会自动重建 |
attachments/ background/ dsh-market/ llm-deepseek/ AGENTS.md | ✅ 可搬 | 用户自己的东西(附件库 / 背景图 / 市场状态 / 全局指令),合计不到 10 MB |
.anonymous-user-id | ❌ 不能 | 机器身份:目标机该有自己的,不该继承旧机的 |
bundle 里有什么
dsh-sync-bundle/
manifest.json # 格式版本、profile 列表、状态层统计、告警
checksums.json # 全部文件的 sha256
settings.yaml # 或 settings.enc
.credentials.yaml # 或 .credentials.enc;需 includeCredentials
AGENTS.md # $DSH_HOME 根下的全局指令
profiles/<name>/ # package.json(link: 已改写) / cordis.patch.yml / cordis.yml /
# .npmrc / pnpm-workspace.yaml / .dsh-market/state.json
plugins/<name>/ # 每个 link: 插件的源码(已排除 node_modules/.git)
state/
sessions/ # 会话文件(真实文件;软链不在此)
storages/ # workspace.json、session_projcache 等
skills/ # 真实文件;软链不在此
attachments/ # 附件库
background/ # 自定义背景图
dsh-market/ # 市场本地状态
llm-deepseek/ # 模型文件索引
links.json # 软链清单:rel → target(迁移时按此重建)
sessions.json # 会话索引摘要
workspaces/<name>/ # 会话真正打开的项目目录(需 includeWorkspaces;含 .git,不含 node_modules)
tools/dsync-helper.mjs
apply.ps1 apply.sh # 零依赖兜底脚本
APPLY.md
软链不复制实体,只记清单。原因是 bundle 一旦被 zip / 网盘 / 聊天软件搬运,符号链接会被摊平或 直接丢弃;记清单再重建,传输方式再粗暴也不会丢信息。
会话是怎么被改写的
一个会话长这样:sessions/<编码后的cwd>/<session-id>/session.jsonl.zstd
日志文件名不止一个,宿主随版本演进留下了三种形态,同一次会话可能同时存在多份:
| 文件名 | header 特征 | 本机实测 |
|---|---|---|
session.jsonl.zstd | 无 version 字段 | 33 份 |
session.v3.jsonl.zstd | version: 3 | 45 份 |
session.v4.jsonl.zstd | version: 4 | 25 份 |
三者结构完全相同(zstd 多帧串接),只是文件名与 header 的 version 不同;同一次会话的多份日志
cwd 一致。所以全部变体一起改写,只认其中一种会漏掉大量会话。
它不是普通压缩文件,而是 zstd 多帧串接:首帧是一行 JSON header(含权威 cwd),其后每帧是一行事件。
所以改写只替换首帧,其余帧逐字节原样保留 —— 历史内容不可能被改坏,也无法被"悄悄重压"。
必须同时满足两个不变量,DSH 才能找到会话:① header.cwd 等于目标机上的真实工作区路径;
② 目录名等于 encode(header.cwd)。目录名编码是有损的(: \ / - 都折叠成 -),
所以只能从 header 正向计算,绝不反向解析目录名。storages/workspace.json 里也存了每个工作区的
path,它必须与会话 cwd 逐字一致 —— 因此同一套映射同时作用于两者。
工作区代码怎么搬
会话的 cwd 指向的是 $DSH_HOME 之外的项目目录(D://deepseek//dsh-chat 这种)。
只搬 .dsh 时,这些会话在目标机上能列出来,点开是空的 —— 换机不算成功。
打开 includeWorkspaces 后按根打包:
| 事实 | 本机实测 |
|---|---|
| 会话 cwd 条数 | 8 |
| 去嵌套后的根 | 5(D://a 与 D://a//b 只搬一次) |
D://deepseek 整盘 | 5.4G(大部分是 DSH 源码检出与临时目录,与换机无关) |
| 真正被引用的 5 个根 | 净 1.18G |
规则只有三条:
- 按根搬,不按盘搬 —— 候选来自会话 cwd 与
storages/workspace.json两处,去嵌套后才是根。 - 默认带
.git,必排node_modules—— 目标机上仓库是完整的,能git log/git pull; 依赖重装一次即可。容器目录里是多个子仓库(dsh-plugin/mine/下 9 个)时也能认出来。 - 落位只要一个参数 ——
workspaceRoot是你在新机器上选的根目录,目录名保持不变。
会话 cwd、workspace.json 里的工作区路径、skills/ 下指向项目目录的软链,三者会一起改到
workspaceRoot 下。想单独放某个项目就用 workspaceMap={"dsh-chat":"E:/projects/dsh-chat"}。
为什么落位参数必须压过一个:这三处只要有一处对不上,就是「会话在、内容是空的」。所以派生映射排在
手写 mappings 之前;手写规则与已搬工作区重叠时,报告会明确告诉你「这条规则对它不生效」,
而不是让你以为生效了。
先确认哪台是新机
这件事不能靠插件判断:方向完全由「在哪台机器上跑哪个工具」决定 —— 跑 dshsync_export 的那台
就是源机(旧),跑 dshsync_apply 的那台就是目标机(新)。搞反了有两种结局,都不会自然报错:
| 搞反的方式 | 结果 | 现在怎么拦 |
|---|---|---|
| 在新机上跑导出 | bundle 几乎是空的;拿回旧机 apply 会把设置与插件覆盖成默认值,而旧机的会话因为新 bundle 里没有反而留着 → 「历史都在、配置全没」 | 导出与预览都会报「这台机器没有任何会话、也没有工作区记录,看起来像刚装好的空壳」 |
| 在旧机上跑落地 | 用一份旧快照改写自己的会话首帧(cwd 与目录名),不可逆 | apply 检测到「同主机 + 同 $DSH_HOME」直接拒绝,要 allowSameMachine=true 才继续 |
判断哪台是新机,三个信号,按不易出错的顺序:
- 项目代码在不在 —— 最硬的一条。旧机有那批项目目录,新机刚装完是没有的。
dshsync_export(preview=true)的清单 —— 它会列出会话数、涉及的工作区路径、profile 里自装的 插件数。有历史的那个就是旧机。本机实测基准:84 个会话 / 8 个工作区路径 / 11 个 vendored 插件。$DSH_HOME体积 —— 旧机 630 MB 左右;刚装好的新机通常不到 100 MB。
导出时 bundle 会记下源机的 主机名 + 用户名 + DSH_HOME,落地时据此核对方向并打印一行:
✓ 方向:源机 DESKTOP-OLD/jichen.huang → 本机 DESKTOP-NEW/jichen.huang
同机但 DSH_HOME 不同(就地预演、一台机器上两套环境)会照常放行并注明;同机同 DSH_HOME 会被拦下。
0.6.0 之前导出的 bundle 没有这个字段,就如实说「判不了方向」,而不是假装通过。
记录里只有这三项,不含 MAC、机器 GUID、磁盘序列号 —— 那些是硬件指纹,搬配置的工具有意不碰。
桌面版与 web 宿主
同一个插件、同样八个工具:这里没有客户端半,也不需要构建链,所以桌面版与 web 宿主的加载路径完全一样。 真正不同的是下面两件事,两件都踩过:
① 你在用的 profile 不一定是 web。 桌面版加载的是 profiles/desktop;只装进 profiles/web 的
插件在桌面版里就是不存在(文件都在、界面里没有)。两处对应的处理:本插件的 profile 默认值是 *
(导全部),不是 web,不传也不会漏掉你真正在用的那份;插件本身则要装进你实际运行的那个 profile,
检查一条命令即可:
dsh plugin --profile desktop list # 桌面版自带 CLI,列出该 profile 的实际依赖
② 桌面版机器上没有 PATH 上的 pnpm 和 dsh。 宿主内部自带一份(实测 pnpm 11.28.5),所以落地后
不要照着 web 的习惯去命令行跑 pnpm install —— 落好 profile 文件后完全退出桌面版(托盘退出)再重开,
宿主会按新的 package.json 重建依赖;重开后插件列表若是空的,去插件管理器点一次「重新安装 / 修复」再重启;
落地后先别在界面里改设置,直接退出重开。
③ 宿主版本从装好的应用里读,不从 dsh-runtimes 快照读。 那份快照是安装时写的,应用升级不会
刷新它:本机实测应用已是 0.2.1-rc.2(内置 node 24.21.0 / pnpm 11.28.5),快照还停在 0.2.0-rc.2 /
pnpm 11.7.0,时间停在十天前。拿陈旧快照比对,会让「换到刚装好的新机」这种最常见场景误报版本不一致;
两边都快照陈旧时,又会把真的跨代差异抹平 —— 而那正是「插件装上了但不加载」的前兆。现在优先读正在运行的
桌面版自己的 resources/app.asar/dsh/desktop-runtime.json,读不到才回退到快照。
核对项
dshsync_verify(bundleDir="D:/incoming/dsh-sync-bundle")
逐条核对 15 项检查(I1–I14,其中 I6 另含子项 I6b),每项给 PASS / FAIL / WARN:
| ID | 核对内容 |
|---|---|
| I1 | bundle 自身 sha256 全部对得上 |
| I2 | 会话目录数与日志份数都与源机一致(后者能抓到「目录在、某一版日志丢了」) |
| I3 | 每个会话目录名 = 其 header cwd 的编码 |
| I4 | 会话容器结构完好(header 可解、帧链完整);源机自带的损坏文件按原样复制,只判 WARN |
| I5 | workspace.json 路径与会话 cwd 逐字对齐(源机原有的孤立会话只算 WARN,迁移新增的孤立才算 FAIL) |
| I6 | 软链按清单重建(内部必须成功且可解引用;外部允许缺路径但必须可见) |
| I6b | I6 的子检查:指向 $DSH_HOME 之外的软链在目标机缺对应路径时单独报 WARN(最多列 5 个) |
| I7 | vendored 插件源码在 bundle 中完好 |
| I8 | 会话 cwd 指向的工作区目录在目标机真的存在(打开 includeWorkspaces 后应当全绿;不打开时缺的会逐个列出) |
| I9 | profile 的 link: 依赖在目标机可解析;node_modules 是否已装 |
| I10 | 状态层 7 个部分全部落地,文件数与源机一致 |
| I11 | 宿主运行时一致(dsh 版本 / 平台 / 架构 / node 大版本);跨 major.minor 时插件会被启动时拒装 |
| I12 | 工作区代码真的落地了(传 workspaceRoot 才核对;不传只报 WARN,不猜) |
| I13 | 落地后回写:apply 写入的 settings / 凭据 / workspace.json 自落地起未被宿主写回盖掉(依据落地回执 .dsh-sync-apply-receipt.json;宿主原样重写不算,内容变了才 FAIL) |
| I14 | projcache 身份残留:session_projcache 两层(dump + 逐会话记录)里不再有指向源机 home 的日志路径 —— 残留即宿主报 corrupt session log 并整棵拒绝激活 workspaceRegistry |
I11 判 WARN 而不是 FAIL:换机顺带升级 dsh 是正当操作,不该被工具拦下。但它挡的是最阴的一种失败 ——
宿主启动时做 peer 兼容性裁决,版本对不上时插件是 stays installed but startup denies it:
文件都在、sha256 全对、全部检查全绿,界面里插件却是空的。这条提示不给,用户会以为是 dsh-sync 搬丢了东西。
安全设计
| 机制 | 作用 |
|---|---|
| 只读导出 | 导出只复制、从不改源机配置 |
| 整包 sha256 校验 | apply 前先验;传坏就在写第一个字节之前停手 |
| 覆盖前备份 | 逐个备份到 $DSH_HOME/.dsh-sync-backup-<时间戳>/,保留相对路径 |
| 原子换入 | 会话改写走 tmp + rename,中途失败不会留下半个文件 |
| 有损操作要显式放行 | 帧链不完整的会话拒绝改写(可能是可自愈的撕裂尾帧,硬写会弄坏) |
| 重跑幂等 | 会话落地前先按映射算落点:目标机已是迁移后状态的会话跳过复制(顺带保住迁移后新增的聊天帧),重跑不会留下「旧目录名 + 新 cwd」的孤儿分组 |
| 回写检测 | apply 记下 settings / 凭据 / workspace.json 落地内容的 sha256:落地结束时当场复核(抓并发回写),并写成落地回执供 dshsync_verify 的 I13 随时复核(抓「落地之后、重启之前」宿主把内存旧配置写回) |
局域网直传这一层额外加了五道,理由都是「同网段的人不是默认可信」:
| 机制 | 作用 |
|---|---|
| 接受只能由源机本机点 | /decide 校验请求源地址是否属于本机网卡;别人从局域网打开页面只拿到只读视图 |
| 配对码不发到远端 | 进度页按「本地 / 远端」两种形态渲染,远端快照里连 pair.code 字段都没有 |
| 码一次性 + 5 分钟 + 5 次试错锁定 | 6 位数字 5 次机会,穷举概率 5e-6;锁定后连正确的码也不放行,必须换码 |
| 只接受私有网段来源 | 10/8、172.16/12、192.168/16、169.254/16 与回环之外一律 403 —— 挡的是「机器直接暴露在公网」的配置事故 |
| 逐文件校验后才落地 | 写 *.dshsync-part,sha256 与长度都对上才 rename;帧里的路径一律 resolve 后确认没越出目标目录 |
配对码的「一次性」有一处刻意豁免:同一台机器可以复用同一个码(不同来源仍拒绝)。理由是实测发现的: 码在第一次配对后即标记已用,而「传一半断了、重跑接着传」必须再配一次对 —— 不给同源豁免,续传在用户视角 就是走不通的。旁人的码一定来自另一个地址,仍然被拒。
实测:2516 个文件 / 172 MB 走回环 11 秒(15.6 MB/s),续传实传 0 字节。
为什么设置卡片要自己写
宿主从插件的 Config schema 自动生成设置页这件事,0.2.x 的 dsh-settings 写得很清楚:
autoGenerate 默认开,但「no shipped client does so yet」 —— 没有任何客户端实现自动页。
所以只导出 Config 的插件在设置里不会出现任何卡片,只能自己注册一个 settings.plugins.tab。
本包因此带一个手写的 client.js(单文件 IIFE,不用构建链):配置走 configForms 共享表单,
只读状态走宿主已认证的 /api/dsh-sync/overview。
状态通道挂不上(旧宿主没有 connection.fetch.register)时,卡片退化成「只有配置区」;
配置表单不可用时,卡片退化成「只读状态」。两边互不连累。