@logictan/dsh-config-manager 0.1.60

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 (429) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/cordis.patch.yml +21 -0
  4. package/lib/adapters/agent-instructions.d.ts +13 -0
  5. package/lib/adapters/agent-instructions.js +46 -0
  6. package/lib/adapters/agent-presets.d.ts +13 -0
  7. package/lib/adapters/agent-presets.js +14 -0
  8. package/lib/adapters/credentials.d.ts +37 -0
  9. package/lib/adapters/credentials.js +122 -0
  10. package/lib/adapters/file-collection.d.ts +15 -0
  11. package/lib/adapters/file-collection.js +127 -0
  12. package/lib/adapters/index.d.ts +57 -0
  13. package/lib/adapters/index.js +55 -0
  14. package/lib/adapters/link-report.d.ts +16 -0
  15. package/lib/adapters/link-report.js +47 -0
  16. package/lib/adapters/mcp.d.ts +28 -0
  17. package/lib/adapters/mcp.js +178 -0
  18. package/lib/adapters/plugin-files.d.ts +18 -0
  19. package/lib/adapters/plugin-files.js +142 -0
  20. package/lib/adapters/plugins.d.ts +41 -0
  21. package/lib/adapters/plugins.js +603 -0
  22. package/lib/adapters/pnpm-workspace.d.ts +26 -0
  23. package/lib/adapters/pnpm-workspace.js +147 -0
  24. package/lib/adapters/prompts.d.ts +30 -0
  25. package/lib/adapters/prompts.js +231 -0
  26. package/lib/adapters/providers.d.ts +34 -0
  27. package/lib/adapters/providers.js +155 -0
  28. package/lib/adapters/self.d.ts +17 -0
  29. package/lib/adapters/self.js +71 -0
  30. package/lib/adapters/sessions.d.ts +14 -0
  31. package/lib/adapters/sessions.js +15 -0
  32. package/lib/adapters/settings.d.ts +28 -0
  33. package/lib/adapters/settings.js +165 -0
  34. package/lib/adapters/skills.d.ts +13 -0
  35. package/lib/adapters/skills.js +14 -0
  36. package/lib/adapters/test-helpers.d.ts +125 -0
  37. package/lib/adapters/test-helpers.js +236 -0
  38. package/lib/adapters/ui.d.ts +27 -0
  39. package/lib/adapters/ui.js +89 -0
  40. package/lib/adapters/workspaces.d.ts +13 -0
  41. package/lib/adapters/workspaces.js +106 -0
  42. package/lib/client.d.ts +1026 -0
  43. package/lib/client.js +10047 -0
  44. package/lib/core/analyzer.d.ts +99 -0
  45. package/lib/core/analyzer.js +934 -0
  46. package/lib/core/backup.d.ts +138 -0
  47. package/lib/core/backup.js +513 -0
  48. package/lib/core/boot-rescue.d.ts +146 -0
  49. package/lib/core/boot-rescue.js +618 -0
  50. package/lib/core/cache-cleaner.d.ts +31 -0
  51. package/lib/core/cache-cleaner.js +95 -0
  52. package/lib/core/config-lifecycle.d.ts +180 -0
  53. package/lib/core/config-lifecycle.js +481 -0
  54. package/lib/core/config-snapshot.d.ts +135 -0
  55. package/lib/core/config-snapshot.js +424 -0
  56. package/lib/core/config-state.d.ts +70 -0
  57. package/lib/core/config-state.js +177 -0
  58. package/lib/core/consult-source.d.ts +58 -0
  59. package/lib/core/consult-source.js +260 -0
  60. package/lib/core/crash-report.d.ts +84 -0
  61. package/lib/core/crash-report.js +317 -0
  62. package/lib/core/exporter.d.ts +68 -0
  63. package/lib/core/exporter.js +375 -0
  64. package/lib/core/ghost-sweep.d.ts +19 -0
  65. package/lib/core/ghost-sweep.js +52 -0
  66. package/lib/core/importer.d.ts +60 -0
  67. package/lib/core/importer.js +29 -0
  68. package/lib/core/index.d.ts +14 -0
  69. package/lib/core/index.js +14 -0
  70. package/lib/core/journal.d.ts +197 -0
  71. package/lib/core/journal.js +418 -0
  72. package/lib/core/local-plugin-host.d.ts +35 -0
  73. package/lib/core/local-plugin-host.js +120 -0
  74. package/lib/core/local-plugin-pack.d.ts +135 -0
  75. package/lib/core/local-plugin-pack.js +278 -0
  76. package/lib/core/messages.d.ts +310 -0
  77. package/lib/core/messages.js +621 -0
  78. package/lib/core/migration-consult.d.ts +117 -0
  79. package/lib/core/migration-consult.js +363 -0
  80. package/lib/core/migration-history.d.ts +153 -0
  81. package/lib/core/migration-history.js +411 -0
  82. package/lib/core/model-tools.d.ts +50 -0
  83. package/lib/core/model-tools.js +148 -0
  84. package/lib/core/msg-types.d.ts +8 -0
  85. package/lib/core/msg-types.js +6 -0
  86. package/lib/core/phase3-child-crash.d.ts +1 -0
  87. package/lib/core/phase3-child-crash.js +87 -0
  88. package/lib/core/phase3-host.d.ts +138 -0
  89. package/lib/core/phase3-host.js +359 -0
  90. package/lib/core/phase3-prod-child.d.ts +1 -0
  91. package/lib/core/phase3-prod-child.js +35 -0
  92. package/lib/core/phase4-crash-child.d.ts +1 -0
  93. package/lib/core/phase4-crash-child.js +103 -0
  94. package/lib/core/plugin-cli.d.ts +150 -0
  95. package/lib/core/plugin-cli.js +497 -0
  96. package/lib/core/reconcile.d.ts +120 -0
  97. package/lib/core/reconcile.js +389 -0
  98. package/lib/core/recovery-orchestrator.d.ts +94 -0
  99. package/lib/core/recovery-orchestrator.js +327 -0
  100. package/lib/core/restore.d.ts +162 -0
  101. package/lib/core/restore.js +703 -0
  102. package/lib/core/rollback.d.ts +12 -0
  103. package/lib/core/rollback.js +145 -0
  104. package/lib/core/run-registry.d.ts +85 -0
  105. package/lib/core/run-registry.js +157 -0
  106. package/lib/core/startup-barrier.d.ts +53 -0
  107. package/lib/core/startup-barrier.js +77 -0
  108. package/lib/core/types.d.ts +515 -0
  109. package/lib/core/types.js +27 -0
  110. package/lib/core/undo.d.ts +73 -0
  111. package/lib/core/undo.js +87 -0
  112. package/lib/core/validator.d.ts +18 -0
  113. package/lib/core/validator.js +77 -0
  114. package/lib/core/verify-recovery.d.ts +21 -0
  115. package/lib/core/verify-recovery.js +256 -0
  116. package/lib/core/watcher.d.ts +101 -0
  117. package/lib/core/watcher.js +219 -0
  118. package/lib/index.d.ts +238 -0
  119. package/lib/index.js +2764 -0
  120. package/lib/migrations/index.d.ts +29 -0
  121. package/lib/migrations/index.js +56 -0
  122. package/lib/migrations/v1-to-v2.d.ts +9 -0
  123. package/lib/migrations/v1-to-v2.js +10 -0
  124. package/lib/profiles/index.d.ts +4 -0
  125. package/lib/profiles/index.js +5 -0
  126. package/lib/profiles/profile-manager.d.ts +106 -0
  127. package/lib/profiles/profile-manager.js +543 -0
  128. package/lib/schema/config.d.ts +28 -0
  129. package/lib/schema/config.js +165 -0
  130. package/lib/schema/index.d.ts +24 -0
  131. package/lib/schema/index.js +26 -0
  132. package/lib/schema/manifest.d.ts +28 -0
  133. package/lib/schema/manifest.js +109 -0
  134. package/lib/schema/tombstones.d.ts +46 -0
  135. package/lib/schema/tombstones.js +64 -0
  136. package/lib/schema/types.d.ts +232 -0
  137. package/lib/schema/types.js +8 -0
  138. package/lib/schema/versions.d.ts +27 -0
  139. package/lib/schema/versions.js +50 -0
  140. package/lib/security/encryption.d.ts +85 -0
  141. package/lib/security/encryption.js +279 -0
  142. package/lib/security/index.d.ts +16 -0
  143. package/lib/security/index.js +17 -0
  144. package/lib/security/integrity.d.ts +30 -0
  145. package/lib/security/integrity.js +78 -0
  146. package/lib/security/redaction.d.ts +26 -0
  147. package/lib/security/redaction.js +95 -0
  148. package/lib/security/secret-scanner.d.ts +86 -0
  149. package/lib/security/secret-scanner.js +404 -0
  150. package/lib/security/vault.d.ts +59 -0
  151. package/lib/security/vault.js +151 -0
  152. package/lib/security/zip-security.d.ts +27 -0
  153. package/lib/security/zip-security.js +154 -0
  154. package/lib/sync/autosync-config.d.ts +39 -0
  155. package/lib/sync/autosync-config.js +151 -0
  156. package/lib/sync/autosync-scheduler.d.ts +139 -0
  157. package/lib/sync/autosync-scheduler.js +564 -0
  158. package/lib/sync/fs.d.ts +18 -0
  159. package/lib/sync/fs.js +49 -0
  160. package/lib/sync/git/git-transport.d.ts +100 -0
  161. package/lib/sync/git/git-transport.js +437 -0
  162. package/lib/sync/github-auth.d.ts +131 -0
  163. package/lib/sync/github-auth.js +264 -0
  164. package/lib/sync/layout.d.ts +43 -0
  165. package/lib/sync/layout.js +203 -0
  166. package/lib/sync/snapshot-json.d.ts +9 -0
  167. package/lib/sync/snapshot-json.js +107 -0
  168. package/lib/sync/sync-config.d.ts +81 -0
  169. package/lib/sync/sync-config.js +281 -0
  170. package/lib/sync/sync-engine.d.ts +269 -0
  171. package/lib/sync/sync-engine.js +591 -0
  172. package/lib/sync/sync-history.d.ts +42 -0
  173. package/lib/sync/sync-history.js +78 -0
  174. package/lib/sync/sync-selection.d.ts +29 -0
  175. package/lib/sync/sync-selection.js +127 -0
  176. package/lib/sync/sync-session.d.ts +40 -0
  177. package/lib/sync/sync-session.js +54 -0
  178. package/lib/sync/sync-state.d.ts +37 -0
  179. package/lib/sync/sync-state.js +108 -0
  180. package/lib/sync/transport.d.ts +75 -0
  181. package/lib/sync/transport.js +55 -0
  182. package/lib/sync/ui-prefs.d.ts +34 -0
  183. package/lib/sync/ui-prefs.js +109 -0
  184. package/lib/sync/webdav/webdav-transport.d.ts +77 -0
  185. package/lib/sync/webdav/webdav-transport.js +451 -0
  186. package/lib/ui/errors.d.ts +19 -0
  187. package/lib/ui/errors.js +81 -0
  188. package/lib/ui/export-flow.d.ts +82 -0
  189. package/lib/ui/export-flow.js +98 -0
  190. package/lib/ui/i18n.d.ts +289 -0
  191. package/lib/ui/i18n.js +581 -0
  192. package/lib/ui/migration-consult-view.d.ts +91 -0
  193. package/lib/ui/migration-consult-view.js +115 -0
  194. package/lib/ui/progress.d.ts +38 -0
  195. package/lib/ui/progress.js +87 -0
  196. package/lib/ui/report.d.ts +23 -0
  197. package/lib/ui/report.js +153 -0
  198. package/lib/ui/test-helpers.d.ts +69 -0
  199. package/lib/ui/test-helpers.js +154 -0
  200. package/lib/ui/types.d.ts +257 -0
  201. package/lib/ui/types.js +21 -0
  202. package/lib/utils/atomic-write.d.ts +88 -0
  203. package/lib/utils/atomic-write.js +399 -0
  204. package/lib/utils/env-lock.d.ts +393 -0
  205. package/lib/utils/env-lock.js +930 -0
  206. package/lib/utils/hashing.d.ts +17 -0
  207. package/lib/utils/hashing.js +41 -0
  208. package/lib/utils/json.d.ts +24 -0
  209. package/lib/utils/json.js +76 -0
  210. package/lib/utils/logger.d.ts +39 -0
  211. package/lib/utils/logger.js +82 -0
  212. package/lib/utils/paths.d.ts +42 -0
  213. package/lib/utils/paths.js +189 -0
  214. package/lib/utils/proxy.d.ts +69 -0
  215. package/lib/utils/proxy.js +455 -0
  216. package/lib/utils/recursive-walk.d.ts +24 -0
  217. package/lib/utils/recursive-walk.js +134 -0
  218. package/lib/utils/zip.d.ts +54 -0
  219. package/lib/utils/zip.js +285 -0
  220. package/package.json +188 -0
  221. package/src/adapters/agent-instructions.ts +48 -0
  222. package/src/adapters/agent-presets.ts +14 -0
  223. package/src/adapters/credentials.test.ts +90 -0
  224. package/src/adapters/credentials.ts +143 -0
  225. package/src/adapters/file-collection.ts +137 -0
  226. package/src/adapters/files.test.ts +353 -0
  227. package/src/adapters/index.ts +94 -0
  228. package/src/adapters/link-report.test.ts +65 -0
  229. package/src/adapters/link-report.ts +61 -0
  230. package/src/adapters/mcp.test.ts +96 -0
  231. package/src/adapters/mcp.ts +185 -0
  232. package/src/adapters/plugin-files.ts +146 -0
  233. package/src/adapters/plugins.test.ts +326 -0
  234. package/src/adapters/plugins.ts +615 -0
  235. package/src/adapters/pnpm-workspace.test.ts +87 -0
  236. package/src/adapters/pnpm-workspace.ts +150 -0
  237. package/src/adapters/prompts.test.ts +110 -0
  238. package/src/adapters/prompts.ts +230 -0
  239. package/src/adapters/providers.test.ts +126 -0
  240. package/src/adapters/providers.ts +173 -0
  241. package/src/adapters/roundtrip.test.ts +190 -0
  242. package/src/adapters/self.test.ts +92 -0
  243. package/src/adapters/self.ts +75 -0
  244. package/src/adapters/sessions.ts +15 -0
  245. package/src/adapters/settings.test.ts +100 -0
  246. package/src/adapters/settings.ts +187 -0
  247. package/src/adapters/skills.ts +14 -0
  248. package/src/adapters/test-helpers.ts +248 -0
  249. package/src/adapters/ui.test.ts +57 -0
  250. package/src/adapters/ui.ts +108 -0
  251. package/src/adapters/workspaces.test.ts +106 -0
  252. package/src/adapters/workspaces.ts +109 -0
  253. package/src/client/ConfigManagerSection.tsx +90 -0
  254. package/src/client/api.ts +66 -0
  255. package/src/client/client-types.ts +18 -0
  256. package/src/client/common/ErrorBanner.tsx +73 -0
  257. package/src/client/common/Icon.tsx +129 -0
  258. package/src/client/common/Modal.tsx +173 -0
  259. package/src/client/common/ToastViewport.tsx +73 -0
  260. package/src/client/common/toast-store.test.ts +192 -0
  261. package/src/client/common/toast-store.ts +181 -0
  262. package/src/client/common/toast-types.ts +7 -0
  263. package/src/client/common/ui.tsx +342 -0
  264. package/src/client/config-manager.module.css +2971 -0
  265. package/src/client/css-modules.d.ts +8 -0
  266. package/src/client/index.ts +65 -0
  267. package/src/client/locales.ts +56 -0
  268. package/src/client/lucide-icons.d.ts +38 -0
  269. package/src/client/run-store.test.ts +223 -0
  270. package/src/client/run-store.ts +372 -0
  271. package/src/client/sync/SyncConfirmView.tsx +379 -0
  272. package/src/client/sync/SyncHistoryView.test.ts +40 -0
  273. package/src/client/sync/SyncHistoryView.tsx +220 -0
  274. package/src/client/sync/SyncSettingsView.tsx +1535 -0
  275. package/src/client/sync/history-model.test.ts +194 -0
  276. package/src/client/sync/history-model.ts +195 -0
  277. package/src/client/sync/sync-api.test.ts +585 -0
  278. package/src/client/sync/sync-api.ts +543 -0
  279. package/src/client/sync/sync-locales.ts +416 -0
  280. package/src/client/sync/sync-push-preview.test.ts +52 -0
  281. package/src/client/sync/sync-view-v2.test.ts +213 -0
  282. package/src/client/sync/sync-view.test.ts +487 -0
  283. package/src/client/sync/sync-view.ts +759 -0
  284. package/src/core/analyzer.ts +1068 -0
  285. package/src/core/backup.ts +605 -0
  286. package/src/core/boot-rescue.test.ts +847 -0
  287. package/src/core/boot-rescue.ts +737 -0
  288. package/src/core/cache-cleaner.test.ts +169 -0
  289. package/src/core/cache-cleaner.ts +121 -0
  290. package/src/core/config-lifecycle.test.ts +727 -0
  291. package/src/core/config-lifecycle.ts +573 -0
  292. package/src/core/config-snapshot.test.ts +369 -0
  293. package/src/core/config-snapshot.ts +522 -0
  294. package/src/core/config-state.test.ts +381 -0
  295. package/src/core/config-state.ts +207 -0
  296. package/src/core/consult-source.test.ts +199 -0
  297. package/src/core/consult-source.ts +293 -0
  298. package/src/core/crash-report.test.ts +380 -0
  299. package/src/core/crash-report.ts +369 -0
  300. package/src/core/exporter.ts +417 -0
  301. package/src/core/ghost-sweep.test.ts +94 -0
  302. package/src/core/ghost-sweep.ts +67 -0
  303. package/src/core/importer.ts +83 -0
  304. package/src/core/index.ts +56 -0
  305. package/src/core/journal.test.ts +257 -0
  306. package/src/core/journal.ts +518 -0
  307. package/src/core/local-plugin-host.ts +152 -0
  308. package/src/core/local-plugin-pack.test.ts +359 -0
  309. package/src/core/local-plugin-pack.ts +363 -0
  310. package/src/core/messages.test.ts +54 -0
  311. package/src/core/messages.ts +664 -0
  312. package/src/core/migration-consult.test.ts +315 -0
  313. package/src/core/migration-consult.ts +494 -0
  314. package/src/core/migration-history.test.ts +319 -0
  315. package/src/core/migration-history.ts +484 -0
  316. package/src/core/model-tools.test.ts +190 -0
  317. package/src/core/model-tools.ts +208 -0
  318. package/src/core/msg-types.ts +10 -0
  319. package/src/core/phase3-child-crash.ts +95 -0
  320. package/src/core/phase3-host.ts +411 -0
  321. package/src/core/phase3-p1.test.ts +212 -0
  322. package/src/core/phase3-p2b-fingerprint.test.ts +189 -0
  323. package/src/core/phase3-prod-child.ts +36 -0
  324. package/src/core/phase3-production-integration.test.ts +286 -0
  325. package/src/core/phase4-crash-child.ts +101 -0
  326. package/src/core/phase4-crash-injection.test.ts +166 -0
  327. package/src/core/plugin-cli.fs.test.ts +184 -0
  328. package/src/core/plugin-cli.test.ts +137 -0
  329. package/src/core/plugin-cli.ts +563 -0
  330. package/src/core/reconcile.test.ts +392 -0
  331. package/src/core/reconcile.ts +498 -0
  332. package/src/core/recovery-orchestrator.test.ts +145 -0
  333. package/src/core/recovery-orchestrator.ts +366 -0
  334. package/src/core/restore-manage.test.ts +94 -0
  335. package/src/core/restore.ts +849 -0
  336. package/src/core/rollback.ts +151 -0
  337. package/src/core/run-progress.test.ts +237 -0
  338. package/src/core/run-registry.test.ts +232 -0
  339. package/src/core/run-registry.ts +209 -0
  340. package/src/core/smoke.test.ts +842 -0
  341. package/src/core/startup-barrier.ts +99 -0
  342. package/src/core/types.ts +542 -0
  343. package/src/core/undo.ts +116 -0
  344. package/src/core/validator.ts +84 -0
  345. package/src/core/verify-recovery.ts +265 -0
  346. package/src/core/watcher.test.ts +326 -0
  347. package/src/core/watcher.ts +263 -0
  348. package/src/index.facade.test.ts +265 -0
  349. package/src/index.sync.test.ts +217 -0
  350. package/src/index.ts +2975 -0
  351. package/src/migrations/index.ts +71 -0
  352. package/src/migrations/v1-to-v2.ts +17 -0
  353. package/src/profiles/index.ts +8 -0
  354. package/src/profiles/profile-manager.test.ts +282 -0
  355. package/src/profiles/profile-manager.ts +630 -0
  356. package/src/schema/config.ts +165 -0
  357. package/src/schema/index.ts +71 -0
  358. package/src/schema/manifest-schema.test.ts +519 -0
  359. package/src/schema/manifest.ts +124 -0
  360. package/src/schema/tombstones.test.ts +131 -0
  361. package/src/schema/tombstones.ts +89 -0
  362. package/src/schema/types.ts +245 -0
  363. package/src/schema/versions.ts +55 -0
  364. package/src/security/encryption.ts +335 -0
  365. package/src/security/index.ts +16 -0
  366. package/src/security/integrity.ts +102 -0
  367. package/src/security/redaction.ts +101 -0
  368. package/src/security/secret-scanner.ts +445 -0
  369. package/src/security/security.test.ts +1215 -0
  370. package/src/security/vault.test.ts +164 -0
  371. package/src/security/vault.ts +198 -0
  372. package/src/security/zip-security.ts +180 -0
  373. package/src/sync/autosync-config.test.ts +147 -0
  374. package/src/sync/autosync-config.ts +179 -0
  375. package/src/sync/autosync-scheduler.test.ts +490 -0
  376. package/src/sync/autosync-scheduler.ts +646 -0
  377. package/src/sync/fs.ts +48 -0
  378. package/src/sync/git/git-transport.test.ts +673 -0
  379. package/src/sync/git/git-transport.ts +491 -0
  380. package/src/sync/github-auth.test.ts +280 -0
  381. package/src/sync/github-auth.ts +356 -0
  382. package/src/sync/layout.test.ts +270 -0
  383. package/src/sync/layout.ts +222 -0
  384. package/src/sync/snapshot-json.test.ts +119 -0
  385. package/src/sync/snapshot-json.ts +128 -0
  386. package/src/sync/sync-config.test.ts +310 -0
  387. package/src/sync/sync-config.ts +299 -0
  388. package/src/sync/sync-engine.test.ts +1074 -0
  389. package/src/sync/sync-engine.ts +754 -0
  390. package/src/sync/sync-history.test.ts +116 -0
  391. package/src/sync/sync-history.ts +120 -0
  392. package/src/sync/sync-selection.test.ts +186 -0
  393. package/src/sync/sync-selection.ts +143 -0
  394. package/src/sync/sync-session.test.ts +143 -0
  395. package/src/sync/sync-session.ts +77 -0
  396. package/src/sync/sync-state.test.ts +164 -0
  397. package/src/sync/sync-state.ts +131 -0
  398. package/src/sync/transport.test.ts +111 -0
  399. package/src/sync/transport.ts +118 -0
  400. package/src/sync/ui-prefs.test.ts +152 -0
  401. package/src/sync/ui-prefs.ts +135 -0
  402. package/src/sync/webdav/webdav-transport.test.ts +871 -0
  403. package/src/sync/webdav/webdav-transport.ts +535 -0
  404. package/src/ui/errors.test.ts +47 -0
  405. package/src/ui/errors.ts +100 -0
  406. package/src/ui/export-flow.test.ts +87 -0
  407. package/src/ui/export-flow.ts +156 -0
  408. package/src/ui/i18n.ts +590 -0
  409. package/src/ui/migration-consult-view.test.ts +151 -0
  410. package/src/ui/migration-consult-view.ts +178 -0
  411. package/src/ui/progress.test.ts +47 -0
  412. package/src/ui/progress.ts +102 -0
  413. package/src/ui/report.test.ts +101 -0
  414. package/src/ui/report.ts +146 -0
  415. package/src/ui/test-helpers.ts +184 -0
  416. package/src/ui/types.ts +325 -0
  417. package/src/utils/atomic-write.test.ts +312 -0
  418. package/src/utils/atomic-write.ts +422 -0
  419. package/src/utils/env-lock.test.ts +999 -0
  420. package/src/utils/env-lock.ts +1086 -0
  421. package/src/utils/hashing.ts +53 -0
  422. package/src/utils/json.ts +77 -0
  423. package/src/utils/logger.ts +114 -0
  424. package/src/utils/paths.ts +176 -0
  425. package/src/utils/proxy.test.ts +465 -0
  426. package/src/utils/proxy.ts +493 -0
  427. package/src/utils/recursive-walk.test.ts +148 -0
  428. package/src/utils/recursive-walk.ts +145 -0
  429. package/src/utils/zip.ts +332 -0
