dsh-config-manager 0.1.78 → 0.1.79

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.
Files changed (290) hide show
  1. package/lib/adapters/credentials.d.ts +6 -2
  2. package/lib/adapters/credentials.js +19 -3
  3. package/lib/adapters/file-collection.js +2 -1
  4. package/lib/adapters/mcp.d.ts +48 -2
  5. package/lib/adapters/mcp.js +265 -3
  6. package/lib/adapters/messages.d.ts +3 -0
  7. package/lib/adapters/messages.js +7 -0
  8. package/lib/adapters/plugin-files.js +2 -1
  9. package/lib/adapters/plugins.js +105 -20
  10. package/lib/adapters/pnpm-workspace.d.ts +32 -0
  11. package/lib/adapters/pnpm-workspace.js +113 -2
  12. package/lib/adapters/prompts.d.ts +6 -2
  13. package/lib/adapters/prompts.js +17 -2
  14. package/lib/adapters/providers.d.ts +19 -2
  15. package/lib/adapters/providers.js +59 -3
  16. package/lib/adapters/settings.d.ts +6 -2
  17. package/lib/adapters/settings.js +13 -2
  18. package/lib/adapters/skills.d.ts +25 -1
  19. package/lib/adapters/skills.js +60 -0
  20. package/lib/adapters/ui.d.ts +6 -2
  21. package/lib/adapters/ui.js +13 -2
  22. package/lib/adapters/units.d.ts +18 -0
  23. package/lib/adapters/units.js +39 -0
  24. package/lib/adapters/workspaces.js +2 -1
  25. package/lib/client.d.ts +400 -112
  26. package/lib/client.js +2845 -1524
  27. package/lib/core/backup.js +20 -0
  28. package/lib/core/mcp-connector-store.d.ts +159 -0
  29. package/lib/core/mcp-connector-store.js +445 -0
  30. package/lib/core/messages-core.d.ts +2 -0
  31. package/lib/core/messages-core.js +4 -0
  32. package/lib/core/messages.d.ts +20 -0
  33. package/lib/core/messages.js +44 -0
  34. package/lib/core/phase3-host.js +18 -1
  35. package/lib/core/plugin-cli.d.ts +22 -2
  36. package/lib/core/plugin-cli.js +22 -3
  37. package/lib/core/recovery-orchestrator.d.ts +3 -1
  38. package/lib/core/recovery-orchestrator.js +75 -16
  39. package/lib/core/rollback-activity.d.ts +89 -0
  40. package/lib/core/rollback-activity.js +350 -0
  41. package/lib/core/rollback-ledger.d.ts +0 -9
  42. package/lib/core/rollback-ledger.js +37 -0
  43. package/lib/core/rollback-merge.d.ts +22 -2
  44. package/lib/core/rollback-merge.js +63 -13
  45. package/lib/core/rollback-point.d.ts +93 -0
  46. package/lib/core/rollback-point.js +420 -101
  47. package/lib/core/rollback-undo-journal.d.ts +123 -0
  48. package/lib/core/rollback-undo-journal.js +517 -0
  49. package/lib/core/self-update.js +2 -4
  50. package/lib/core/text-fidelity.d.ts +89 -0
  51. package/lib/core/text-fidelity.js +176 -0
  52. package/lib/core/types.d.ts +12 -3
  53. package/lib/core/update-check.js +2 -4
  54. package/lib/core/validator.d.ts +9 -0
  55. package/lib/core/validator.js +12 -0
  56. package/lib/foreign/bundle.js +42 -3
  57. package/lib/foreign/claude-code.js +358 -26
  58. package/lib/foreign/claude-sessions.d.ts +51 -8
  59. package/lib/foreign/claude-sessions.js +111 -13
  60. package/lib/foreign/read-claude-code.d.ts +33 -4
  61. package/lib/foreign/read-claude-code.js +446 -65
  62. package/lib/foreign/read-claude-plugins.d.ts +36 -0
  63. package/lib/foreign/read-claude-plugins.js +403 -0
  64. package/lib/foreign/registry.js +25 -2
  65. package/lib/foreign/truth-table.js +6 -4
  66. package/lib/foreign/types.d.ts +170 -2
  67. package/lib/index.d.ts +14 -5
  68. package/lib/index.js +60 -16
  69. package/lib/market/git-file-writer.js +7 -1
  70. package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
  71. package/lib/profiles/dsh-profile-launcher.js +58 -1
  72. package/lib/profiles/dsh-profile-shared.d.ts +8 -2
  73. package/lib/profiles/dsh-profile-shared.js +8 -2
  74. package/lib/routes/foreign.js +8 -1
  75. package/lib/routes/index.d.ts +1 -1
  76. package/lib/routes/kit.d.ts +2 -2
  77. package/lib/routes/sync.js +173 -9
  78. package/lib/schema/manifest.d.ts +6 -1
  79. package/lib/schema/manifest.js +55 -0
  80. package/lib/schema/types.d.ts +47 -2
  81. package/lib/sync/autosync-config.d.ts +73 -0
  82. package/lib/sync/autosync-config.js +89 -0
  83. package/lib/sync/autosync-scheduler.d.ts +80 -6
  84. package/lib/sync/autosync-scheduler.js +263 -40
  85. package/lib/sync/gist/gist-transport.d.ts +15 -1
  86. package/lib/sync/gist/gist-transport.js +66 -5
  87. package/lib/sync/gist/messages.d.ts +3 -0
  88. package/lib/sync/gist/messages.js +7 -0
  89. package/lib/sync/git/git-transport.d.ts +42 -3
  90. package/lib/sync/git/git-transport.js +146 -3
  91. package/lib/sync/merge.d.ts +47 -14
  92. package/lib/sync/merge.js +242 -222
  93. package/lib/sync/messages.d.ts +8 -0
  94. package/lib/sync/messages.js +19 -0
  95. package/lib/sync/risk.d.ts +19 -2
  96. package/lib/sync/risk.js +10 -3
  97. package/lib/sync/s3/messages.d.ts +6 -0
  98. package/lib/sync/s3/messages.js +13 -0
  99. package/lib/sync/s3/s3-transport.d.ts +9 -1
  100. package/lib/sync/s3/s3-transport.js +78 -6
  101. package/lib/sync/sync-engine.d.ts +193 -4
  102. package/lib/sync/sync-engine.js +450 -37
  103. package/lib/sync/sync-history.d.ts +11 -0
  104. package/lib/sync/sync-history.js +20 -0
  105. package/lib/sync/sync-state.d.ts +43 -0
  106. package/lib/sync/sync-state.js +44 -0
  107. package/lib/sync/sync-watcher.d.ts +76 -0
  108. package/lib/sync/sync-watcher.js +202 -0
  109. package/lib/sync/text-merge.d.ts +85 -0
  110. package/lib/sync/text-merge.js +513 -0
  111. package/lib/sync/transport.d.ts +48 -0
  112. package/lib/sync/transport.js +12 -0
  113. package/lib/sync/watch-scope.d.ts +38 -0
  114. package/lib/sync/watch-scope.js +95 -0
  115. package/lib/sync/webdav/webdav-transport.d.ts +16 -2
  116. package/lib/sync/webdav/webdav-transport.js +128 -11
  117. package/lib/ui/foreign-view.js +10 -0
  118. package/lib/ui/i18n.d.ts +8 -0
  119. package/lib/ui/i18n.js +20 -0
  120. package/lib/ui/rollback-view.d.ts +12 -0
  121. package/lib/ui/rollback-view.js +2 -0
  122. package/lib/ui/sync-settings-view.d.ts +47 -0
  123. package/lib/ui/sync-settings-view.js +24 -0
  124. package/lib/ui/types.d.ts +21 -0
  125. package/lib/utils/env-lock.d.ts +1 -0
  126. package/lib/utils/env-lock.js +7 -4
  127. package/package.json +6 -6
  128. package/src/adapters/credentials.ts +22 -4
  129. package/src/adapters/file-collection.ts +2 -1
  130. package/src/adapters/mcp.test.ts +235 -1
  131. package/src/adapters/mcp.ts +276 -5
  132. package/src/adapters/messages.ts +7 -0
  133. package/src/adapters/plugin-files.ts +2 -1
  134. package/src/adapters/plugins.test.ts +176 -2
  135. package/src/adapters/plugins.ts +107 -21
  136. package/src/adapters/pnpm-workspace.test.ts +89 -1
  137. package/src/adapters/pnpm-workspace.ts +127 -2
  138. package/src/adapters/prompts.ts +20 -3
  139. package/src/adapters/providers.ts +62 -4
  140. package/src/adapters/settings.ts +16 -3
  141. package/src/adapters/skills.test.ts +141 -0
  142. package/src/adapters/skills.ts +58 -2
  143. package/src/adapters/ui.ts +16 -3
  144. package/src/adapters/units-entries.test.ts +190 -0
  145. package/src/adapters/units.ts +50 -0
  146. package/src/adapters/workspaces.ts +2 -1
  147. package/src/client/about/PluginUpdateDialog.tsx +4 -2
  148. package/src/client/about/ReleaseNotesDialog.tsx +4 -2
  149. package/src/client/chat/RollbackReviewDialog.tsx +3 -2
  150. package/src/client/common/Skeleton.tsx +32 -0
  151. package/src/client/common/routes.ts +9 -0
  152. package/src/client/config-manager.module.css +321 -11
  153. package/src/client/home/HomePanel.tsx +14 -6
  154. package/src/client/import/ForeignImportView.tsx +3 -1
  155. package/src/client/import/import-wizard-steps.tsx +2 -1
  156. package/src/client/library/ArtifactRow.tsx +12 -2
  157. package/src/client/library/LibraryActions.tsx +6 -3
  158. package/src/client/library/LibraryPanel.tsx +21 -3
  159. package/src/client/library/RestorePlanView.tsx +4 -2
  160. package/src/client/locales.ts +3 -0
  161. package/src/client/recovery/RecoveryPanel.tsx +54 -7
  162. package/src/client/recovery/RollbackCard.tsx +7 -2
  163. package/src/client/recovery/RollbackDiffPane.tsx +3 -2
  164. package/src/client/recovery/recovery-locales.ts +20 -2
  165. package/src/client/recovery/recovery-view.test.ts +39 -1
  166. package/src/client/recovery/recovery-view.ts +23 -1
  167. package/src/client/run-store.test.ts +1 -1
  168. package/src/client/run-store.ts +4 -0
  169. package/src/client/sync/AutosyncCard.tsx +49 -6
  170. package/src/client/sync/MergeModal.tsx +219 -0
  171. package/src/client/sync/MergePreviewView.tsx +306 -0
  172. package/src/client/sync/SyncActivityBar.tsx +31 -0
  173. package/src/client/sync/SyncConfirmView.tsx +2 -1
  174. package/src/client/sync/SyncPanel.tsx +130 -87
  175. package/src/client/sync/SyncProbeRow.tsx +162 -0
  176. package/src/client/sync/SyncSettingsView.test.ts +27 -2
  177. package/src/client/sync/WorkDirCard.tsx +44 -6
  178. package/src/client/sync/history-model.ts +4 -1
  179. package/src/client/sync/merge-view.test.ts +234 -0
  180. package/src/client/sync/merge-view.ts +371 -0
  181. package/src/client/sync/probe-view.test.ts +90 -0
  182. package/src/client/sync/probe-view.ts +63 -0
  183. package/src/client/sync/remote-device-view.test.ts +35 -0
  184. package/src/client/sync/remote-device-view.ts +25 -0
  185. package/src/client/sync/sync-api.ts +212 -2
  186. package/src/client/sync/sync-entry.test.ts +78 -0
  187. package/src/client/sync/sync-entry.ts +53 -0
  188. package/src/client/sync/sync-locales.ts +234 -2
  189. package/src/client/sync/sync-view.test.ts +18 -1
  190. package/src/client/sync/sync-view.ts +20 -0
  191. package/src/core/backup.test.ts +41 -0
  192. package/src/core/backup.ts +20 -0
  193. package/src/core/mcp-connector-store.test.ts +265 -0
  194. package/src/core/mcp-connector-store.ts +516 -0
  195. package/src/core/messages-core.ts +4 -0
  196. package/src/core/messages.ts +46 -0
  197. package/src/core/phase3-host.ts +18 -1
  198. package/src/core/phase3-production-integration.test.ts +4 -0
  199. package/src/core/plugin-cli.test.ts +26 -0
  200. package/src/core/plugin-cli.ts +30 -4
  201. package/src/core/recovery-orchestrator.test.ts +104 -5
  202. package/src/core/recovery-orchestrator.ts +74 -17
  203. package/src/core/rollback-activity.test.ts +311 -0
  204. package/src/core/rollback-activity.ts +367 -0
  205. package/src/core/rollback-cross-session.test.ts +694 -0
  206. package/src/core/rollback-interrupted.test.ts +137 -0
  207. package/src/core/rollback-ledger.ts +32 -0
  208. package/src/core/rollback-merge.test.ts +130 -0
  209. package/src/core/rollback-merge.ts +78 -14
  210. package/src/core/rollback-point.ts +468 -91
  211. package/src/core/rollback-undo-journal.test.ts +508 -0
  212. package/src/core/rollback-undo-journal.ts +571 -0
  213. package/src/core/self-update.ts +2 -5
  214. package/src/core/text-fidelity.test.ts +189 -0
  215. package/src/core/text-fidelity.ts +197 -0
  216. package/src/core/types.ts +11 -3
  217. package/src/core/update-check.ts +2 -5
  218. package/src/core/validator.ts +14 -0
  219. package/src/foreign/bundle-origin.test.ts +265 -0
  220. package/src/foreign/bundle.ts +45 -5
  221. package/src/foreign/claude-code.test.ts +268 -0
  222. package/src/foreign/claude-code.ts +406 -25
  223. package/src/foreign/claude-plugins.test.ts +234 -0
  224. package/src/foreign/claude-sessions.test.ts +141 -0
  225. package/src/foreign/claude-sessions.ts +179 -23
  226. package/src/foreign/file-budget.test.ts +4 -1
  227. package/src/foreign/read-claude-code.test.ts +320 -0
  228. package/src/foreign/read-claude-code.ts +490 -71
  229. package/src/foreign/read-claude-plugins.ts +465 -0
  230. package/src/foreign/registry.ts +25 -2
  231. package/src/foreign/session-ir.test.ts +65 -0
  232. package/src/foreign/truth-table.test.ts +81 -0
  233. package/src/foreign/truth-table.ts +6 -3
  234. package/src/foreign/types.ts +162 -2
  235. package/src/index.facade.test.ts +26 -4
  236. package/src/index.ts +76 -19
  237. package/src/market/git-file-writer.test.ts +4 -0
  238. package/src/market/git-file-writer.ts +7 -1
  239. package/src/profiles/dsh-profile-launcher.test.ts +68 -3
  240. package/src/profiles/dsh-profile-launcher.ts +56 -1
  241. package/src/profiles/dsh-profile-shared.ts +8 -2
  242. package/src/routes/foreign.ts +8 -1
  243. package/src/routes/index.ts +1 -1
  244. package/src/routes/kit.ts +2 -2
  245. package/src/routes/sync.ts +176 -9
  246. package/src/schema/manifest-schema.test.ts +87 -2
  247. package/src/schema/manifest.ts +57 -1
  248. package/src/schema/types.ts +45 -2
  249. package/src/sync/autosync-config.test.ts +73 -1
  250. package/src/sync/autosync-config.ts +124 -0
  251. package/src/sync/autosync-risk-tier.test.ts +38 -2
  252. package/src/sync/autosync-scheduler.test.ts +264 -12
  253. package/src/sync/autosync-scheduler.ts +296 -44
  254. package/src/sync/gist/gist-conditional-write.test.ts +223 -0
  255. package/src/sync/gist/gist-transport.ts +73 -7
  256. package/src/sync/gist/messages.ts +7 -0
  257. package/src/sync/git/git-probe.test.ts +80 -0
  258. package/src/sync/git/git-transport.test.ts +185 -0
  259. package/src/sync/git/git-transport.ts +178 -5
  260. package/src/sync/merge.test.ts +188 -55
  261. package/src/sync/merge.ts +282 -224
  262. package/src/sync/messages.ts +19 -0
  263. package/src/sync/risk.ts +29 -5
  264. package/src/sync/s3/messages.ts +13 -0
  265. package/src/sync/s3/s3-conditional-write.test.ts +330 -0
  266. package/src/sync/s3/s3-transport.ts +87 -9
  267. package/src/sync/sync-device-identity.test.ts +267 -0
  268. package/src/sync/sync-engine.test.ts +379 -0
  269. package/src/sync/sync-engine.ts +535 -36
  270. package/src/sync/sync-history.test.ts +16 -1
  271. package/src/sync/sync-history.ts +26 -0
  272. package/src/sync/sync-state.test.ts +64 -0
  273. package/src/sync/sync-state.ts +79 -0
  274. package/src/sync/sync-watcher.test.ts +197 -0
  275. package/src/sync/sync-watcher.ts +246 -0
  276. package/src/sync/text-merge.test.ts +358 -0
  277. package/src/sync/text-merge.ts +554 -0
  278. package/src/sync/transport.ts +57 -0
  279. package/src/sync/watch-scope.test.ts +122 -0
  280. package/src/sync/watch-scope.ts +120 -0
  281. package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
  282. package/src/sync/webdav/webdav-transport.ts +137 -14
  283. package/src/ui/foreign-view.test.ts +34 -0
  284. package/src/ui/foreign-view.ts +10 -0
  285. package/src/ui/i18n.ts +20 -0
  286. package/src/ui/rollback-view.ts +14 -0
  287. package/src/ui/sync-settings-view.test.ts +28 -1
  288. package/src/ui/sync-settings-view.ts +43 -0
  289. package/src/ui/types.ts +22 -0
  290. package/src/utils/env-lock.ts +8 -5
