@pandablessing/dsh-user-sync 1.0.3 → 1.0.4

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
@@ -2,13 +2,14 @@
2
2
 
3
3
  DeepSeek Harness(DSH)的**用户级配置同步插件**:把技能、插件清单、配置、记忆四类资产在设备间对齐。
4
4
 
5
- > 当前状态:**v1.0.3**(已发布 npm:`@pandablessing/dsh-user-sync`)。本机打包/应用、跨机上传/恢复、缺插件装回、自动检查均已实现并实测;服务端已部署(见 [部署文档](docs/deploy.md))。
5
+ > 当前状态:**v1.0.4**(已发布 npm:`@pandablessing/dsh-user-sync`)。本机打包/应用、跨机上传/恢复、缺插件装回、自动检查均已实现并实测;服务端已部署(见 [部署文档](docs/deploy.md))。
6
6
  > 设计依据:[docs/design.md](docs/design.md);测试用例:[docs/test-cases.md](docs/test-cases.md)。
7
7
 
8
8
  ## 功能
9
9
 
10
10
  - **四类用户级资产**:技能(`~/.agents/skills`)、插件清单(profile 声明)、配置(`cordis.patch.yml`)、用户记忆(`AGENTS.md` + `memories/` 根);项目记忆默认不选,会话不参与同步。
11
11
  - **新覆盖旧**:包级 `createdAt` 比较,不做合并;覆盖前必须确认,先快照再写盘,失败可整批还原。
12
+ - **上传前查远端**:远端已被别的设备更新时先拦下(`E_REMOTE_NEWER`),用户点「用本机覆盖远端」才覆盖。
12
13
  - **敏感数据硬拒绝**:文件名黑名单 + 内容模式,命中即拒绝打包并列出文件与行号。
13
14
  - **官方账号自动配对**:用 `deepseekAccount` 取账号 id 派生 `accountKey`,自动向服务端换 token,**没有运营方账号登录入口**。
14
15
  - **缺插件自动装回**:覆盖确认里列出缺失 bundle,一键 `installBundle`;`link:` 本地包列入手工清单。
@@ -23,6 +24,8 @@ cd ~/.dsh/profiles/desktop
23
24
  pnpm add @pandablessing/dsh-user-sync
24
25
  ```
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
+
26
29
  安装后在 DSH 设置页启用「设置同步」。
27
30
 
28
31
  ### 开发期(junction 到本仓库)
@@ -41,10 +44,10 @@ scripts\uninstall-dev.cmd # 双击即可
41
44
 
42
45
  1. 设置 → 设置同步 → 首次点「同意并启用上传」(说明上传范围与存储位置);
43
46
  2. 勾选同步范围(默认四类用户级);
44
- 3. 「上传用户配置」→ 打包并上传;
47
+ 3. 「上传用户配置」→ 打包并上传(远端更新时先确认再用本机覆盖);
45
48
  4. 「从远端同步」→ 检查远端,覆盖前确认。
46
49
 
47
- 服务地址默认内置(`https://dsh-user-sync-test.wenzhi.info`),传输固定走运营方 API,面板不再暴露这两项配置。
50
+ 服务地址默认内置(`https://dsh-user-sync-test.wenzhi.info`),传输固定走运营方 API;面板只读显示当前地址(`(内置)` / `(自定义)`),不提供输入框。需要改用自建服务时,在 `state.json` 写 `baseUrl` 即可覆盖。
48
51
 
49
52
  ## 服务端
50
53
 
@@ -71,8 +74,8 @@ pnpm install # 仅开发:安装 typescript 与 @types/node
71
74
  pnpm run build # 插件:src/*.ts → lib/*.js
72
75
  pnpm run build:server # 服务端:server/src/*.ts → server/dist/*.js
73
76
 
