dsh-profile-sync
Manifest valid★ 5Plugin migration between profiles: safely migrate the plugin set from the web version (web) to the desktop (desktop) — compute differences, pre-check, generate scripts to run after exit, verify after restart
dsh-profile-sync
在 dsh 的各个 profile 之间迁移插件。默认方向是 网页版 web → 桌面版 desktop。
它的定位很窄:自己不装任何东西。它只做四件事 —— 算差异、预检、写一个「退出后执行的脚本」、
下次启动后核对是否真的落地;真正的安装交给官方通道(桌面端走应用内管理器,其它 profile 走 dsh plugin)。
装完的效果:左侧栏多一个「插件迁移」面板 —— 选源/目标 → 算差异 → 生成执行脚本 → 核对上次迁移。
两条写入路径(以及为什么不是"一键")
dsh 里改一个 profile 有两条合法路径,取决于那个 profile 归谁管:
| 目标 profile | 谁有权写 | 走哪条 | 应用时机 |
|---|---|---|---|
desktop(归桌面应用管) | 应用内的官方插件管理器 | installBundle(cordis 服务 pluginManager,就是「设置 → 插件」用的那个) | 当场生效,不用退出 |
web / headless(归 CLI 管) | dsh CLI | dsh plugin --profile <p> install,或用本插件生成的离线脚本 | 目标端退出后 |
桌面端 profile 禁止用 CLI 改 —— 这是硬拦,不是"要先退出"。
@deepseek-ai/dsh/lib/bin.js 里有一条按名字拦住一切 CLI 调用的守卫:
function rejectElectronProfile(program, profile) {
if (profile.toLowerCase() === 'desktop')
program.error('error: profile "desktop" is managed exclusively by the Electron application')
}
它对所有 CLI 调用生效,连 dsh --profile desktop --dump-config 都会被拒
(dshmarket 源码里也留着同一句注释:Never fall back to dsh plugin --profile desktop: that CLI is forbidden.)。
所以桌面端这一侧,本插件只调用官方管理器:改 dependencies、注册 dsh.profile.bundles、
跑兼容性校验、失败回滚都由它负责 —— 本插件不自己写 manifest。命令行的 plan / apply
是给 web / headless 这类由 CLI 拥有的 profile 用的。
要搬的不是 node_modules,是五样东西
少一样就会静默失效:
| 要搬的 | 漏掉 / 搬错的后果 |
|---|---|
package.json 的 dependencies | 包装不上,加载失败 |
package.json 的 dsh.profile.bundles | 包装了但永远不会加载,而且不报错 —— 最阴的一种失败 |
pnpm-workspace.yaml 的 allowBuilds | pnpm 静默跳过原生构建(比如 node-pty 的 conpty.dll 铺不出来) |
compatibility.json 里的精确版本豁免 | 目标核心不认识该插件时,只给你一句看不懂的拒绝 |
cordis.patch.yml | 故意不同步,见下 |
故意不同步的那一份:cordis.patch.yml
里面装的是实例配置,不是插件配置:端口(web 绑 3080、桌面端绑 19387)、宠物坐标、
remote-web-ui 的 Tailscale 地址。整份抄过去会直接端口冲突。
所以本插件只把差异列出来给你看,一个字节都不写 —— 这是设计,不是没做完。
安装
桌面端不用退出 —— 恰恰相反,它必须在运行(要调用的那个官方管理器就在应用里面):
1. 打开 DeepSeek Harness(正在运行就对了)
2. 双击 install.cmd
3. 刷新页面 —— 左侧栏出现「插件迁移」
install.cmd 做的事:装前自检 → 确认官方的 /api/plugin-manager 端点在跑 →
把 link:<本目录> 提交给应用内的官方管理器 → 轮询到包真的出现在已装列表里才算成功。
宿主半装完通过 HMR 当场生效(工具立即可用);左侧栏面板刷新页面即可。
需要 Node.js 20+ 在 PATH 上。install.cmd / sync-plan.cmd / 生成的 apply.cmd
都会自己找 node,找不到就明确报错退出 —— 不会退回去执行桌面端 exe(原因见「修过的 bug」第 1 条)。
不装插件也能先看效果:bin/plan-cli.mjs 完全独立,不依赖 DSH 在跑 ——
现在就可以双击 sync-plan.cmd 看一份真实的差异报告。
装前自检(node bin/check.mjs)
install.cmd 在调官方通道之前会先跑一次自检:
- 清单不变式:
package.json的name、cordis.patch.yml里的挂载名、客户端 loader 的id三者必须一致;dsh.client.inject必须和客户端代码里的inject一致 (前者写包名、后者写服务名,写混是这个生态的经典错误)。 - 真加载:真的
import一次宿主半和客户端半,确认apply导出了、 客户端半确实按模块加载器契约注册了自己。语法对 ≠ 加载得起来 —— 顶层 import 一个不存在的相对路径就能骗过--check。
挡的是最坏的失败模式:bundle 被写进 dsh.profile.bundles 却加载不起来 → profile 组装失败 →
桌面端连窗口都打不开,而那时候你已经在应用外面了,只能用文本编辑器改 package.json 才救得回来。
自检不通过会拒绝安装(退出码 1),不会硬着头皮往下走。
用法
双击
| 文件 | 作用 |
|---|---|
install.cmd | 装前自检 + 把本插件装进 profile(桌面端要在运行状态) |
sync-plan.cmd | 算差异并生成计划 + apply.cmd |
命令行
# 算差异并落成产物(plans/<时间>/plan.json + plan.txt + apply.cmd)
node bin/plan-cli.mjs plan --source web --target desktop --write
# 只看报告
node bin/plan-cli.mjs plan
# 核对上一次迁移是否真的落地
node bin/plan-cli.mjs status
# 应用计划(会先查目标端是否还在运行)
node bin/apply.mjs --plan "<plans/.../plan.json>"
# 只算不写,看看会改什么
node bin/apply.mjs --plan "<...>" --dry-run
# 退回最近一次快照
node bin/apply.mjs --rollback
# 只做进程检查
node bin/apply.mjs --guard-only --profile desktop
# 装前自检(清单不变式 + 真加载;也可以对别的插件目录用 --dir)
node bin/check.mjs
退出码(方便写脚本串起来):
bin/plan-cli.mjs:
| 码 | 含义 |
|---|---|
| 0 | 计划无阻断 |
| 1 | 用法 / 运行时错误 |
| 3 | 有阻断项(不会生成 apply.cmd) |
bin/check.mjs:0 = 可以装;1 = 不要装。
bin/apply.mjs:
| 码 | 含义 |
|---|---|
| 0 | 成功 |
| 1 | 用法 / 运行时错误 |
| 2 | 目标端还在运行(或被守卫拦下)—— 退出应用后重跑 |
| 3 | 核对不通过(含 manifest-only:manifest 写对了但包没装) |
| 4 | 官方安装通道失败,已自动回滚到快照 |
面板
左侧栏「插件迁移」:选源/目标 → 算差异 → 生成执行脚本 → 核对上次迁移。 显示每个 profile 的端口和插件数,计划里给出「新增 / 变更 / 仅钉法不同 / bundle 新增 / allowBuilds 新增 / 阻断 / 提醒」的分项摘要。
给 agent 的工具
profile_sync,action 取 plan / write / status / profiles。
预检会挡住什么
装之前查这五件事(官方 dsh plugin add 不查第 2、3 条):
- 本机路径型 spec:
link:/file:指的本机路径是否存在、类型对不对。 - cordis entry id 撞车:两个 bundle 插入同一个 loader entry id 时,
cordis 会拒绝启动整棵树,而不是跳过其中一个。官方通道不查这件事,
所以必须在装之前自查 —— 这是
blockers里的entry-id-collision。 只统计insert:块里的 id:patch 里另一类是「给别人的行改配置」,那类不算。 - 没有可加载入口的包:源码签出没有
lib/的包被提升进 bundle 层, 下一次启动就是ERR_MODULE_NOT_FOUND—— 整个 profile 起不来。 - 声明了
dsh.bundle.patch吗:进了bundles列表却没声明的包, 官方只会当普通依赖装(并打印同样的警告),不会成为 profile 层。 - 引擎范围(建议性):只认
>=x.y.z/>x.y.z/^x.y.z/ 裸版本, 并且正确处理预发布号(这个生态全是0.2.0-rc.2;只比数字三元组会把>=0.2.0-rc.3对着0.2.0-rc.2判成满足)。复合范围老实说「不认识」。 权威判断交给官方通道。
钉法不同 ≠ 版本变更
有些 profile 是故意把版本钉成精确号的(例如桌面端 0.11.3),另一些用范围号
(例如网页端 ^0.11.3)。两者指向同一个版本,只是钉的松紧不同。
朴素 diff 会把它当成「变更」,从而覆盖掉目标端那个加固。所以本插件单独归一类
repin,只报告、默认不动。
allowBuilds 连值一起搬,且不会把 false 翻成 true
pnpm-workspace.yaml 的 allowBuilds 里,值本身是有语义的:显式写 false 是
「有意关掉这个包的原生构建脚本」(有的 profile 就是靠这个把 cloudflared / node-pty /
ssh2 / cpu-features 的构建默认关着)。
所以同步时:
- 新增键沿用源侧的值 —— 源侧
node-pty: false就搬成false,不会一律写true把人家有意关掉的东西静默打开。 - 两边都有但值不同 → 归入
valueMismatch,只报告、不自动改(目标侧可能是 故意的,跟repin同一个道理)。 - 拼写与引号规则沿用 dshmarket 那套(作用域包名要加引号、CRLF 要保住、已经坏成两个
allowBuilds:块的会合并成一个)。
面板上的端口是怎么读出来的
端口不在 profile 的 cordis.patch.yml 里 —— 它现在的归属是启动参数
(@deepseek-ai/dsh-web-app 的 port: !!js ctx.webStartup.port ?? 3080;桌面端宿主则把
19387 硬编码在它的启动参数里)。所以 profilePortInfo() 按权威性分三档:
- 当前 profile → 用
DSH_WEB_URL解析(对--port 0的随机端口也准); - 该 profile 自己的
cordis.patch.yml里的port:(旧版本 DSH 或用户手写的场景); - 只有名字是
web的 profile 才退回出厂默认3080(因为它可能被--port覆盖, 所以面板会把来源一并标出来)。
其它 profile 读不到就老实返回 null 并说明原因 —— 不给所有 profile 都套一个 3080,
那样只会把请求发到错误的进程上。
执行顺序(以及为什么是这个顺序)
1. 快照 package.json / pnpm-workspace.yaml / pnpm-lock.yaml
/ cordis.patch.yml / compatibility.json
2. 先合并 allowBuilds ← 必须在 install 之前,否则 pnpm 静默跳过原生构建
3. 写 pending 日志 ← 必须在 install 之前,进程被杀也还知道「本来期望什么」
4. 原子写 package.json(自己写,见下)
5. dsh plugin --profile <p> install ← 官方:兼容性校验 + 自动回滚 + reconcile 激活
6. 失败就把快照整个退回去
第 4 步为什么不直接用 dsh plugin add name@^0.11.3:
Windows 上 dsh 是 .cmd,spawn 必须走 shell,而 cmd.exe 把 ^ 当转义字符 ——
dsh-x@^0.11.3 传到 pnpm 手里就成了 dsh-x@0.11.3,静默把范围号变成精确号。
所以改成自己原子写 manifest(可单测、无转义问题),再让官方做安装。
重启后核对
官方 install 退出码为 0,只说明 pnpm 装完了 ——
说明不了新 bundle 真的进了加载层(可能没声明 dsh.bundle、
可能被上层 patch 覆盖、也可能装完又被兼容性拒绝回滚)。
唯一可信的确认是下次启动后读 manifest 和 bundles 对一遍。
pending.json 就是那个对账依据,插件在每次启动时自动核对并打印结果。
状态目录
~/.dsh/profile-sync/
plans/<时间>/ plan.json(可复核)+ plan.txt + apply.cmd
backups/<时间>/ 快照 + meta.json(记着哪些文件原本不存在)
pending.json 上次 apply 的期望值,重启后用它核对
测试
node test-plan.mjs # 18 项:纯函数 + 合成 fixture + 对真实 profile 算一遍
node test-apply.mjs # 7 项:快照/回滚/原子写/dry-run/拒绝条件(用合成 profile)
node test-host.mjs # 12 项:路由与工具契约、客户端席位注册与注销
node test-regressions.mjs # 13 项:每个用例对应一个**真实修过的 bug**
node test-manifest.mjs # 14 项:清单不变式 + 真加载(坏包必须被挡住)
node test-managed.mjs # 15 项:官方管理器解析、进程内应用、HTTP 端点通道
共 79 项,都不启动 DSH、不占端口、不跑 pnpm(runner 是注入的假函数)。
test-apply.mjs / test-regressions.mjs 会在 ~/.dsh/profiles/ 下建
synctest-* 合成 profile,跑完删掉 —— 不碰真实的 web / desktop。
客户端启动验证(bin/verify-client.mjs)
上面那些测试碰不到客户端启动审计 —— 而这正是一个踩过的坑:宿主半装载成功、 日志一切正常,客户端那一半却是死的,桌面端直接打不开窗口。
node bin/verify-client.mjs # 验本插件
node bin/verify-client.mjs --plugin-dir X # 验别处一份(阳性对照用)
它做的事:建一个一次性 profile(名字硬拒绝 desktop / web / headless)→
link 装入目标插件 → 起服务(加 --no-open,绝不许弹用户的浏览器)→
用无头 Chrome/Edge 的 --dump-dom 抓那个页面的真实文本 → 断言
「不含 did not activate」且「含面板标签」→ 无论成败都杀进程、删 profile、删浏览器临时目录。
两个实测出来的坑,别改回去:
- 不能加
--virtual-time-budget:页面有 SSE 长连接(HMR 用的),虚拟时间永远 等不到「网络空闲」,--dump-dom就永不返回(实测 40 秒超时、0 字节)。 - 带 token 的 URL 是异步打印的:端口先能连上,
?token=…那一行稍后才出现。 早一步拿就只能拿到authentication required的 222 字节页面。
它必须配阳性对照才有意义:先把 client.js 的 inject 故意改回包名(用一份
临时副本,不碰源码),验证器必须报失败;再改回服务名,必须通过。
没有这一步,它就可能变成第二个「假安全网」。
修过的 bug(test-regressions.mjs 盯着不让回来)
- 面板生成的
apply.cmd会去再启一遍桌面端。 规划时用的是process.execPath, 而在 Electron 里那是DeepSeek Harness.exe。这个构建的ELECTRON_RUN_AS_NODE=1还是失效的(实测无输出、无退出码 —— 打包时RunAsNodefuse 被关了), 所以只能去 PATH 上找真 node;找不到就明确报错,绝不退回应用 exe。 (之前测出来是对的,只因为plan-cli.mjs是在真 node 下跑的,掩盖了这条。) --prune之后自己报「缺依赖」。 期望值在writePending里照计划又推导了一遍, 而 apply 层是按prune删的 —— 两份推导逻辑一漂就自己骗自己。 现在期望值只从「即将写入的那份 manifest」抽一次。--no-install会在核对时假装「已落地」。 现在 pending 里如实记installed, 核对结果多一个manifest-only状态,明说「包没装」。- 目标没有
pnpm-workspace.yaml时会凭空造一个残废文件(只含allowBuilds, 丢掉nodeLinker: hoisted,pnpm 会用 isolated 把安装搞坏)。现在跳过并警告。 - 校验函数自己会崩。 它先记下「
add必须是数组」,然后又拿那个非数组值 去.entries()—— 校验函数崩了等于没校验。 - 预发布号被当成不存在。
>=0.2.0-rc.3对着运行时0.2.0-rc.2会被判成「满足」。 这个生态全是 rc 版本,属常态;现在按 semver 正确比较预发布段。
诚实的边界
- 不自动同步配置层。
cordis.patch.yml只报告差异,这是设计,不是没做完。 - 默认不删目标端多出来的依赖(只报告);要删得显式
--prune。 - 兼容性判断以官方为准。本插件的引擎检查是建议性的,读不到运行时版本时说「不知道」。
- 不支持显式目录形式的 profile。
dsh plugin --profile按 profile 名解析目录, 所以apply会拒绝目标目录不等于profiles/<名字>的情况。 allowBuilds的 YAML 处理是抄dshmarket的(CRLF、作用域包名要加引号、 已经坏成两个allowBuilds:块的会合并成一个)。那块被真实 bug 打磨过,不重写。- 进程守卫靠进程名 + 命令行。桌面端命令行里没有
--profile desktop(是DeepSeek Harness.exe+ host 脚本),只能按进程名认;两路检查都跑不起来时 会拒绝并要求--yes,而不是默默继续。
Versions
| Latest version | Published | Size |
|---|---|---|
| 0.1.0 | — | — |
| 0.1.1 | — | — |
| 0.1.3 | — | — |
Comments
Loading…
Similar plugins
by zzh799
dsh web 移动端适配插件:侧边栏抽屉 + 设置页单列布局;目录浏览移动端化 + 本地上传到工作区 ./上传/
★ 0
MIT
TypeScript
Aug 16, 2026
dsh plugin --profile web add dsh-mobile-adaptiveby ygcdsj
一个插件,把本地的 DSH 配置(皮肤、预设、注入包)打包成文件,到另一台 Windows 上直接还原。
★ 3
↓ 63/wk
MIT
TypeScript
Aug 23, 2026
dsh plugin --profile web add dsh-migrateby webkong
DSH 插件管理器:在 Web 设置页管理内置与三方插件——安装、卸载、启用、停用,支持 GitHub 搜索安装与非 bundle 插件一键装载
★ 4
MIT
JavaScript
Sep 1, 2026
dsh plugin --profile web add @webkong/dsh-plugin-managerby CaT-Hode
Windows desktop plugin for DeepSeek Harness — shared Web sessions and plugins, startup logs and update recovery. DSH 桌面插件:共用 Web 对话与插件。
★ 0
MIT
JavaScript
Sep 28, 2026
dsh plugin --profile web add dsh-appby anywhere-labs
为 DeepSeek Harness (DSH) 插件生态打造的现代化桌面端解决方案。万物皆「插件」,桌面本身也是「插件」。
★ 30.4k
↓ 108/wk
MIT
TypeScript
Oct 10, 2026
dsh plugin --profile web add dsh-plugin-desktopby baihejiangnan
本地插件开发管理面板:主动登记通过 file:/link: 安装的真实路径项目,并通过带风险确认的工作区会话协助开发与发布。
★ 0
MIT
JavaScript
Aug 16, 2026
dsh plugin --profile web add @baihejiangnan/dsh-plugin-dev