@@ -0,0 +1,267 @@
1
+ /**
2
+ * ⑥ 设备身份(sync-state.resolveDeviceIdentity + push manifest 携带)+ ② 引擎探针的单测。
3
+ *
4
+ * - resolveDeviceIdentity:首次调用生成并落盘 deviceId/deviceName(读回一致);
5
+ * 第二次调用**不再写盘**(写计数 fs 断言);空主机名 → 字典兜底文案;
6
+ * 已有 id 而无 name 的旧文件 → 纯读(磁盘逐字节不变);
7
+ * - engine.push:快照 manifest 带 deviceId/deviceName,且与 sync-state 落盘值一致、第二次 push 稳定;
8
+ * - engine.probe:通道无 probe → 抛 sync.probeUnsupported(绝不谎报连接正常);有 probe → 原样透出。
9
+ */
10
+ import test from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import fs from 'node:fs/promises';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ import { SyncEngine } from './sync-engine.ts';
17
+ import { createSnapshotFs } from './fs.ts';
18
+ import type { SnapshotFs } from './fs.ts';
19
+ import {
20
+ loadSyncState, resolveDeviceIdentity, saveSyncState, SYNC_STATE_FILE, SYNC_STATE_SCHEMA_VERSION,
21
+ } from './sync-state.ts';
22
+ import { computeSnapshotMeta, SyncTransportError } from './transport.ts';
23
+ import type { SyncSnapshot, SyncSnapshotMeta, SyncTransport } from './transport.ts';
24
+ import { createAdapters } from '../adapters/index.ts';
25
+ import { makeContext, MemSnapshotStore } from '../adapters/test-helpers.ts';
26
+ import { Importer } from '../core/importer.ts';
27
+ import { zhMsg } from '../core/messages.ts';
28
+
29
+ /* ---------------- 写计数 fs(断言「第二次不再写盘」) ---------------- */
30
+
31
+ function countingFs(): { fsx: SnapshotFs; stats: { writes: number; mkdirs: number } } {
32
+ const inner = createSnapshotFs();
33
+ const stats = { writes: 0, mkdirs: 0 };
34
+ const fsx: SnapshotFs = {
35
+ async readFile(p) { return inner.readFile(p); },
36
+ async writeFile(p, data) { stats.writes += 1; await inner.writeFile(p, data); },
37
+ async mkdir(p) { stats.mkdirs += 1; await inner.mkdir(p); },
38
+ async readdir(p) { return inner.readdir(p); },
39
+ async isDir(p) { return inner.isDir(p); },
40
+ async exists(p) { return inner.exists(p); },
41
+ async remove(p) { await inner.remove(p); },
42
+ };
43
+ return { fsx, stats };
44
+ }
45
+
46
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
47
+
48
+ /* ---------------- ⑥ resolveDeviceIdentity ---------------- */
49
+
50
+ test('⑥ 设备身份:首次调用生成 UUID + 主机名并落盘;第二次调用零写入', async () => {
51
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-device-id-'));
52
+ try {
53
+ const { fsx, stats } = countingFs();
54
+
55
+ const first = await resolveDeviceIdentity(tmp, ' workstation-a ', fsx);
56
+ assert.match(first.id, UUID_RE, 'deviceId 必须是随机 UUID');
57
+ assert.equal(first.name, 'workstation-a', 'deviceName 取主机名(两端空白去掉)');
58
+ assert.ok(stats.writes >= 1, '首次必须落盘');
59
+
60
+ // 读回一致(走真实 fs + loadSyncState)
61
+ const onDisk = JSON.parse(await fs.readFile(path.join(tmp, SYNC_STATE_FILE), 'utf8')) as Record<string, unknown>;
62
+ assert.equal(onDisk['deviceId'], first.id);
63
+ assert.equal(onDisk['deviceName'], 'workstation-a');
64
+ const loaded = await loadSyncState(tmp);
65
+ assert.equal(loaded.deviceId, first.id);
66
+ assert.equal(loaded.deviceName, first.name);
67
+
68
+ // 第二次:已有 id/name → 纯读
69
+ stats.writes = 0;
70
+ stats.mkdirs = 0;
71
+ const second = await resolveDeviceIdentity(tmp, 'workstation-a', fsx);
72
+ assert.equal(second.id, first.id, '同一台设备必须复用 deviceId');
73
+ assert.equal(second.name, 'workstation-a');
74
+ assert.equal(stats.writes, 0, '第二次调用绝不写盘');
75
+ assert.equal(stats.mkdirs, 0, '第二次调用连目录都不建');
76
+
77
+ // 换主机名也不变(deviceId 是稳定标识)
78
+ const renamed = await resolveDeviceIdentity(tmp, 'renamed-host', fsx);
79
+ assert.equal(renamed.id, first.id);
80
+ assert.equal(renamed.name, 'workstation-a', '已记录的 deviceName 不被改写');
81
+ assert.equal(stats.writes, 0);
82
+ } finally {
83
+ await fs.rm(tmp, { recursive: true, force: true });
84
+ }
85
+ });
86
+
87
+ test('⑥ 设备身份:主机名为空串 → deviceName 落到字典兜底文案(绝不写空串)', async () => {
88
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-device-fallback-'));
89
+ try {
90
+ const { fsx } = countingFs();
91
+ const r = await resolveDeviceIdentity(tmp, ' ', fsx);
92
+
93
+ assert.equal(r.name, zhMsg('sync.deviceNameFallback'));
94
+ assert.equal(r.name, '未命名设备', 'zh 字典兜底文案');
95
+ assert.ok(r.name.trim() !== '', 'deviceName 不得为空/纯空白');
96
+
97
+ const persisted = await loadSyncState(tmp);
98
+ assert.equal(persisted.deviceId, r.id);
99
+ assert.equal(persisted.deviceName, r.name, '兜底文案必须一并落盘(否则下次仍要重算)');
100
+ } finally {
101
+ await fs.rm(tmp, { recursive: true, force: true });
102
+ }
103
+ });
104
+
105
+ test('⑥ 设备身份:文件里已有 deviceId 但缺 deviceName → 纯读,磁盘逐字节不变', async () => {
106
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-device-legacy-'));
107
+ try {
108
+ await saveSyncState(tmp, {
109
+ schemaVersion: SYNC_STATE_SCHEMA_VERSION,
110
+ lastSyncAt: '',
111
+ sections: {},
112
+ lastSnapshotId: '',
113
+ ancestorId: '',
114
+ deviceId: 'existing-device-id-123',
115
+ });
116
+ const before = await fs.readFile(path.join(tmp, SYNC_STATE_FILE), 'utf8');
117
+
118
+ const { fsx, stats } = countingFs();
119
+ const r = await resolveDeviceIdentity(tmp, 'host-x', fsx);
120
+
121
+ assert.equal(r.id, 'existing-device-id-123', '已有 id 必须原样复用');
122
+ assert.equal(r.name, 'host-x', '缺 name 时按主机名解析');
123
+ assert.equal(stats.writes, 0, '已有 id ⇒ 绝不补写');
124
+ assert.equal(await fs.readFile(path.join(tmp, SYNC_STATE_FILE), 'utf8'), before, '磁盘逐字节不变');
125
+ } finally {
126
+ await fs.rm(tmp, { recursive: true, force: true });
127
+ }
128
+ });
129
+
130
+ /* ---------------- 引擎:manifest 设备身份 + probe ---------------- */
131
+
132
+ class MemTransport implements SyncTransport {
133
+ readonly type = 'memory';
134
+ snapshots = new Map<string, SyncSnapshot>();
135
+ metas: SyncSnapshotMeta[] = [];
136
+ calls: string[] = [];
137
+
138
+ async list(): Promise<SyncSnapshotMeta[]> {
139
+ this.calls.push('list');
140
+ return [...this.metas].sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
141
+ }
142
+
143
+ async upload(snapshot: SyncSnapshot): Promise<SyncSnapshotMeta> {
144
+ this.calls.push('upload');
145
+ this.snapshots.set(snapshot.id, snapshot);
146
+ this.metas = this.metas.filter((m) => m.id !== snapshot.id);
147
+ this.metas.push(computeSnapshotMeta(snapshot));
148
+ return computeSnapshotMeta(snapshot);
149
+ }
150
+
151
+ async download(id: string): Promise<SyncSnapshot> {
152
+ this.calls.push('download');
153
+ const snap = this.snapshots.get(id);
154
+ if (snap === undefined) throw new Error('missing snapshot: ' + id);
155
+ return snap;
156
+ }
157
+
158
+ async delete(id: string): Promise<void> {
159
+ this.calls.push('delete');
160
+ this.snapshots.delete(id);
161
+ this.metas = this.metas.filter((m) => m.id !== id);
162
+ }
163
+ }
164
+
165
+ function seedSettings(ctx: ReturnType<typeof makeContext>): void {
166
+ ctx.settings.ns.set('general', { value: { theme: 'dark', language: 'zh-CN' }, revision: 1, secrets: [] });
167
+ }
168
+
169
+ function makeEngine(opts: {
170
+ ctx: ReturnType<typeof makeContext>;
171
+ transport: SyncTransport;
172
+ stateDir: string;
173
+ }): SyncEngine {
174
+ const adapters = createAdapters({ namespaces: ['general', 'theme'] });
175
+ const importer = new Importer({ ctx: opts.ctx, adapters, snapshotStore: new MemSnapshotStore() });
176
+ return new SyncEngine({
177
+ ctx: opts.ctx,
178
+ transport: opts.transport,
179
+ stateDir: opts.stateDir,
180
+ adapters,
181
+ importer,
182
+ now: () => new Date('2026-10-18T12:00:00.000Z'),
183
+ } as ConstructorParameters<typeof SyncEngine>[0]);
184
+ }
185
+
186
+ test('⑥ push:快照 manifest 带 deviceId/deviceName,与 sync-state 落盘值一致且跨 push 稳定', async () => {
187
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-engine-device-'));
188
+ try {
189
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
190
+ seedSettings(ctx);
191
+ const transport = new MemTransport();
192
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
193
+
194
+ const report = await engine.push({ snapshotId: 'sync-dev-1' });
195
+ assert.equal(report.ok, true, 'push 必须成功:' + JSON.stringify(report.warnings));
196
+
197
+ const uploaded = transport.snapshots.get('sync-dev-1');
198
+ assert.ok(uploaded !== undefined, '快照已上传');
199
+ const deviceId = uploaded.manifest.deviceId;
200
+ const deviceName = uploaded.manifest.deviceName;
201
+ assert.ok(typeof deviceId === 'string' && UUID_RE.test(deviceId), 'manifest.deviceId 必须是 UUID,实际=' + String(deviceId));
202
+ assert.ok(typeof deviceName === 'string' && deviceName.trim() !== '', 'manifest.deviceName 必须非空');
203
+
204
+ const expectedName = os.hostname().trim() !== '' ? os.hostname().trim() : zhMsg('sync.deviceNameFallback');
205
+ assert.equal(deviceName, expectedName, 'deviceName = 本机主机名(取不到才是兜底文案)');
206
+
207
+ const state = await loadSyncState(tmp);
208
+ assert.equal(state.deviceId, deviceId, 'manifest 的 deviceId 必须就是同步身份落盘值');
209
+ assert.equal(state.deviceName, deviceName);
210
+
211
+ await engine.push({ snapshotId: 'sync-dev-2' });
212
+ const second = transport.snapshots.get('sync-dev-2');
213
+ assert.ok(second !== undefined);
214
+ assert.equal(second.manifest.deviceId, deviceId, '同一台设备连续 push 的 deviceId 必须稳定');
215
+ assert.equal(second.manifest.deviceName, deviceName);
216
+ } finally {
217
+ await fs.rm(tmp, { recursive: true, force: true });
218
+ }
219
+ });
220
+
221
+ test('② engine.probe():通道没实现 probe → 抛 sync.probeUnsupported,绝不谎报连接正常', async () => {
222
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-engine-probe-'));
223
+ try {
224
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
225
+ seedSettings(ctx);
226
+ const transport = new MemTransport();
227
+ assert.equal(typeof (transport as { probe?: unknown }).probe, 'undefined', '本用例前提:该通道没有 probe');
228
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
229
+
230
+ await assert.rejects(
231
+ () => engine.probe(),
232
+ (err: unknown) => {
233
+ assert.ok(err instanceof SyncTransportError);
234
+ assert.equal(err.kind, 'client');
235
+ assert.equal(err.retryable, false);
236
+ assert.equal(err.message, zhMsg('sync.probeUnsupported'));
237
+ assert.equal(err.message, '当前同步通道不支持连通性自检');
238
+ return true;
239
+ },
240
+ );
241
+ assert.deepEqual(transport.calls, [], '不支持 probe 时不得退化成任何通道调用');
242
+ } finally {
243
+ await fs.rm(tmp, { recursive: true, force: true });
244
+ }
245
+ });
246
+
247
+ test('② engine.probe():通道实现了 probe → 原样透出(this 绑定在通道实例上)', async () => {
248
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-engine-probe-ok-'));
249
+ try {
250
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
251
+ seedSettings(ctx);
252
+ const transport = new (class extends MemTransport {
253
+ probeCalls = 0;
254
+ async probe() {
255
+ this.probeCalls += 1;
256
+ return { message: 'ok:' + this.type, snapshotCount: 7 };
257
+ }
258
+ })();
259
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
260
+
261
+ const result = await engine.probe();
262
+ assert.deepEqual(result, { message: 'ok:memory', snapshotCount: 7 });
263
+ assert.equal(transport.probeCalls, 1);
264
+ } finally {
265
+ await fs.rm(tmp, { recursive: true, force: true });
266
+ }
267
+ });
@@ -1373,6 +1373,33 @@ test('hasNewRemoteSnapshot: 空远端→false;从未同步且远端非空→tr
1373
1373
  }
