@pandablessing/dsh-user-sync 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,124 +1,124 @@
1
- # dsh-user-sync
2
-
3
- DeepSeek Harness(DSH)的**用户级配置同步插件**:把技能、插件清单、配置、记忆四类资产在设备间对齐。
4
-
5
- > 当前状态:**v1.0.4**(已发布 npm:`@pandablessing/dsh-user-sync`)。本机打包/应用、跨机上传/恢复、缺插件装回、自动检查均已实现并实测;服务端已部署(见 [部署文档](docs/deploy.md))。
6
- > 设计依据:[docs/design.md](docs/design.md);测试用例:[docs/test-cases.md](docs/test-cases.md)。
7
-
8
- ## 功能
9
-
10
- - **四类用户级资产**:技能(`~/.agents/skills`)、插件清单(profile 声明)、配置(`cordis.patch.yml`)、用户记忆(`AGENTS.md` + `memories/` 根);项目记忆默认不选,会话不参与同步。
11
- - **新覆盖旧**:包级 `createdAt` 比较,不做合并;覆盖前必须确认,先快照再写盘,失败可整批还原。
12
- - **上传前查远端**:远端已被别的设备更新时先拦下(`E_REMOTE_NEWER`),用户点「用本机覆盖远端」才覆盖。
13
- - **敏感数据硬拒绝**:文件名黑名单 + 内容模式,命中即拒绝打包并列出文件与行号。
14
- - **官方账号自动配对**:用 `deepseekAccount` 取账号 id 派生 `accountKey`,自动向服务端换 token,**没有运营方账号登录入口**。
15
- - **缺插件自动装回**:覆盖确认里列出缺失 bundle,一键 `installBundle`;`link:` 本地包列入手工清单。
16
- - **自动检查**:启动检查 + 每 6 小时(可配)只读远端元数据,发现更新只提醒、不自动应用。
17
-
18
- ## 安装
19
-
20
- ### 从 npm(发布后)
21
-
22
- ```powershell
23
- cd ~/.dsh/profiles/desktop
24
- pnpm add @pandablessing/dsh-user-sync
25
- ```
26
-
27
- macOS / Linux 用同一组命令(`cd ~/.dsh/profiles/desktop && pnpm add @pandablessing/dsh-user-sync`);profile 的 `package.json` 与 `cordis.patch.yml` 里必须写 npm 包名 `@pandablessing/dsh-user-sync`(见 [v1.0.2 修复](CHANGELOG.md))。
28
-
29
- > **升级/安装前先清旧条目**:如果 profile 里还留着旧名 `dsh-user-sync` 的 junction、`dependencies` 或 bundles 条目,先删掉再装。旧名与新名会各自注册一次 client bundle,同一面板 id 冲突——1.0.4 遇到这种情况会直接把 `web boot` 打挂(`duplicate factory registration`),1.0.5 起只是多一条 warn,但画面上会出现两个「设置同步」。
30
- >
31
- > 建议顺序:① 备份 `~/.dsh/profiles/desktop/{package.json,cordis.patch.yml}`;② 移除旧名条目;③ `pnpm add @pandablessing/dsh-user-sync@1.0.5`;④ 在 profile 的 `dsh.profile.bundles` 里确认只出现一次 `@pandablessing/dsh-user-sync`;⑤ 重启 DSH。
32
-
33
- 安装后在 DSH 设置页启用「设置同步」。
34
-
35
- ### 开发期(junction 到本仓库)
36
-
37
- ```powershell
38
- # 以 junction 把仓库装进 desktop profile,改 package.json 前自动备份,失败自动回滚
39
- powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-dev.ps1
40
-
41
- # 回滚
42
- scripts\uninstall-dev.cmd # 双击即可
43
- ```
44
-
45
- 安装后**重启 DSH**,设置页出现「设置同步」。
46
-
47
- ## 使用
48
-
49
- 1. 设置 → 设置同步 → 首次点「同意并启用上传」(说明上传范围与存储位置);
50
- 2. 勾选同步范围(默认四类用户级);
51
- 3. 「上传用户配置」→ 打包并上传(远端更新时先确认再用本机覆盖);
52
- 4. 「从远端同步」→ 检查远端,覆盖前确认。
53
-
54
- 服务地址默认内置(`https://dsh-user-sync-test.wenzhi.info`),传输固定走运营方 API;面板只读显示当前地址(`(内置)` / `(自定义)`),不提供输入框。需要改用自建服务时,在 `state.json` 写 `baseUrl` 即可覆盖。
55
-
56
- ## 服务端
57
-
58
- 纯 `node:http`、无第三方依赖,支持坚果云 WebDAV(凭据缺失回退本地磁盘)。部署步骤见 [docs/deploy.md](docs/deploy.md)。
59
-
60
- ## 架构
61
-
62
- ```text
63
- 插件(HTTP 客户端) ──HTTPS──▶ 运营方 API ──WebDAV──▶ 坚果云
64
- │ │
65
- └─ 本地 state.json └─ 鉴权 / 配额 / 隔离 / 审计
66
- ```
67
-
68
- - 插件侧只实现 `api` 传输;`folder` 仅用于本机调试。
69
- - 存储凭据只在服务端,插件包内不含任何存储口令。
70
- - 身份:官方账号 id → `accountKey`(sha256 前 16 位)→ 服务端分区。
71
-
72
- ## 构建与测试
73
-
74
- 源码是 TypeScript,构建产物 `lib/` 与 `server/dist/` 已提交(junction 安装与部署直接可用)。
75
-
76
- ```powershell
77
- pnpm install # 仅开发:安装 typescript 与 @types/node
78
- pnpm run build # 插件:src/*.ts → lib/*.js
79
- pnpm run build:server # 服务端:server/src/*.ts → server/dist/*.js
80
-
81
- node --test test/*.test.js # 154 条:单元 / 组件 / 时序(直接跑源码,macOS 全绿)
82
- node scripts/audit-cases.mjs # 用例清单与结果记录一致性审计(149/149)
83
- node scripts/e2e-clone.mjs # 替代换机演练(默认本地模式,临时起服务端,不写真实 WebDAV)
84
- # node scripts/e2e-clone.mjs --remote https://dsh-user-sync-test.wenzhi.info # 真实服务端(会留测试目录)
85
- powershell -File scripts/smoke.ps1 # 真实 DSH 路由冒烟
86
- ```
87
-
88
- ## 发布
89
-
90
- token 只放本地文件,绝不进仓库(`.env.local` 已在 `.gitignore`):
91
-
92
- ```bash
93
- cp .env.local.example .env.local && chmod 600 .env.local # 填 NPM_TOKEN=(发布 token)
94
- node scripts/publish.mjs --dry-run # 先看打包内容(24 个文件)
95
- node scripts/publish.mjs --otp=123456 # 账号开 2FA 时带当次验证码
96
- ```
97
-
98
- - 脚本把 token 写进 `0600` 的临时 npmrc,发布结束立即删除;token 不出现在命令行参数、日志与 git 历史里。
99
- - CI 用 secret 注入 `NPM_TOKEN` 环境变量即可,优先级高于 `.env.local`。
100
- - 发布前记得先 `pnpm run build && pnpm run build:server`,产物与源码不一致时 tarball 里会是旧代码。
101
-
102
- ## 仓库结构
103
-
104
- ```text
105
- docs/design.md 完整设计(数据面 / 同步 / 安全 / UI / 契约 / 多用户)
106
- docs/test-cases.md 测试用例清单(结果单独放 docs/test-results/)
107
- docs/deploy.md 服务端部署与验证
108
- lib/ 插件(宿主 + client + 传输 + 同步编排)
109
- server/ 运营方 API(纯 node:http)
110
- scripts/ 开发安装 / 卸载 / 冒烟 / 替代换机 / 发布
111
- test/ 单元、组件、时序测试与 fixtures
112
- legacy/ 需求反馈稿与原型脚本
113
- ```
114
-
115
- ## 已知限制
116
-
117
- - macOS 上单测已全绿(Node 22.23.3),但 DSH 桌面端整机闭环只在 Windows 实测过;Linux 未验证。
118
- - 服务端单节点,未做限流与多副本;坚果云单账号有流量配额限制。
119
- - `link:` 本地依赖需要手工放回目标机。
120
- - 远端只保留 1 份包,上传即覆盖(回滚靠本地 `pre-restore` 快照;每次应用生成独立快照目录)。
121
-
122
- ## License
123
-
124
- [Mulan PSL v2](LICENSE)
1
+ # dsh-user-sync
2
+
3
+ DeepSeek Harness(DSH)的**用户级配置同步插件**:把技能、插件清单、配置、记忆四类资产在设备间对齐。
4
+
5
+ > 当前状态:**v1.0.4**(已发布 npm:`@pandablessing/dsh-user-sync`)。本机打包/应用、跨机上传/恢复、缺插件装回、自动检查均已实现并实测;服务端已部署(见 [部署文档](docs/deploy.md))。
6
+ > 设计依据:[docs/design.md](docs/design.md);测试用例:[docs/test-cases.md](docs/test-cases.md)。
7
+
8
+ ## 功能
9
+
10
+ - **四类用户级资产**:技能(`~/.agents/skills`)、插件清单(profile 声明)、配置(`cordis.patch.yml`)、用户记忆(`AGENTS.md` + `memories/` 根);项目记忆默认不选,会话不参与同步。
11
+ - **新覆盖旧**:包级 `createdAt` 比较,不做合并;覆盖前必须确认,先快照再写盘,失败可整批还原。
12
+ - **上传前查远端**:远端已被别的设备更新时先拦下(`E_REMOTE_NEWER`),用户点「用本机覆盖远端」才覆盖。
13
+ - **敏感数据硬拒绝**:文件名黑名单 + 内容模式,命中即拒绝打包并列出文件与行号。
14
+ - **官方账号自动配对**:用 `deepseekAccount` 取账号 id 派生 `accountKey`,自动向服务端换 token,**没有运营方账号登录入口**。
15
+ - **缺插件自动装回**:覆盖确认里列出缺失 bundle,一键 `installBundle`;`link:` 本地包列入手工清单。
16
+ - **自动检查**:启动检查 + 每 6 小时(可配)只读远端元数据,发现更新只提醒、不自动应用。
17
+
18
+ ## 安装
19
+
20
+ ### 从 npm(发布后)
21
+
22
+ ```powershell
23
+ cd ~/.dsh/profiles/desktop
24
+ pnpm add @pandablessing/dsh-user-sync
25
+ ```
26
+
27
+ macOS / Linux 用同一组命令(`cd ~/.dsh/profiles/desktop && pnpm add @pandablessing/dsh-user-sync`);profile 的 `package.json` 与 `cordis.patch.yml` 里必须写 npm 包名 `@pandablessing/dsh-user-sync`(见 [v1.0.2 修复](CHANGELOG.md))。
28
+
29
+ > **升级/安装前先清旧条目**:如果 profile 里还留着旧名 `dsh-user-sync` 的 junction、`dependencies` 或 bundles 条目,先删掉再装。旧名与新名会各自注册一次 client bundle,同一面板 id 冲突——1.0.4 遇到这种情况会直接把 `web boot` 打挂(`duplicate factory registration`),1.0.5 起只是多一条 warn,但画面上会出现两个「设置同步」。
30
+ >
31
+ > 建议顺序:① 备份 `~/.dsh/profiles/desktop/{package.json,cordis.patch.yml}`;② 移除旧名条目;③ `pnpm add @pandablessing/dsh-user-sync@1.0.5`;④ 在 profile 的 `dsh.profile.bundles` 里确认只出现一次 `@pandablessing/dsh-user-sync`;⑤ 重启 DSH。
32
+
33
+ 安装后在 DSH 设置页启用「设置同步」。
34
+
35
+ ### 开发期(junction 到本仓库)
36
+
37
+ ```powershell
38
+ # 以 junction 把仓库装进 desktop profile,改 package.json 前自动备份,失败自动回滚
39
+ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-dev.ps1
40
+
41
+ # 回滚
42
+ scripts\uninstall-dev.cmd # 双击即可
43
+ ```
44
+
45
+ 安装后**重启 DSH**,设置页出现「设置同步」。
46
+
47
+ ## 使用
48
+
49
+ 1. 设置 → 设置同步 → 首次点「同意并启用上传」(说明上传范围与存储位置);
50
+ 2. 勾选同步范围(默认四类用户级);
51
+ 3. 「上传用户配置」→ 打包并上传(远端更新时先确认再用本机覆盖);
52
+ 4. 「从远端同步」→ 检查远端,覆盖前确认。
53
+
54
+ 服务地址默认内置(`https://dsh-user-sync-test.wenzhi.info`),传输固定走运营方 API;面板只读显示当前地址(`(内置)` / `(自定义)`),不提供输入框。需要改用自建服务时,在 `state.json` 写 `baseUrl` 即可覆盖。
55
+
56
+ ## 服务端
57
+
58
+ 纯 `node:http`、无第三方依赖,支持坚果云 WebDAV(凭据缺失回退本地磁盘)。部署步骤见 [docs/deploy.md](docs/deploy.md)。
59
+
60
+ ## 架构
61
+
62
+ ```text
63
+ 插件(HTTP 客户端) ──HTTPS──▶ 运营方 API ──WebDAV──▶ 坚果云
64
+ │ │
65
+ └─ 本地 state.json └─ 鉴权 / 配额 / 隔离 / 审计
66
+ ```
67
+
68
+ - 插件侧只实现 `api` 传输;`folder` 仅用于本机调试。
69
+ - 存储凭据只在服务端,插件包内不含任何存储口令。
70
+ - 身份:官方账号 id → `accountKey`(sha256 前 16 位)→ 服务端分区。
71
+
72
+ ## 构建与测试
73
+
74
+ 源码是 TypeScript,构建产物 `lib/` 与 `server/dist/` 已提交(junction 安装与部署直接可用)。
75
+
76
+ ```powershell
77
+ pnpm install # 仅开发:安装 typescript 与 @types/node
78
+ pnpm run build # 插件:src/*.ts → lib/*.js
79
+ pnpm run build:server # 服务端:server/src/*.ts → server/dist/*.js
80
+
81
+ node --test test/*.test.js # 154 条:单元 / 组件 / 时序(直接跑源码,macOS 全绿)
82
+ node scripts/audit-cases.mjs # 用例清单与结果记录一致性审计(149/149)
83
+ node scripts/e2e-clone.mjs # 替代换机演练(默认本地模式,临时起服务端,不写真实 WebDAV)
84
+ # node scripts/e2e-clone.mjs --remote https://dsh-user-sync-test.wenzhi.info # 真实服务端(会留测试目录)
85
+ powershell -File scripts/smoke.ps1 # 真实 DSH 路由冒烟
86
+ ```
87
+
88
+ ## 发布
89
+
90
+ token 只放本地文件,绝不进仓库(`.env.local` 已在 `.gitignore`):
91
+
92
+ ```bash
93
+ cp .env.local.example .env.local && chmod 600 .env.local # 填 NPM_TOKEN=(发布 token)
94
+ node scripts/publish.mjs --dry-run # 先看打包内容(24 个文件)
95
+ node scripts/publish.mjs --otp=123456 # 账号开 2FA 时带当次验证码
96
+ ```
97
+
98
+ - 脚本把 token 写进 `0600` 的临时 npmrc,发布结束立即删除;token 不出现在命令行参数、日志与 git 历史里。
99
+ - CI 用 secret 注入 `NPM_TOKEN` 环境变量即可,优先级高于 `.env.local`。
100
+ - 发布前记得先 `pnpm run build && pnpm run build:server`,产物与源码不一致时 tarball 里会是旧代码。
101
+
102
+ ## 仓库结构
103
+
104
+ ```text
105
+ docs/design.md 完整设计(数据面 / 同步 / 安全 / UI / 契约 / 多用户)
106
+ docs/test-cases.md 测试用例清单(结果单独放 docs/test-results/)
107
+ docs/deploy.md 服务端部署与验证
108
+ lib/ 插件(宿主 + client + 传输 + 同步编排)
109
+ server/ 运营方 API(纯 node:http)
110
+ scripts/ 开发安装 / 卸载 / 冒烟 / 替代换机 / 发布
111
+ test/ 单元、组件、时序测试与 fixtures
112
+ legacy/ 需求反馈稿与原型脚本
113
+ ```
114
+
115
+ ## 已知限制
116
+
117
+ - macOS 上单测已全绿(Node 22.23.3),但 DSH 桌面端整机闭环只在 Windows 实测过;Linux 未验证。
118
+ - 服务端单节点,未做限流与多副本;坚果云单账号有流量配额限制。
119
+ - `link:` 本地依赖需要手工放回目标机。
120
+ - 远端只保留 1 份包,上传即覆盖(回滚靠本地 `pre-restore` 快照;每次应用生成独立快照目录)。
121
+
122
+ ## License
123
+
124
+ [Mulan PSL v2](LICENSE)
package/lib/client.js CHANGED
@@ -178,7 +178,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
178
178
  ? R.createElement('p', { style: styles.muted }, `本地包需手动放回:${linkDeps.map((item) => item.name).join('、')}`)