74
- node --test test/*.test.js # 137 条:单元 / 组件 / 时序(直接跑源码)
75
- node scripts/audit-cases.mjs # 用例清单与结果记录一致性审计(129/129)
77
+ node --test test/*.test.js # 150 条:单元 / 组件 / 时序(直接跑源码,macOS 全绿)
78
+ node scripts/audit-cases.mjs # 用例清单与结果记录一致性审计(145/145)
76
79
  node scripts/e2e-clone.mjs # 替代换机演练(默认本地模式,临时起服务端,不写真实 WebDAV)
77
80
  # node scripts/e2e-clone.mjs --remote https://dsh-user-sync-test.wenzhi.info # 真实服务端(会留测试目录)
78
81
  powershell -File scripts/smoke.ps1 # 真实 DSH 路由冒烟
@@ -93,10 +96,10 @@ legacy/ 需求反馈稿与原型脚本
93
96
 
94
97
  ## 已知限制
95
98
 
96
- - 未在 macOS/Linux 实机验证;跨平台哈希由单测(HASH-01/06)覆盖。
99
+ - macOS 上单测已全绿(Node 22.23.3),但 DSH 桌面端整机闭环只在 Windows 实测过;Linux 未验证。
97
100
  - 服务端单节点,未做限流与多副本;坚果云单账号有流量配额限制。
98
101
  - `link:` 本地依赖需要手工放回目标机。
99
- - 远端只保留 1 份包,上传即覆盖(回滚靠本地 `pre-restore` 快照)。
102
+ - 远端只保留 1 份包,上传即覆盖(回滚靠本地 `pre-restore` 快照;每次应用生成独立快照目录)。
100
103
 
101
104
  ## License
102
105
 
package/lib/client.js CHANGED
@@ -159,7 +159,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
159
159
  }
160
160
  return parts.length > 0 ? parts.join(' · ') : '尚未同步';
161
161
  }
162
- function ResultCard({ notice, onApply, onConfirmModules, onDismiss, onInstallPlugins }) {
162
+ function ResultCard({ notice, onApply, onConfirmModules, onDismiss, onInstallPlugins, onForceUpload }) {
163
163
  if (!notice)
164
164
  return null;
165
165
  const { kind, data } = notice;
@@ -180,8 +180,13 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
180
180
  return R.createElement('div', { style: styles.card }, R.createElement('strong', null, decision.action === 'skip' ? '本机已是最新' : '本机有改动'), R.createElement('p', { style: styles.muted }, `判定:${decision.reason ?? decision.action}`));
181
181
  }
182
182
  if (kind === 'error') {
183
- return R.createElement('div', { style: styles.card }, R.createElement('strong', { style: styles.error }, '操作失败'), R.createElement('p', { style: styles.muted }, data.detail ?? data.error ?? '未知错误'), data.hits
183
+ const remoteNewer = data.code === 'E_REMOTE_NEWER';
184
+ 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
184
185
  ? R.createElement('p', { style: styles.error }, `敏感扫描命中 ${data.hits.length} 处:${data.hits.map((h) => `${h.file}:${h.line}(${h.pattern})`).join(';')}`)
186
+ : null,
187
+ // 上传前发现远端更新:默认不覆盖,用户确认后才用本机覆盖远端(G2)
188
+ remoteNewer
189
+ ? R.createElement('div', { style: styles.actions }, R.createElement('button', { style: styles.primary, onClick: () => onForceUpload() }, '用本机覆盖远端'), R.createElement('button', { style: styles.secondary, onClick: () => onDismiss() }, '取消'))
185
190
  : null);
186
191
  }
187
192
  return R.createElement('div', { style: styles.card }, R.createElement('strong', null, '完成'), R.createElement('p', { style: styles.muted }, data.detail
@@ -354,11 +359,16 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
354
359
  setBusy(false);
355
360
  }
356
361
  };
357
- const upload = async () => {
362
+ /** force=true 表示用户已确认「用本机覆盖远端」(远端更新时才需要)。 */
363
+ const upload = async (force = false) => {
358
364
  setBusy(true);
359
365
  setNotice(null);
360
366
  try {
361
- const result = await fetchJson(`${API}/upload`, { method: 'POST' });
367
+ const result = await fetchJson(`${API}/upload`, {
368
+ method: 'POST',
369
+ headers: { 'content-type': 'application/json' },
370
+ body: JSON.stringify({ force }),
371
+ });
362
372
  setNotice({ kind: result.ok ? 'result' : 'error', data: result });
363
373
  refresh();
364
374
  }
@@ -491,12 +501,15 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
491
501
  style: { width: 72, padding: '2px 6px', fontSize: 13 },
492
502
  }))), R.createElement('div', { style: styles.actions },
493
503
  // 两个操作都用普通按钮:主色高亮会被看成「已选中」
494
- R.createElement('button', { style: styles.secondary, disabled: busy || !consentGiven, onClick: upload }, busy ? '处理中…' : '上传用户配置'), R.createElement('button', { style: styles.secondary, disabled: busy, onClick: checkRemote }, '从远端同步')), R.createElement('p', { style: styles.muted }, lastSyncSummary(status)), R.createElement(ResultCard, {
504
+ R.createElement('button', { style: styles.secondary, disabled: busy || !consentGiven, onClick: () => upload() }, busy ? '处理中…' : '上传用户配置'), R.createElement('button', { style: styles.secondary, disabled: busy, onClick: checkRemote }, '从远端同步')), R.createElement('p', { style: styles.muted }, lastSyncSummary(status)),
505
+ // 服务地址只读展示:默认内置,用户覆盖后标注来源(面板不再提供输入框)
506
+ R.createElement('p', { style: styles.muted }, `服务地址:${status?.baseUrl ?? '(未解析)'}${status?.baseUrlSource === 'user' ? '(自定义)' : '(内置)'}`), R.createElement(ResultCard, {
495
507
  notice,
496
508
  onApply: applyRemote,
497
509
  onConfirmModules: confirmModules,
498
510
  onDismiss: () => setNotice(null),
499
511
  onInstallPlugins: installPlugins,
512
+ onForceUpload: () => upload(true),
500
513
  }), confirmDialog
501
514
  ? 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) }, '取消'))))