1374
1374
  });
1375
1375
 
1376
+ test('hasNewRemoteSnapshot(listed):传入本轮列表 → 不再 list(自动同步一轮至多一次网络往返)', async () => {
1377
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-hasnew-reuse-'));
1378
+ try {
1379
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
1380
+ seedSource(ctx);
1381
+ const transport = new MemSyncTransport();
1382
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
1383
+ await engine.push({ snapshotId: 'sync-001' });
1384
+
1385
+ const before = transport.calls.filter((c) => c === 'list').length;
1386
+ const listed = await engine.listSnapshots();
1387
+ assert.equal(transport.calls.filter((c) => c === 'list').length, before + 1);
1388
+
1389
+ assert.equal(await engine.hasNewRemoteSnapshot(listed), false, '复用传入列表判定「远端最新=祖先」');
1390
+ assert.equal(
1391
+ transport.calls.filter((c) => c === 'list').length, before + 1,
1392
+ '传了列表就绝不能再 list 一次(否则每个回合白跑一次远端往返)',
1393
+ );
1394
+
1395
+ // 对照组:不传 → 自己 list(保持旧行为)
1396
+ assert.equal(await engine.hasNewRemoteSnapshot(), false);
1397
+ assert.equal(transport.calls.filter((c) => c === 'list').length, before + 2, '不传时必须自己 list');
1398
+ } finally {
1399
+ await fs.rm(tmp, { recursive: true, force: true });
1400
+ }
1401
+ });
1402
+
1376
1403
  test('hasLocalChanges: 从未同步→true;推后无改动→false;本地改动→true', async () => {
1377
1404
  const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-haslocal-'));
1378
1405
  try {
@@ -2309,3 +2336,355 @@ test('issue #78: 拉取侧 —— 只放行 mcp 时,远端快照里的 session
2309
2336
  await fs.rm(tmp, { recursive: true, force: true });
2310
2337
  }
2311
2338
  });
2339
+
2340
+
2341
+ /* ---------------- 远程合并:push 远端推进检测 + 基线纪律(2026-10) ---------------- */
2342
+
2343
+ /** 造一份「另一台设备推的」远端快照(仅 settings 分区),与 e2e 同款夹具。 */
2344
+ function remoteSettingsSnapshot(id: string, theme: string, revision: number): SyncSnapshot {
2345
+ return {
2346
+ id,
2347
+ createdAt: '2026-08-16T12:00:00.000Z',
2348
+ manifest: { schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32', sectionIds: ['settings'], containsSecrets: false },
2349
+ sections: {
2350
+ settings: { version: 1, namespaces: { general: { value: { theme }, revision, secrets: [] } } },
2351
+ },
2352
+ };
2353
+ }
2354
+
2355
+ /** 把「别人的快照」塞进内存通道(模拟另一台设备推送)。 */
2356
+ function seedRemoteSnapshot(transport: MemSyncTransport, snapshot: SyncSnapshot): void {
2357
+ transport.snapshots.set(snapshot.id, snapshot);
2358
+ transport.metas.push(computeSnapshotMeta(snapshot));
2359
+ }
2360
+
2361
+ /** 递归收集目录下全部文件(相对路径 → 文本内容):用于断言「零写入」。 */
2362
+ async function snapshotTree(dir: string): Promise<Record<string, string>> {
2363
+ const out: Record<string, string> = {};
2364
+ const walk = async (rel: string): Promise<void> => {
2365
+ const entries = await fs.readdir(path.join(dir, rel), { withFileTypes: true }).catch(() => [] as import('node:fs').Dirent[]);
2366
+ for (const e of entries) {
2367
+ const child = rel === '' ? e.name : rel + '/' + e.name;
2368
+ if (e.isDirectory()) await walk(child);
2369
+ else out[child] = await fs.readFile(path.join(dir, child), 'utf8');
2370
+ }
2371
+ };
2372
+ await walk('');
2373
+ return out;
2374
+ }
2375
+
2376
+ /** list() 可注入失败的通道(覆盖「读不到远端列表」这条分支)。 */
2377
+ class FlakyListTransport extends MemSyncTransport {
2378
+ failList = false;
2379
+ override async list(): Promise<SyncSnapshotMeta[]> {
2380
+ if (this.failList) throw new Error('list-boom');
2381
+ return await super.list();
2382
+ }
2383
+ }
2384
+
2385
+ test('push: 远端已被别的设备推进 → 拒绝写入(不 upload、sync-state 逐字节不变),force 才放行', async () => {
2386
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-push-advance-'));
2387
+ try {
2388
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2389
+ seedSource(ctx);
2390
+ const transport = new MemSyncTransport();
2391
+ const engine = makeEngine({ ctx, transport, stateDir: tmp, localSnapshotsDir: path.join(tmp, 'anc') });
2392
+ // 首次同步:远端为空 → 不拦(本机没有基线可丢)
2393
+ assert.equal((await engine.push({ snapshotId: 'sync-base' })).ok, true);
2394
+ const stateBefore = await fs.readFile(path.join(tmp, SYNC_STATE_FILE), 'utf8');
2395
+
2396
+ // 另一台设备推了 sync-other(本机基线仍是 sync-base)
2397
+ seedRemoteSnapshot(transport, remoteSettingsSnapshot('sync-other', 'light', 5));
2398
+ transport.calls.length = 0;
2399
+ const refused = await engine.push({ snapshotId: 'sync-mine' });
2400
+
2401
+ assert.equal(refused.ok, false, '远端已推进 → 拒绝');
2402
+ assert.equal(refused.conflict, true, '这是「需要先合并远端」的冲突态,不是通道故障');
2403
+ assert.equal(refused.snapshotId, '');
2404
+ assert.deepEqual(refused.sections, []);
2405
+ assert.equal(refused.warnings.length, 0, '拒绝路径本身不产生额外告警');
2406
+ assert.ok((refused.message ?? '').includes('sync-other'), '说明里点出远端快照 id: ' + refused.message);
2407
+ assert.ok((refused.message ?? '').includes('sync-base'), '说明里点出本地基线 id: ' + refused.message);
2408
+ assert.equal(transport.snapshots.has('sync-mine'), false, '绝不 upload');
2409
+ assert.ok(!transport.calls.includes('upload'), 'transport 未收到 upload: ' + transport.calls.join(','));
2410
+ assert.equal(await fs.readFile(path.join(tmp, SYNC_STATE_FILE), 'utf8'), stateBefore, 'sync-state 逐字节不变');
2411
+
2412
+ // 用户显式强制才写
2413
+ const forced = await engine.push({ snapshotId: 'sync-mine', force: true });
2414
+ assert.equal(forced.ok, true, 'force=true 放行');
2415
+ assert.equal(transport.snapshots.has('sync-mine'), true);
2416
+ assert.equal((await loadSyncState(tmp)).lastSnapshotId, 'sync-mine');
2417
+ } finally {
2418
+ await fs.rm(tmp, { recursive: true, force: true });
2419
+ }
2420
+ });
2421
+
2422
+ test('push: 首次同步(lastSnapshotId="")即使远端已有别人的快照也不拦', async () => {
2423
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-push-first-'));
2424
+ try {
2425
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2426
+ seedSource(ctx);
2427
+ const transport = new MemSyncTransport();
2428
+ seedRemoteSnapshot(transport, remoteSettingsSnapshot('sync-other', 'light', 5));
2429
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
2430
+ const report = await engine.push({ snapshotId: 'sync-first' });
2431
+ assert.equal(report.ok, true, '没有基线就没有可丢的东西 → 不拦');
2432
+ assert.equal((await loadSyncState(tmp)).lastSnapshotId, 'sync-first');
2433
+ } finally {
2434
+ await fs.rm(tmp, { recursive: true, force: true });
2435
+ }
2436
+ });
2437
+
2438
+ test('push: 远端列表读不出来 → 无法判定,按原样继续但如实告警(不静默、不拦)', async () => {
2439
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-push-listfail-'));
2440
+ try {
2441
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2442
+ seedSource(ctx);
2443
+ const transport = new FlakyListTransport();
2444
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
2445
+ await engine.push({ snapshotId: 'sync-base' });
2446
+ transport.failList = true;
2447
+ const report = await engine.push({ snapshotId: 'sync-mine' });
2448
+ assert.equal(report.ok, true, '读不到远端列表不拦推送(上传本身会暴露通道问题)');
2449
+ assert.ok(report.warnings.some((w) => w.includes('list-boom')), '必须如实说明无法判定: ' + report.warnings.join(' | '));
2450
+ } finally {
2451
+ await fs.rm(tmp, { recursive: true, force: true });
2452
+ }
2453
+ });
2454
+
2455
+ test('previewMerge: 只读零写入 + 决策/计数/三侧预览(敏感值必须已脱敏)', async () => {
2456
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-preview-merge-'));
2457
+ try {
2458
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2459
+ seedSource(ctx);
2460
+ const transport = new MemSyncTransport();
2461
+ const ancestors = path.join(tmp, 'anc');
2462
+ const engine = makeEngine({ ctx, transport, stateDir: tmp, localSnapshotsDir: ancestors });
2463
+ // 先建基线(祖先 = dark + 一个值形态敏感内容:字段名不敏感,只能靠 redact 的值形状模式兜底)
2464
+ // 注意:载荷必须是「像密钥」的形状(含大写/数字)——纯小写词的 sk- 形态被值形状守卫刻意放行(防过剥)
2465
+ ctx.settings.ns.set('general', { value: { theme: 'dark', webhook: 'sk-anc-0000000000000000' }, revision: 3, secrets: [] });
2466
+ await engine.push({ snapshotId: 'sync-base' });
2467
+ // 本地改 blue / 远端改 red,且两侧的敏感值不同 → 该键必然留冲突,预览里必须有值可脱敏
2468
+ ctx.settings.ns.set('general', { value: { theme: 'blue', webhook: 'sk-live-AB12cd34EF56gh78' }, revision: 4, secrets: [] });
2469
+ seedRemoteSnapshot(transport, {
2470
+ id: 'sync-remote',
2471
+ createdAt: '2026-08-16T12:00:00.000Z',
2472
+ manifest: { schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32', sectionIds: ['settings'], containsSecrets: false },
2473
+ sections: {
2474
+ settings: {
2475
+ version: 1,
2476
+ namespaces: { general: { value: { theme: 'red', webhook: 'sk-live-Zx98wv76ut54sr32' }, revision: 9, secrets: [] } },
2477
+ },
2478
+ },
2479
+ });
2480
+
2481
+ const before = { state: await snapshotTree(tmp), anc: await snapshotTree(ancestors) };
2482
+ const preview = await engine.previewMerge();
2483
+ const after = { state: await snapshotTree(tmp), anc: await snapshotTree(ancestors) };
2484
+ assert.deepEqual(after, before, 'previewMerge 必须零写入(状态目录 + 祖先目录逐字节不变)');
2485
+
2486
+ assert.equal(preview.ok, true);
2487
+ assert.equal(preview.snapshotId, 'sync-remote');
2488
+ assert.equal(preview.hasAncestor, true, '有祖先副本 → 三方合并');
2489
+ const settings = preview.sections.find((s) => s.id === 'settings');
2490
+ assert.ok(settings !== undefined, 'plan 应含 settings');
2491
+ assert.equal(settings!.decision, 'conflict');
2492
+ assert.ok(settings!.conflictCount >= 1);
2493
+ const conflict = settings!.conflicts[0]!;
2494
+ assert.ok(conflict.localPreview.includes('blue'), '本机侧预览: ' + conflict.localPreview);
2495
+ assert.ok(conflict.remotePreview.includes('red'), '远端侧预览: ' + conflict.remotePreview);
2496
+ const serialized = JSON.stringify(preview);
2497
+ assert.ok(!serialized.includes('sk-live-AB12cd34EF56gh78'), '本机侧敏感值绝不回传浏览器');
2498
+ assert.ok(!serialized.includes('sk-live-Zx98wv76ut54sr32'), '远端侧敏感值绝不回传浏览器');
2499
+ assert.ok(!serialized.includes('sk-anc-0000000000000000'), '祖先侧敏感值绝不回传浏览器');
2500
+ assert.ok(serialized.includes('REDACTED'), '预览文本必须过 redact: ' + serialized.slice(0, 400));
2501
+ // 预览必须是**字符串**(原始值对象绝不直接上抛给浏览器)
2502
+ assert.ok(preview.sections.every((s) => s.conflicts.every((c) =>
2503
+ typeof c.localPreview === 'string' && typeof c.remotePreview === 'string' && typeof c.ancestorPreview === 'string')));
2504
+ assert.equal(preview.summary.conflicts, 1);
2505
+ assert.ok(preview.summary.review >= 1, '冲突项必须计入需人工');
2506
+ assert.equal(preview.summary.skipped >= 0, true);
2507
+ } finally {
2508
+ await fs.rm(tmp, { recursive: true, force: true });
2509
+ }
2510
+ });
2511
+
2512
+ test('previewMerge: 无祖先副本 → hasAncestor=false 且两侧差异一律呈现为 conflict', async () => {
2513
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-preview-noanc-'));
2514
+ try {
2515
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2516
+ seedSource(ctx);
2517
+ const transport = new MemSyncTransport();
2518
+ // 不给 localSnapshotsDir → 没有本地祖先副本
2519
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
2520
+ seedRemoteSnapshot(transport, remoteSettingsSnapshot('sync-remote', 'light', 5));
2521
+ const preview = await engine.previewMerge();
2522
+ assert.equal(preview.ok, true);
2523
+ assert.equal(preview.hasAncestor, false);
2524
+ const settings = preview.sections.find((s) => s.id === 'settings');
2525
+ assert.equal(settings?.decision, 'conflict', '无祖先 → 不猜,两侧差异按冲突呈现');
2526
+ } finally {
2527
+ await fs.rm(tmp, { recursive: true, force: true });
2528
+ }
2529
+ });
2530
+
2531
+ test('applyMergeResolutions: skip 的裁决 → 基线不推进 + pendingMerge 记录 + 本机不被写', async () => {
2532
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-merge-pending-'));
2533
+ try {
2534
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2535
+ seedSource(ctx);
2536
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 3, secrets: [] });
2537
+ const transport = new MemSyncTransport();
2538
+ const engine = makeEngine({ ctx, transport, stateDir: tmp, localSnapshotsDir: path.join(tmp, 'anc') });
2539
+ await engine.push({ snapshotId: 'sync-base' });
2540
+ ctx.settings.ns.set('general', { value: { theme: 'blue' }, revision: 4, secrets: [] });
2541
+ seedRemoteSnapshot(transport, remoteSettingsSnapshot('sync-remote', 'red', 9));
2542
+
2543
+ const report = await engine.applyMergeResolutions([{ id: 'settings', action: 'skip' }]);
2544
+ assert.equal(report.ok, true);
2545
+ assert.deepEqual(report.applied, []);
2546
+ assert.deepEqual(report.pendingConflicts, ['settings']);
2547
+ assert.ok(report.warnings.some((w) => w.includes('settings')), '待裁决必须可见: ' + report.warnings.join(' | '));
2548
+
2549
+ const state = await loadSyncState(tmp);
2550
+ assert.equal(state.lastSnapshotId, 'sync-base', '冲突未裁决 → 远端指针保持原值');
2551
+ assert.deepEqual(state.pendingMerge?.sections, ['settings']);
2552
+ assert.equal(state.pendingMerge?.remoteSnapshotId, 'sync-remote');
2553
+ assert.notEqual(state.pendingMerge?.since, '');
2554
+ assert.equal((ctx.settings.ns.get('general')!.value as { theme: string }).theme, 'blue', '本机未被覆盖');
2555
+ // 磁盘原文断言(不经 loadSyncState 的归一化):lastSnapshotId 必须逐字不变,pendingMerge 必须落盘
2556
+ const raw = JSON.parse(await fs.readFile(path.join(tmp, SYNC_STATE_FILE), 'utf8'));
2557
+ assert.equal(raw.lastSnapshotId, 'sync-base', 'sync-state.json 里的基线指针原文不变');
2558
+ assert.equal(raw.schemaVersion, SYNC_STATE_SCHEMA_VERSION, '不提升 schemaVersion');
2559
+ assert.deepEqual(raw.pendingMerge.sections, ['settings']);
2560
+ assert.equal(raw.pendingMerge.remoteSnapshotId, 'sync-remote');
2561
+
2562
+ // 冲突下一轮重新出现(基线没推进 → 远端那版仍被当作新快照)
2563
+ const again = await engine.previewMerge();
2564
+ assert.equal(again.sections.find((s) => s.id === 'settings')?.decision, 'conflict');
2565
+ } finally {
2566
+ await fs.rm(tmp, { recursive: true, force: true });
2567
+ }
2568
+ });
2569
+
2570
+ test('applyMergeResolutions: 全部裁决 → 基线推进到远端快照 + pendingMerge 清空 + 本机被写', async () => {
2571
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-merge-resolved-'));
2572
+ try {
2573
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2574
+ seedSource(ctx);
2575
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 3, secrets: [] });
2576
+ const transport = new MemSyncTransport();
2577
+ const engine = makeEngine({ ctx, transport, stateDir: tmp, localSnapshotsDir: path.join(tmp, 'anc') });
2578
+ await engine.push({ snapshotId: 'sync-base' });
2579
+ ctx.settings.ns.set('general', { value: { theme: 'blue' }, revision: 4, secrets: [] });
2580
+ seedRemoteSnapshot(transport, remoteSettingsSnapshot('sync-remote', 'red', 9));
2581
+
2582
+ // 先留一个待裁决状态,再全部裁决 → 字段必须被清掉
2583
+ const pending = await engine.applyMergeResolutions([{ id: 'settings', action: 'skip' }]);
2584
+ assert.deepEqual(pending.pendingConflicts, ['settings']);
2585
+ assert.equal((await loadSyncState(tmp)).lastSnapshotId, 'sync-base');
2586
+
2587
+ const report = await engine.applyMergeResolutions([{ id: 'settings', action: 'useRemote' }]);
2588
+ assert.equal(report.ok, true);
2589
+ assert.deepEqual(report.applied, ['settings']);
2590
+ assert.deepEqual(report.pendingConflicts, [], '没有待裁决分区了');
2591
+ assert.equal((ctx.settings.ns.get('general')!.value as { theme: string }).theme, 'red', '远端整分区胜出');
2592
+ const state = await loadSyncState(tmp);
2593
+ assert.equal(state.lastSnapshotId, 'sync-remote', '全部裁决完 → 基线推进');
2594
+ assert.equal(state.pendingMerge, undefined, 'pendingMerge 必须清空');
2595
+ } finally {
2596
+ await fs.rm(tmp, { recursive: true, force: true });
2597
+ }
2598
+ });
2599
+
2600
+ test('applyMergeResolutions: useMerged 对 conflict 分区(无 merged)→ 计入待裁决,不写本机', async () => {
2601
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-merge-usemerged-'));
2602
+ try {
2603
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2604
+ seedSource(ctx);
2605
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 3, secrets: [] });
2606
+ const transport = new MemSyncTransport();
2607
+ const engine = makeEngine({ ctx, transport, stateDir: tmp, localSnapshotsDir: path.join(tmp, 'anc') });
2608
+ await engine.push({ snapshotId: 'sync-base' });
2609
+ ctx.settings.ns.set('general', { value: { theme: 'blue' }, revision: 4, secrets: [] });
2610
+ seedRemoteSnapshot(transport, remoteSettingsSnapshot('sync-remote', 'red', 9));
2611
+
2612
+ const report = await engine.applyMergeResolutions([{ id: 'settings', action: 'useMerged' }]);
2613
+ assert.deepEqual(report.pendingConflicts, ['settings']);
2614
+ assert.deepEqual(report.applied, []);
2615
+ assert.equal((ctx.settings.ns.get('general')!.value as { theme: string }).theme, 'blue');
2616
+ assert.equal((await loadSyncState(tmp)).lastSnapshotId, 'sync-base');
2617
+ } finally {
2618
+ await fs.rm(tmp, { recursive: true, force: true });
2619
+ }
2620
+ });
2621
+
2622
+ test('applyMergeResolutions: keepLocal 全部裁决 → 本机不变且基线推进(决策不被下一轮推翻)', async () => {
2623
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-merge-keeplocal-'));
2624
+ try {
2625
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2626
+ seedSource(ctx);
2627
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 3, secrets: [] });
2628
+ const transport = new MemSyncTransport();
2629
+ const engine = makeEngine({ ctx, transport, stateDir: tmp, localSnapshotsDir: path.join(tmp, 'anc') });
2630
+ await engine.push({ snapshotId: 'sync-base' });
2631
+ ctx.settings.ns.set('general', { value: { theme: 'blue' }, revision: 4, secrets: [] });
2632
+ seedRemoteSnapshot(transport, remoteSettingsSnapshot('sync-remote', 'red', 9));
2633
+
2634
+ const report = await engine.applyMergeResolutions([{ id: 'settings', action: 'keepLocal' }]);
2635
+ assert.equal(report.ok, true);
2636
+ assert.deepEqual(report.pendingConflicts, []);
2637
+ assert.equal((ctx.settings.ns.get('general')!.value as { theme: string }).theme, 'blue', '本机裁决胜出');
2638
+ assert.equal((await loadSyncState(tmp)).lastSnapshotId, 'sync-remote');
2639
+ } finally {
2640
+ await fs.rm(tmp, { recursive: true, force: true });
2641
+ }
2642
+ });
2643
+
2644
+ test('applyMergeResolutions: 写回失败 → 整体回滚 + 基线不变 + 不写 pendingMerge', async () => {
2645
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-merge-applyfail-'));
2646
+ try {
2647
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2648
+ seedSource(ctx);
2649
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 3, secrets: [] });
2650
+ const transport = new MemSyncTransport();
2651
+ const mock = new MockImporter();
2652
+ mock.ok = false;
2653
+ const engine = makeEngineWithMockImporter({
2654
+ ctx, transport, stateDir: tmp, localSnapshotsDir: path.join(tmp, 'anc'), mockImporter: mock,
2655
+ });
2656
+ await engine.push({ snapshotId: 'sync-base' });
2657
+ ctx.settings.ns.set('general', { value: { theme: 'blue' }, revision: 4, secrets: [] });
2658
+ seedRemoteSnapshot(transport, remoteSettingsSnapshot('sync-remote', 'red', 9));
2659
+
2660
+ const report = await engine.applyMergeResolutions([{ id: 'settings', action: 'useRemote' }]);
2661
+ assert.equal(report.ok, false, '失败 → ok:false');
2662
+ assert.equal(report.rolledBack, true, '失败 → 整体回滚');
2663
+ assert.deepEqual(report.applied, []);
2664
+ const state = await loadSyncState(tmp);
2665
+ assert.equal(state.lastSnapshotId, 'sync-base', '失败绝不推进基线');
2666
+ assert.equal(state.pendingMerge, undefined, '失败路径不写待裁决记录');
2667
+ } finally {
2668
+ await fs.rm(tmp, { recursive: true, force: true });
2669
+ }
2670
+ });
2671
+
2672
+ test('applyMergeResolutions: 远端无快照 → 空报告(ok:true,不构造计划、不抛错)', async () => {
2673
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-merge-empty-'));
2674
+ try {
2675
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2676
+ seedSource(ctx);
2677
+ const transport = new MemSyncTransport();
2678
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
2679
+ const report = await engine.applyMergeResolutions([{ id: 'settings', action: 'useRemote' }]);
2680
+ assert.equal(report.ok, true);
2681
+ assert.deepEqual(report.pendingConflicts, []);
2682
+ assert.deepEqual(report.applied, []);
2683
+ const preview = await engine.previewMerge();
2684
+ assert.equal(preview.ok, false);
2685
+ assert.equal(preview.snapshotId, '');
2686
+ } finally {
2687
+ await fs.rm(tmp, { recursive: true, force: true });
2688
+ }
2689
+ });
2690
+