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
@@ -9,7 +9,7 @@ import os from 'node:os';
9
9
  import path from 'node:path';
10
10
 
11
11
  import {
12
- readSyncHistory, appendAutosyncEntry, SYNC_HISTORY_FILE,
12
+ readSyncHistory, appendAutosyncEntry, markAutosyncNotified, SYNC_HISTORY_FILE,
13
13
  AUTOSYNC_HISTORY_KEEP,
14
14
  } from './sync-history.ts';
15
15
  import type { AutosyncHistoryEntry } from './sync-history.ts';
@@ -114,3 +114,18 @@ test('appendAutosyncEntry:原子写(不残留 .tmp 文件)', async () => {
114
114
  assert.ok(!files.some((f) => f.includes('.tmp')), '不应残留 .tmp 临时文件');
115
115
  } finally { await fs.rm(dir, { recursive: true, force: true }); }
116
116
  });
117
+ test('markAutosyncNotified:连续失败通知的 notifiedAt 真正落盘(issue #105)', async () => {
118
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-history-notified-'));
119
+ try {
120
+ const entry = makeEntry(1, 'failed');
121
+ await appendAutosyncEntry(dir, entry);
122
+ const notifiedAt = '2026-08-17T11:00:00.000Z';
123
+ assert.equal(await markAutosyncNotified(dir, entry.createdAt, notifiedAt), true);
124
+ const hist = await readSyncHistory(dir);
125
+ // 此前只改内存对象、从不落盘 —— 通知事实等于没留下
126
+ assert.equal(hist.autosyncEntries[0]!.notifiedAt, notifiedAt, '通知时间必须留在历史记录里');
127
+ // 未命中记录 → false,且不产生新条目、不改条目数
128
+ assert.equal(await markAutosyncNotified(dir, '2026-01-01T00:00:00.000Z', notifiedAt), false);
129
+ assert.equal((await readSyncHistory(dir)).autosyncEntries.length, 1);
130
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
131
+ });
@@ -118,3 +118,29 @@ export async function appendAutosyncEntry(
118
118
  const data = stringifyJsonSafe(current, { space: 2 });
119
119
  await atomicWriteFile(target, data, { mode: 0o600 });
120
120
  }
121
+
122
+ /**
123
+ * 给指定 createdAt 的自动同步记录补 notifiedAt 并落盘(issue #105)。
124
+ *
125
+ * 为什么不再借道 autosync-config:此前「连续失败 ≥ 3」的通知文案被写进 lastRunMessage,
126
+ * 与「本轮错误原文」抢同一个字段 —— 界面据此渲染出与真实状态无关的通知文案。
127
+ * 通知的持久痕迹只留历史 entry 的 notifiedAt;未命中记录返回 false(不写文件)。
128
+ */
129
+ export async function markAutosyncNotified(
130
+ dir: string,
131
+ createdAt: string,
132
+ notifiedAt: string,
133
+ opts: { now?: () => Date; readHistory?: (dir: string) => Promise<SyncHistoryFile> } = {},
134
+ ): Promise<boolean> {
135
+ const readHistory = opts.readHistory ?? readSyncHistory;
136
+ const current = await readHistory(dir);
137
+ const entry = current.autosyncEntries.find((e) => e.createdAt === createdAt);
138
+ if (entry === undefined) return false;
139
+ entry.notifiedAt = notifiedAt;
140
+ current.updatedAt = (opts.now ?? (() => new Date()))().toISOString();
141
+
142
+ await fs.mkdir(dir, { recursive: true });
143
+ const target = path.join(dir, SYNC_HISTORY_FILE);
144
+ await atomicWriteFile(target, stringifyJsonSafe(current, { space: 2 }), { mode: 0o600 });
145
+ return true;
146
+ }
@@ -231,6 +231,70 @@ test('saveSyncState / loadSyncState: ancestorId 与 lastSnapshotId 相互独立
231
231
  }
232
232
  });
233
233
 