502
515
  : null);
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
+ import { spawn } from 'node:child_process';
1
2
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
2
- import { execFileSync } from 'node:child_process';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
@@ -16,6 +16,8 @@ import { createFolderTransport } from './transport/folder.js';
16
16
  export const name = 'dsh-user-sync';
17
17
  /** 不硬依赖任何服务:依赖缺失时降级,不把条目停在 pending。 */
18
18
  export const inject = [];
19
+ /** 内置服务地址(运营方 API)。用户可在 state.baseUrl 覆盖;这里必须是可用的正式地址。 */
20
+ export const DEFAULT_BASE_URL = 'https://dsh-user-sync-test.wenzhi.info';
19
21
  /** 非用户级模块:默认不选,首次勾选要提示一次。 */
20
22
  const NON_USER_LEVEL = ['projectMemory', 'sessions'];
21
23
  /**
@@ -23,6 +25,27 @@ const NON_USER_LEVEL = ['projectMemory', 'sessions'];
23
25
  * 日志落在 $DSH_HOME/user-sync/plugin-error.log(与状态文件同目录)。
24
26
  * base 可显式传入,测试用它隔离到临时目录,避免污染真实日志。
25
27
  */
28
+ /** 异步执行外部命令:绝不阻塞宿主事件循环(Electron 主进程卡死会拖垮整个界面)。 */
29
+ function runCommand(file, args, options) {
30
+ return new Promise((resolve, reject) => {
31
+ const child = spawn(file, args, { cwd: options.cwd, stdio: 'ignore' });
32
+ const timer = setTimeout(() => {
33
+ child.kill();
34
+ reject(new Error(`命令超时(${options.timeout} ms): ${file}`));
35
+ }, options.timeout);
36
+ child.on('error', (error) => {
37
+ clearTimeout(timer);
38
+ reject(error);
39
+ });
40
+ child.on('close', (code) => {
41
+ clearTimeout(timer);
42
+ if (code === 0)
43
+ resolve();
44
+ else
45
+ reject(new Error(`命令退出码 ${code}: ${file}`));
46
+ });
47
+ });
48
+ }
26
49
  /** 找 DSH 运行时自带的 node 可执行文件(Electron 的 process.execPath 不能用)。 */
