dsh-config-manager 0.1.78 → 0.1.80

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 (302) 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 +401 -112
  26. package/lib/client.js +2899 -1525
  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/ancestor.d.ts +22 -0
  82. package/lib/sync/ancestor.js +66 -2
  83. package/lib/sync/autosync-config.d.ts +73 -0
  84. package/lib/sync/autosync-config.js +89 -0
  85. package/lib/sync/autosync-scheduler.d.ts +80 -6
  86. package/lib/sync/autosync-scheduler.js +263 -40
  87. package/lib/sync/gist/gist-transport.d.ts +18 -1
  88. package/lib/sync/gist/gist-transport.js +42 -3
  89. package/lib/sync/gist/messages.d.ts +2 -0
  90. package/lib/sync/gist/messages.js +5 -0
  91. package/lib/sync/git/git-transport.d.ts +42 -3
  92. package/lib/sync/git/git-transport.js +146 -3
  93. package/lib/sync/merge.d.ts +47 -14
  94. package/lib/sync/merge.js +242 -222
  95. package/lib/sync/messages.d.ts +8 -0
  96. package/lib/sync/messages.js +19 -0
  97. package/lib/sync/risk.d.ts +19 -2
  98. package/lib/sync/risk.js +10 -3
  99. package/lib/sync/s3/messages.d.ts +6 -0
  100. package/lib/sync/s3/messages.js +13 -0
  101. package/lib/sync/s3/s3-transport.d.ts +9 -1
  102. package/lib/sync/s3/s3-transport.js +78 -6
  103. package/lib/sync/s3/sigv4.js +11 -2
  104. package/lib/sync/sync-engine.d.ts +212 -8
  105. package/lib/sync/sync-engine.js +514 -58
  106. package/lib/sync/sync-history.d.ts +11 -0
  107. package/lib/sync/sync-history.js +20 -0
  108. package/lib/sync/sync-state.d.ts +43 -0
  109. package/lib/sync/sync-state.js +44 -0
  110. package/lib/sync/sync-watcher.d.ts +76 -0
  111. package/lib/sync/sync-watcher.js +202 -0
  112. package/lib/sync/text-merge.d.ts +85 -0
  113. package/lib/sync/text-merge.js +513 -0
  114. package/lib/sync/transport.d.ts +48 -0
  115. package/lib/sync/transport.js +12 -0
  116. package/lib/sync/watch-scope.d.ts +38 -0
  117. package/lib/sync/watch-scope.js +95 -0
  118. package/lib/sync/webdav/webdav-transport.d.ts +16 -2
  119. package/lib/sync/webdav/webdav-transport.js +128 -11
  120. package/lib/ui/foreign-view.js +10 -0
  121. package/lib/ui/i18n.d.ts +9 -0
  122. package/lib/ui/i18n.js +22 -0
  123. package/lib/ui/rollback-view.d.ts +12 -0
  124. package/lib/ui/rollback-view.js +2 -0
  125. package/lib/ui/sync-settings-view.d.ts +47 -0
  126. package/lib/ui/sync-settings-view.js +24 -0
  127. package/lib/ui/types.d.ts +21 -0
  128. package/lib/utils/env-lock.d.ts +1 -0
  129. package/lib/utils/env-lock.js +7 -4
  130. package/package.json +6 -6
  131. package/src/adapters/credentials.ts +22 -4
  132. package/src/adapters/file-collection.ts +2 -1
  133. package/src/adapters/mcp.test.ts +235 -1
  134. package/src/adapters/mcp.ts +276 -5
  135. package/src/adapters/messages.ts +7 -0
  136. package/src/adapters/plugin-files.ts +2 -1
  137. package/src/adapters/plugins.test.ts +176 -2
  138. package/src/adapters/plugins.ts +107 -21
  139. package/src/adapters/pnpm-workspace.test.ts +89 -1
  140. package/src/adapters/pnpm-workspace.ts +127 -2
  141. package/src/adapters/prompts.ts +20 -3
  142. package/src/adapters/providers.ts +62 -4
  143. package/src/adapters/settings.ts +16 -3
  144. package/src/adapters/skills.test.ts +141 -0
  145. package/src/adapters/skills.ts +58 -2
  146. package/src/adapters/ui.ts +16 -3
  147. package/src/adapters/units-entries.test.ts +190 -0
  148. package/src/adapters/units.ts +50 -0
  149. package/src/adapters/workspaces.ts +2 -1
  150. package/src/client/about/PluginUpdateDialog.tsx +4 -2
  151. package/src/client/about/ReleaseNotesDialog.tsx +4 -2
  152. package/src/client/chat/RollbackReviewDialog.tsx +3 -2
  153. package/src/client/common/Skeleton.tsx +32 -0
  154. package/src/client/common/api-base.test.ts +36 -0
  155. package/src/client/common/http.test.ts +12 -1
  156. package/src/client/common/http.ts +8 -2
  157. package/src/client/common/routes.ts +46 -3
  158. package/src/client/config-manager.module.css +321 -11
  159. package/src/client/home/HomePanel.tsx +14 -6
  160. package/src/client/import/ForeignImportView.tsx +3 -1
  161. package/src/client/import/import-wizard-steps.tsx +2 -1
  162. package/src/client/library/ArtifactRow.tsx +12 -2
  163. package/src/client/library/LibraryActions.tsx +6 -3
  164. package/src/client/library/LibraryPanel.tsx +21 -3
  165. package/src/client/library/RestorePlanView.tsx +4 -2
  166. package/src/client/locales.ts +3 -0
  167. package/src/client/market/market-api.test.ts +3 -2
  168. package/src/client/recovery/RecoveryPanel.tsx +54 -7
  169. package/src/client/recovery/RollbackCard.tsx +7 -2
  170. package/src/client/recovery/RollbackDiffPane.tsx +3 -2
  171. package/src/client/recovery/recovery-locales.ts +20 -2
  172. package/src/client/recovery/recovery-view.test.ts +39 -1
  173. package/src/client/recovery/recovery-view.ts +23 -1
  174. package/src/client/run-store.test.ts +1 -1
  175. package/src/client/run-store.ts +4 -0
  176. package/src/client/sync/AutosyncCard.tsx +49 -6
  177. package/src/client/sync/MergeModal.tsx +220 -0
  178. package/src/client/sync/MergePreviewView.tsx +306 -0
  179. package/src/client/sync/SyncActivityBar.tsx +31 -0
  180. package/src/client/sync/SyncConfirmView.tsx +2 -1
  181. package/src/client/sync/SyncPanel.tsx +130 -87
  182. package/src/client/sync/SyncProbeRow.tsx +162 -0
  183. package/src/client/sync/SyncSettingsView.test.ts +27 -2
  184. package/src/client/sync/WorkDirCard.tsx +44 -6
  185. package/src/client/sync/history-model.ts +4 -1
  186. package/src/client/sync/merge-view.test.ts +248 -0
  187. package/src/client/sync/merge-view.ts +398 -0
  188. package/src/client/sync/probe-view.test.ts +90 -0
  189. package/src/client/sync/probe-view.ts +63 -0
  190. package/src/client/sync/remote-device-view.test.ts +35 -0
  191. package/src/client/sync/remote-device-view.ts +25 -0
  192. package/src/client/sync/sync-api.test.ts +5 -3
  193. package/src/client/sync/sync-api.ts +212 -2
  194. package/src/client/sync/sync-entry.test.ts +78 -0
  195. package/src/client/sync/sync-entry.ts +53 -0
  196. package/src/client/sync/sync-locales.ts +234 -2
  197. package/src/client/sync/sync-view.test.ts +18 -1
  198. package/src/client/sync/sync-view.ts +20 -0
  199. package/src/core/backup.test.ts +41 -0
  200. package/src/core/backup.ts +20 -0
  201. package/src/core/mcp-connector-store.test.ts +265 -0
  202. package/src/core/mcp-connector-store.ts +516 -0
  203. package/src/core/messages-core.ts +4 -0
  204. package/src/core/messages.ts +46 -0
  205. package/src/core/phase3-host.ts +18 -1
  206. package/src/core/phase3-production-integration.test.ts +4 -0
  207. package/src/core/plugin-cli.test.ts +26 -0
  208. package/src/core/plugin-cli.ts +30 -4
  209. package/src/core/recovery-orchestrator.test.ts +104 -5
  210. package/src/core/recovery-orchestrator.ts +74 -17
  211. package/src/core/rollback-activity.test.ts +311 -0
  212. package/src/core/rollback-activity.ts +367 -0
  213. package/src/core/rollback-cross-session.test.ts +694 -0
  214. package/src/core/rollback-interrupted.test.ts +137 -0
  215. package/src/core/rollback-ledger.ts +32 -0
  216. package/src/core/rollback-merge.test.ts +130 -0
  217. package/src/core/rollback-merge.ts +78 -14
  218. package/src/core/rollback-point.ts +468 -91
  219. package/src/core/rollback-undo-journal.test.ts +508 -0
  220. package/src/core/rollback-undo-journal.ts +571 -0
  221. package/src/core/self-update.ts +2 -5
  222. package/src/core/text-fidelity.test.ts +189 -0
  223. package/src/core/text-fidelity.ts +197 -0
  224. package/src/core/types.ts +11 -3
  225. package/src/core/update-check.ts +2 -5
  226. package/src/core/validator.ts +14 -0
  227. package/src/foreign/bundle-origin.test.ts +265 -0
  228. package/src/foreign/bundle.ts +45 -5
  229. package/src/foreign/claude-code.test.ts +268 -0
  230. package/src/foreign/claude-code.ts +406 -25
  231. package/src/foreign/claude-plugins.test.ts +234 -0
  232. package/src/foreign/claude-sessions.test.ts +141 -0
  233. package/src/foreign/claude-sessions.ts +179 -23
  234. package/src/foreign/file-budget.test.ts +4 -1
  235. package/src/foreign/read-claude-code.test.ts +320 -0
  236. package/src/foreign/read-claude-code.ts +490 -71
  237. package/src/foreign/read-claude-plugins.ts +465 -0
  238. package/src/foreign/registry.ts +25 -2
  239. package/src/foreign/session-ir.test.ts +65 -0
  240. package/src/foreign/truth-table.test.ts +81 -0
  241. package/src/foreign/truth-table.ts +6 -3
  242. package/src/foreign/types.ts +162 -2
  243. package/src/index.facade.test.ts +26 -4
  244. package/src/index.ts +76 -19
  245. package/src/market/git-file-writer.test.ts +4 -0
  246. package/src/market/git-file-writer.ts +7 -1
  247. package/src/profiles/dsh-profile-launcher.test.ts +68 -3
  248. package/src/profiles/dsh-profile-launcher.ts +56 -1
  249. package/src/profiles/dsh-profile-shared.ts +8 -2
  250. package/src/routes/foreign.ts +8 -1
  251. package/src/routes/index.ts +1 -1
  252. package/src/routes/kit.ts +2 -2
  253. package/src/routes/sync.ts +176 -9
  254. package/src/schema/manifest-schema.test.ts +87 -2
  255. package/src/schema/manifest.ts +57 -1
  256. package/src/schema/types.ts +45 -2
  257. package/src/sync/ancestor.ts +76 -2
  258. package/src/sync/autosync-config.test.ts +73 -1
  259. package/src/sync/autosync-config.ts +124 -0
  260. package/src/sync/autosync-risk-tier.test.ts +38 -2
  261. package/src/sync/autosync-scheduler.test.ts +264 -12
  262. package/src/sync/autosync-scheduler.ts +296 -44
  263. package/src/sync/gist/gist-conditional-write.test.ts +203 -0
  264. package/src/sync/gist/gist-transport.ts +53 -5
  265. package/src/sync/gist/messages.ts +5 -0
  266. package/src/sync/git/git-probe.test.ts +80 -0
  267. package/src/sync/git/git-transport.test.ts +185 -0
  268. package/src/sync/git/git-transport.ts +178 -5
  269. package/src/sync/merge.test.ts +188 -55
  270. package/src/sync/merge.ts +282 -224
  271. package/src/sync/messages.ts +19 -0
  272. package/src/sync/risk.ts +29 -5
  273. package/src/sync/s3/messages.ts +13 -0
  274. package/src/sync/s3/s3-conditional-write.test.ts +330 -0
  275. package/src/sync/s3/s3-transport.test.ts +2 -1
  276. package/src/sync/s3/s3-transport.ts +87 -9
  277. package/src/sync/s3/sigv4.test.ts +4 -1
  278. package/src/sync/s3/sigv4.ts +11 -2
  279. package/src/sync/sync-device-identity.test.ts +267 -0
  280. package/src/sync/sync-engine.test.ts +410 -19
  281. package/src/sync/sync-engine.ts +602 -57
  282. package/src/sync/sync-history.test.ts +16 -1
  283. package/src/sync/sync-history.ts +26 -0
  284. package/src/sync/sync-state.test.ts +64 -0
  285. package/src/sync/sync-state.ts +79 -0
  286. package/src/sync/sync-watcher.test.ts +197 -0
  287. package/src/sync/sync-watcher.ts +246 -0
  288. package/src/sync/text-merge.test.ts +358 -0
  289. package/src/sync/text-merge.ts +554 -0
  290. package/src/sync/transport.ts +57 -0
  291. package/src/sync/watch-scope.test.ts +122 -0
  292. package/src/sync/watch-scope.ts +120 -0
  293. package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
  294. package/src/sync/webdav/webdav-transport.ts +137 -14
  295. package/src/ui/foreign-view.test.ts +34 -0
  296. package/src/ui/foreign-view.ts +10 -0
  297. package/src/ui/i18n.ts +22 -0
  298. package/src/ui/rollback-view.ts +14 -0
  299. package/src/ui/sync-settings-view.test.ts +28 -1
  300. package/src/ui/sync-settings-view.ts +43 -0
  301. package/src/ui/types.ts +22 -0
  302. package/src/utils/env-lock.ts +8 -5