179
179
  : null, R.createElement('div', { style: styles.actions }, missing.length > 0
180
180
  ? R.createElement('button', { style: styles.primary, onClick: () => onInstallPlugins(missing) }, '先安装缺失插件')
181
- : null, R.createElement('button', { style: styles.primary, onClick: () => onApply(data.latest?.name ?? '') }, '用远端覆盖本机'), R.createElement('button', { style: styles.secondary, onClick: () => onDismiss() }, '取消')));
181
+ : null, R.createElement('button', { style: styles.secondary, onClick: () => onDismiss() }, '取消')), R.createElement('p', { style: styles.muted }, '覆盖动作会在弹窗中确认。'));
182
182
  }
183
183
  return R.createElement('div', { style: styles.card }, R.createElement('strong', null, decision.action === 'skip' ? '本机已是最新' : '本机有改动'), R.createElement('p', { style: styles.muted }, `判定:${decision.reason ?? decision.action}`));
184
184
  }
@@ -187,12 +187,27 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
187
187
  return R.createElement('div', { style: styles.card }, R.createElement('strong', { style: styles.error }, remoteNewer ? '远端有更新的包' : '操作失败'), R.createElement('p', { style: styles.muted }, data.detail ?? data.error ?? '未知错误'), data.hits
188
188
  ? R.createElement('p', { style: styles.error }, `敏感扫描命中 ${data.hits.length} 处:${data.hits.map((h) => `${h.file}:${h.line}(${h.pattern})`).join(';')}`)
189
189
  : null,
190
- // 上传前发现远端更新:默认不覆盖,用户确认后才用本机覆盖远端(G2)
191
- remoteNewer
192
- ? R.createElement('div', { style: styles.actions }, R.createElement('button', { style: styles.primary, onClick: () => onForceUpload() }, '用本机覆盖远端'), R.createElement('button', { style: styles.secondary, onClick: () => onDismiss() }, '取消'))
193
- : null);
190
+ // 覆盖确认已改为弹窗;卡片只提示
191
+ remoteNewer ? R.createElement('p', { style: styles.muted }, '覆盖动作会在弹窗中确认。') : null);
194
192
  }