@@ -0,0 +1,1074 @@
1
+ /**
2
+ * m-sync-flow:SyncEngine push/pull 编排测试。
3
+ * - push:收集勾选分区(真实值)→ 组装 SyncSnapshot → 更新 sync-state → 上传 transport
4
+ * - push:manifest.security.containsSecrets 按实际内容如实标注(含明文密钥即 true)
5
+ * - push:sections 范围过滤(显式勾选 / 构造注入 / 未知分区告警)
6
+ * - pull:复用 Importer 预览流程(analyzeImport/createImportPlan),绝不直接写配置、绝不执行导入
7
+ * - pull:无远端快照 / 旧版加密快照拒绝 / 冲突 → needsReview
8
+ */
9
+ import test from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import fs from 'node:fs/promises';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ import { SyncEngine, MAX_REMOTE_SNAPSHOTS, sectionsCarrySecrets } from './sync-engine.ts';
16
+ import { hashSection, loadSyncState, SYNC_STATE_FILE } from './sync-state.ts';
17
+ import { encryptSectionsPayload } from '../../tests/fixtures/legacy-snapshot-crypto.ts';
18
+ import { computeSnapshotMeta } from './transport.ts';
19
+ import type { SyncSnapshot, SyncSnapshotMeta, SyncTransport } from './transport.ts';
20
+ import { WebDavTransport } from './webdav/webdav-transport.ts';
21
+ import type { WebDavRequestFn } from './webdav/webdav-transport.ts';
22
+ import { createAdapters } from '../adapters/index.ts';
23
+ import { makeContext, MemSnapshotStore } from '../adapters/test-helpers.ts';
24
+ import { Importer } from '../core/importer.ts';
25
+ import type { SectionId } from '../schema/types.ts';
26
+ import type { SectionData } from '../schema/types.ts';
27
+ import type { ImportPlan } from '../core/types.ts';
28
+
29
+ function makeImportPlan(seed: string): ImportPlan {
30
+ return {
31
+ items: [{
32
+ id: `settings:general-${seed}`,
33
+ kind: 'Update',
34
+ adapter: 'settings',
35
+ description: `Update settings.general (${seed})`,
36
+ severity: 'info',
37
+ target: { adapter: 'settings', ref: 'general' },
38
+ }],
39
+ globalStrategy: 'merge',
40
+ pathMappings: [],
41
+ missingSecrets: [],
42
+ needsRestart: false,
43
+ estimatedActions: { settings: 1 } as unknown as Record<SectionId, number>,
44
+ };
45
+ }
46
+
47
+ /** 测试辅助:取明文 sections(同步测试构造/上传的快照均为普通快照,非加密载荷)。 */
48
+ function plainSections(s: SyncSnapshot['sections']): Partial<Record<SectionId, SectionData>> {
49
+ return s as Partial<Record<SectionId, SectionData>>;
50
+ }
51
+
52
+ /** 内存 SyncTransport:记录方法调用(spy),供断言「pull 不写远端」 */
53
+ class MemSyncTransport implements SyncTransport { readonly type = 'memory';
54
+ snapshots = new Map<string, SyncSnapshot>();
55
+ metas: SyncSnapshotMeta[] = [];
56
+ calls: string[] = [];
57
+ async list(): Promise<SyncSnapshotMeta[]> {
58
+ this.calls.push('list');
59
+ return [...this.metas].sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
60
+ }
61
+ async upload(snapshot: SyncSnapshot): Promise<SyncSnapshotMeta> {
62
+ this.calls.push('upload');
63
+ this.snapshots.set(snapshot.id, snapshot);
64
+ this.metas.push(computeSnapshotMeta(snapshot));
65
+ return computeSnapshotMeta(snapshot);
66
+ }
67
+ async download(id: string): Promise<SyncSnapshot> {
68
+ this.calls.push('download');
69
+ const s = this.snapshots.get(id);
70
+ if (!s) throw new Error(`快照不存在: ${id}`);
71
+ return s;
72
+ }
73
+ async delete(id: string): Promise<void> {
74
+ this.calls.push('delete');
75
+ this.snapshots.delete(id);
76
+ this.metas = this.metas.filter((m) => m.id !== id);
77
+ }
78
+ }
79
+
80
+ const NS = ['general', 'theme'];
81
+
82
+ function seedSource(ctx: ReturnType<typeof makeContext>): void {
83
+ ctx.settings.ns.set('general', { value: { theme: 'dark', language: 'zh-CN' }, revision: 3, secrets: [] });
84
+ ctx.settings.ns.set('theme', { value: { mode: 'dark' }, revision: 1, secrets: [] });
85
+ ctx.plugins.installed.set('@deepseek-ai/dsh-ssh', { name: '@deepseek-ai/dsh-ssh', version: '1.0.0', enabled: true });
86
+ }
87
+
88
+ function makeEngine(opts: {
89
+ ctx: ReturnType<typeof makeContext>;
90
+ transport: MemSyncTransport;
91
+ stateDir: string;
92
+ localSnapshotsDir?: string;
93
+ extra?: Partial<ConstructorParameters<typeof SyncEngine>[0]>;
94
+ }) {
95
+ const adapters = createAdapters({ namespaces: NS });
96
+ const importer = new Importer({ ctx: opts.ctx, adapters, snapshotStore: new MemSnapshotStore() });
97
+ return new SyncEngine({
98
+ ctx: opts.ctx,
99
+ transport: opts.transport,
100
+ stateDir: opts.stateDir,
101
+ localSnapshotsDir: opts.localSnapshotsDir,
102
+ adapters,
103
+ importer,
104
+ now: () => new Date('2026-08-16T12:00:00.000Z'),
105
+ ...opts.extra,
106
+ } as ConstructorParameters<typeof SyncEngine>[0]);
107
+ }
108
+
109
+ test('push: 收集 portable 分区 → 上传快照 → 更新 sync-state → 本地散文件副本', async () => {
110
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-push-'));
111
+ try {
112
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
113
+ seedSource(ctx);
114
+ await ctx.fs.writeFile('skills/coding.md', Buffer.from('# Coding\n', 'utf8'));
115
+ const transport = new MemSyncTransport();
116
+ const local = path.join(tmp, 'local-snapshots');
117
+ const engine = makeEngine({ ctx, transport, stateDir: tmp, localSnapshotsDir: local });
118
+
119
+ const report = await engine.push({ snapshotId: 'sync-001' });
120
+ assert.equal(report.ok, true);
121
+ assert.equal(report.snapshotId, 'sync-001');
122
+ assert.ok(report.sections.includes('settings'), 'settings 进入同步');
123
+ assert.ok(report.sections.includes('skills'), 'skills(文件类)进入同步');
124
+ // credentialsStatus 只导出 configured/source 标记(hasValue 恒 false,绝不导出值),可勾选同步
125
+ assert.ok(report.sections.includes('credentialsStatus'), 'credentialsStatus(仅状态标记)进入同步');
126
+ assert.ok(!report.sections.includes('secrets' as SectionId), 'secrets 不是 ConfigAdapter,不进同步');
127
+
128
+ // 上传载荷:内容 + manifest 摘要
129
+ const uploaded = transport.snapshots.get('sync-001')!;
130
+ assert.ok(uploaded, '快照已上传');
131
+ assert.equal(uploaded.createdAt, '2026-08-16T12:00:00.000Z');
132
+ assert.equal(uploaded.manifest.containsSecrets, false);
133
+ const exportedGeneral = (plainSections(uploaded.sections)['settings'] as { namespaces: Record<string, { value: unknown; revision: number; secrets: unknown[] }> }).namespaces['general']!;
134
+ assert.ok(exportedGeneral, 'settings.general 已导出');
135
+ assert.deepEqual(exportedGeneral.value, { theme: 'dark', language: 'zh-CN' });
136
+ assert.equal(exportedGeneral.revision, 3);
137
+ assert.deepEqual(exportedGeneral.secrets, []);
138
+ assert.equal((plainSections(uploaded.sections)['skills'] as { files: unknown[] }).files.length, 1);
139
+
140
+ // sync-state 更新:每分区 hash + updatedAt + lastSyncAt + transport 绑定
141
+ const state = await loadSyncState(tmp);
142
+ assert.equal(state.lastSyncAt, '2026-08-16T12:00:00.000Z');
143
+ assert.equal(state.sections['settings']?.hash, hashSection(plainSections(uploaded.sections)['settings']!));
144
+ assert.equal(state.sections['settings']?.updatedAt, '2026-08-16T12:00:00.000Z');
145
+ assert.deepEqual(state.transport, { type: 'memory', ref: '' });
146
+ const raw = JSON.parse(await fs.readFile(path.join(tmp, SYNC_STATE_FILE), 'utf8'));
147
+ assert.equal(raw.schemaVersion, 2);
148
+
149
+ // 本地散文件副本(复用 t2 layout 布局)
150
+ assert.ok((await fs.stat(path.join(local, 'sync-001', 'manifest.json'))).isFile());
151
+ assert.ok((await fs.stat(path.join(local, 'sync-001', 'config', 'settings.json'))).isFile());
152
+ assert.equal(await fs.readFile(path.join(local, 'sync-001', 'custom', 'skills', 'coding.md'), 'utf8'), '# Coding\n');
153
+ } finally {
154
+ await fs.rm(tmp, { recursive: true, force: true });
155
+ }
156
+ });
157
+
158
+ test('push: 明文同步——勾选分区携带真实值,containsSecrets 如实标注为 true', async () => {
159
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-secret-'));
160
+ try {
161
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
162
+ ctx.settings.ns.set('general', {
163
+ value: { theme: 'dark', apiToken: 'sk-super-secret-value', password: 'p@ss' },
164
+ revision: 3,
165
+ secrets: [{ path: ['apiToken'], set: true }],
166
+ });
167
+ const transport = new MemSyncTransport();
168
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
169
+
170
+ const report = await engine.push({ snapshotId: 'sync-sec' });
171
+ assert.equal(report.ok, true);
172
+ const uploaded = transport.snapshots.get('sync-sec')!;
173
+ // 明文语义:值原样进入快照
174
+ const general = (plainSections(uploaded.sections)['settings'] as { namespaces: Record<string, unknown> }).namespaces['general'] as { value: Record<string, unknown> };
175
+ assert.equal(general.value['apiToken'], 'sk-super-secret-value', '真实凭据值进入明文快照');
176
+ // 标注与内容一致:含密钥即 true(不得硬编码 false)
177
+ assert.equal(uploaded.manifest.containsSecrets, true, 'manifest 如实标注 containsSecrets');
178
+ // 凭据分区本身不参与同步(它不是 ConfigAdapter)
179
+ for (const forbidden of ['credentials', 'secrets'] as SectionId[]) {
180
+ assert.ok(!(forbidden in uploaded.sections), `分区 ${forbidden} 不得进入快照`);
181
+ }
182
+ } finally {
183
+ await fs.rm(tmp, { recursive: true, force: true });
184
+ }
185
+ });
186
+
187
+ test('sectionsCarrySecrets: 无敏感字段 → false;含 apiKey/token/password → true', () => {
188
+ assert.equal(sectionsCarrySecrets({
189
+ settings: { version: 1, namespaces: { general: { value: { theme: 'dark' }, revision: 1, secrets: [] } } },
190
+ } as never), false, '普通值不误报');
191
+ assert.equal(sectionsCarrySecrets({
192
+ providers: { version: 1, providers: [{ name: 'x', apiKey: 'sk-1' }] },
193
+ } as never), true, 'apiKey 命中');
194
+ assert.equal(sectionsCarrySecrets({
195
+ settings: { version: 1, namespaces: { general: { value: { token: 't' }, revision: 1, secrets: [] } } },
196
+ } as never), true, 'token 命中');
197
+ assert.equal(sectionsCarrySecrets({
198
+ settings: { version: 1, namespaces: { general: { value: { apiKeyEnv: 'DEEPSEEK_API_KEY' }, revision: 1, secrets: [] } } },
199
+ } as never), false, '仅环境变量引用名不算秘密');
200
+ });
201
+
202
+ test('push: 默认范围 = 推荐分区(原 platformSpecific/deviceSpecific 也可同步,但 defaultIncluded=false 的仍不进)', async () => {
203
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-portable-'));
204
+ try {
205
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
206
+ seedSource(ctx);
207
+ ctx.workspace.records.set('w1', { id: 'w1', path: 'C:\\work', title: 'work', sessionIds: [] });
208
+ await ctx.fs.writeFile('dsh-ssh.json', Buffer.from('{"hosts":{}}', 'utf8')); // pluginFiles 白名单
209
+ const transport = new MemSyncTransport();
210
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
211
+
212
+ await engine.push({ snapshotId: 'sync-p' });
213
+ const uploaded = transport.snapshots.get('sync-p')!;
214
+ assert.ok(uploaded.manifest.sectionIds.includes('settings'));
215
+ assert.ok(uploaded.manifest.sectionIds.includes('workspaces' as SectionId), 'workspaces(推荐分区)默认同步');
216
+ assert.ok(uploaded.manifest.sectionIds.includes('mcp' as SectionId), 'mcp(推荐分区)默认同步');
217
+ // credentialsStatus / secrets 不是 ConfigAdapter,结构上不可能进入快照
218
+ assert.ok(!('credentials' in uploaded.sections));
219
+ assert.ok(!('secrets' in uploaded.sections));
220
+ } finally {
221
+ await fs.rm(tmp, { recursive: true, force: true });
222
+ }
223
+ });
224
+
225
+ test('push: 默认范围尊重 adapter 的 defaultIncluded 契约(sessions/pluginFiles 不得默认上传)', async () => {
226
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-default-included-'));
227
+ try {
228
+ const ctx = makeContext('darwin', '/Users/alice');
229
+ seedSource(ctx);
230
+ // 复刻 Host 接线:includeSessions=true(index.ts 挂载 sessions 供显式勾选)
231
+ await ctx.fs.writeFile('sessions/proj/s1.jsonl', Buffer.from('{"secret":"history"}\n', 'utf8'));
232
+ await ctx.fs.writeFile('dsh-ssh.json', Buffer.from('{"hosts":{}}', 'utf8')); // pluginFiles 白名单
233
+ const adapters = createAdapters({ namespaces: NS, includeSessions: true, selfDir: 'dsh-config-manager' });
234
+ const transport = new MemSyncTransport();
235
+ const engine = new SyncEngine({
236
+ ctx, transport, adapters,
237
+ importer: new Importer({ ctx, adapters, snapshotStore: new MemSnapshotStore() }),
238
+ stateDir: tmp, localSnapshotsDir: path.join(tmp, 'snap'),
239
+ now: () => new Date('2026-08-16T12:00:00.000Z'),
240
+ } as ConstructorParameters<typeof SyncEngine>[0]);
241
+
242
+ await engine.push({ snapshotId: 'sync-di' });
243
+ const uploaded = transport.snapshots.get('sync-di')!;
244
+ const ids = uploaded.manifest.sectionIds;
245
+ // 默认模式(未显式勾选)= 推荐分区:defaultIncluded=false 的分区不得被静默上传
246
+ assert.ok(!ids.includes('sessions' as SectionId), 'sessions 默认关闭:含历史会话敏感内容,必须显式勾选才上传');
247
+ assert.ok(!ids.includes('pluginFiles' as SectionId), 'pluginFiles 默认关闭:必须显式勾选才上传');
248
+ assert.ok(ids.includes('settings' as SectionId), '推荐分区照常同步');
249
+ // 与 UI「将同步 N 个推荐分区」的计数同源
250
+ assert.deepEqual(
251
+ [...ids].sort(),
252
+ adapters.filter((a) => a.defaultIncluded).map((a) => a.id).sort(),
253
+ '默认上传集合 === defaultIncluded 集合',
254
+ );
255
+ } finally {
256
+ await fs.rm(tmp, { recursive: true, force: true });
257
+ }
258
+ });
259
+
260
+ test('push: 显式勾选 sessions → 仍可上传(默认关闭不等于不可同步)', async () => {
261
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-explicit-sessions-'));
262
+ try {
263
+ const ctx = makeContext('darwin', '/Users/alice');
264
+ seedSource(ctx);
265
+ await ctx.fs.writeFile('sessions/proj/s1.jsonl', Buffer.from('{"secret":"history"}\n', 'utf8'));
266
+ const adapters = createAdapters({ namespaces: NS, includeSessions: true, selfDir: 'dsh-config-manager' });
267
+ const transport = new MemSyncTransport();
268
+ const engine = new SyncEngine({
269
+ ctx, transport, adapters,
270
+ importer: new Importer({ ctx, adapters, snapshotStore: new MemSnapshotStore() }),
271
+ stateDir: tmp, localSnapshotsDir: path.join(tmp, 'snap'),
272
+ now: () => new Date('2026-08-16T12:00:00.000Z'),
273
+ } as ConstructorParameters<typeof SyncEngine>[0]);
274
+
275
+ const report = await engine.push({ snapshotId: 'sync-es', sections: ['sessions'] });
276
+ assert.equal(report.ok, true);
277
+ assert.ok(transport.snapshots.get('sync-es')!.manifest.sectionIds.includes('sessions' as SectionId),
278
+ '高级模式显式勾选 sessions → 允许上传(与 defaultIncluded 无关)');
279
+ } finally {
280
+ await fs.rm(tmp, { recursive: true, force: true });
281
+ }
282
+ });
283
+
284
+ test('push: 显式 sections(自定义同步)→ 只同步指定分区', async () => {
285
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-sections-'));
286
+ try {
287
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
288
+ seedSource(ctx);
289
+ await ctx.fs.writeFile('skills/coding.md', Buffer.from('# Coding\n', 'utf8'));
290
+ const transport = new MemSyncTransport();
291
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
292
+
293
+ const report = await engine.push({ snapshotId: 'sync-sel', sections: ['settings', 'skills'] });
294
+ assert.equal(report.ok, true);
295
+ const uploaded = transport.snapshots.get('sync-sel')!;
296
+ // 只含勾选的 portable 分区
297
+ assert.deepEqual(uploaded.manifest.sectionIds.sort(), ['settings', 'skills']);
298
+ assert.ok('settings' in uploaded.sections, 'settings 进入');
299
+ assert.ok('skills' in uploaded.sections, 'skills 进入');
300
+ // 未勾选的 portable 分区(providers/plugins/ui 等)不进入
301
+ assert.ok(!('providers' in uploaded.sections), '未勾选分区不进入');
302
+ assert.ok(!('plugins' in uploaded.sections), '未勾选分区不进入');
303
+ } finally {
304
+ await fs.rm(tmp, { recursive: true, force: true });
305
+ }
306
+ });
307
+
308
+ test('push: 构造注入持久化勾选后,显式 opts.sections 仍可覆盖(不被注入范围截断)', async () => {
309
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-ctor-override-'));
310
+ try {
311
+ const ctx = makeContext('darwin', '/Users/alice');
312
+ seedSource(ctx);
313
+ await ctx.fs.writeFile('skills/coding.md', Buffer.from('# Coding\n', 'utf8'));
314
+ await ctx.fs.writeFile('sessions/proj/s1.jsonl', Buffer.from('{"a":1}\n', 'utf8'));
315
+ const adapters = createAdapters({ namespaces: NS, includeSessions: true, selfDir: 'dsh-config-manager' });
316
+ const transport = new MemSyncTransport();
317
+ const engine = new SyncEngine({
318
+ ctx, transport, adapters,
319
+ importer: new Importer({ ctx, adapters, snapshotStore: new MemSnapshotStore() }),
320
+ stateDir: tmp, localSnapshotsDir: path.join(tmp, 'snap'),
321
+ // 模拟 makeSyncEngine:注入用户持久化的高级勾选
322
+ sections: ['settings'],
323
+ now: () => new Date('2026-08-16T12:00:00.000Z'),
324
+ } as ConstructorParameters<typeof SyncEngine>[0]);
325
+
326
+ // 显式覆盖成注入范围之外的分区:必须生效,不得被误判为未知分区
327
+ const report = await engine.push({ snapshotId: 'sync-ov', sections: ['skills', 'sessions'] });
328
+ assert.equal(report.ok, true, '显式覆盖的合法分区不得导致 ok=false');
329
+ assert.deepEqual(report.warnings, [], '合法分区不得产生 unknownSection 告警');
330
+ assert.deepEqual(
331
+ transport.snapshots.get('sync-ov')!.manifest.sectionIds.sort(),
332
+ ['sessions', 'skills'],
333
+ '显式 opts.sections 覆盖构造注入范围',
334
+ );
335
+ } finally {
336
+ await fs.rm(tmp, { recursive: true, force: true });
337
+ }
338
+ });
339
+
340
+ test('push: sections 含未知分区 → 警告跳过,其余照常同步', async () => {
341
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-sections-skip-'));
342
+ try {
343
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
344
+ seedSource(ctx);
345
+ const transport = new MemSyncTransport();
346
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
347
+
348
+ const report = await engine.push({ snapshotId: 'sync-mix', sections: ['settings', 'mcp' as SectionId, 'nope' as SectionId] });
349
+ assert.equal(report.ok, true);
350
+ const uploaded = transport.snapshots.get('sync-mix')!;
351
+ assert.deepEqual(uploaded.manifest.sectionIds.sort(), ['mcp', 'settings'], '同步已知分区');
352
+ // 未知分区给出明确告警(不静默)
353
+ const warnText = report.warnings.join('\n');
354
+ assert.ok(/nope/.test(warnText), `告警应点名 nope:${warnText}`);
355
+ } finally {
356
+ await fs.rm(tmp, { recursive: true, force: true });
357
+ }
358
+ });
359
+
360
+ test('push: sections 全为未知分区 → ok=false + 明确 message', async () => {
361
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-sections-none-'));
362
+ try {
363
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
364
+ seedSource(ctx);
365
+ const transport = new MemSyncTransport();
366
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
367
+
368
+ const report = await engine.push({ snapshotId: 'sync-empty', sections: ['nope' as SectionId] });
369
+ assert.equal(report.ok, false);
370
+ assert.equal(transport.snapshots.has('sync-empty'), false, '无有效分区时不上传快照');
371
+ assert.ok(report.message && report.message.includes('没有可同步'), `message:${report.message}`);
372
+ } finally {
373
+ await fs.rm(tmp, { recursive: true, force: true });
374
+ }
375
+ });
376
+
377
+ test('push: sections 缺省/空数组 → 全部分区(默认模式)', async () => {
378
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-sections-default-'));
379
+ try {
380
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
381
+ seedSource(ctx);
382
+ const transport = new MemSyncTransport();
383
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
384
+
385
+ const report = await engine.push({ snapshotId: 'sync-def', sections: [] });
386
+ assert.equal(report.ok, true);
387
+ const uploaded = transport.snapshots.get('sync-def')!;
388
+ // 空数组 = 全量(默认模式);应含 settings/providers 等多个分区
389
+ assert.ok('settings' in uploaded.sections);
390
+ assert.ok('providers' in uploaded.sections);
391
+ assert.ok(uploaded.manifest.sectionIds.length >= 4, `应同步全部推荐分区,实际 ${uploaded.manifest.sectionIds.length}`);
392
+ } finally {
393
+ await fs.rm(tmp, { recursive: true, force: true });
394
+ }
395
+ });
396
+
397
+ test('push: 构造注入 sections(自动同步持久化配置)→ 未显式传 opts 也按注入范围同步', async () => {
398
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-ctor-sections-'));
399
+ try {
400
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
401
+ seedSource(ctx);
402
+ const transport = new MemSyncTransport();
403
+ // 模拟 autosync:makeSyncEngine 注入持久化的高级模式勾选(advanced + sections)
404
+ const engine = makeEngine({
405
+ ctx, transport, stateDir: tmp,
406
+ extra: { sections: ['settings', 'skills'] },
407
+ });
408
+
409
+ // 不传 opts.sections(调度器 Phase C 调用 engine.push() 的样子)
410
+ const report = await engine.push({ snapshotId: 'sync-auto' });
411
+ assert.equal(report.ok, true);
412
+ const uploaded = transport.snapshots.get('sync-auto')!;
413
+ assert.deepEqual(uploaded.manifest.sectionIds.sort(), ['settings', 'skills']);
414
+ assert.ok(!('providers' in uploaded.sections), '注入范围外的分区不进入');
415
+ assert.ok(!('plugins' in uploaded.sections));
416
+ } finally {
417
+ await fs.rm(tmp, { recursive: true, force: true });
418
+ }
419
+ });
420
+
421
+ /* ---------------- 旧版加密快照:明确拒绝 ---------------- */
422
+
423
+ test('pull: 远端为旧版加密快照 → 明确拒绝(同步通道不再产生/读取加密快照)', async () => {
424
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-pull-enc-'));
425
+ try {
426
+ const plain: SyncSnapshot = {
427
+ id: 'remote-enc',
428
+ createdAt: '2026-08-16T12:00:00.000Z',
429
+ manifest: { schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32', sectionIds: ['settings'], containsSecrets: false },
430
+ sections: { settings: { version: 1, namespaces: {} } },
431
+ };
432
+ const enc = await encryptSectionsPayload(plain.sections as Partial<Record<SectionId, SectionData>>, 'pw-12345678');
433
+ const remote: SyncSnapshot = {
434
+ ...plain,
435
+ manifest: { ...plain.manifest, encrypted: true },
436
+ sections: enc,
437
+ };
438
+ const transport = new MemSyncTransport();
439
+ transport.snapshots.set('remote-enc', remote);
440
+ transport.metas.push(computeSnapshotMeta(remote));
441
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
442
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
443
+ await assert.rejects(() => engine.pull(), /旧版加密快照/);
444
+ } finally {
445
+ await fs.rm(tmp, { recursive: true, force: true });
446
+ }
447
+ });
448
+
449
+ test('pull: 复用 Importer 预览流程,绝不直接写配置,产出差异报告', async () => {
450
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-pull-'));
451
+ try {
452
+ const remote: SyncSnapshot = {
453
+ id: 'remote-1',
454
+ createdAt: '2026-08-16T12:00:00.000Z',
455
+ manifest: { schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32', sectionIds: ['settings'], containsSecrets: false },
456
+ sections: {
457
+ settings: { version: 1, namespaces: { general: { value: { theme: 'dark', language: 'zh-CN' }, revision: 5, secrets: [] } } },
458
+ },
459
+ };
460
+ const transport = new MemSyncTransport();
461
+ transport.snapshots.set('remote-1', remote);
462
+ transport.metas.push(computeSnapshotMeta(remote));
463
+
464
+ // 本地目标:general 已注册但从未配置 → Create(初始化),无需人工决策
465
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
466
+ for (const n of NS) ctx.settings.registered.add(n);
467
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
468
+
469
+ const report = await engine.pull();
470
+ assert.equal(report.ok, true);
471
+ assert.equal(report.snapshotId, 'remote-1');
472
+ const createItem = report.changes.find((c) => c.id === 'settings:general');
473
+ assert.ok(createItem, '差异报告含 settings:general');
474
+ assert.equal(createItem?.kind, 'Create');
475
+ assert.equal(createItem?.adapter, 'settings');
476
+ assert.equal(report.needsReview, false, '纯 Create 无需人工决策');
477
+
478
+ // 零写入:目标配置未被修改,远端未被写
479
+ assert.equal(ctx.settings.ns.get('general'), undefined, '目标 settings 未被写入');
480
+ assert.deepEqual(transport.calls, ['list', 'download'], 'pull 只读远端(list/download),不 upload/delete');
481
+ } finally {
482
+ await fs.rm(tmp, { recursive: true, force: true });
483
+ }
484
+ });
485
+
486
+ test('pull: 双侧都改 → replace 覆盖,仍零写入(pull 只预览)', async () => {
487
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-conflict-'));
488
+ try {
489
+ const remote: SyncSnapshot = {
490
+ id: 'remote-2',
491
+ createdAt: '2026-08-16T12:00:00.000Z',
492
+ manifest: { schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32', sectionIds: ['settings'], containsSecrets: false },
493
+ sections: {
494
+ settings: { version: 1, namespaces: { general: { value: { theme: 'dark' }, revision: 5, secrets: [] } } },
495
+ },
496
+ };
497
+ const transport = new MemSyncTransport();
498
+ transport.snapshots.set('remote-2', remote);
499
+ transport.metas.push(computeSnapshotMeta(remote));
500
+
501
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
502
+ for (const n of NS) ctx.settings.registered.add(n);
503
+ ctx.settings.ns.set('general', { value: { theme: 'light' }, revision: 9, secrets: [] });
504
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
505
+
506
+ const report = await engine.pull();
507
+ assert.equal(report.ok, true);
508
+ assert.ok(report.changes.some((c) => c.kind === 'Update'), '本地与远端不同 → Update(replace 策略)');
509
+ assert.equal(report.needsReview, false, 'replace 策略无待决策项');
510
+ assert.deepEqual(ctx.settings.ns.get('general')?.value, { theme: 'light' }, 'pull 只预览,不写本地');
511
+ } finally {
512
+ await fs.rm(tmp, { recursive: true, force: true });
513
+ }
514
+ });
515
+
516
+ test('pull: 远端无快照 → 空报告(不报错)', async () => {
517
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-none-'));
518
+ try {
519
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
520
+ const engine = makeEngine({ ctx, transport: new MemSyncTransport(), stateDir: tmp });
521
+ const report = await engine.pull();
522
+ assert.equal(report.ok, true);
523
+ assert.equal(report.snapshotId, '');
524
+ assert.deepEqual(report.changes, []);
525
+ assert.equal(report.needsReview, false);
526
+ assert.ok(report.message && report.message.includes('无快照'));
527
+ } finally {
528
+ await fs.rm(tmp, { recursive: true, force: true });
529
+ }
530
+ });
531
+
532
+ test('pull: 远端快照声明 containsSecrets=true → 正常拉取(明文同步为预期行为)', async () => {
533
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-leak-'));
534
+ try {
535
+ const remote: SyncSnapshot = {
536
+ id: 'remote-secrets',
537
+ createdAt: '2026-08-16T12:00:00.000Z',
538
+ manifest: { schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32', sectionIds: ['settings'], containsSecrets: true },
539
+ sections: { settings: { version: 1, namespaces: { general: { value: { theme: 'dark' }, revision: 5, secrets: [] } } } },
540
+ };
541
+ const transport = new MemSyncTransport();
542
+ transport.snapshots.set('remote-secrets', remote);
543
+ transport.metas.push(computeSnapshotMeta(remote));
544
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
545
+ for (const n of NS) ctx.settings.registered.add(n);
546
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
547
+ const report = await engine.pull();
548
+ assert.equal(report.ok, true, '含秘密的明文快照可正常拉取');
549
+ assert.ok(report.changes.length > 0);
550
+ } finally {
551
+ await fs.rm(tmp, { recursive: true, force: true });
552
+ }
553
+ });
554
+
555
+ test('push: 空配置 → 空分区快照仍上传(不视为失败)', async () => {
556
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-fail-'));
557
+ try {
558
+ const ctx = makeContext('win32', 'C:\\Users\\alice'); // 无任何 namespace/文件/插件
559
+ const transport = new MemSyncTransport();
560
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
561
+ const report = await engine.push({ snapshotId: 'sync-fail' });
562
+ // 空数据仍视为「成功导出(空分区)」还是失败?settings 无 namespace 时 adapter 返回空(不抛错)
563
+ assert.equal(report.ok, true, '空配置导出为空快照而非失败');
564
+ assert.equal(transport.snapshots.has('sync-fail'), true);
565
+ } finally {
566
+ await fs.rm(tmp, { recursive: true, force: true });
567
+ }
568
+ });
569
+
570
+ // ─── P2c M2:applyItems 兜底快照 / 回滚单元测试 ──────────────────────────────────────
571
+
572
+ /** 测试用 mock Importer:注入 analyzeImport / createImportPlan / executeImportPlan 的可控行为。 */
573
+ class MockImporter {
574
+ ok = true;
575
+ executeCalls = 0;
576
+ warnings: string[] = [];
577
+ analyzeImpl: () => Promise<unknown> = async () => ({ valid: true, compatibility: 'full' });
578
+ createPlanImpl: () => Promise<unknown> = async () => ({
579
+ items: [{ id: 'mock', kind: 'Update', adapter: 'settings', description: 'mock', severity: 'info', target: undefined }],
580
+ globalStrategy: 'merge',
581
+ pathMappings: [],
582
+ missingSecrets: [],
583
+ needsRestart: false,
584
+ estimatedActions: { settings: 1 } as Record<string, number>,
585
+ });
586
+ executeImpl: () => Promise<unknown> = async () => ({
587
+ ok: this.ok,
588
+ executed: [],
589
+ needsRestart: false,
590
+ missingSecrets: [],
591
+ warnings: this.warnings,
592
+ rollback: null,
593
+ snapshotId: null,
594
+ });
595
+ async analyzeImport(_zipPath: string): Promise<unknown> { return await this.analyzeImpl(); }
596
+ async createImportPlan(_zipPath: string, _decisions: unknown): Promise<unknown> { return await this.createPlanImpl(); }
597
+ async executeImportPlan(_zipPath: string, _plan: unknown, _opts: unknown): Promise<unknown> {
598
+ this.executeCalls += 1;
599
+ return await this.executeImpl();
600
+ }
601
+ }
602
+
603
+ function makeEngineWithMockImporter(opts: {
604
+ ctx: ReturnType<typeof makeContext>;
605
+ transport: MemSyncTransport;
606
+ stateDir: string;
607
+ localSnapshotsDir?: string;
608
+ mockImporter: MockImporter;
609
+ }) {
610
+ // 把 mock 注入到 SyncEngine 的 importer 槽位 —— Importer 是类,类型上不能完全替换;
611
+ // 这里用对象字面量 duck-type 兼容 Importer 的三个方法。
612
+ return makeEngine({
613
+ ...opts,
614
+ extra: { importer: opts.mockImporter as unknown as Importer },
615
+ });
616
+ }
617
+
618
+ test('applyItems: 成功路径写祖先副本 + 更新 sync-state', async () => {
619
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-apply-ok-'));
620
+ const localDir = path.join(tmp, 'snapshots');
621
+ try {
622
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
623
+ seedSource(ctx);
624
+ const transport = new MemSyncTransport();
625
+ const mock = new MockImporter();
626
+ mock.ok = true;
627
+ const engine = makeEngineWithMockImporter({
628
+ ctx, transport, stateDir: tmp, localSnapshotsDir: localDir, mockImporter: mock,
629
+ });
630
+ const zipPath = path.join(tmp, 'session.zip');
631
+ await fs.writeFile(zipPath, 'mock-zip-content');
632
+ const report = await engine.applyItems(zipPath, makeImportPlan('ok'));
633
+ assert.equal(report.ok, true, 'success path → ok:true');
634
+ assert.deepEqual(report.applied, ['settings']);
635
+ assert.notEqual(report.restoreId, '', 'restoreId 应非空');
636
+ assert.equal(report.rolledBack, false);
637
+ assert.equal(report.warnings.length, 0);
638
+ assert.equal(mock.executeCalls, 1, 'Importer.executeImportPlan 应被调用一次');
639
+ // 基线应被更新:sync-state.lastSnapshotId 非空 + 落本地副本
640
+ const state = await loadSyncState(tmp);
641
+ assert.notEqual(state.lastSnapshotId, '', 'applyItems 后 lastSnapshotId 已被 recordBaseline 更新');
642
+ const dirs = await fs.readdir(localDir);
643
+ assert.ok(dirs.length > 0, '本地快照副本已写入');
644
+ } finally {
645
+ await fs.rm(tmp, { recursive: true, force: true });
646
+ }
647
+ });
648
+
649
+ test('applyItems: 失败路径 → 整体回滚 + ok:false(不写基线)', async () => {
650
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-apply-fail-'));
651
+ try {
652
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
653
+ seedSource(ctx);
654
+ const transport = new MemSyncTransport();
655
+ const mock = new MockImporter();
656
+ mock.ok = false; // Importer.executeImportPlan 返回 ok:false
657
+ const engine = makeEngineWithMockImporter({
658
+ ctx, transport, stateDir: tmp, mockImporter: mock,
659
+ });
660
+ const zipPath = path.join(tmp, 'session.zip');
661
+ await fs.writeFile(zipPath, 'mock-zip-content');
662
+ const report = await engine.applyItems(zipPath, makeImportPlan('fail'));
663
+ assert.equal(report.ok, false, 'failure path → ok:false');
664
+ assert.equal(report.rolledBack, true);
665
+ assert.equal(report.applied.length, 0);
666
+ assert.notEqual(report.restoreId, '', 'restoreId 应透传以便排查');
667
+ // recordBaseline 不应在失败路径调用
668
+ const state = await loadSyncState(tmp);
669
+ assert.equal(state.lastSnapshotId, '', '失败时不应 recordBaseline');
670
+ } finally {
671
+ await fs.rm(tmp, { recursive: true, force: true });
672
+ }
673
+ });
674
+
675
+ test('applyItems: Importer 缺失 → 抛错', async () => {
676
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-apply-noimporter-'));
677
+ try {
678
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
679
+ seedSource(ctx);
680
+ const transport = new MemSyncTransport();
681
+ // 不传 importer → SyncEngine 内部无 importer
682
+ const engine = new SyncEngine({
683
+ ctx, transport, stateDir: tmp, adapters: createAdapters({ namespaces: NS }),
684
+ now: () => new Date('2026-08-16T12:00:00.000Z'),
685
+ });
686
+ await assert.rejects(
687
+ () => engine.applyItems(path.join(tmp, 'x.zip'), makeImportPlan('no-imp')),
688
+ /缺少 importer/,
689
+ );
690
+ } finally {
691
+ await fs.rm(tmp, { recursive: true, force: true });
692
+ }
693
+ });
694
+
695
+ // ─── P2a M4:recordBaseline / push-baseline ──────────────────────────────
696
+
697
+ test('push: 完成后 sync-state.lastSnapshotId 指向本次推送快照(祖先基线已记录)', async () => {
698
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-baseline-'));
699
+ try {
700
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
701
+ seedSource(ctx);
702
+ const transport = new MemSyncTransport();
703
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
704
+ await engine.push({ snapshotId: 'sync-base' });
705
+ const state = await loadSyncState(tmp);
706
+ assert.equal(state.lastSnapshotId, 'sync-base', 'push 后 lastSnapshotId 应等于本次快照 id');
707
+ assert.equal(state.lastSyncAt, '2026-08-16T12:00:00.000Z');
708
+ } finally {
709
+ await fs.rm(tmp, { recursive: true, force: true });
710
+ }
711
+ });
712
+
713
+ test('recordBaseline: 写本地祖先副本 + 更新 sync-state + 触发裁剪', async () => {
714
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-record-'));
715
+ try {
716
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
717
+ seedSource(ctx);
718
+ const transport = new MemSyncTransport();
719
+ const local = path.join(tmp, 'ancestors');
720
+ const engine = makeEngine({ ctx, transport, stateDir: tmp, localSnapshotsDir: local });
721
+ const snapshot = (await transport.list()).length === 0
722
+ ? null
723
+ : (transport.metas[0] && (await transport.download(transport.metas[0].id)));
724
+ void snapshot;
725
+ // 走一遍 push 让 ancestors 目录被建立
726
+ await engine.push({ snapshotId: 'sync-anc-1' });
727
+ await engine.push({ snapshotId: 'sync-anc-2' });
728
+ // 显式再调一次 recordBaseline(模拟合并 apply 完成后更新基线)
729
+ const newSnap: SyncSnapshot = {
730
+ id: 'sync-explicit',
731
+ createdAt: '2026-08-16T13:00:00.000Z',
732
+ manifest: {
733
+ schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32',
734
+ sectionIds: ['settings'], containsSecrets: false,
735
+ },
736
+ sections: { settings: { version: 1, namespaces: {} } },
737
+ };
738
+ await engine.recordBaseline('sync-explicit', newSnap.sections, '2026-08-16T13:00:00.000Z');
739
+ const state = await loadSyncState(tmp);
740
+ assert.equal(state.lastSnapshotId, 'sync-explicit');
741
+ assert.ok((await fs.stat(path.join(local, 'sync-explicit', 'manifest.json'))).isFile(), '祖先副本已写');
742
+ } finally {
743
+ await fs.rm(tmp, { recursive: true, force: true });
744
+ }
745
+ });
746
+
747
+ // ─── P3:applyItems(一键同步逐项执行)测试 ──────────────────────────────
748
+
749
+ test('applyItems: 成功路径 → 执行子计划 + recordBaseline', async () => {
750
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-applyitems-ok-'));
751
+ try {
752
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
753
+ seedSource(ctx);
754
+ const transport = new MemSyncTransport();
755
+ const mock = new MockImporter();
756
+ mock.ok = true;
757
+ const engine = makeEngineWithMockImporter({
758
+ ctx, transport, stateDir: tmp, mockImporter: mock,
759
+ });
760
+
761
+ // 需要真实 ZIP 路径(applyItems 用 executeImportPlan 的 zipPath)
762
+ // 用 mock importer 时 zipPath 可以被 mock 忽略
763
+ const zipPath = path.join(tmp, 'session.zip');
764
+ await fs.writeFile(zipPath, 'mock-zip-content');
765
+
766
+ const report = await engine.applyItems(zipPath, makeImportPlan('ok'));
767
+ assert.equal(report.ok, true);
768
+ assert.deepEqual(report.applied, ['settings']);
769
+ assert.notEqual(report.restoreId, '', 'restoreId 应非空');
770
+ assert.equal(report.rolledBack, false);
771
+ assert.equal(mock.executeCalls, 1, 'Importer.executeImportPlan 应被调用一次');
772
+ // recordBaseline 应被调用(lastSnapshotId 非空)
773
+ const state = await loadSyncState(tmp);
774
+ assert.notEqual(state.lastSnapshotId, '', 'applyItems 成功后 lastSnapshotId 非空');
775
+ } finally {
776
+ await fs.rm(tmp, { recursive: true, force: true });
777
+ }
778
+ });
779
+
780
+ test('applyItems: 失败路径 → 整体回滚 + ok:false', async () => {
781
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-applyitems-fail-'));
782
+ try {
783
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
784
+ seedSource(ctx);
785
+ const transport = new MemSyncTransport();
786
+ const mock = new MockImporter();
787
+ mock.ok = false;
788
+ const engine = makeEngineWithMockImporter({
789
+ ctx, transport, stateDir: tmp, mockImporter: mock,
790
+ });
791
+
792
+ const zipPath = path.join(tmp, 'session.zip');
793
+ await fs.writeFile(zipPath, 'mock-zip-content');
794
+
795
+ const report = await engine.applyItems(zipPath, makeImportPlan('fail'));
796
+ assert.equal(report.ok, false);
797
+ assert.equal(report.rolledBack, true);
798
+ assert.notEqual(report.restoreId, '', 'restoreId 应透传');
799
+ // 不再写 review-queue
800
+ const rqPath = path.join(tmp, 'sync-review-queue.json');
801
+ const rqExists = await fs.stat(rqPath).then(() => true).catch(() => false);
802
+ assert.equal(rqExists, false, '失败路径不应写 review-queue');
803
+ } finally {
804
+ await fs.rm(tmp, { recursive: true, force: true });
805
+ }
806
+ });
807
+
808
+ test('applyItems: 空子计划 → 直接返回 ok:true(不调 Importer)', async () => {
809
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-applyitems-empty-'));
810
+ try {
811
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
812
+ seedSource(ctx);
813
+ const transport = new MemSyncTransport();
814
+ const mock = new MockImporter();
815
+ const engine = makeEngineWithMockImporter({
816
+ ctx, transport, stateDir: tmp, mockImporter: mock,
817
+ });
818
+ const emptyPlan: ImportPlan = {
819
+ items: [], globalStrategy: 'merge', pathMappings: [], missingSecrets: [], needsRestart: false, estimatedActions: {} as unknown as Record<SectionId, number>,
820
+ };
821
+ const report = await engine.applyItems(path.join(tmp, 'none.zip'), emptyPlan);
822
+ assert.equal(report.ok, true);
823
+ assert.deepEqual(report.applied, []);
824
+ assert.equal(mock.executeCalls, 0, '空子计划不应触发 Importer');
825
+ } finally {
826
+ await fs.rm(tmp, { recursive: true, force: true });
827
+ }
828
+ });
829
+
830
+ // ─── t5:push 后远端快照裁剪(保留最新 MAX_REMOTE_SNAPSHOTS 个) ─────────────────
831
+
832
+ /** 预置 n 个远端快照(id=remote-N,createdAt 递增) */
833
+ function seedRemoteSnapshots(transport: MemSyncTransport, n: number): void {
834
+ for (let i = 1; i <= n; i++) {
835
+ const id = `remote-${String(i).padStart(2, '0')}`;
836
+ const snap: SyncSnapshot = {
837
+ id,
838
+ createdAt: `2026-08-15T${String(i - 1).padStart(2, '0')}:00:00.000Z`,
839
+ manifest: { schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32', sectionIds: ['settings'], containsSecrets: false },
840
+ sections: { settings: { version: 1, namespaces: {} } },
841
+ };
842
+ transport.snapshots.set(id, snap);
843
+ transport.metas.push(computeSnapshotMeta(snap));
844
+ }
845
+ }
846
+
847
+ test('push: 远端快照数超过 MAX_REMOTE_SNAPSHOTS → 裁剪只保留最新 10 个(含刚 push 的)', async () => {
848
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-prune-'));
849
+ try {
850
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
851
+ seedSource(ctx);
852
+ const transport = new MemSyncTransport();
853
+ seedRemoteSnapshots(transport, 13); // 预置 13 个旧快照
854
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
855
+
856
+ const report = await engine.push({ snapshotId: 'sync-new' });
857
+ assert.equal(report.ok, true);
858
+ // 本次 push 的快照应保留
859
+ assert.ok(transport.snapshots.has('sync-new'), '刚 push 的快照必须保留');
860
+
861
+ const remaining = [...transport.snapshots.keys()];
862
+ // 保留 13 个预置里最新的 9 个(remote-05..remote-13)+ 本次 push 的 sync-new = 10
863
+ assert.equal(remaining.length, MAX_REMOTE_SNAPSHOTS, `裁剪后应恰剩 ${MAX_REMOTE_SNAPSHOTS} 个`);
864
+ // 最旧的 4 个(remote-01..remote-04)被删
865
+ for (let i = 1; i <= 4; i++) {
866
+ assert.ok(!transport.snapshots.has(`remote-0${i}`), `最旧的 remote-0${i} 应被裁剪`);
867
+ }
868
+ // 最新保留集含 5..13 与 sync-new
869
+ for (let i = 5; i <= 13; i++) {
870
+ assert.ok(transport.snapshots.has(`remote-${String(i).padStart(2, '0')}`), `最新的 remote-${i} 应保留`);
871
+ }
872
+ // 裁剪通过 transport.delete 逐个删除(删除调用次数 = 4)
873
+ assert.equal(transport.calls.filter((c) => c === 'delete').length, 4);
874
+ // 顺序:upload → list(裁剪) → delete×4 → recordBaseline(无本地裁剪)→ push 返回
875
+ // 断言 upload 在首次 delete 之前(保证新快照先推送成功再删旧的)
876
+ assert.ok(transport.calls.indexOf('upload') < transport.calls.indexOf('delete'), '先 push 新快照再删旧的');
877
+ // 无裁剪告警(push 会带若干基础导出告警,如未注册 namespace,但不应有裁剪告警)
878
+ assert.ok(!report.warnings.some((w) => w.includes('裁剪')), '正常裁剪不应产生告警');
879
+ } finally {
880
+ await fs.rm(tmp, { recursive: true, force: true });
881
+ }
882
+ });
883
+
884
+ test('push: 远端快照数未超上限 → 不触发任何删除', async () => {
885
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-prune-ok-'));
886
+ try {
887
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
888
+ seedSource(ctx);
889
+ const transport = new MemSyncTransport();
890
+ seedRemoteSnapshots(transport, 9); // 9 旧 + 本次 1 = 10,恰好达标不裁剪
891
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
892
+
893
+ const report = await engine.push({ snapshotId: 'sync-new' });
894
+ assert.equal(report.ok, true);
895
+ assert.equal(transport.calls.filter((c) => c === 'delete').length, 0, '未超上限不得删除');
896
+ assert.equal(transport.snapshots.size, 10);
897
+ assert.ok(!report.warnings.some((w) => w.includes('裁剪')), '未超上限不应有裁剪告警');
898
+ } finally {
899
+ await fs.rm(tmp, { recursive: true, force: true });
900
+ }
901
+ });
902
+
903
+ test('push: 裁剪 list 失败 → 只告警不上抛,push 仍 ok', async () => {
904
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-prune-listfail-'));
905
+ try {
906
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
907
+ seedSource(ctx);
908
+ const transport = new MemSyncTransport();
909
+ seedRemoteSnapshots(transport, 13);
910
+ transport.list = async () => { throw new Error('list boom'); };
911
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
912
+
913
+ const report = await engine.push({ snapshotId: 'sync-new' });
914
+ assert.equal(report.ok, true, '裁剪失败不得阻断 push');
915
+ assert.ok(transport.snapshots.has('sync-new'), '快照本身已上传');
916
+ assert.ok(report.warnings.some((w) => w.includes('裁剪')), '应有裁剪告警');
917
+ assert.ok(report.warnings.some((w) => w.includes('无法列出远端快照')), '告警应说明 list 失败');
918
+ } finally {
919
+ await fs.rm(tmp, { recursive: true, force: true });
920
+ }
921
+ });
922
+
923
+ test('push: 裁剪单个 delete 失败 → 只告警不上抛,其余旧快照照常删除', async () => {
924
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-prune-delfail-'));
925
+ try {
926
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
927
+ seedSource(ctx);
928
+ const transport = new MemSyncTransport();
929
+ seedRemoteSnapshots(transport, 13);
930
+ // 让删除 remote-02 时抛错,其余正常
931
+ const origDelete = transport.delete.bind(transport);
932
+ transport.delete = async (id: string) => {
933
+ if (id === 'remote-02') throw new Error('delete boom');
934
+ return origDelete(id);
935
+ };
936
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
937
+
938
+ const report = await engine.push({ snapshotId: 'sync-new' });
939
+ assert.equal(report.ok, true, '单个删除失败不得阻断 push');
940
+ assert.ok(report.warnings.some((w) => w.includes('删除快照 remote-02 失败')), '应有删除失败告警');
941
+ // remote-02 仍残留(删除失败),其余旧快照被删
942
+ assert.ok(transport.snapshots.has('remote-02'), '删除失败的 remote-02 应残留');
943
+ assert.ok(!transport.snapshots.has('remote-01'), '其余旧快照照常删除');
944
+ } finally {
945
+ await fs.rm(tmp, { recursive: true, force: true });
946
+ }
947
+ });
948
+
949
+ // ─── t6:事件驱动触发检测(§3.1 本地变化 / §3.2 远端新快照) ─────────────────
950
+
951
+ test('hasNewRemoteSnapshot: 空远端→false;从未同步且远端非空→true;远端最新=祖先→false;比祖先新→true', async () => {
952
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-hasnew-'));
953
+ try {
954
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
955
+ seedSource(ctx);
956
+ const transport = new MemSyncTransport();
957
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
958
+
959
+ // 远端为空 → 无新生
960
+ assert.equal(await engine.hasNewRemoteSnapshot(), false, '空远端 → false');
961
+
962
+ // push:上传 sync-001 并记录祖先 = sync-001(远端最新即祖先)
963
+ await engine.push({ snapshotId: 'sync-001' });
964
+ assert.equal(await engine.hasNewRemoteSnapshot(), false, '远端最新=本地祖先 → 无新生');
965
+
966
+ // 远端出现比祖先更新的快照 → 有新生
967
+ const base = transport.snapshots.get('sync-001')!;
968
+ const newer: SyncSnapshot = {
969
+ ...base,
970
+ id: 'remote-newer',
971
+ createdAt: '2026-08-16T13:00:00.000Z',
972
+ };
973
+ transport.snapshots.set('remote-newer', newer);
974
+ transport.metas.push(computeSnapshotMeta(newer));
975
+ assert.equal(await engine.hasNewRemoteSnapshot(), true, '远端出现比祖先更新的快照 → 有新生');
976
+ } finally {
977
+ await fs.rm(tmp, { recursive: true, force: true });
978
+ }
979
+ });
980
+
981
+ test('hasLocalChanges: 从未同步→true;推后无改动→false;本地改动→true', async () => {
982
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-haslocal-'));
983
+ try {
984
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
985
+ seedSource(ctx);
986
+ const transport = new MemSyncTransport();
987
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
988
+
989
+ // 从未同步(sync-state.sections 为空)→ 视为有改动
990
+ assert.equal(await engine.hasLocalChanges(), true, '从未同步 → true');
991
+
992
+ // push 记录基线后:本地与基线一致 → 无改动
993
+ await engine.push({ snapshotId: 'sync-001' });
994
+ assert.equal(await engine.hasLocalChanges(), false, '推后本地与基线一致 → false');
995
+
996
+ // 改动一个 portable 分区(settings.general theme dark→light)→ 有改动
997
+ ctx.settings.ns.set('general', { value: { theme: 'light', language: 'zh-CN' }, revision: 4, secrets: [] });
998
+ assert.equal(await engine.hasLocalChanges(), true, '改动 portable 分区 → true');
999
+ } finally {
1000
+ await fs.rm(tmp, { recursive: true, force: true });
1001
+ }
1002
+ });
1003
+
1004
+ test('push + webdav 快照级跳过:同 id 同内容二次 push → 不重复 PUT 快照文件(端到端组合)', async () => {
1005
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-webdav-skip-'));
1006
+ try {
1007
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
1008
+ seedSource(ctx);
1009
+ // 内存 WebDAV「服务器」:snapshots/ 集合 + <id>.json 文件 + index.json
1010
+ const files = new Map<string, string>();
1011
+ const putCalls: string[] = [];
1012
+ const request: WebDavRequestFn = async (method, url, opts = {}) => {
1013
+ const key = url.replace(/\/+$/, '');
1014
+ if (method === 'MKCOL') return { status: 201, ok: true, text: async () => '' };
1015
+ if (method === 'GET') {
1016
+ const body = files.get(key);
1017
+ return body === undefined
1018
+ ? { status: 404, ok: false, text: async () => '' }
1019
+ : { status: 200, ok: true, text: async () => body };
1020
+ }
1021
+ if (method === 'PUT') {
1022
+ putCalls.push(key);
1023
+ files.set(key, opts.body ?? '');
1024
+ return { status: 201, ok: true, text: async () => '' };
1025
+ }
1026
+ if (method === 'DELETE') {
1027
+ files.delete(key);
1028
+ return { status: 204, ok: true, text: async () => '' };
1029
+ }
1030
+ return { status: 405, ok: false, text: async () => '' };
1031
+ };
1032
+ const transport = new WebDavTransport({
1033
+ baseUrl: 'https://dav.example.com/dav/config',
1034
+ username: 'alice',
1035
+ credentials: { getPassword: async () => 'test-password' },
1036
+ request,
1037
+ });
1038
+ const adapters = createAdapters({ namespaces: NS });
1039
+ const importer = new Importer({ ctx, adapters, snapshotStore: new MemSnapshotStore() });
1040
+ const engine = new SyncEngine({
1041
+ ctx,
1042
+ transport,
1043
+ stateDir: tmp,
1044
+ adapters,
1045
+ importer,
1046
+ now: () => new Date('2026-08-16T12:00:00.000Z'),
1047
+ } as ConstructorParameters<typeof SyncEngine>[0]);
1048
+
1049
+ // 首次 push:上传快照文件 + 写 index
1050
+ const first = await engine.push({ snapshotId: 'sync-001' });
1051
+ assert.equal(first.ok, true);
1052
+ assert.equal(putCalls.filter((k) => k.endsWith('/sync-001.json')).length, 1, '首次 push 应 PUT 快照文件');
1053
+ assert.ok(putCalls.some((k) => k.endsWith('/index.json')), '首次 push 应写 index(meta 最后落盘)');
1054
+
1055
+ // 二次 push:同 id 同内容 → webdav 快照级跳过(不 PUT 快照文件、不写 index)
1056
+ const second = await engine.push({ snapshotId: 'sync-001' });
1057
+ assert.equal(second.ok, true);
1058
+ assert.equal(
1059
+ putCalls.filter((k) => k.endsWith('/sync-001.json')).length,
1060
+ 1,
1061
+ '内容无变化 → 不得再次 PUT 快照文件',
1062
+ );
1063
+ assert.equal(
1064
+ putCalls.filter((k) => k.endsWith('/index.json')).length,
1065
+ 1,
1066
+ '内容无变化 → 不得再次写 index',
1067
+ );
1068
+ // 远端快照文件仍存在且为首次内容
1069
+ const remote = files.get('https://dav.example.com/dav/config/dsh-config-manager/sync-001.json');
1070
+ assert.ok(remote !== undefined && remote.length > 0, '远端快照文件存在');
1071
+ } finally {
1072
+ await fs.rm(tmp, { recursive: true, force: true });
1073
+ }
1074
+ });