27
50
  function findRuntimeNode() {
28
51
  const root = join(homedir(), '.dsh', 'dsh-runtimes');
@@ -87,18 +110,24 @@ export function apply(ctx) {
87
110
  const authPath = join(paths.dshHome, 'user-sync', 'auth.json');
88
111
  const folderDir = join(paths.dshHome, 'user-sync', 'remote');
89
112
  /**
90
- * 传输选择:配了服务地址就用运营方 API,否则回退本地目录(无服务端调试)。
113
+ * 内置服务地址:用户未在 state 里覆盖时用它。
114
+ * 没有它,全新安装的机器会静默落到本地目录,既不配对也不连服务端(面板已不提供地址输入)。
115
+ */
116
+ const effectiveBaseUrl = () => {
117
+ const state = loadState(statePath);
118
+ const configured = typeof state.baseUrl === 'string' ? state.baseUrl.trim() : '';
119
+ return configured || DEFAULT_BASE_URL;
120
+ };
121
+ /**
122
+ * 传输选择:默认走运营方 API(内置地址),地址非法或用户显式清空时才回退本地目录。
91
123
  * 面板已隐藏「传输方式」,不再读 state.transport——避免历史设置把正式环境切成 folder。
92
124
  */
93
125
  const buildTransport = () => {
94
- const state = loadState(statePath);
95
- if (state.baseUrl) {
96
- try {
97
- return createApiTransport({ baseUrl: state.baseUrl, authPath });
98
- }
99
- catch (error) {
100
- log('transport-api', error);
101
- }
126
+ try {
127
+ return createApiTransport({ baseUrl: effectiveBaseUrl(), authPath });
128
+ }
129
+ catch (error) {
130
+ log('transport-api', error);
102
131
  }
103
132
  return createFolderTransport({ dir: folderDir });
104
133
  };
@@ -213,8 +242,10 @@ export function apply(ctx) {
213
242
  resolvedBy: paths.resolvedBy,
214
243
  probe,
215
244
  transport: sync.getTransportId(),
216
- transportSetting: state.transport ?? 'folder',
217
- baseUrl: state.baseUrl ?? null,
245
+ // 实际生效的传输方式(transport 已按内置地址解析,不再由 state.transport 决定)
246
+ transportSetting: sync.getTransportId(),
247
+ baseUrl: effectiveBaseUrl(),
248
+ baseUrlSource: typeof state.baseUrl === 'string' && state.baseUrl.trim() ? 'user' : 'default',
218
249
  account: { hint: state.accountKeyHint ?? null, paired: Boolean(state.pairedAt) },
219
250
  deviceId: state.deviceId ?? null,
220
251
  lastApplied: state.lastApplied ?? null,
@@ -284,8 +315,9 @@ export function apply(ctx) {
284
315
  modules: sync.getModules(),
285
316
  autoCheck: settings.autoCheck,
286
317
  intervalHours: settings.intervalHours,
287
- transport: state.transport ?? 'folder',
288
- baseUrl: state.baseUrl ?? null,
318
+ transport: sync.getTransportId(),
319
+ baseUrl: effectiveBaseUrl(),
320
+ baseUrlSource: typeof state.baseUrl === 'string' && state.baseUrl.trim() ? 'user' : 'default',
289
321
  });
290
322
  }
291
323
  const body = (await request.json());
@@ -333,8 +365,8 @@ export function apply(ctx) {
333
365
  modules: Array.isArray(state.modules) ? state.modules : sync.getModules(),
334
366
  autoCheck: state.autoCheck ?? true,
335
367
  intervalHours: state.intervalHours ?? 6,
336
- transport: state.transport ?? 'folder',
337
- baseUrl: state.baseUrl ?? null,
368
+ transport: sync.getTransportId(),
369
+ baseUrl: effectiveBaseUrl(),
338
370
  transportNow: sync.getTransportId(),
339
371
  });
340
372
  },