195
- return R.createElement('div', { style: styles.card }, R.createElement('strong', null, '完成'), R.createElement('p', { style: styles.muted }, data.detail
193
+ // 同步后自动安装的结果:优先展示「装了哪些插件 / 哪些失败 / 哪些需手动放回」
194
+ const pluginSummary = (() => {
195
+ const installed = Array.isArray(data.installed) ? data.installed : [];
196
+ const linkDeps = Array.isArray(data.linkDeps) ? data.linkDeps : [];
197
+ if (installed.length === 0 && linkDeps.length === 0)
198
+ return null;
199
+ const okList = installed.filter((item) => item.ok).map((item) => item.name);
200
+ const failList = installed.filter((item) => !item.ok).map((item) => item.name);
201
+ const parts = [okList.length > 0 ? `已安装 ${okList.length} 个插件:${okList.join('、')}` : '没有需要安装的插件'];
202
+ if (failList.length > 0)
203
+ parts.push(`安装失败 ${failList.length} 个:${failList.join('、')}`);
204
+ if (linkDeps.length > 0)
205
+ parts.push(`需手动放回:${linkDeps.map((item) => item.name).join('、')}`);
206
+ return parts.join(';');
207
+ })();
208
+ return R.createElement('div', { style: styles.card }, R.createElement('strong', null, '完成'), pluginSummary
209
+ ? R.createElement('p', { style: styles.muted }, pluginSummary)
210
+ : null, R.createElement('p', { style: styles.muted }, data.detail
196
211
  ? String(data.detail)
197
212
  : data.packageId
198
213
  ? `包 ${data.packageId} · ${fmtBytes(data.size ?? 0)}`
@@ -208,6 +223,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
208
223
  const [notice, setNotice] = R.useState(null);
209
224
  // 非用户级模块确认走弹窗(dialog),不再用底部结果卡
210
225
  const [confirmDialog, setConfirmDialog] = R.useState(null);
226
+ // 覆盖确认弹窗:上传发现远端更新 / 从远端同步发现远端更新时弹出(不再用结果卡按钮)
227
+ const [overwriteDialog, setOverwriteDialog] = R.useState(null);
211
228
  // 当前展开文件清单的模块(null 表示都收起)
212
229
  const [expandedModule, setExpandedModule] = R.useState(null);
213
230
  // 设置改动后自动保存(600ms 防抖),不再提供「保存设置」按钮
@@ -372,7 +389,19 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
372
389
  headers: { 'content-type': 'application/json' },
373
390
  body: JSON.stringify({ force }),
374
391
  });
375
- setNotice({ kind: result.ok ? 'result' : 'error', data: result });
392
+ if (result.ok) {
393
+ setNotice({ kind: 'result', data: result });
394
+ refresh();
395
+ }
396
+ else if (result.code === 'E_REMOTE_NEWER') {
397
+ setOverwriteDialog({
398
+ kind: 'upload',
399
+ detail: `远端已有更新的包 ${result.latest?.name ?? ''};继续会用本机配置覆盖它。`,
400
+ });
401
+ }
402
+ else {
403
+ setNotice({ kind: 'error', data: result });
404
+ }
376
405
  refresh();
377
406
  }
378
407
  catch (error) {
@@ -410,7 +439,22 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
410
439
  setNotice(null);
411
440
  try {
412
441
  const result = await fetchJson(`${API}/check`);
413
- setNotice(result.error ? { kind: 'error', data: result } : { kind: 'check', data: result });
442
+ if (result.error) {
443
+ setNotice({ kind: 'error', data: result });
444
+ }
445
+ else {
446
+ // 详情仍用结果卡展示;覆盖动作改为弹窗确认
447
+ setNotice({ kind: 'check', data: result });
448
+ const action = result.decision?.action;
449
+ if ((action === 'pull' || action === 'confirm') && result.latest?.name) {
450
+ const overwritten = (result.plan?.overwritten ?? []).length;
451
+ setOverwriteDialog({
452
+ kind: 'pull',
453
+ name: result.latest.name,
454
+ detail: `远端包 ${result.latest.name}(${result.meta?.createdAt ?? '时间未知'}),将覆盖本机 ${overwritten} 个文件。`,
455
+ });
456
+ }
457
+ }
414
458
  }
415
459
  catch (error) {
416
460
  setNotice({ kind: 'error', data: { detail: String(error) } });
@@ -515,6 +559,20 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
515
559
  onForceUpload: () => upload(true),
516
560
  }), confirmDialog
517
561
  ? R.createElement('div', { style: styles.overlay, onClick: () => setConfirmDialog(null) }, R.createElement('div', { style: styles.dialog, onClick: (event) => event.stopPropagation?.() }, R.createElement('strong', null, '同步项目/会话内容?'), R.createElement('p', { style: styles.muted }, `${confirmDialog.warning.map((id) => MODULE_LABELS[id] ?? id).join('、')} 属于项目/会话级,可能包含具体工作内容。确认要跨机器同步吗?`), R.createElement('div', { style: styles.actions }, R.createElement('button', { style: styles.primary, onClick: () => confirmModules(confirmDialog.modules) }, '仍要同步'), R.createElement('button', { style: styles.secondary, onClick: () => setConfirmDialog(null) }, '取消'))))
562
+ : null, overwriteDialog
563
+ ? R.createElement('div', { style: styles.overlay, onClick: () => setOverwriteDialog(null) }, R.createElement('div', { style: styles.dialog, onClick: (event) => event.stopPropagation?.() }, R.createElement('strong', null, overwriteDialog.kind === 'upload' ? '用本机覆盖远端?' : '用远端覆盖本机?'), R.createElement('p', { style: styles.muted }, overwriteDialog.detail), R.createElement('p', { style: styles.muted }, overwriteDialog.kind === 'upload'
564
+ ? '远端已有的包会被本机内容替换(远端只保留 1 份)。'
565
+ : '本机会先做快照,再用远端包覆盖同步范围内的文件。'), R.createElement('div', { style: styles.actions }, R.createElement('button', {
566
+ style: styles.primary,
567
+ onClick: () => {
568
+ const target = overwriteDialog;
569
+ setOverwriteDialog(null);
570
+ if (target.kind === 'upload')
571
+ void upload(true);
572
+ else if (target.name)
573
+ void applyRemote(target.name);
574
+ },
575
+ }, overwriteDialog.kind === 'upload' ? '用本机覆盖远端' : '用远端覆盖本机'), R.createElement('button', { style: styles.secondary, onClick: () => setOverwriteDialog(null) }, '取消'))))
518
576
  : null);