234
+ /* ---------------- 基线纪律:pendingMerge(纯可选字段,不提升 schemaVersion) ---------------- */
235
+
236
+ test('saveSyncState / loadSyncState: pendingMerge 往返(旧调用方缺省不写该字段)', async () => {
237
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-state-pending-'));
238
+ try {
239
+ const state: SyncState = {
240
+ schemaVersion: SYNC_STATE_SCHEMA_VERSION,
241
+ lastSyncAt: '2026-08-16T12:00:00.000Z',
242
+ sections: {},
243
+ lastSnapshotId: 'sync-base',
244
+ ancestorId: '',
245
+ pendingMerge: { remoteSnapshotId: 'sync-remote', sections: ['settings', 'skills'], since: '2026-08-16T12:00:00.000Z' },
246
+ };
247
+ await saveSyncState(tmp, state);
248
+ const raw = JSON.parse(await fs.readFile(path.join(tmp, SYNC_STATE_FILE), 'utf8'));
249
+ assert.equal(raw.schemaVersion, SYNC_STATE_SCHEMA_VERSION, '纯可选字段不提升 schemaVersion');
250
+ assert.deepEqual(raw.pendingMerge, state.pendingMerge, '逐字落盘');
251
+ const loaded = await loadSyncState(tmp);
252
+ assert.deepEqual(loaded.pendingMerge, state.pendingMerge, '读回形状一致');
253
+ assert.deepEqual(loaded, state);
254
+ } finally {
255
+ await fs.rm(tmp, { recursive: true, force: true });
256
+ }
257
+ });
258
+
259
+ test('loadSyncState: 缺 pendingMerge 的旧文件 → 读回形状逐键不变(字段缺席,不是 undefined 键)', async () => {
260
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-state-nopending-'));
261
+ try {
262
+ await fs.writeFile(
263
+ path.join(tmp, SYNC_STATE_FILE),
264
+ JSON.stringify({ schemaVersion: 3, lastSyncAt: '', sections: {}, lastSnapshotId: '', ancestorId: '' }),
265
+ 'utf8',
266
+ );
267
+ const state = await loadSyncState(tmp);
268
+ assert.equal('pendingMerge' in state, false, '缺席 = 无待裁决冲突(不凭空造一个空记录)');
269
+ } finally {
270
+ await fs.rm(tmp, { recursive: true, force: true });
271
+ }
272
+ });
273
+
274
+ test('loadSyncState: 形状损坏/分区清单为空的 pendingMerge → 按「无待裁决冲突」处理,不抛错', async () => {
275
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-state-pending-bad-'));
276
+ try {
277
+ const cases: unknown[] = [
278
+ 'not-an-object',
279
+ { remoteSnapshotId: 'x', since: 'y', sections: 'nope' },
280
+ { remoteSnapshotId: 42, since: 'y', sections: ['settings'] },
281
+ { remoteSnapshotId: 'x', sections: ['settings'] }, // 缺 since
282
+ { remoteSnapshotId: 'x', since: 'y', sections: [] }, // 空清单 = 没有内容可记录
283
+ ];
284
+ for (const bad of cases) {
285
+ await fs.writeFile(
286
+ path.join(tmp, SYNC_STATE_FILE),
287
+ JSON.stringify({ schemaVersion: 3, lastSyncAt: '', sections: {}, lastSnapshotId: '', ancestorId: '', pendingMerge: bad }),
288
+ 'utf8',
289
+ );
290
+ const state = await loadSyncState(tmp);
291
+ assert.equal('pendingMerge' in state, false, '损坏形态不得让 loadSyncState 失败: ' + JSON.stringify(bad));
292
+ }
293
+ } finally {
294
+ await fs.rm(tmp, { recursive: true, force: true });
295
+ }
296
+ });
297
+
234
298
  test('loadSyncState: 非字符串 ancestorId(损坏/被篡改)→ 按「无本地祖先副本」处理,不抛错', async () => {
235
299
  const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-state-anc-bad-'));
236
300
  try {
@@ -3,6 +3,7 @@
3
3
  * 记录上次同步时间与各分区内容 hash,供变更检测(与远端快照对比决定是否重传)。
4
4
  * 纯逻辑 + 可注入 fs;不触碰真实 ~/.dsh。
5
5
  */
6
+ import { randomUUID } from 'node:crypto';
6
7
  import { sha256Hex } from '../utils/hashing.ts';
7
8
  import { parseJsonSafe, stringifyJsonSafe } from '../utils/json.ts';
8
9
  import { zhMsg } from '../core/messages.ts';
@@ -10,6 +11,8 @@ import type { MsgFunc } from '../core/messages.ts';
10
11
  import type { FilesSection, SectionData, SectionId } from '../schema/types.ts';
11
12
  import { createSnapshotFs, joinFs } from './fs.ts';
12
13
  import type { SnapshotFs } from './fs.ts';
14
+ // 侧效应导入:注册同步域追加消息目录(sync.deviceNameFallback 等 ⑥ 新键必须能解析,绝不露裸键名)
15
+ import './messages.ts';
13
16
 
14
17
  export const SYNC_STATE_SCHEMA_VERSION = 3;
15
18
  /**
@@ -28,6 +31,25 @@ export interface SyncSectionState {
28
31
  updatedAt: string; // ISO-8601 UTC
29
32
  }
30
33
 
34
+ /**
35
+ * 待裁决的合并状态(基线纪律,2026-10)。
36
+ *
37
+ * 非空 = 上一次三方合并**仍有未裁决的冲突**,因此 lastSnapshotId 刻意**不推进**:
38
+ * 远端的最新快照下一轮仍会被当作「比基线新」而重新合并,冲突因此重新出现,直到用户裁决完。
39
+ * 若在冲突未裁决时就推进基线,冲突内容会在下一轮静默消失(远端那一版改动永远不再被讨论)。
40
+ *
41
+ * 纯可选字段:**不提升 SYNC_STATE_SCHEMA_VERSION**(旧文件缺该字段 → 读取形状逐键不变;
42
+ * 旧版本代码读到新文件也只是忽略它)。
43
+ */
44
+ export interface PendingMerge {
45
+ /** 冲突所针对的**远端**快照 id(基线指针此刻仍停在更早的那一个)。 */
46
+ remoteSnapshotId: string;
47
+ /** 仍未裁决的分区 id(非空;空 = 没有待裁决内容,调用方应清除该字段)。 */
48
+ sections: SectionId[];
49
+ /** 该待裁决状态**首次**出现的时刻(ISO-8601 UTC;跨轮保留首次时间,便于界面讲「挂了多久」)。 */
50
+ since: string;
51
+ }
52
+
31
53
  /** sync-state.json 结构 */
32
54
  export interface SyncState {
33
55
  schemaVersion: number;
@@ -57,6 +79,19 @@ export interface SyncState {
57
79
  * 本机若又出现该会话的实体,对应墓碑会被撤销(有实体 = 没删)。
58
80
  */
59
81
  deletedSessions?: string[];
82
+ /**
83
+ * cloud-sync 借鉴项 ⑥:本设备的**稳定**同步身份(首次推送时生成并持久化)。
84
+ *
85
+ * deviceId = 随机 UUID(换主机名/改机器名都不变,历史列表据此认出「同一台设备」);
86
+ * deviceName = 人类可读名(缺省取主机名)。缺省 = 旧文件 / 从未推送(不猜)。
87
+ */
88
+ deviceId?: string;
89
+ deviceName?: string;
90
+ /**
91
+ * 基线纪律(2026-10):未裁决冲突记录。非空 = 基线指针**刻意停在旧值**,
92
+ * 让冲突在下一轮重新出现(详见 PendingMerge)。
93
+ */
94
+ pendingMerge?: PendingMerge;
60
95
  }
61
96
 
62
97
  /** 文件类分区判定:{ version: 1, files: [...] }(duck-typing,与 JSON 分区区分) */
@@ -114,6 +149,22 @@ function readStringArray(raw: unknown): string[] {
114
149
  return out;
115
150
  }
116
151
 
152
+ /**
153
+ * 读取可选 pendingMerge(基线纪律)。
154
+ *
155
+ * 形状不符(损坏/被篡改/分区清单为空)→ undefined = 按「无待裁决冲突」处理:
156
+ * 这里**绝不抛错**(一个诊断字段不该让 loadSyncState 整体失败),也绝不把空清单当成
157
+ * 「所有冲突都解决了」—— 空清单只是没有内容可记录。
158
+ */
159
+ function readPendingMerge(raw: unknown): PendingMerge | undefined {
160
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
161
+ const o = raw as Record<string, unknown>;
162
+ if (typeof o['remoteSnapshotId'] !== 'string' || typeof o['since'] !== 'string') return undefined;
163
+ const sections = readStringArray(o['sections']);
164
+ if (sections.length === 0) return undefined;
165
+ return { remoteSnapshotId: o['remoteSnapshotId'], sections: sections as SectionId[], since: o['since'] };
166
+ }
167
+
117
168
  /** 读取同步状态;文件不存在 → 返回缺省空状态(从未同步)。
118
169
  * 旧版本文件(v1/v2)→ 在内存中迁移到当前版本(见 SYNC_STATE_SUPPORTED_VERSIONS),
119
170
  * 不在磁盘上就地升级(首次 saveSyncState 时才写回新版本)。 */
@@ -164,6 +215,11 @@ export async function loadSyncState(dir: string, fsx: SnapshotFs = createSnapsho
164
215
  // 绝不采用一个来路不明的目录名。
165
216
  ancestorId: rawAncestorId ?? (fileVersion < SYNC_STATE_SCHEMA_VERSION ? rawLastSnapshotId : ''),
166
217
  lastSnapshotId: rawLastSnapshotId,
218
+ // ⑥:只有文件里确实写过才回填(缺席 = 从未记录 → 下次推送才生成,绝不在这里凭空造一个 id)
219
+ ...(typeof obj['deviceId'] === 'string' && obj['deviceId'] !== '' ? { deviceId: obj['deviceId'] } : {}),
220
+ ...(typeof obj['deviceName'] === 'string' && obj['deviceName'] !== '' ? { deviceName: obj['deviceName'] } : {}),
221
+ // 基线纪律:同样「写过才回填」—— 缺席 = 无待裁决冲突(旧文件读回形状逐键不变)
222
+ ...(readPendingMerge(obj['pendingMerge']) !== undefined ? { pendingMerge: readPendingMerge(obj['pendingMerge'])! } : {}),
167
223
  };
168
224
  const t = obj['transport'];
169
225
  if (t !== undefined) {
@@ -175,6 +231,29 @@ export async function loadSyncState(dir: string, fsx: SnapshotFs = createSnapsho
175
231
  return state;
176
232
  }
177
233
 
234
+ /**
235
+ * 解析(必要时生成并持久化)本设备的同步身份(cloud-sync 借鉴项 ⑥)。
236
+ *
237
+ * - deviceId 一旦生成就持久化在 sync-state.json → 历史列表能认出「同一台设备」;
238
+ * - deviceName 缺省取主机名,取不到(容器/无主机名)时用字典里的兜底文案,绝不写空串;
239
+ * - **只在首次生成时写盘**(已有 id → 纯读,不产生额外写)。
240
+ */
241
+ export async function resolveDeviceIdentity(
242
+ dir: string,
243
+ hostName: string,
244
+ fsx: SnapshotFs = createSnapshotFs(),
245
+ msg: MsgFunc = zhMsg,
246
+ ): Promise<{ id: string; name: string }> {
247
+ const state = await loadSyncState(dir, fsx, msg);
248
+ const trimmedHost = typeof hostName === 'string' ? hostName.trim() : '';
249
+ const name = (typeof state.deviceName === 'string' && state.deviceName !== '' ? state.deviceName : '')
250
+ || (trimmedHost !== '' ? trimmedHost : msg('sync.deviceNameFallback'));
251
+ if (typeof state.deviceId === 'string' && state.deviceId !== '') return { id: state.deviceId, name };
252
+ const id = randomUUID();
253
+ await saveSyncState(dir, { ...state, deviceId: id, deviceName: name }, fsx);
254
+ return { id, name };
255
+ }
256
+
178
257
  /** 保存同步状态(自动创建目录) */
179
258
  export async function saveSyncState(dir: string, state: SyncState, fsx: SnapshotFs = createSnapshotFs()): Promise<void> {
180
259
  await fsx.mkdir(dir);
@@ -0,0 +1,197 @@
1
+ /**
2
+ * **本地改动监听器**单测(src/sync/sync-watcher.ts)。
3
+ *
4
+ * 盯四件事:
5
+ * 1. 文件目标监听父目录 + basename 过滤(原子写 rename 后句柄不失);
6
+ * 2. 一次保存连写多个文件 → 去抖合并成一次 onFlush,且噪声路径被挡掉;
7
+ * 3. suspend/resume 的自写回声屏蔽(否则 push→apply→push 死循环);
8
+ * 4. 监听失败绝不抛错(兜底是定时器),stop/setTargets 必须关掉旧句柄。
9
+ */
10
+ import test from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import path from 'node:path';
13
+
14
+ import { SyncWatcher } from './sync-watcher.ts';
15
+ import { buildWatchTargets, type WatchTarget } from './watch-scope.ts';
16
+ import type { Logger } from '../utils/logger.ts';
17
+
18
+ const HOME = path.join(path.sep, 'home', 'u', '.dsh');
19
+
20
+ interface FakeListener { absPath: string; recursive: boolean; fn: (event: string, filename: string | null) => void }
21
+
22
+ function makeLogger(): { log: Logger; warns: string[]; debugs: string[] } {
23
+ const warns: string[] = [];
24
+ const debugs: string[] = [];
25
+ const log = {
26
+ debug: (m: string) => { debugs.push(m); },
27
+ info: () => undefined,
28
+ warn: (m: string) => { warns.push(m); },
29
+ error: () => undefined,
30
+ } as unknown as Logger;
31
+ return { log, warns, debugs };
32
+ }
33
+
34
+ function makeHarness(targets: readonly WatchTarget[], opts: { failOn?: string } = {}) {
35
+ const listeners: FakeListener[] = [];
36
+ const handles: Array<{ absPath: string; closed: boolean }> = [];
37
+ const flushes: string[][] = [];
38
+ const timers: Array<() => void> = [];
39
+ let nowMs = 10_000;
40
+ const { log, warns, debugs } = makeLogger();
41
+ const watcher = new SyncWatcher({
42
+ homeDir: HOME,
43
+ targets,
44
+ log,
45
+ onFlush: (changed) => { flushes.push([...changed]); },
46
+ watchFactory: (absPath, o, fn) => {
47
+ if (opts.failOn !== undefined && absPath === opts.failOn) throw new Error('ENOENT: 不存在');
48
+ const h = { absPath, closed: false };
49
+ handles.push(h);
50
+ listeners.push({ absPath, recursive: o.recursive, fn });
51
+ return { close: () => { h.closed = true; } };
52
+ },
53
+ now: () => nowMs,
54
+ setTimer: (fn) => { timers.push(fn); return timers.length as unknown as ReturnType<typeof setTimeout>; },
55
+ clearTimer: () => undefined,
56
+ });
57
+ /** 触发某目录上的原生事件(按绝对路径找监听句柄)。 */
58
+ const fire = (absPath: string, filename: string | null): void => {
59
+ const hit = listeners.filter((l) => l.absPath === absPath);
60
+ assert.ok(hit.length > 0, '没有监听 ' + absPath);
61
+ for (const l of hit) l.fn('change', filename);
62
+ };
63
+ const runTimers = (): void => {
64
+ const pending = [...timers];
65
+ timers.length = 0;
66
+ for (const t of pending) t();
67
+ };
68
+ return { watcher, listeners, handles, flushes, timers, fire, runTimers, warns, debugs, setNow: (v: number) => { nowMs = v; } };
69
+ }
70
+
71
+ const TARGETS = buildWatchTargets({ homeDir: HOME });
72
+ const settingsAbs = path.join(HOME, 'settings.yaml');
73
+ const skillsAbs = path.join(HOME, 'skills');
74
+
75
+ test('start():每个目标挂一个监听(文件目标监听父目录且非递归),重复调用幂等', () => {
76
+ const h = makeHarness(TARGETS);
77
+ h.watcher.start();
78
+ assert.equal(h.watcher.isStarted(), true);
79
+ assert.equal(h.watcher.attachmentCount(), TARGETS.length);
80
+ const fileListener = h.listeners.find((l) => l.absPath === path.dirname(settingsAbs));
81
+ assert.ok(fileListener !== undefined, 'settings.yaml 必须监听父目录(原子写 rename 后句柄不失)');
82
+ assert.equal(fileListener!.recursive, false, '文件目标不得递归');
83
+ const dirListener = h.listeners.find((l) => l.absPath === skillsAbs);
84
+ assert.equal(dirListener!.recursive, true, '目录目标递归');
85
+ h.watcher.start();
86
+ assert.equal(h.watcher.attachmentCount(), TARGETS.length, 'start() 幂等');
87
+ h.watcher.stop();
88
+ assert.equal(h.watcher.attachmentCount(), 0);
89
+ assert.ok(h.handles.every((x) => x.closed), 'stop() 必须关掉每个句柄');
90
+ });
91
+
92
+ test('文件目标:只认同名 basename;一次保存的连续事件去抖成一次 onFlush', () => {
93
+ const h = makeHarness(TARGETS);
94
+ h.watcher.start();
95
+ const dir = path.dirname(settingsAbs);
96
+ h.fire(dir, 'other.yaml');
97
+ assert.equal(h.timers.length, 0, '父目录里别的文件不得触发(否则整目录噪声)');
98
+ h.fire(dir, 'settings.yaml.tmp-1234-0');
99
+ assert.equal(h.timers.length, 0, '原子写临时文件是噪声');
100
+ h.fire(dir, 'settings.yaml');
101
+ h.fire(dir, 'settings.yaml');
102
+ assert.equal(h.timers.length, 1, '去抖:多次事件只排一个定时器');
103
+ h.runTimers();
104
+ assert.deepEqual(h.flushes, [['settings.yaml']], '去重后只报一次路径');
105
+ });
106
+
107
+ test('目录目标:递归事件的相对路径按 home 归一化;filename=null 忽略', () => {
108
+ const h = makeHarness(TARGETS);
109
+ h.watcher.start();
110
+ h.fire(skillsAbs, null);
111
+ assert.equal(h.timers.length, 0, '拿不到文件名的事件不能猜路径');
112
+ h.fire(skillsAbs, path.join('nodejs-testing', 'SKILL.md'));
113
+ h.runTimers();
114
+ assert.deepEqual(h.flushes, [['skills/nodejs-testing/SKILL.md']]);
115
+ });
116
+
117
+ test('suspend() 丢弃在途事件;resume() 后静默窗口内的事件被推迟到窗口结束(自写回声不触发、用户改动不丢)', () => {
118
+ const h = makeHarness(TARGETS);
119
+ h.watcher.start();
120
+ const dir = path.dirname(settingsAbs);
121
+ h.fire(dir, 'settings.yaml');
122
+ assert.equal(h.timers.length, 1);
123
+ h.watcher.suspend();
124
+ // suspend 清掉在途去抖:即便那枚已被 clearTimer 的定时器仍被触发,也不得回调
125
+ h.runTimers();
126
+ assert.deepEqual(h.flushes, [], 'suspend 必须丢弃在途变更(同步自家 apply/push 的写入不算用户改动)');
127
+ h.fire(dir, 'settings.yaml');
128
+ assert.equal(h.timers.length, 0, 'suspend 期间的事件一律屏蔽');
129
+ h.runTimers();
130
+ assert.deepEqual(h.flushes, [], '挂起期间不得回调');
131
+ h.watcher.resume();
132
+ h.setNow(10_000 + 1_999);
133
+ h.fire(dir, 'settings.yaml');
134
+ assert.deepEqual(h.flushes, [], '静默窗口内(<2000ms)不得立刻回调');
135
+ assert.equal(h.timers.length, 1, '但必须排一枚窗口结束后的补跑定时器(真机实测:直接丢弃会让用户改动等一整轮兜底间隔)');
136
+ // 平台抖动 / clearTimer 未生效导致定时器提前到期:仍不得在窗口内回调,而是继续推迟
137
+ h.runTimers();
138
+ assert.deepEqual(h.flushes, [], '窗口内即便定时器到期也不得回调');
139
+ assert.equal(h.timers.length, 1, '必须重新排期,绝不把这次改动丢掉');
140
+ h.setNow(10_000 + 2_001);
141
+ h.runTimers();
142
+ assert.deepEqual(h.flushes, [['settings.yaml']], '窗口结束后补跑一次:用户改动照常上行,自家回声由引擎 hash 比对判无变化');
143
+ });
144
+
145
+ test('stop() 后不再触发;onFlush 抛错只告警(监听不能拖垮宿主)', () => {
146
+ const { log } = makeLogger();
147
+ const warns: string[] = [];
148
+ const throwing = { ...log, warn: (m: string) => { warns.push(m); } } as unknown as Logger;
149
+ const h = makeHarness(TARGETS);
150
+ h.watcher.start();
151
+ const dir = path.dirname(settingsAbs);
152
+ const stale = h.listeners.filter((l) => l.absPath === dir);
153
+ assert.ok(stale.length > 0);
154
+ h.watcher.stop();
155
+ for (const l of stale) l.fn('change', 'settings.yaml');
156
+ assert.equal(h.timers.length, 0, 'stop 后事件不得再排期(挂起的句柄回声也要挡)');
157
+ // 单独构造一个回调抛错的实例
158
+ const timers: Array<() => void> = [];
159
+ const w2 = new SyncWatcher({
160
+ homeDir: HOME,
161
+ targets: TARGETS,
162
+ log: throwing,
163
+ onFlush: () => { throw new Error('上层炸了'); },
164
+ watchFactory: (absPath, _o, fn) => { fn('change', 'settings.yaml'); return { close: () => undefined }; },
165
+ setTimer: (fn) => { timers.push(fn); return timers.length as unknown as ReturnType<typeof setTimeout>; },
166
+ clearTimer: () => undefined,
167
+ });
168
+ w2.start();
169
+ for (const t of [...timers]) t();
170
+ assert.ok(warns.some((m) => m.includes('自动同步监听回调失败')), '回调抛错必须落 warn,不得冒泡');
171
+ });
172
+
173
+ test('监听挂载失败(路径不存在)不抛错、不计入附件数,只留 debug', () => {
174
+ // 用 skills/ 目录目标做失败点:它独占一个监听路径(settings.yaml 等三个文件目标共用 HOME,会一起失败)
175
+ const h = makeHarness(TARGETS, { failOn: skillsAbs });
176
+ h.watcher.start();
177
+ assert.equal(h.watcher.attachmentCount(), TARGETS.length - 1, '失败目标不挂载');
178
+ assert.ok(h.debugs.some((m) => m.includes('自动同步监听未挂载')), '失败原因写进 debug 日志');
179
+ });
180
+
181
+ test('setTargets():原子重启,旧句柄关闭、新目标挂上', () => {
182
+ const h = makeHarness(TARGETS);
183
+ h.watcher.start();
184
+ const before = h.handles.length;
185
+ const narrowed = TARGETS.filter((t) => t.relPath === 'settings.yaml');
186
+ h.watcher.setTargets(narrowed);
187
+ assert.ok(h.handles.slice(0, before).every((x) => x.closed), '换目标必须关掉旧句柄');
188
+ assert.equal(h.watcher.attachmentCount(), 1);
189
+ h.watcher.stop();
190
+ });
191
+
192
+ test('未 start 时 setTargets 只记目标、不挂监听', () => {
193
+ const h = makeHarness(TARGETS);
194
+ h.watcher.setTargets(TARGETS);
195
+ assert.equal(h.watcher.attachmentCount(), 0);
196
+ assert.equal(h.watcher.isStarted(), false);
197
+ });