@@ -948,17 +948,8 @@ test('recordBaseline: 写本地祖先副本 + 更新 sync-state + 触发裁剪',
948
948
  // 走一遍 push 让 ancestors 目录被建立
949
949
  await engine.push({ snapshotId: 'sync-anc-1' });
950
950
  await engine.push({ snapshotId: 'sync-anc-2' });
951
- // 显式再调一次 recordBaseline(模拟合并 apply 完成后更新基线)
952
- const newSnap: SyncSnapshot = {
953
- id: 'sync-explicit',
954
- createdAt: '2026-08-16T13:00:00.000Z',
955
- manifest: {
956
- schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32',
957
- sectionIds: ['settings'], containsSecrets: false,
958
- },
959
- sections: { settings: { version: 1, namespaces: {} } },
960
- };
961
- await engine.recordBaseline('sync-explicit', newSnap.sections, '2026-08-16T13:00:00.000Z');
951
+ // 显式再调一次 recordBaseline(模拟合并 apply 完成后更新基线;基线恒为**明文** sections)
952
+ await engine.recordBaseline('sync-explicit', { settings: { version: 1, namespaces: {} } }, '2026-08-16T13:00:00.000Z');
962
953
  const state = await loadSyncState(tmp);
963
954
  assert.equal(state.lastSnapshotId, 'sync-explicit');
964
955
  assert.ok((await fs.stat(path.join(local, 'sync-explicit', 'manifest.json'))).isFile(), '祖先副本已写');
@@ -1373,6 +1364,33 @@ test('hasNewRemoteSnapshot: 空远端→false;从未同步且远端非空→tr
1373
1364
  }
1374
1365
  });