519
577
  }
520
578
  function apply(ctx) {
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
@@ -201,6 +201,117 @@ export function apply(ctx) {
201
201
  }
202
202
  };
203
203
  let pluginManager = null;
204
+ /** 运行时 pnpm 的 node + pnpm.cjs 路径;找不到时返回 null。 */
205
+ const pnpmRuntime = () => {
206
+ const runtime = findRuntimeNode();
207
+ if (!runtime)
208
+ return null;
209
+ const pnpmCjs = join(dirname(runtime), '..', '..', 'pnpm', 'bin', 'pnpm.cjs');
210
+ return existsSync(pnpmCjs) ? { node: runtime, pnpmCjs } : null;
211
+ };
212
+ /**
213
+ * 按 profile 的 package.json 安装依赖。
214
+ * `link:` / `file:` 指向不存在的本地路径时,pnpm install 会整体失败(连带普通包也装不上),
215
+ * 因此安装期间临时把这些坏 link 从 package.json 摘掉,装完原样恢复。
216
+ */
217
+ const installProfileDependencies = async () => {
218
+ const rt = pnpmRuntime();
219
+ if (!rt)
220
+ throw new Error('runtime node/pnpm not found');
221
+ const pkgPath = join(paths.profileDir, 'package.json');
222
+ const workspacePath = join(paths.profileDir, 'pnpm-workspace.yaml');
223
+ const raw = readFileSync(pkgPath, 'utf8');
224
+ const removedLinks = [];
225
+ let workspaceRaw = null;
226
+ const missingPatchLines = [];
227
+ try {
228
+ const pkg = JSON.parse(raw);
229
+ const dependencies = pkg.dependencies ?? {};
230
+ // pnpm-workspace.yaml 的 patchedDependencies 指向 patches/*.patch;文件缺失时 pnpm install 会整体失败
231
+ try {
232
+ workspaceRaw = readFileSync(workspacePath, 'utf8');
233
+ for (const line of workspaceRaw.split('\n')) {
234
+ const match = line.match(/^\s+'?([^':]+)'?@[^:]+:\s*patches\/(.+?)\s*$/);
235
+ if (match && !existsSync(join(paths.profileDir, 'patches', match[2])))
236
+ missingPatchLines.push(line);
237
+ }
238
+ if (missingPatchLines.length > 0) {
239
+ const patched = workspaceRaw
240
+ .split('\n')
241
+ .filter((line) => !missingPatchLines.includes(line))
242
+ .join('\n');
243
+ writeFileSync(workspacePath, patched, 'utf8');
244
+ }
245
+ }
246
+ catch {
247
+ workspaceRaw = null;
248
+ }
249
+ const brokenLinks = Object.entries(dependencies).filter(([, spec]) => {
250
+ if (!spec.startsWith('link:') && !spec.startsWith('file:'))
251
+ return false;
252
+ const target = join(paths.profileDir, spec.slice(5));
253
+ return !existsSync(target);
254
+ });
255
+ if (brokenLinks.length > 0) {
256
+ const patched = { ...pkg, dependencies: { ...dependencies } };
257
+ for (const [name] of brokenLinks) {
258
+ delete patched.dependencies[name];
259
+ removedLinks.push(name);
260
+ }
261
+ writeFileSync(pkgPath, `${JSON.stringify(patched, null, 2)}\n`, 'utf8');
262
+ }
263
+ await runCommand(rt.node, [rt.pnpmCjs, 'install', '--force'], { cwd: paths.profileDir, timeout: 300_000 });
264
+ }
265
+ finally {
266
+ if (removedLinks.length > 0)
267
+ writeFileSync(pkgPath, raw, 'utf8');
268
+ if (workspaceRaw !== null && missingPatchLines?.length > 0)
269
+ writeFileSync(join(paths.profileDir, 'pnpm-workspace.yaml'), workspaceRaw, 'utf8');
270
+ }
271
+ return { removedLinks };
272
+ };
273
+ /** 缺失插件落盘校验:pnpm 的 .modules.yaml 可能残留记录,缺失时再跑一次 pnpm install。 */
274
+ const forceReinstallIfMissing = async (names) => {
275
+ const missing = names.filter((name) => !existsSync(join(paths.profileDir, 'node_modules', name)));
276
+ if (missing.length === 0)
277
+ return;
278
+ try {
279
+ await installProfileDependencies();
280
+ }
281
+ catch (error) {
282
+ log('pnpm-force', error);
283
+ }
284
+ };
285
+ /** 把远端包声明的依赖装上:先按 package.json 整体安装,再逐个 installBundle 兜底,link/file 依赖只提示手动处理。 */
286
+ const ensurePluginsInstalled = async (pm, dependencies) => {
287
+ const plan = planPluginRecovery({ dependencies, installed: [] });
288
+ let removedLinks = [];
289
+ try {
290
+ const result = await installProfileDependencies();
291
+ removedLinks = result.removedLinks;
292
+ }
293
+ catch (error) {
294
+ log('install-dependencies', error);
295
+ }
296
+ // 安装后重新判定:DSH 的 listBundles 只要声明在就算 installed,实际落盘要看 node_modules
297
+ const bundles = await pm.listBundles();
298
+ const installedNow = bundles
299
+ .filter((bundle) => bundle.installed)
300
+ .map((bundle) => bundle.name)
301
+ .filter((name) => existsSync(join(paths.profileDir, 'node_modules', name)));
302
+ const stillMissing = planPluginRecovery({ dependencies, installed: installedNow }).missing;
303
+ const results = await installMissing({
304
+ plan: { missing: stillMissing },
305
+ installBundle: (spec) => pm.installBundle(spec),
306
+ });
307
+ await forceReinstallIfMissing(stillMissing.map((item) => item.name));
308
+ const installed = plan.missing.map((item) => ({
309
+ name: item.name,
310
+ ok: existsSync(join(paths.profileDir, 'node_modules', item.name)),
311
+ error: results.find((result) => result.name === item.name)?.error,
312
+ }));
313
+ return { installed, linkDeps: plan.linkDeps.map(({ name, spec }) => ({ name, spec })) };
314
+ };
204
315
  const routes = [
205
316
  {
206
317
  // 最小同步路由:用于区分「同步 handler 本身不可用」与「status/scan 具体逻辑抛错」
@@ -484,7 +595,24 @@ export function apply(ctx) {
484
595
  const body = (await request.json());
485
596
  if (!isPackageId(String(body?.name ?? '')))
486
597
  return json({ error: 'E_BAD_NAME' }, 400);
487
- return json(await sync.applyRemote(String(body.name)));
598
+ const result = (await sync.applyRemote(String(body.name)));
599
+ // 同步只恢复声明文件;应用后必须真正把插件装上,否则「清单同步了却没装」
600
+ const pm = pluginManager;
601
+ const manifest = result.manifest;
602
+ const dependencies = manifest?.dependencies ?? null;
603
+ if (pm && result.ok && dependencies && Object.keys(dependencies).length > 0) {
604
+ try {
605
+ const { installed, linkDeps } = await ensurePluginsInstalled(pm, dependencies);
606
+ result.installed = installed;
607
+ result.linkDeps = linkDeps;
608
+ }
609
+ catch (error) {
610
+ log('apply-install', error);
611
+ result.installed = [];
612
+ result.installError = error?.message ?? String(error);
613
+ }
614
+ }
615
+ return json(result);
488
616
  },
489
617
  },
490
618
  ];