@@ -360,12 +392,21 @@ export function apply(ctx) {
360
392
  path: '/api/dsh-user-sync/upload',
361
393
  methods: ['POST'],
362
394
  requestBody: 'buffered',
363
- fetch: async () => {
395
+ fetch: async (request) => {
364
396
  const state = loadState(statePath);
365
397
  if (!state.consent?.acceptedAt) {
366
398
  return json({ ok: false, code: 'E_CONSENT_REQUIRED', detail: '请先同意隐私说明再上传' }, 403);
367
399
  }
368
- return json(await sync.upload());
400
+ // force=true:用户已看到「远端有更新」并确认用本机覆盖远端(G2)
401
+ let force = false;
402
+ try {
403
+ const body = (await request.json());
404
+ force = body?.force === true;
405
+ }
406
+ catch {
407
+ // 无 body(面板首次点击)时按未确认处理
408
+ }
409
+ return json(await sync.upload({ force }));
369
410
  },
370
411
  },
371
412
  {
@@ -416,10 +457,10 @@ export function apply(ctx) {
416
457
  const pnpmCjs = runtime ? join(dirname(runtime), '..', '..', 'pnpm', 'bin', 'pnpm.cjs') : '';
417
458
  if (!runtime || !existsSync(pnpmCjs))
418
459
  throw new Error('runtime node/pnpm not found');
419
- execFileSync(runtime, [pnpmCjs, 'install', '--force'], {
460
+ // 异步执行:pnpm 装插件可能要几十秒,同步调用会卡住宿主主线程
461
+ await runCommand(runtime, [pnpmCjs, 'install', '--force'], {
420
462
  cwd: paths.profileDir,
421
- stdio: 'ignore',
422
- timeout: 300000,
463
+ timeout: 300_000,
423
464
  });
424
465
  for (const item of missingDirs) {
425
466
  const ok = existsSync(join(paths.profileDir, 'node_modules', item.name));
@@ -546,13 +587,12 @@ export function apply(ctx) {
546
587
  const state = loadState(statePath);
547
588
  state.accountKeyHint = accountKey.slice(0, 6);
548
589
  saveState(statePath, state);
549
- if (state.transport === 'api' && state.baseUrl) {
550
- const api = createApiTransport({ baseUrl: state.baseUrl, authPath });
551
- await api.register(accountKey);
552
- const next = loadState(statePath);
553
- next.pairedAt = new Date().toISOString();
554
- saveState(statePath, next);
555
- }
590
+ // 传输固定走运营方 API(内置地址或用户覆盖),未登录/离线时只记日志、不阻塞启动
591
+ const api = createApiTransport({ baseUrl: effectiveBaseUrl(), authPath });
592
+ await api.register(accountKey);
593
+ const next = loadState(statePath);
594
+ next.pairedAt = new Date().toISOString();
595
+ saveState(statePath, next);
556
596
  }
557
597
  catch (error) {
558
598
  log('pair', error);
package/lib/sync.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { readFileSync } from 'node:fs';
2
3
  import { join } from 'node:path';
3
4
  import { buildManifest, buildTarGz, MANIFEST_PATH, normalizeManifest, parseTarGz } from './archive.js';
@@ -21,8 +22,17 @@ export function createSync({ dshHome, userHome, profileName = 'desktop', transpo
21
22
  state: statePath ?? join(dshHome, 'user-sync', 'state.json'),
22
23
  history: historyPath ?? join(dshHome, 'user-sync', 'history.jsonl'),
23
24
  lock: lockPath ?? join(dshHome, 'user-sync', 'lock'),
24
- preRestore: preRestoreRoot ?? join(dshHome, 'user-sync', `pre-restore-${Date.now()}`),
25
+ preRestoreBase: preRestoreRoot ?? join(dshHome, 'user-sync'),
25
26
  };
27
+ /**
28
+ * 每次应用都生成独立快照目录:同进程内多次恢复不会互相覆盖快照(§7.5)。
29
+ * 显式传入 preRestoreRoot 时它作为基目录,快照仍落在其下的时间戳子目录。
30
+ */
31
+ function nextPreRestoreRoot() {
32
+ // 精确到毫秒 + 随机后缀:同一秒内连续两次应用也不会撞目录
33
+ const stamp = `${new Date().toISOString().replace(/[:.]/g, '-')}-${randomUUID().slice(0, 6)}`;
34
+ return join(paths.preRestoreBase, `pre-restore-${stamp}`);
35
+ }
26
36
  /** 包内路径 → 绝对路径:skills/ 落到 userHome,其余落到 dshHome。 */
27
37
  const resolveTarget = (rel) => {
28
38
  if (rel.startsWith('skills/'))
@@ -110,13 +120,67 @@ export function createSync({ dshHome, userHome, profileName = 'desktop', transpo
110
120
  }
111
121
  return { ok: true, buffer: buildTarGz(tarEntries), manifest };
112
122
  }
113
- /** 上传当前本地包;上传前必须由调用方完成覆盖确认。 */
114
- async function upload() {
123
+ /**
124
+ * 只读远端最新包(list + meta),失败时返回 null。
125
+ * 用于上传前判断「远端是否已被别的设备更新」,离线时不影响上传。
126
+ */
127
+ async function latestRemote() {
128
+ let remoteList;
129
+ try {
130
+ remoteList = await activeTransport.list();
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ if (remoteList.length === 0)
136
+ return null;
137
+ const latest = [...remoteList].sort((a, b) => (Date.parse(b.modifiedAt) || 0) - (Date.parse(a.modifiedAt) || 0))[0];
138
+ let packageId = null;
139
+ let createdAt = latest.modifiedAt;
140
+ try {
141
+ const meta = await activeTransport.meta(latest.name);
142
+ if (meta?.packageId)
143
+ packageId = String(meta.packageId);
144
+ if (meta?.createdAt)
145
+ createdAt = String(meta.createdAt);
146
+ }
147
+ catch {
148
+ // 元数据不可读时退回列表时间;不阻塞上传
149
+ }
150
+ return { name: latest.name, packageId, createdAt };
151
+ }
152
+ /**
153
+ * 上传当前本地包。上传前先只读检查远端:
154
+ * 远端是另一份更新的包时返回 `E_REMOTE_NEWER`,由用户确认后带 `force` 重试(G2)。
155
+ */
156
+ async function upload({ force = false } = {}) {
115
157
  const lock = acquireLock({ lockPath: paths.lock });
116
158
  if (!lock.acquired)
117
159
  return { ok: false, code: 'E_LOCKED', holder: lock.holder };
118
160
  const started = Date.now();
119
161
  try {
162
+ if (!force) {
163
+ const state = loadState(paths.state);
164
+ const lastUploaded = state.lastUploaded;
165
+ const lastApplied = state.lastApplied;
166
+ const remote = await latestRemote();
167
+ // 远端就是本机上次上传/应用的包:不算「别人更新」,直接覆盖自己
168
+ const isMine = remote?.packageId
169
+ ? remote.packageId === lastUploaded?.packageId || remote.packageId === lastApplied?.packageId
170
+ : false;
171
+ if (remote && !isMine) {
172
+ const remoteTime = Date.parse(remote.createdAt) || 0;
173
+ const localTime = Math.max(Date.parse(lastUploaded?.createdAt ?? '') || 0, Date.parse(lastApplied?.createdAt ?? '') || 0);
174
+ if (remoteTime > localTime) {
175
+ return {
176
+ ok: false,
177
+ code: 'E_REMOTE_NEWER',
178
+ detail: `远端有更新的包(${remote.name},${remote.createdAt}),确认后再用本机覆盖`,
179
+ remote: { name: remote.name, createdAt: remote.createdAt },
180
+ };
181
+ }
182
+ }
183
+ }
120
184
  const packed = pack();
121
185
  if (!packed.ok)
122
186
  return packed;
@@ -151,7 +215,8 @@ export function createSync({ dshHome, userHome, profileName = 'desktop', transpo
151
215
  /** 只读远端元数据,返回判定结果;不下载、不写盘。 */
152
216
  async function check() {
153
217
  const remoteList = await activeTransport.list();
154
- const latest = remoteList[0] ?? null;
218
+ // 服务端按账号只留 1 份,但历史遗留多份时按时间取最新,不能依赖列表顺序
219
+ const latest = [...remoteList].sort((a, b) => (Date.parse(b.modifiedAt) || 0) - (Date.parse(a.modifiedAt) || 0))[0] ?? null;
155
220
  const meta = latest ? await activeTransport.meta(latest.name) : null;
156
221
  const state = loadState(paths.state);
157
222
  const { map: localFiles } = currentFiles();
@@ -210,7 +275,7 @@ export function createSync({ dshHome, userHome, profileName = 'desktop', transpo
210
275
  }
211
276
  const { map: localFiles } = currentFiles();
212
277
  const plan = planApply({ manifestFiles: manifest.files, localFiles });
213
- const applied = applyEntries({ entries, resolveTarget, preRestoreRoot: paths.preRestore });
278
+ const applied = applyEntries({ entries, resolveTarget, preRestoreRoot: nextPreRestoreRoot() });
214
279
  // PLUG-06:包内 cordis.patch.yml 引用的环境变量在本机缺失时提示(「插件装了但用不了」)
215
280
  const missingEnv = [];
216
281
  for (const entry of entries) {
@@ -85,6 +85,17 @@ export function createApiTransport({ baseUrl, authPath, token: initialToken, fet
85
85
  const text = await response.text();
86
86
  return text ? JSON.parse(text) : null;
87
87
  };
88
+ /**
89
+ * 用同一 accountKey 取回同一个 token(不签发新 token)。
90
+ * 抽成独立函数:register 里改走 login 时不依赖 `this`,解构调用也不会炸。
91
+ */
92
+ async function loginWith(accountKey) {
93
+ const response = await request('POST', '/v1/auth/login', { body: JSON.stringify({ accountKey }) });
94
+ const data = await parseJson(response);
95
+ const token = String(data?.token ?? '');
96
+ persistAuth({ token, accountKeyHint: accountKey.slice(0, 6), registeredAt: new Date().toISOString() });
97
+ return { ok: true, token };
98
+ }
88
99
  return {
89
100
  id: 'api',
90
101
  baseUrl,
@@ -102,17 +113,11 @@ export function createApiTransport({ baseUrl, authPath, token: initialToken, fet
102
113
  }
103
114
  catch (error) {
104
115
  if (error.code === 'E_HTTP_409')
105
- return this.login(accountKey);
116
+ return loginWith(accountKey);
106
117
  throw error;
107
118
  }
108
119
  },
109
- async login(accountKey) {
110
- const response = await request('POST', '/v1/auth/login', { body: JSON.stringify({ accountKey }) });
111
- const data = await parseJson(response);
112
- const token = String(data?.token ?? '');
113
- persistAuth({ token, accountKeyHint: accountKey.slice(0, 6), registeredAt: new Date().toISOString() });
114
- return { ok: true, token };
115
- },
120
+ login: loginWith,
116
121
  async reset() {
117
122
  await request('POST', '/v1/auth/reset');
118
123
  clearAuth();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pandablessing/dsh-user-sync",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "DeepSeek Harness 用户级配置同步插件",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -31,6 +31,11 @@
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
+ },
34
39
  "license": "MulanPSL-2.0",
35
40
  "keywords": [
36
41
  "dsh",
@@ -54,10 +59,5 @@
54
59
  },
55
60
  "publishConfig": {
56
61
  "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
62
  }
63
- }
63
+ }