1375
1366
 
1367
+ test('hasNewRemoteSnapshot(listed):传入本轮列表 → 不再 list(自动同步一轮至多一次网络往返)', async () => {
1368
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-hasnew-reuse-'));
1369
+ try {
1370
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
1371
+ seedSource(ctx);
1372
+ const transport = new MemSyncTransport();
1373
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
1374
+ await engine.push({ snapshotId: 'sync-001' });
1375
+
1376
+ const before = transport.calls.filter((c) => c === 'list').length;
1377
+ const listed = await engine.listSnapshots();
1378
+ assert.equal(transport.calls.filter((c) => c === 'list').length, before + 1);
1379
+
1380
+ assert.equal(await engine.hasNewRemoteSnapshot(listed), false, '复用传入列表判定「远端最新=祖先」');
1381
+ assert.equal(
1382
+ transport.calls.filter((c) => c === 'list').length, before + 1,
1383
+ '传了列表就绝不能再 list 一次(否则每个回合白跑一次远端往返)',
1384
+ );
1385
+
1386
+ // 对照组:不传 → 自己 list(保持旧行为)
1387
+ assert.equal(await engine.hasNewRemoteSnapshot(), false);
1388
+ assert.equal(transport.calls.filter((c) => c === 'list').length, before + 2, '不传时必须自己 list');
1389
+ } finally {
1390
+ await fs.rm(tmp, { recursive: true, force: true });
1391
+ }
1392
+ });
1393
+
1376
1394
  test('hasLocalChanges: 从未同步→true;推后无改动→false;本地改动→true', async () => {
1377
1395
  const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-haslocal-'));
1378
1396
  try {
@@ -1485,7 +1503,7 @@ function mkRemote(id: string, createdAt: string, theme: string, revision: number
1485
1503
  };
1486
1504
  }
1487
1505
 
1488
- test('P0-7: 加密快照 push 后,下一轮 merge 不再抛「快照目录缺少 manifest.json」→ 降级为两方合并', async () => {
1506
+ test('P0-7 + issue #107: 加密快照 push 落**密文**祖先副本 → 无密码时降级两方合并、带密码恢复三方合并', async () => {
1489
1507
  const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-p07-enc-merge-'));
1490
1508
  try {
1491
1509
  const ctx = makeContext('win32', 'C:\\Users\\alice');
@@ -1493,23 +1511,44 @@ test('P0-7: 加密快照 push 后,下一轮 merge 不再抛「快照目录缺
1493
1511
  const transport = new MemSyncTransport();
1494
1512
  const local = path.join(tmp, 'ancestors');
1495
1513
  const engine = makeEngine({ ctx, transport, stateDir: tmp, localSnapshotsDir: local });
1514
+ const password = 'pw-12345678';
1496
1515
 
1497
- // 加密 push:按既有安全语义不落本地明文祖先副本
1498
- const push = await engine.push({ snapshotId: 'sync-enc-1', encrypt: true, password: 'pw-12345678' });
1516
+ // issue #107:加密 push 也落祖先副本,但**只落密文**(manifest.encrypted + 单密文载荷)。
1517
+ const push = await engine.push({ snapshotId: 'sync-enc-1', encrypt: true, password });
1499
1518
  assert.equal(push.ok, true);
1500
1519
  const state = await loadSyncState(tmp);
1501
1520
  assert.equal(state.lastSnapshotId, 'sync-enc-1', 'lastSnapshotId 必须等于远端快照 id');
1502
- assert.equal(state.ancestorId ?? '', '', '加密 push 不落本地祖先副本 → ancestorId 必须为空');
1521
+ assert.equal(state.ancestorId, 'sync-enc-1', '加密 push 必须落祖先副本(issue #107:否则每轮缺祖先)');
1522
+
1523
+ // 磁盘形态:只有 manifest.json + 密文载荷,绝无明文分区文件
1524
+ const ancestorDir = path.join(local, 'sync-enc-1');
1525
+ const names = (await fs.readdir(ancestorDir)).sort();
1526
+ assert.deepEqual(names, ['manifest.json', 'sections.encrypted.json'], '加密祖先目录只能是 manifest + 密文单载荷');
1527
+ const payloadText = await fs.readFile(path.join(ancestorDir, 'sections.encrypted.json'), 'utf8');
1528
+ assert.ok(!payloadText.includes('dark'), '密文载荷里不得出现明文配置内容(祖先副本不落明文)');
1529
+ const payloadRaw = JSON.parse(payloadText) as EncryptedSections;
1530
+ const decrypted = await decryptSectionsPayload(payloadRaw.encrypted, password);
1531
+ assert.equal(decrypted.settings !== undefined, true, '用同一密码可解回明文 sections(供 merge 三方比较)');
1503
1532
 
1504
1533
  // 另一台机器推了一个普通快照 → 自动同步会走 merge(修复前:loadAncestor 抛错 → 整轮 failed 且每轮复现)
1505
1534
  const remote2 = mkRemote('remote-2', '2026-08-16T13:00:00.000Z', 'light', 9);
1506
1535
  transport.snapshots.set(remote2.id, remote2);
1507
1536
  transport.metas.push(computeSnapshotMeta(remote2));
1508
1537
 
1509
- const plan = await engine.merge();
1510
- const settings = plan.sections.find((s) => s.id === 'settings');
1511
- assert.ok(settings, 'merge 必须返回 settings 分区结果');
1512
- assert.equal(settings!.decision, 'conflict', '祖先缺失 → 两方差异按整分区 conflict 交用户裁决');
1538
+ // 无密码:密文祖先解不开 → 如实降级为两方合并(整分区 conflict),绝不抛错
1539
+ const twoWay = await engine.previewMerge();
1540
+ assert.equal(twoWay.hasAncestor, false, '无密码时密文祖先不可用 → 如实两方合并');
1541
+ const conflictRow = twoWay.sections.find((s) => s.id === 'settings');
1542
+ assert.ok(conflictRow, 'merge 必须返回 settings 分区结果');
1543
+ assert.equal(conflictRow!.decision, 'conflict', '祖先不可用 → 两方差异按整分区 conflict 交用户裁决');
1544
+
1545
+ // 带密码:解密祖先 → 三方比较;本机与祖先相同、只有远端改了 → useRemote(不再是冲突)
1546
+ const threeWay = await engine.previewMerge({ password });
1547
+ assert.equal(threeWay.hasAncestor, true, '带密码时密文祖先可解密 → 恢复三方合并(issue #107)');
1548
+ const merged = threeWay.sections.find((s) => s.id === 'settings');
1549
+ assert.ok(merged, 'merge 必须返回 settings 分区结果');
1550
+ assert.notEqual(merged!.decision, 'conflict', '有共同祖先时不得把单侧改动误判成冲突');
1551
+ assert.equal(merged!.decision, 'useRemote', '只有远端改过 → 自动采用远端值');
1513
1552
  } finally {
1514
1553
  await fs.rm(tmp, { recursive: true, force: true });
1515
1554
  }
@@ -2309,3 +2348,355 @@ test('issue #78: 拉取侧 —— 只放行 mcp 时,远端快照里的 session
2309
2348
  await fs.rm(tmp, { recursive: true, force: true });
2310
2349
  }
2311
2350
  });
2351
+
2352
+
2353
+ /* ---------------- 远程合并:push 远端推进检测 + 基线纪律(2026-10) ---------------- */
2354
+
2355
+ /** 造一份「另一台设备推的」远端快照(仅 settings 分区),与 e2e 同款夹具。 */
2356
+ function remoteSettingsSnapshot(id: string, theme: string, revision: number): SyncSnapshot {
2357
+ return {
2358
+ id,
2359
+ createdAt: '2026-08-16T12:00:00.000Z',
2360
+ manifest: { schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32', sectionIds: ['settings'], containsSecrets: false },
2361
+ sections: {
2362
+ settings: { version: 1, namespaces: { general: { value: { theme }, revision, secrets: [] } } },
2363
+ },
2364
+ };
2365
+ }
2366
+
2367
+ /** 把「别人的快照」塞进内存通道(模拟另一台设备推送)。 */
2368
+ function seedRemoteSnapshot(transport: MemSyncTransport, snapshot: SyncSnapshot): void {
2369
+ transport.snapshots.set(snapshot.id, snapshot);
2370
+ transport.metas.push(computeSnapshotMeta(snapshot));
2371
+ }
2372
+
2373
+ /** 递归收集目录下全部文件(相对路径 → 文本内容):用于断言「零写入」。 */
2374
+ async function snapshotTree(dir: string): Promise<Record<string, string>> {
2375
+ const out: Record<string, string> = {};
2376
+ const walk = async (rel: string): Promise<void> => {
2377
+ const entries = await fs.readdir(path.join(dir, rel), { withFileTypes: true }).catch(() => [] as import('node:fs').Dirent[]);
2378
+ for (const e of entries) {
2379
+ const child = rel === '' ? e.name : rel + '/' + e.name;
2380
+ if (e.isDirectory()) await walk(child);
2381
+ else out[child] = await fs.readFile(path.join(dir, child), 'utf8');
2382
+ }
2383
+ };
2384
+ await walk('');
2385
+ return out;
2386
+ }
2387
+
2388
+ /** list() 可注入失败的通道(覆盖「读不到远端列表」这条分支)。 */
2389
+ class FlakyListTransport extends MemSyncTransport {
2390
+ failList = false;
2391
+ override async list(): Promise<SyncSnapshotMeta[]> {
2392
+ if (this.failList) throw new Error('list-boom');
2393
+ return await super.list();
2394
+ }
2395
+ }
2396
+
2397
+ test('push: 远端已被别的设备推进 → 拒绝写入(不 upload、sync-state 逐字节不变),force 才放行', async () => {
2398
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-push-advance-'));
2399
+ try {
2400
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2401
+ seedSource(ctx);
2402
+ const transport = new MemSyncTransport();
2403
+ const engine = makeEngine({ ctx, transport, stateDir: tmp, localSnapshotsDir: path.join(tmp, 'anc') });
2404
+ // 首次同步:远端为空 → 不拦(本机没有基线可丢)
2405
+ assert.equal((await engine.push({ snapshotId: 'sync-base' })).ok, true);
2406
+ const stateBefore = await fs.readFile(path.join(tmp, SYNC_STATE_FILE), 'utf8');
2407
+
2408
+ // 另一台设备推了 sync-other(本机基线仍是 sync-base)
2409
+ seedRemoteSnapshot(transport, remoteSettingsSnapshot('sync-other', 'light', 5));
2410
+ transport.calls.length = 0;
2411
+ const refused = await engine.push({ snapshotId: 'sync-mine' });
2412
+
2413
+ assert.equal(refused.ok, false, '远端已推进 → 拒绝');
2414
+ assert.equal(refused.conflict, true, '这是「需要先合并远端」的冲突态,不是通道故障');
2415
+ assert.equal(refused.snapshotId, '');
2416
+ assert.deepEqual(refused.sections, []);
2417
+ assert.equal(refused.warnings.length, 0, '拒绝路径本身不产生额外告警');
2418
+ assert.ok((refused.message ?? '').includes('sync-other'), '说明里点出远端快照 id: ' + refused.message);
2419
+ assert.ok((refused.message ?? '').includes('sync-base'), '说明里点出本地基线 id: ' + refused.message);
2420
+ assert.equal(transport.snapshots.has('sync-mine'), false, '绝不 upload');
2421
+ assert.ok(!transport.calls.includes('upload'), 'transport 未收到 upload: ' + transport.calls.join(','));
2422
+ assert.equal(await fs.readFile(path.join(tmp, SYNC_STATE_FILE), 'utf8'), stateBefore, 'sync-state 逐字节不变');
2423
+
2424
+ // 用户显式强制才写
2425
+ const forced = await engine.push({ snapshotId: 'sync-mine', force: true });
2426
+ assert.equal(forced.ok, true, 'force=true 放行');
2427
+ assert.equal(transport.snapshots.has('sync-mine'), true);
2428
+ assert.equal((await loadSyncState(tmp)).lastSnapshotId, 'sync-mine');
2429
+ } finally {
2430
+ await fs.rm(tmp, { recursive: true, force: true });
2431
+ }
2432
+ });
2433
+
2434
+ test('push: 首次同步(lastSnapshotId="")即使远端已有别人的快照也不拦', async () => {
2435
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-push-first-'));
2436
+ try {
2437
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2438
+ seedSource(ctx);
2439
+ const transport = new MemSyncTransport();
2440
+ seedRemoteSnapshot(transport, remoteSettingsSnapshot('sync-other', 'light', 5));
2441
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
2442
+ const report = await engine.push({ snapshotId: 'sync-first' });
2443
+ assert.equal(report.ok, true, '没有基线就没有可丢的东西 → 不拦');
2444
+ assert.equal((await loadSyncState(tmp)).lastSnapshotId, 'sync-first');
2445
+ } finally {
2446
+ await fs.rm(tmp, { recursive: true, force: true });
2447
+ }
2448
+ });
2449
+
2450
+ test('push: 远端列表读不出来 → 无法判定,按原样继续但如实告警(不静默、不拦)', async () => {
2451
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-push-listfail-'));
2452
+ try {
2453
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2454
+ seedSource(ctx);
2455
+ const transport = new FlakyListTransport();
2456
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
2457
+ await engine.push({ snapshotId: 'sync-base' });
2458
+ transport.failList = true;
2459
+ const report = await engine.push({ snapshotId: 'sync-mine' });
2460
+ assert.equal(report.ok, true, '读不到远端列表不拦推送(上传本身会暴露通道问题)');
2461
+ assert.ok(report.warnings.some((w) => w.includes('list-boom')), '必须如实说明无法判定: ' + report.warnings.join(' | '));
2462
+ } finally {
2463
+ await fs.rm(tmp, { recursive: true, force: true });
2464
+ }
2465
+ });
2466
+
2467
+ test('previewMerge: 只读零写入 + 决策/计数/三侧预览(敏感值必须已脱敏)', async () => {
2468
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-preview-merge-'));
2469
+ try {
2470
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2471
+ seedSource(ctx);
2472
+ const transport = new MemSyncTransport();
2473
+ const ancestors = path.join(tmp, 'anc');
2474
+ const engine = makeEngine({ ctx, transport, stateDir: tmp, localSnapshotsDir: ancestors });
2475
+ // 先建基线(祖先 = dark + 一个值形态敏感内容:字段名不敏感,只能靠 redact 的值形状模式兜底)
2476
+ // 注意:载荷必须是「像密钥」的形状(含大写/数字)——纯小写词的 sk- 形态被值形状守卫刻意放行(防过剥)
2477
+ ctx.settings.ns.set('general', { value: { theme: 'dark', webhook: 'sk-anc-0000000000000000' }, revision: 3, secrets: [] });
2478
+ await engine.push({ snapshotId: 'sync-base' });
2479
+ // 本地改 blue / 远端改 red,且两侧的敏感值不同 → 该键必然留冲突,预览里必须有值可脱敏
2480
+ ctx.settings.ns.set('general', { value: { theme: 'blue', webhook: 'sk-live-AB12cd34EF56gh78' }, revision: 4, secrets: [] });
2481
+ seedRemoteSnapshot(transport, {
2482
+ id: 'sync-remote',
2483
+ createdAt: '2026-08-16T12:00:00.000Z',
2484
+ manifest: { schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32', sectionIds: ['settings'], containsSecrets: false },
2485
+ sections: {
2486
+ settings: {
2487
+ version: 1,
2488
+ namespaces: { general: { value: { theme: 'red', webhook: 'sk-live-Zx98wv76ut54sr32' }, revision: 9, secrets: [] } },
2489
+ },
2490
+ },
2491
+ });
2492
+
2493
+ const before = { state: await snapshotTree(tmp), anc: await snapshotTree(ancestors) };
2494
+ const preview = await engine.previewMerge();
2495
+ const after = { state: await snapshotTree(tmp), anc: await snapshotTree(ancestors) };
2496
+ assert.deepEqual(after, before, 'previewMerge 必须零写入(状态目录 + 祖先目录逐字节不变)');
2497
+
2498
+ assert.equal(preview.ok, true);
2499
+ assert.equal(preview.snapshotId, 'sync-remote');
2500
+ assert.equal(preview.hasAncestor, true, '有祖先副本 → 三方合并');
2501
+ const settings = preview.sections.find((s) => s.id === 'settings');
2502
+ assert.ok(settings !== undefined, 'plan 应含 settings');
2503
+ assert.equal(settings!.decision, 'conflict');
2504
+ assert.ok(settings!.conflictCount >= 1);
2505
+ const conflict = settings!.conflicts[0]!;
2506
+ assert.ok(conflict.localPreview.includes('blue'), '本机侧预览: ' + conflict.localPreview);
2507
+ assert.ok(conflict.remotePreview.includes('red'), '远端侧预览: ' + conflict.remotePreview);
2508
+ const serialized = JSON.stringify(preview);
2509
+ assert.ok(!serialized.includes('sk-live-AB12cd34EF56gh78'), '本机侧敏感值绝不回传浏览器');
2510
+ assert.ok(!serialized.includes('sk-live-Zx98wv76ut54sr32'), '远端侧敏感值绝不回传浏览器');
2511
+ assert.ok(!serialized.includes('sk-anc-0000000000000000'), '祖先侧敏感值绝不回传浏览器');
2512
+ assert.ok(serialized.includes('REDACTED'), '预览文本必须过 redact: ' + serialized.slice(0, 400));
2513
+ // 预览必须是**字符串**(原始值对象绝不直接上抛给浏览器)
2514
+ assert.ok(preview.sections.every((s) => s.conflicts.every((c) =>
2515
+ typeof c.localPreview === 'string' && typeof c.remotePreview === 'string' && typeof c.ancestorPreview === 'string')));
2516
+ assert.equal(preview.summary.conflicts, 1);
2517
+ assert.ok(preview.summary.review >= 1, '冲突项必须计入需人工');
2518
+ assert.equal(preview.summary.skipped >= 0, true);
2519
+ } finally {
2520
+ await fs.rm(tmp, { recursive: true, force: true });
2521
+ }
2522
+ });
2523
+
2524
+ test('previewMerge: 无祖先副本 → hasAncestor=false 且两侧差异一律呈现为 conflict', async () => {
2525
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-preview-noanc-'));
2526
+ try {
2527
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2528
+ seedSource(ctx);
2529
+ const transport = new MemSyncTransport();
2530
+ // 不给 localSnapshotsDir → 没有本地祖先副本
2531
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
2532
+ seedRemoteSnapshot(transport, remoteSettingsSnapshot('sync-remote', 'light', 5));
2533
+ const preview = await engine.previewMerge();
2534
+ assert.equal(preview.ok, true);
2535
+ assert.equal(preview.hasAncestor, false);
2536
+ const settings = preview.sections.find((s) => s.id === 'settings');
2537
+ assert.equal(settings?.decision, 'conflict', '无祖先 → 不猜,两侧差异按冲突呈现');
2538
+ } finally {
2539
+ await fs.rm(tmp, { recursive: true, force: true });
2540
+ }
2541
+ });
2542
+
2543
+ test('applyMergeResolutions: skip 的裁决 → 基线不推进 + pendingMerge 记录 + 本机不被写', async () => {
2544
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-merge-pending-'));
2545
+ try {
2546
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2547
+ seedSource(ctx);
2548
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 3, secrets: [] });
2549
+ const transport = new MemSyncTransport();
2550
+ const engine = makeEngine({ ctx, transport, stateDir: tmp, localSnapshotsDir: path.join(tmp, 'anc') });
2551
+ await engine.push({ snapshotId: 'sync-base' });
2552
+ ctx.settings.ns.set('general', { value: { theme: 'blue' }, revision: 4, secrets: [] });
2553
+ seedRemoteSnapshot(transport, remoteSettingsSnapshot('sync-remote', 'red', 9));
2554
+
2555
+ const report = await engine.applyMergeResolutions([{ id: 'settings', action: 'skip' }]);
2556
+ assert.equal(report.ok, true);
2557
+ assert.deepEqual(report.applied, []);
2558
+ assert.deepEqual(report.pendingConflicts, ['settings']);
2559
+ assert.ok(report.warnings.some((w) => w.includes('settings')), '待裁决必须可见: ' + report.warnings.join(' | '));
2560
+
2561
+ const state = await loadSyncState(tmp);
2562
+ assert.equal(state.lastSnapshotId, 'sync-base', '冲突未裁决 → 远端指针保持原值');
2563
+ assert.deepEqual(state.pendingMerge?.sections, ['settings']);
2564
+ assert.equal(state.pendingMerge?.remoteSnapshotId, 'sync-remote');
2565
+ assert.notEqual(state.pendingMerge?.since, '');
2566
+ assert.equal((ctx.settings.ns.get('general')!.value as { theme: string }).theme, 'blue', '本机未被覆盖');
2567
+ // 磁盘原文断言(不经 loadSyncState 的归一化):lastSnapshotId 必须逐字不变,pendingMerge 必须落盘
2568
+ const raw = JSON.parse(await fs.readFile(path.join(tmp, SYNC_STATE_FILE), 'utf8'));
2569
+ assert.equal(raw.lastSnapshotId, 'sync-base', 'sync-state.json 里的基线指针原文不变');
2570
+ assert.equal(raw.schemaVersion, SYNC_STATE_SCHEMA_VERSION, '不提升 schemaVersion');
2571
+ assert.deepEqual(raw.pendingMerge.sections, ['settings']);
2572
+ assert.equal(raw.pendingMerge.remoteSnapshotId, 'sync-remote');
2573
+
2574
+ // 冲突下一轮重新出现(基线没推进 → 远端那版仍被当作新快照)
2575
+ const again = await engine.previewMerge();
2576
+ assert.equal(again.sections.find((s) => s.id === 'settings')?.decision, 'conflict');
2577
+ } finally {
2578
+ await fs.rm(tmp, { recursive: true, force: true });
2579
+ }
2580
+ });
2581
+
2582
+ test('applyMergeResolutions: 全部裁决 → 基线推进到远端快照 + pendingMerge 清空 + 本机被写', async () => {
2583
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-merge-resolved-'));
2584
+ try {
2585
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2586
+ seedSource(ctx);
2587
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 3, secrets: [] });
2588
+ const transport = new MemSyncTransport();
2589
+ const engine = makeEngine({ ctx, transport, stateDir: tmp, localSnapshotsDir: path.join(tmp, 'anc') });
2590
+ await engine.push({ snapshotId: 'sync-base' });
2591
+ ctx.settings.ns.set('general', { value: { theme: 'blue' }, revision: 4, secrets: [] });
2592
+ seedRemoteSnapshot(transport, remoteSettingsSnapshot('sync-remote', 'red', 9));
2593
+
2594
+ // 先留一个待裁决状态,再全部裁决 → 字段必须被清掉
2595
+ const pending = await engine.applyMergeResolutions([{ id: 'settings', action: 'skip' }]);
2596
+ assert.deepEqual(pending.pendingConflicts, ['settings']);
2597
+ assert.equal((await loadSyncState(tmp)).lastSnapshotId, 'sync-base');
2598
+
2599
+ const report = await engine.applyMergeResolutions([{ id: 'settings', action: 'useRemote' }]);
2600
+ assert.equal(report.ok, true);
2601
+ assert.deepEqual(report.applied, ['settings']);
2602
+ assert.deepEqual(report.pendingConflicts, [], '没有待裁决分区了');
2603
+ assert.equal((ctx.settings.ns.get('general')!.value as { theme: string }).theme, 'red', '远端整分区胜出');
2604
+ const state = await loadSyncState(tmp);
2605
+ assert.equal(state.lastSnapshotId, 'sync-remote', '全部裁决完 → 基线推进');
2606
+ assert.equal(state.pendingMerge, undefined, 'pendingMerge 必须清空');
2607
+ } finally {
2608
+ await fs.rm(tmp, { recursive: true, force: true });
2609
+ }
2610
+ });
2611
+
2612
+ test('applyMergeResolutions: useMerged 对 conflict 分区(无 merged)→ 计入待裁决,不写本机', async () => {
2613
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-merge-usemerged-'));
2614
+ try {
2615
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2616
+ seedSource(ctx);
2617
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 3, secrets: [] });
2618
+ const transport = new MemSyncTransport();
2619
+ const engine = makeEngine({ ctx, transport, stateDir: tmp, localSnapshotsDir: path.join(tmp, 'anc') });
2620
+ await engine.push({ snapshotId: 'sync-base' });
2621
+ ctx.settings.ns.set('general', { value: { theme: 'blue' }, revision: 4, secrets: [] });
2622
+ seedRemoteSnapshot(transport, remoteSettingsSnapshot('sync-remote', 'red', 9));
2623
+
2624
+ const report = await engine.applyMergeResolutions([{ id: 'settings', action: 'useMerged' }]);
2625
+ assert.deepEqual(report.pendingConflicts, ['settings']);
2626
+ assert.deepEqual(report.applied, []);
2627
+ assert.equal((ctx.settings.ns.get('general')!.value as { theme: string }).theme, 'blue');
2628
+ assert.equal((await loadSyncState(tmp)).lastSnapshotId, 'sync-base');
2629
+ } finally {
2630
+ await fs.rm(tmp, { recursive: true, force: true });
2631
+ }
2632
+ });
2633
+
2634
+ test('applyMergeResolutions: keepLocal 全部裁决 → 本机不变且基线推进(决策不被下一轮推翻)', async () => {
2635
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-merge-keeplocal-'));
2636
+ try {
2637
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2638
+ seedSource(ctx);
2639
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 3, secrets: [] });
2640
+ const transport = new MemSyncTransport();
2641
+ const engine = makeEngine({ ctx, transport, stateDir: tmp, localSnapshotsDir: path.join(tmp, 'anc') });
2642
+ await engine.push({ snapshotId: 'sync-base' });
2643
+ ctx.settings.ns.set('general', { value: { theme: 'blue' }, revision: 4, secrets: [] });
2644
+ seedRemoteSnapshot(transport, remoteSettingsSnapshot('sync-remote', 'red', 9));
2645
+
2646
+ const report = await engine.applyMergeResolutions([{ id: 'settings', action: 'keepLocal' }]);
2647
+ assert.equal(report.ok, true);
2648
+ assert.deepEqual(report.pendingConflicts, []);
2649
+ assert.equal((ctx.settings.ns.get('general')!.value as { theme: string }).theme, 'blue', '本机裁决胜出');
2650
+ assert.equal((await loadSyncState(tmp)).lastSnapshotId, 'sync-remote');
2651
+ } finally {
2652
+ await fs.rm(tmp, { recursive: true, force: true });
2653
+ }
2654
+ });
2655
+
2656
+ test('applyMergeResolutions: 写回失败 → 整体回滚 + 基线不变 + 不写 pendingMerge', async () => {
2657
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-merge-applyfail-'));
2658
+ try {
2659
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2660
+ seedSource(ctx);
2661
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 3, secrets: [] });
2662
+ const transport = new MemSyncTransport();
2663
+ const mock = new MockImporter();
2664
+ mock.ok = false;
2665
+ const engine = makeEngineWithMockImporter({
2666
+ ctx, transport, stateDir: tmp, localSnapshotsDir: path.join(tmp, 'anc'), mockImporter: mock,
2667
+ });
2668
+ await engine.push({ snapshotId: 'sync-base' });
2669
+ ctx.settings.ns.set('general', { value: { theme: 'blue' }, revision: 4, secrets: [] });
2670
+ seedRemoteSnapshot(transport, remoteSettingsSnapshot('sync-remote', 'red', 9));
2671
+
2672
+ const report = await engine.applyMergeResolutions([{ id: 'settings', action: 'useRemote' }]);
2673
+ assert.equal(report.ok, false, '失败 → ok:false');
2674
+ assert.equal(report.rolledBack, true, '失败 → 整体回滚');
2675
+ assert.deepEqual(report.applied, []);
2676
+ const state = await loadSyncState(tmp);
2677
+ assert.equal(state.lastSnapshotId, 'sync-base', '失败绝不推进基线');
2678
+ assert.equal(state.pendingMerge, undefined, '失败路径不写待裁决记录');
2679
+ } finally {
2680
+ await fs.rm(tmp, { recursive: true, force: true });
2681
+ }
2682
+ });
2683
+
2684
+ test('applyMergeResolutions: 远端无快照 → 空报告(ok:true,不构造计划、不抛错)', async () => {
2685
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-merge-empty-'));
2686
+ try {
2687
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
2688
+ seedSource(ctx);
2689
+ const transport = new MemSyncTransport();
2690
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
2691
+ const report = await engine.applyMergeResolutions([{ id: 'settings', action: 'useRemote' }]);
2692
+ assert.equal(report.ok, true);
2693
+ assert.deepEqual(report.pendingConflicts, []);
2694
+ assert.deepEqual(report.applied, []);
2695
+ const preview = await engine.previewMerge();
2696
+ assert.equal(preview.ok, false);
2697
+ assert.equal(preview.snapshotId, '');
2698
+ } finally {
2699
+ await fs.rm(tmp, { recursive: true, force: true });
2700
+ }
2701
+ });
2702
+