package/lib/scan.js CHANGED
@@ -155,6 +155,19 @@ function collectProfileFiles({ dshHome, profileName, modules, ctx, }) {
155
155
  if (modules.includes('plugins')) {
156
156
  for (const name of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'cordis.yml'])
157
157
  push(name, 'plugins');
158
+ // pnpm-workspace.yaml 的 patchedDependencies 指向 patches/*.patch;
159
+ // 少了这些文件,换机后 pnpm install 会报 ERR_PNPM_PATCH_NOT_FOUND,插件全部装不上
160
+ let patchFiles = [];
161
+ try {
162
+ patchFiles = readdirSync(join(profileDir, 'patches'), { withFileTypes: true })
163
+ .filter((dirent) => dirent.isFile() && dirent.name.endsWith('.patch'))
164
+ .map((dirent) => dirent.name);
165
+ }
166
+ catch {
167
+ patchFiles = [];
168
+ }
169
+ for (const name of patchFiles)
170
+ push(`patches/${name}`, 'plugins');
158
171
  }
159
172
  if (modules.includes('config')) {
160
173
  push('cordis.patch.yml', 'config');
package/lib/sync.js CHANGED
@@ -97,6 +97,15 @@ export function createSync({ dshHome, userHome, profileName = 'desktop', transpo
97
97
  }
98
98
  if (hits.length > 0)
99
99
  return { ok: false, code: 'E_SENSITIVE', hits };
100
+ // 把 profile 的依赖声明写进 manifest:目标机应用后据此自动安装缺失插件
101
+ let dependencies = {};
102
+ try {
103
+ const pkg = JSON.parse(readFileSync(join(dshHome, 'profiles', profileName, 'package.json'), 'utf8'));
104
+ dependencies = pkg.dependencies ?? {};
105
+ }
106
+ catch {
107
+ dependencies = {};
108
+ }
100
109
  const packageId = makePackageId({ deviceId: currentDeviceId() });
101
110
  const manifest = buildManifest(files, {
102
111
  packageId,
@@ -106,6 +115,7 @@ export function createSync({ dshHome, userHome, profileName = 'desktop', transpo
106
115
  arch: process.arch,
107
116
  profile: profileName,
108
117
  modules: modulesNow(),
118
+ dependencies,
109
119
  });
110
120
  const tarEntries = [
111
121
  { path: MANIFEST_PATH, data: Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8') },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pandablessing/dsh-user-sync",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "DeepSeek Harness 用户级配置同步插件",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -31,13 +31,6 @@
31
31
  "platform": "web"
32
32
  }
33
33
  },
34
- "scripts": {
35
- "build": "tsc -p tsconfig.json",
36
- "build:server": "tsc -p server/tsconfig.json",
37
- "test": "node --test test/*.test.js",
38
- "publish:npm": "node scripts/publish.mjs",
39
- "publish:dry": "node scripts/publish.mjs --dry-run"
40
- },
41
34
  "license": "MulanPSL-2.0",
42
35
  "keywords": [
43
36
  "dsh",
@@ -61,5 +54,12 @@
61
54
  },
62
55
  "publishConfig": {
63
56
  "access": "public"
57
+ },
58
+ "scripts": {
59
+ "build": "tsc -p tsconfig.json",
60
+ "build:server": "tsc -p server/tsconfig.json",
61
+ "test": "node --test test/*.test.js",
62
+ "publish:npm": "node scripts/publish.mjs",
63
+ "publish:dry": "node scripts/publish.mjs --dry-run"
64
64
  }
65
- }
65
+ }