@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,270 @@
1
+ /**
2
+ * m-sync-transport:散文件目录布局测试。
3
+ * - writeSnapshotToDir / readSnapshotFromDir 往返(JSON 分区平铺 + 文件类分区目录)
4
+ * - manifest.json 内容正确性
5
+ * - 安全:路径穿越拒绝、不支持分区拒绝、缺 manifest 拒绝、缺文件拒绝
6
+ * - 注入验证:内存 SnapshotFs 驱动同一逻辑
7
+ */
8
+ import test from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import fs from 'node:fs/promises';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+
14
+ import { readSnapshotFromDir, writeSnapshotToDir, SNAPSHOT_KEEP_CONTENT, SNAPSHOT_KEEP_FILE, SNAPSHOT_MANIFEST_FILE, isSafeRelPath, listSnapshotFiles } from './layout.ts';
15
+ import { createSnapshotFs } from './fs.ts';
16
+ import { hashSection } from './sync-state.ts';
17
+ import type { SnapshotFs } from './fs.ts';
18
+ import type { SectionData, SectionId } from '../schema/types.ts';
19
+ import type { SyncSnapshot } from './transport.ts';
20
+
21
+ /** 测试用「明文快照」类型:sections 恒为普通分区 Record(layout 测试不涉及加密载荷)。 */
22
+ type PlainSnapshot = SyncSnapshot & { sections: Record<string, SectionData> };
23
+
24
+ function sampleSnapshot(): PlainSnapshot {
25
+ return {
26
+ id: 'snap-001',
27
+ createdAt: '2026-08-16T12:00:00.000Z',
28
+ manifest: {
29
+ schemaVersion: 1,
30
+ dshVersion: '1.2.3',
31
+ platform: 'win32',
32
+ sectionIds: ['settings', 'providers', 'skills', 'pluginFiles'],
33
+ containsSecrets: false,
34
+ },
35
+ sections: {
36
+ settings: { version: 1, namespaces: { general: { value: { theme: 'dark' }, revision: 1, secrets: [] } } },
37
+ providers: { version: 1, providers: { deepseek: { route: '/v1' } } },
38
+ skills: {
39
+ version: 1,
40
+ files: [
41
+ { relativePath: 'coding.md', data: new TextEncoder().encode('# Coding\n'), contentHash: 'x' },
42
+ { relativePath: 'nested/tool.md', data: new TextEncoder().encode('# Tool\n'), contentHash: 'y' },
43
+ ],
44
+ },
45
+ pluginFiles: { version: 1, files: [{ relativePath: 'dsh-ssh/main.js', data: new TextEncoder().encode('module.exports = 1\n'), contentHash: 'z' }] },
46
+ },
47
+ };
48
+ }
49
+
50
+ function assertBytesEqual(actual: Uint8Array, expected: Uint8Array, msg?: string) {
51
+ assert.equal(Buffer.from(actual).equals(Buffer.from(expected)), true, msg ?? `字节不一致: ${Buffer.from(expected).toString('utf8')}`);
52
+ }
53
+
54
+ test('isSafeRelPath: 拒绝穿越/绝对路径/反斜杠,接受常规相对路径', () => {
55
+ assert.equal(isSafeRelPath('a/b/c.md'), true);
56
+ assert.equal(isSafeRelPath('编码.md'), true);
57
+ assert.equal(isSafeRelPath('../evil.md'), false);
58
+ assert.equal(isSafeRelPath('a/../../evil.md'), false);
59
+ assert.equal(isSafeRelPath('/abs/path.md'), false);
60
+ assert.equal(isSafeRelPath('C:\\evil.md'), false);
61
+ assert.equal(isSafeRelPath('a\\b.md'), false);
62
+ assert.equal(isSafeRelPath(''), false);
63
+ assert.equal(isSafeRelPath('.'), false);
64
+ });
65
+
66
+ test('writeSnapshotToDir + readSnapshotFromDir: 完整往返(JSON 平铺 + 文件目录)', async () => {
67
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-layout-'));
68
+ try {
69
+ const snap = sampleSnapshot();
70
+ const manifest = await writeSnapshotToDir(snap, tmp);
71
+
72
+ // 布局:manifest.json + config/settings.json + ai/providers.json + custom/skills/… + plugin-files/…
73
+ assert.equal(manifest.id, snap.id);
74
+ assert.ok((await fs.stat(path.join(tmp, SNAPSHOT_MANIFEST_FILE))).isFile());
75
+ assert.ok((await fs.stat(path.join(tmp, 'config', 'settings.json'))).isFile());
76
+ assert.ok((await fs.stat(path.join(tmp, 'ai', 'providers.json'))).isFile());
77
+ assert.equal((await fs.readFile(path.join(tmp, 'custom', 'skills', 'coding.md'), 'utf8')), '# Coding\n');
78
+ assert.equal((await fs.readFile(path.join(tmp, 'custom', 'skills', 'nested', 'tool.md'), 'utf8')), '# Tool\n');
79
+ assert.equal((await fs.readFile(path.join(tmp, 'plugin-files', 'dsh-ssh', 'main.js'), 'utf8')), 'module.exports = 1\n');
80
+
81
+ const back = (await readSnapshotFromDir(tmp)) as unknown as PlainSnapshot;
82
+ assert.equal(back.id, snap.id);
83
+ assert.equal(back.createdAt, snap.createdAt);
84
+ assert.deepEqual(back.manifest, snap.manifest);
85
+ assert.deepEqual(back.sections['settings'], snap.sections['settings']);
86
+ assert.deepEqual(back.sections['providers'], snap.sections['providers']);
87
+ const skills = back.sections['skills'] as { files: { relativePath: string; data: Uint8Array; contentHash: string }[] };
88
+ const skillsOrig = snap.sections['skills'] as { files: { relativePath: string; data: Uint8Array; contentHash: string }[] };
89
+ assert.equal(skills.files.length, 2);
90
+ assert.deepEqual(skills.files.map((f) => f.relativePath).sort(), ['coding.md', 'nested/tool.md']);
91
+ assertBytesEqual(skills.files.find((f) => f.relativePath === 'coding.md')!.data, skillsOrig.files[0]!.data);
92
+ // 读回内容 hash 与写入时 manifest 记录一致(完整性自洽)
93
+ assert.equal(hashSection(back.sections['skills']!), manifest.sectionHashes['skills']);
94
+ assert.equal(hashSection(back.sections['settings']!), manifest.sectionHashes['settings']);
95
+ } finally {
96
+ await fs.rm(tmp, { recursive: true, force: true });
97
+ }
98
+ });
99
+
100
+ test('writeSnapshotToDir: manifest.sectionHashes 覆盖全部包含分区', async () => {
101
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-layout-hash-'));
102
+ try {
103
+ const snap = sampleSnapshot();
104
+ const manifest = await writeSnapshotToDir(snap, tmp);
105
+ assert.deepEqual(Object.keys(manifest.sectionHashes).sort(), ['pluginFiles', 'providers', 'settings', 'skills']);
106
+ for (const [id, data] of Object.entries(snap.sections)) {
107
+ assert.equal(manifest.sectionHashes[id as SectionId], hashSection(data));
108
+ }
109
+ } finally {
110
+ await fs.rm(tmp, { recursive: true, force: true });
111
+ }
112
+ });
113
+
114
+ test('writeSnapshotToDir: 空文件类分区也保留(目录存在 → 读回含空 files)', async () => {
115
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-layout-empty-'));
116
+ try {
117
+ const snap = sampleSnapshot();
118
+ snap.sections['skills'] = { version: 1, files: [] };
119
+ await writeSnapshotToDir(snap, tmp);
120
+ const back = (await readSnapshotFromDir(tmp)) as unknown as PlainSnapshot;
121
+ assert.deepEqual(back.sections['skills'], { version: 1, files: [] });
122
+ } finally {
123
+ await fs.rm(tmp, { recursive: true, force: true });
124
+ }
125
+ });
126
+
127
+ test('writeSnapshotToDir: 拒绝路径穿越的文件相对路径', async () => {
128
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-layout-evil-'));
129
+ try {
130
+ const snap = sampleSnapshot();
131
+ snap.sections['skills'] = { version: 1, files: [{ relativePath: '../../evil.md', data: new TextEncoder().encode('x'), contentHash: '' }] };
132
+ await assert.rejects(() => writeSnapshotToDir(snap, tmp), /非法路径|relativePath|路径/);
133
+ // 绝对路径同样拒绝
134
+ snap.sections['skills'] = { version: 1, files: [{ relativePath: 'C:\\evil.md', data: new TextEncoder().encode('x'), contentHash: '' }] };
135
+ await assert.rejects(() => writeSnapshotToDir(snap, tmp));
136
+ } finally {
137
+ await fs.rm(tmp, { recursive: true, force: true });
138
+ }
139
+ });
140
+
141
+ test('writeSnapshotToDir: 拒绝布局不支持的子分区(如 secrets)', async () => {
142
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-layout-nosec-'));
143
+ try {
144
+ const snap = sampleSnapshot();
145
+ (snap.sections as Record<string, unknown>)['secrets'] = { version: 1, values: [] };
146
+ await assert.rejects(() => writeSnapshotToDir(snap, tmp), /不支持|secrets|布局/);
147
+ } finally {
148
+ await fs.rm(tmp, { recursive: true, force: true });
149
+ }
150
+ });
151
+
152
+ test('readSnapshotFromDir: 目录缺 manifest.json → 拒绝', async () => {
153
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-layout-nomani-'));
154
+ try {
155
+ await assert.rejects(() => readSnapshotFromDir(tmp), /manifest/);
156
+ } finally {
157
+ await fs.rm(tmp, { recursive: true, force: true });
158
+ }
159
+ });
160
+
161
+ test('readSnapshotFromDir: manifest 声明了分区但文件缺失 → 拒绝(不静默降级)', async () => {
162
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-layout-missing-'));
163
+ try {
164
+ await writeSnapshotToDir(sampleSnapshot(), tmp);
165
+ await fs.rm(path.join(tmp, 'config', 'settings.json'));
166
+ await assert.rejects(() => readSnapshotFromDir(tmp), /settings/);
167
+ } finally {
168
+ await fs.rm(tmp, { recursive: true, force: true });
169
+ }
170
+ });
171
+
172
+ test('readSnapshotFromDir: 过滤 git 占位文件(名+内容匹配),保留真实同名文件', async () => {
173
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-layout-keep-'));
174
+ try {
175
+ await writeSnapshotToDir(sampleSnapshot(), tmp);
176
+ // 模拟 GitTransport.upload 的空分区占位:custom/skills/.gitkeep(占位内容)
177
+ await fs.writeFile(path.join(tmp, 'custom', 'skills', SNAPSHOT_KEEP_FILE), SNAPSHOT_KEEP_CONTENT, 'utf8');
178
+ // 真实同名文件(非占位内容)不得被吞:plugin-files/.gitkeep
179
+ await fs.writeFile(path.join(tmp, 'plugin-files', SNAPSHOT_KEEP_FILE), 'real user file\n', 'utf8');
180
+
181
+ const back = (await readSnapshotFromDir(tmp)) as unknown as PlainSnapshot;
182
+ const skills = back.sections['skills'] as { files: { relativePath: string; data: Uint8Array }[] };
183
+ assert.deepEqual(skills.files.map((f) => f.relativePath), ['coding.md', 'nested/tool.md'], '占位文件应从 files 中过滤');
184
+ const pluginFiles = back.sections['pluginFiles'] as { files: { relativePath: string; data: Uint8Array }[] };
185
+ assert.deepEqual(pluginFiles.files.map((f) => f.relativePath), [SNAPSHOT_KEEP_FILE, 'dsh-ssh/main.js'], '真实 .gitkeep(非占位内容)应保留');
186
+ } finally {
187
+ await fs.rm(tmp, { recursive: true, force: true });
188
+ }
189
+ });
190
+
191
+ test('readSnapshotFromDir: missingFileDir=empty → 文件分区目录缺失降级为空分区;默认仍抛错', async () => {
192
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-layout-empty-dir-'));
193
+ try {
194
+ const snap = sampleSnapshot();
195
+ snap.sections['skills'] = { version: 1, files: [] };
196
+ await writeSnapshotToDir(snap, tmp);
197
+ // 模拟 git 远端:空目录未被跟踪 → 目录不存在(manifest 仍声明 skills)
198
+ await fs.rm(path.join(tmp, 'custom', 'skills'), { recursive: true, force: true });
199
+
200
+ // 默认(throw):目录缺失 → 抛错(不静默降级)
201
+ await assert.rejects(() => readSnapshotFromDir(tmp), /快照缺少文件分区目录 custom\/skills\/(skills)/);
202
+ // git 通道语义(empty):目录缺失 = 空文件分区
203
+ const back = (await readSnapshotFromDir(tmp, createSnapshotFs(), { missingFileDir: 'empty' })) as unknown as PlainSnapshot;
204
+ assert.deepEqual(back.sections['skills'], { version: 1, files: [] });
205
+ } finally {
206
+ await fs.rm(tmp, { recursive: true, force: true });
207
+ }
208
+ });
209
+
210
+ test('注入验证:内存 SnapshotFs 驱动同一往返逻辑', async () => {
211
+ const mem: Map<string, Uint8Array> = new Map();
212
+ const snap = sampleSnapshot();
213
+ const memFs: SnapshotFs = {
214
+ async readFile(p) {
215
+ const v = mem.get(p);
216
+ if (!v) throw new Error(`ENOENT: ${p}`);
217
+ return v;
218
+ },
219
+ async writeFile(p, d) { mem.set(p, new Uint8Array(d)); },
220
+ async mkdir(p) { mem.set(p + '/.dir', new Uint8Array()); }, // 目录哨兵
221
+ async readdir(p) {
222
+ const prefix = p.endsWith('/') ? p : p + '/';
223
+ const out = new Set<string>();
224
+ for (const k of mem.keys()) {
225
+ if (!k.startsWith(prefix)) continue;
226
+ const rest = k.slice(prefix.length);
227
+ if (rest === '' || rest === '.dir') continue;
228
+ out.add(rest.split('/')[0]!);
229
+ }
230
+ return [...out];
231
+ },
232
+ async isDir(p) {
233
+ if (mem.has(p + '/.dir')) return true;
234
+ for (const k of mem.keys()) if (k.startsWith(p + '/')) return true;
235
+ return false;
236
+ },
237
+ async exists(p) { return mem.has(p) || mem.has(p + '/.dir'); },
238
+ async remove(p) {
239
+ const prefix = p.endsWith('/') ? p : p + '/';
240
+ for (const k of [...mem.keys()]) if (k === p || k.startsWith(prefix)) mem.delete(k);
241
+ },
242
+ };
243
+ const manifest = await writeSnapshotToDir(snap, 'snap/001', memFs);
244
+ const back = await readSnapshotFromDir('snap/001', memFs);
245
+ assert.equal(back.id, snap.id);
246
+ const backPlain = back.sections as Partial<Record<SectionId, SectionData>>;
247
+ const snapPlain = snap.sections as Partial<Record<SectionId, SectionData>>;
248
+ assert.deepEqual(backPlain['settings'], snapPlain['settings']);
249
+ const skills = backPlain['skills'] as { files: { relativePath: string; data: Uint8Array; contentHash: string }[] };
250
+ assert.equal(skills.files.length, 2);
251
+ assert.equal(hashSection(backPlain['skills']!), manifest.sectionHashes['skills']);
252
+ });
253
+
254
+ test('createSnapshotFs: 默认 node:fs 适配器可用(真实临时目录)', async () => {
255
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-layout-adapter-'));
256
+ try {
257
+ const fsx = createSnapshotFs();
258
+ await fsx.mkdir(path.join(tmp, 'a/b'));
259
+ await fsx.writeFile(path.join(tmp, 'a/b/x.md'), new TextEncoder().encode('hi'));
260
+ assert.equal(await fsx.exists(path.join(tmp, 'a/b/x.md')), true);
261
+ assert.equal(await fsx.isDir(path.join(tmp, 'a/b')), true);
262
+ assert.deepEqual(await fsx.readdir(path.join(tmp, 'a')), ['b']);
263
+ assert.equal(new TextDecoder().decode(await fsx.readFile(path.join(tmp, 'a/b/x.md'))), 'hi');
264
+ assert.deepEqual(await listSnapshotFiles(fsx, path.join(tmp, 'a')), ['b/x.md']);
265
+ await fsx.remove(path.join(tmp, 'a'));
266
+ assert.equal(await fsx.exists(path.join(tmp, 'a')), false);
267
+ } finally {
268
+ await fs.rm(tmp, { recursive: true, force: true });
269
+ }
270
+ });
@@ -0,0 +1,222 @@
1
+ /**
2
+ * m-sync-transport:散文件目录布局。
3
+ * 快照根目录 = manifest.json + 按 SECTION_JSON_PATHS 平铺的 JSON 分区
4
+ * + 按 SECTION_FILE_PREFIXES 的文件类分区目录(skills 等真实文件)。
5
+ * 复用 src/schema/config.ts 的路径表;核心逻辑经 SnapshotFs 注入(默认 node:fs/promises)。
6
+ */
7
+ import path from 'node:path';
8
+ import { parseJsonSafe, stringifyJsonSafe } from '../utils/json.ts';
9
+ import { sha256Hex } from '../utils/hashing.ts';
10
+ import { SECTION_FILE_PREFIXES, SECTION_JSON_PATHS } from '../schema/config.ts';
11
+ import type { FilesSection, SectionData, SectionId } from '../schema/types.ts';
12
+ import { createSnapshotFs, joinFs } from './fs.ts';
13
+ import type { SnapshotFs } from './fs.ts';
14
+ import { hashSection } from './sync-state.ts';
15
+ import type { SyncSnapshot } from './transport.ts';
16
+ import { isEncryptedSections } from './transport.ts';
17
+
18
+ export const SNAPSHOT_MANIFEST_FILE = 'manifest.json';
19
+
20
+ /**
21
+ * 空文件类分区的 git 占位文件(git 不跟踪空目录):
22
+ * 上传方(GitTransport.upload)给 files 为空的文件类分区目录写入本占位文件,
23
+ * 保证远端仓库保留该目录;读回时仅当「文件名 + 内容」同时匹配才过滤,
24
+ * 避免吞掉用户真实同名文件。占位文件不参与 sectionHashes(基于传入数据计算)。
25
+ * 注意:占位内容不得含换行 —— Windows core.autocrlf 会把 LF 转 CRLF,内容校验会失败。
26
+ */
27
+ export const SNAPSHOT_KEEP_FILE = '.gitkeep';
28
+ export const SNAPSHOT_KEEP_CONTENT = 'DSH Config Manager sync placeholder (keep empty section dir)';
29
+ const SNAPSHOT_KEEP_BYTES = new TextEncoder().encode(SNAPSHOT_KEEP_CONTENT);
30
+
31
+ function isKeepFile(rel: string, data: Uint8Array): boolean {
32
+ if (rel !== SNAPSHOT_KEEP_FILE) return false;
33
+ if (data.length !== SNAPSHOT_KEEP_BYTES.length) return false;
34
+ for (let i = 0; i < data.length; i++) {
35
+ if (data[i] !== SNAPSHOT_KEEP_BYTES[i]) return false;
36
+ }
37
+ return true;
38
+ }
39
+
40
+ /** 快照根目录下的 manifest.json 结构 */
41
+ export interface SnapshotDirManifest {
42
+ id: string;
43
+ createdAt: string; // ISO-8601 UTC
44
+ manifest: SyncSnapshot['manifest'];
45
+ /** 各分区内容 hash(写入时由 hashSection 计算) */
46
+ sectionHashes: Partial<Record<SectionId, string>>;
47
+ }
48
+
49
+ /** 布局支持的分区 = SECTION_JSON_PATHS ∪ SECTION_FILE_PREFIXES(secrets 等不在内) */
50
+ const LAYOUT_SECTION_IDS: readonly SectionId[] = [
51
+ ...Object.keys(SECTION_JSON_PATHS),
52
+ ...Object.keys(SECTION_FILE_PREFIXES),
53
+ ] as SectionId[];
54
+
55
+ /**
56
+ * 文件相对路径安全检查:拒绝空/'.'/'..'、绝对路径、Windows 盘符、反斜杠、
57
+ * 含 '..' 或 '.' 片段(防穿越快照根目录)。
58
+ */
59
+ export function isSafeRelPath(rel: string): boolean {
60
+ if (rel === '' || rel === '.' || rel === '..') return false;
61
+ if (rel.startsWith('/') || /^[A-Za-z]:[\\/]/.test(rel)) return false;
62
+ if (rel.includes('\\')) return false;
63
+ const parts = rel.split('/');
64
+ if (parts.some((p) => p === '' || p === '.' || p === '..')) return false;
65
+ return true;
66
+ }
67
+
68
+ /** 递归列出 dir 下的全部文件(相对 dir 的正斜杠路径,字典序);目录不存在 → [] */
69
+ export async function listSnapshotFiles(fsx: SnapshotFs, dir: string): Promise<string[]> {
70
+ const out: string[] = [];
71
+ const walk = async (abs: string, rel: string): Promise<void> => {
72
+ const names = await fsx.readdir(abs);
73
+ for (const name of names) {
74
+ if (name === '' || name === '.' || name === '..') continue; // 防御异常/恶意 fs
75
+ const childAbs = joinFs(abs, name);
76
+ const childRel = rel === '' ? name : `${rel}/${name}`;
77
+ if (await fsx.isDir(childAbs)) {
78
+ await walk(childAbs, childRel);
79
+ } else {
80
+ out.push(childRel);
81
+ }
82
+ }
83
+ };
84
+ await walk(dir, '');
85
+ return out.sort();
86
+ }
87
+
88
+ /**
89
+ * 将快照写入散文件目录。
90
+ * 非法输入(不支持的子分区 / 穿越路径)在任何写入发生前抛错,不留半成品目录。
91
+ */
92
+ export async function writeSnapshotToDir(
93
+ snapshot: SyncSnapshot,
94
+ dir: string,
95
+ fsx: SnapshotFs = createSnapshotFs(),
96
+ ): Promise<SnapshotDirManifest> {
97
+ // 加密快照不写散文件目录(本地不落盘:密文/明文都不落;远端已存密文)
98
+ if (isEncryptedSections(snapshot.sections)) {
99
+ throw new Error('加密快照不写散文件目录(本地不落盘;请直接使用密文传输)');
100
+ }
101
+ const sectionHashes: SnapshotDirManifest['sectionHashes'] = {};
102
+ for (const [id, data] of Object.entries(snapshot.sections)) {
103
+ const sid = id as SectionId;
104
+ if (!LAYOUT_SECTION_IDS.includes(sid)) {
105
+ throw new Error(`分区 ${sid} 不在散文件布局支持范围(SECTION_JSON_PATHS / SECTION_FILE_PREFIXES)`);
106
+ }
107
+ sectionHashes[sid] = hashSection(data as SectionData);
108
+ }
109
+ // 预校验全部文件相对路径(不合法 → 抛错且零写入)
110
+ for (const [sid, data] of Object.entries(snapshot.sections)) {
111
+ if (!(sid in SECTION_FILE_PREFIXES)) continue;
112
+ for (const file of (data as FilesSection).files) {
113
+ if (!isSafeRelPath(file.relativePath)) {
114
+ throw new Error(`非法文件相对路径: ${file.relativePath}(分区 ${sid})`);
115
+ }
116
+ }
117
+ }
118
+
119
+ await fsx.mkdir(dir);
120
+ const manifest: SnapshotDirManifest = {
121
+ id: snapshot.id,
122
+ createdAt: snapshot.createdAt,
123
+ manifest: snapshot.manifest,
124
+ sectionHashes,
125
+ };
126
+ await fsx.writeFile(joinFs(dir, SNAPSHOT_MANIFEST_FILE), new TextEncoder().encode(stringifyJsonSafe(manifest, { space: 2 })));
127
+
128
+ // JSON 分区:按 SECTION_JSON_PATHS 平铺
129
+ for (const [sid, rel] of Object.entries(SECTION_JSON_PATHS)) {
130
+ const data = snapshot.sections[sid as SectionId];
131
+ if (data === undefined) continue;
132
+ const abs = joinFs(dir, rel);
133
+ await fsx.mkdir(path.dirname(abs));
134
+ await fsx.writeFile(abs, new TextEncoder().encode(stringifyJsonSafe(data, { space: 2 })));
135
+ }
136
+
137
+ // 文件类分区:目录前缀 + 真实文件
138
+ for (const [sid, prefix] of Object.entries(SECTION_FILE_PREFIXES)) {
139
+ const data = snapshot.sections[sid as SectionId] as FilesSection | undefined;
140
+ if (data === undefined) continue;
141
+ const baseAbs = joinFs(dir, prefix);
142
+ await fsx.mkdir(baseAbs); // 空文件类分区也保留目录(读回可还原空 files)
143
+ for (const file of data.files) {
144
+ const abs = joinFs(baseAbs, file.relativePath);
145
+ await fsx.mkdir(path.dirname(abs));
146
+ await fsx.writeFile(abs, file.data);
147
+ }
148
+ }
149
+ return manifest;
150
+ }
151
+
152
+ /**
153
+ * 从散文件目录读回快照。
154
+ * manifest 声明的分区必须可解析:JSON 分区文件缺失 / 文件分区目录缺失 → 抛错(不静默降级),
155
+ * 但 opts.missingFileDir='empty' 时文件分区目录缺失降级为空分区
156
+ * (git 通道专用:git 不跟踪空目录,目录缺失 = 空文件分区;提交原子性保证非空目录不会缺失)。
157
+ * 读回时过滤 git 占位文件(SNAPSHOT_KEEP_FILE),使 files 与上传方数据一致。
158
+ */
159
+ export async function readSnapshotFromDir(
160
+ dir: string,
161
+ fsx: SnapshotFs = createSnapshotFs(),
162
+ opts: { missingFileDir?: 'throw' | 'empty' } = {},
163
+ ): Promise<SyncSnapshot> {
164
+ const manifestAbs = joinFs(dir, SNAPSHOT_MANIFEST_FILE);
165
+ if (!(await fsx.exists(manifestAbs))) {
166
+ throw new Error(`快照目录缺少 ${SNAPSHOT_MANIFEST_FILE}: ${dir}`);
167
+ }
168
+ const parsed = parseJsonSafe(Buffer.from(await fsx.readFile(manifestAbs)).toString('utf8'));
169
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
170
+ throw new Error('manifest.json 损坏:必须是对象');
171
+ }
172
+ const m = parsed as Record<string, unknown>;
173
+ if (typeof m['id'] !== 'string' || typeof m['createdAt'] !== 'string'
174
+ || m['manifest'] === null || typeof m['manifest'] !== 'object'
175
+ || m['sectionHashes'] === null || typeof m['sectionHashes'] !== 'object') {
176
+ throw new Error('manifest.json 损坏:缺少 id/createdAt/manifest/sectionHashes');
177
+ }
178
+ const manifest = parsed as SnapshotDirManifest;
179
+
180
+ const sections: SyncSnapshot['sections'] = {};
181
+ const declared = Object.keys(manifest.sectionHashes) as SectionId[];
182
+ for (const sid of declared) {
183
+ if (!LAYOUT_SECTION_IDS.includes(sid)) {
184
+ throw new Error(`manifest 声明了布局不支持的分区 ${sid}`);
185
+ }
186
+ }
187
+
188
+ // JSON 分区
189
+ for (const [sid, rel] of Object.entries(SECTION_JSON_PATHS)) {
190
+ if (!(sid in manifest.sectionHashes)) continue;
191
+ const abs = joinFs(dir, rel);
192
+ if (!(await fsx.exists(abs))) {
193
+ throw new Error(`快照缺少 JSON 分区文件 ${rel}(${sid})`);
194
+ }
195
+ sections[sid as SectionId] = parseJsonSafe(Buffer.from(await fsx.readFile(abs)).toString('utf8')) as SectionData;
196
+ }
197
+
198
+ // 文件类分区
199
+ for (const [sid, prefix] of Object.entries(SECTION_FILE_PREFIXES)) {
200
+ if (!(sid in manifest.sectionHashes)) continue;
201
+ const baseAbs = joinFs(dir, prefix);
202
+ if (!(await fsx.isDir(baseAbs))) {
203
+ if (opts.missingFileDir === 'empty') {
204
+ // git 通道降级:目录缺失 = 空文件分区(git 不跟踪空目录,详见函数注释)
205
+ sections[sid as SectionId] = { version: 1, files: [] };
206
+ continue;
207
+ }
208
+ throw new Error(`快照缺少文件分区目录 ${prefix}(${sid})`);
209
+ }
210
+ const rels = await listSnapshotFiles(fsx, baseAbs);
211
+ const files: FilesSection['files'] = [];
212
+ for (const rel of rels) {
213
+ const data = await fsx.readFile(joinFs(baseAbs, rel));
214
+ // 过滤 git 占位文件(名 + 内容同时匹配才过滤,避免吞用户真实同名文件)
215
+ if (isKeepFile(rel, data)) continue;
216
+ files.push({ relativePath: rel, data, contentHash: sha256Hex(data) });
217
+ }
218
+ sections[sid as SectionId] = { version: 1, files };
219
+ }
220
+
221
+ return { id: manifest.id, createdAt: manifest.createdAt, manifest: manifest.manifest, sections };
222
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * m-sync-json:SyncSnapshot JSON 安全序列化测试。
3
+ * 核心回归:文件类分区(Uint8Array)经 JSON 往返必须还原成 Uint8Array
4
+ * (此前 JSON.stringify 把 TypedArray 变成数字索引对象,导致
5
+ * 「The "data" argument must be of type string or an instance of Buffer…」)。
6
+ */
7
+ import test from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+
10
+ import {
11
+ deserializeSnapshot, sectionsFromJsonSafe, sectionsToJsonSafe, serializeSnapshot,
12
+ } from './snapshot-json.ts';
13
+ import { encryptSectionsPayload, decryptSectionsPayload } from '../../tests/fixtures/legacy-snapshot-crypto.ts';
14
+ import type { SyncSnapshot } from './transport.ts';
15
+ import { isEncryptedSections } from './transport.ts';
16
+ import type { SectionId } from '../schema/types.ts';
17
+
18
+ function fileSnapshot(overrides: Partial<SyncSnapshot> = {}): SyncSnapshot {
19
+ return {
20
+ id: 'snap-files',
21
+ createdAt: '2026-08-16T12:00:00.000Z',
22
+ manifest: {
23
+ schemaVersion: 1,
24
+ dshVersion: '1.2.3',
25
+ platform: 'win32',
26
+ sectionIds: ['settings', 'skills'],
27
+ containsSecrets: false,
28
+ },
29
+ sections: {
30
+ settings: { version: 1, namespaces: { general: { value: { theme: 'dark' }, revision: 1, secrets: [] } } },
31
+ skills: {
32
+ version: 1,
33
+ files: [
34
+ { relativePath: 'coding.md', data: new Uint8Array(Buffer.from('# Coding\n', 'utf8')), contentHash: 'h1' },
35
+ { relativePath: 'sub/notes.txt', data: new Uint8Array(Buffer.from('hello world', 'utf8')), contentHash: 'h2' },
36
+ ],
37
+ },
38
+ },
39
+ ...overrides,
40
+ } as SyncSnapshot;
41
+ }
42
+
43
+ test('serializeSnapshot:文件分区 data 以 base64 形传输(JSON 往返无损还原 Uint8Array)', () => {
44
+ const raw = serializeSnapshot(fileSnapshot());
45
+ const parsed = JSON.parse(raw) as SyncSnapshot;
46
+ // 传输形态:不是数字索引对象,而是 {$bin: base64}
47
+ const files = (parsed.sections as { skills: { files: Array<{ data: unknown }> } }).skills.files;
48
+ for (const f of files) {
49
+ const d = f.data as { $bin?: string };
50
+ assert.equal(typeof d.$bin, 'string', '文件 data 应为 { $bin: base64 }');
51
+ assert.equal(d.$bin!.length > 0, true);
52
+ }
53
+ // 往返还原:字节必须分毫不差
54
+ const roundtrip = deserializeSnapshot(raw);
55
+ const rfiles = (roundtrip.sections as { skills: { files: Array<{ data: Uint8Array; relativePath: string }> } }).skills.files;
56
+ assert.equal(rfiles.length, 2);
57
+ assert.ok(rfiles[0]!.data instanceof Uint8Array, '还原后 data 必须是 Uint8Array');
58
+ assert.equal(Buffer.from(rfiles[0]!.data).toString('utf8'), '# Coding\n');
59
+ assert.equal(rfiles[1]!.relativePath, 'sub/notes.txt');
60
+ assert.equal(Buffer.from(rfiles[1]!.data).toString('utf8'), 'hello world');
61
+ // JSON 分区原样保留
62
+ const settings = (roundtrip.sections as { settings: { namespaces: Record<string, unknown> } }).settings;
63
+ assert.deepEqual(settings.namespaces['general'], { value: { theme: 'dark' }, revision: 1, secrets: [] });
64
+ });
65
+
66
+ test('serializeSnapshot:加密快照(EncryptedSections)原样透传不破坏', () => {
67
+ const encrypted: SyncSnapshot = {
68
+ id: 'snap-enc',
69
+ createdAt: '2026-08-16T12:00:00.000Z',
70
+ manifest: { schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32', sectionIds: ['settings'], containsSecrets: true, encrypted: true },
71
+ sections: { encrypted: { info: { algo: 'aes-256-gcm', kdf: 'scrypt' } as never, data: 'c2VjcmV0IGNpcGhlcnRleHQ=' } },
72
+ };
73
+ const raw = serializeSnapshot(encrypted);
74
+ const roundtrip = deserializeSnapshot(raw);
75
+ assert.ok(isEncryptedSections(roundtrip.sections), '加密载荷必须保持 EncryptedSections 形态');
76
+ assert.deepEqual(roundtrip.sections, encrypted.sections);
77
+ assert.equal(roundtrip.manifest.encrypted, true);
78
+ });
79
+
80
+ test('sectionsToJsonSafe / sectionsFromJsonSafe:明文 sections 往返等价', () => {
81
+ const snap = fileSnapshot();
82
+ const safe = sectionsToJsonSafe(snap.sections);
83
+ const back = sectionsFromJsonSafe(safe);
84
+ assert.deepEqual(back, snap.sections, '明文 sections 往返应深等价(data 还原为 Uint8Array)');
85
+ });
86
+
87
+ test('deserializeSnapshot:形状非法 → 抛错', () => {
88
+ assert.throws(() => deserializeSnapshot('{"not":"snapshot"}'), /形状非法/);
89
+ assert.throws(() => deserializeSnapshot('not-json'), /JSON/);
90
+ });
91
+
92
+ test('加密载荷:含文件分区加密 → 解密还原 Uint8Array(字节无损)', async () => {
93
+ const snap = fileSnapshot();
94
+ const enc = await encryptSectionsPayload(snap.sections as Parameters<typeof encryptSectionsPayload>[0], 'pw-12345678');
95
+ assert.ok(isEncryptedSections({ encrypted: enc.encrypted }));
96
+ const decrypted = await decryptSectionsPayload(enc.encrypted, 'pw-12345678');
97
+ const files = (decrypted as { skills: { files: Array<{ data: Uint8Array; relativePath: string }> } }).skills.files;
98
+ assert.ok(files[0]!.data instanceof Uint8Array, '解密后文件 data 必须是 Uint8Array');
99
+ assert.equal(Buffer.from(files[0]!.data).toString('utf8'), '# Coding\n');
100
+ assert.equal(Buffer.from(files[1]!.data).toString('utf8'), 'hello world');
101
+ });
102
+
103
+ test('回归:webdav 序列化快照含文件分区 → JSON 中无数字索引对象({0:..,1:..})', () => {
104
+ const snap = fileSnapshot();
105
+ const raw = serializeSnapshot(snap);
106
+ assert.ok(!/\"0\"\s*:/.test(raw), '序列化不得出现数字索引对象形态');
107
+ // 空 data(零字节文件)也可往返
108
+ const empty = fileSnapshot();
109
+ (empty.sections as unknown as { skills: { files: Array<{ relativePath: string; data: Uint8Array }> } }).skills.files = [
110
+ { relativePath: 'empty.bin', data: new Uint8Array(0) },
111
+ ];
112
+ const rt = deserializeSnapshot(serializeSnapshot(empty));
113
+ const files = (rt.sections as { skills: { files: Array<{ data: Uint8Array }> } }).skills.files;
114
+ assert.equal(files[0]!.data.length, 0);
115
+ assert.ok(files[0]!.data instanceof Uint8Array);
116
+ });
117
+
118
+ // 类型辅助(避免未使用告警)
119
+ void ([] as SectionId[]);