@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
package/src/index.ts ADDED
@@ -0,0 +1,2975 @@
1
+ /**
2
+ * dsh-config-manager — host half.
3
+ *
4
+ * Mounts the backup / export / import engine (src/core Exporter + Importer
5
+ * three-stage flow) behind the `/api/dsh-config-manager/*` route family that
6
+ * the browser half (`./client`) calls, and wraps the real DSH host services
7
+ * into the engine's `HostContext` facade (src/core/types.ts):
8
+ *
9
+ * ctx.settings -> SettingsFacade (@deepseek-ai/dsh-settings)
10
+ * ctx.credentials -> CredentialsFacade (@deepseek-ai/dsh-credentials)
11
+ * ctx.plugins -> PluginsFacade (官方 dsh plugin CLI 通道 + profile 文件,
12
+ * 见 src/core/plugin-cli.ts)
13
+ * ctx.workspaceRegistry -> WorkspaceFacade (@deepseek-ai/dsh-workspace)
14
+ * ~/.dsh/cordis.patch.yml-> PatchFileFacade (js-yaml)
15
+ * $DSH_HOME files -> FileSystemFacade (node:fs, home-relative)
16
+ * resolveDshHome() -> homeDir (@deepseek-ai/dsh-home-paths)
17
+ *
18
+ * Security posture (mirrors the verified @linxin666/dsh-ssh@0.1.12 routes):
19
+ * - every route carries the loopback-only + same-origin trust fence
20
+ * (isLoopbackRequest); LAN-exposed deployments never serve these endpoints;
21
+ * - uploads/exported ZIPs are staged under $DSH_HOME/dsh-config-manager/{tmp,exports}
22
+ * and every `path`/`zipPath` reference is confined to those roots;
23
+ * - the encryption password is in-memory only: used to derive the AES-256-GCM
24
+ * key for secrets.enc, never written to any file, manifest, or log;
25
+ * - the import execute endpoint refuses to run without `confirm: true`
26
+ * (core ImportNotConfirmedError safety valve).
27
+ *
28
+ * Optional services are read with ctx.get() at call time (never injected), so
29
+ * the engine keeps working in profiles without the web-only workspace
30
+ * service; hard dependencies are the core `settings`/`credentials` services
31
+ * present in every profile. Plugin install/list no longer depend on the
32
+ * web-only pluginMarketplace/pluginInventory services: both go through the
33
+ * official `dsh plugin --profile <name>` CLI (pnpm forwarder) and read the
34
+ * profile's package.json / node_modules directly.
35
+ */
36
+
37
+ import { randomBytes } from 'node:crypto'
38
+ import { execFile } from 'node:child_process'
39
+ import { createReadStream, createWriteStream, mkdirSync, readFileSync } from 'node:fs'
40
+ import fs from 'node:fs/promises'
41
+ import type { IncomingMessage, ServerResponse } from 'node:http'
42
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
43
+ import { promisify } from 'node:util'
44
+
45
+ import type { Context } from '@deepseek-ai/cordis'
46
+ import * as dshSettings from '@deepseek-ai/dsh-settings'
47
+ import type { SettingsProvider } from '@deepseek-ai/dsh-settings'
48
+ import * as dshCredentials from '@deepseek-ai/dsh-credentials'
49
+ import type { CredentialProvider } from '@deepseek-ai/dsh-credentials'
50
+ import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
51
+ // Type-only: pull the Cordis Context augmentations (webServer / workspaceRegistry)
52
+ // and the WebRoute contract without any runtime import.
53
+ import type {} from '@deepseek-ai/dsh-host-webserver'
54
+ import type { WebRoute, WebServer } from '@deepseek-ai/dsh-host-webserver'
55
+ import type {} from '@deepseek-ai/dsh-workspace'
56
+ import type { WorkspaceId } from '@deepseek-ai/dsh-workspace'
57
+ import * as yaml from 'js-yaml'
58
+
59
+ import { Exporter, FileSnapshotStore, Importer, verifySnapshot } from './core/index.ts'
60
+ import { APPLY_ORDER, ProfileManager, isValidProfileName } from './profiles/index.ts'
61
+ import { cleanupCaches } from './core/cache-cleaner.ts'
62
+ import { deleteSnapshot, isValidSnapshotId, listSnapshots, planRestore, setSnapshotPinned, validateSnapshotForRestore, type RestorePlan, type RestoreReport, type RestoreSnapshotVerdict } from './core/restore.ts'
63
+ import { rollback as performRollback } from './core/rollback.ts'
64
+ // Phase 1 P0-1/P0-2:配置生命周期(自动快照 / 撤销 / 重做)与 P0-5 崩溃归因。
65
+ // 监听工厂用真 fs.watch 注入(core 侧只依赖抽象,便于测试驱动时序)。
66
+ import { ConfigLifecycle } from './core/config-lifecycle.ts'
67
+ import { deleteConfigSnapshot } from './core/config-snapshot.ts'
68
+ import {
69
+ adviceFor, beginBoot, computeBootAlert, listCandidateLogs, markBootOk,
70
+ readBootState, readCrashLogTail, writeBootState,
71
+ } from './core/crash-report.ts'
72
+ // Phase 1 P0-3:启动救援模式(备份 patch/package.json → 写最小 patch → 中和 bundles)
73
+ import { enterRescueMode, exitRescueMode, rescueModeStatus } from './core/boot-rescue.ts'
74
+ import { watch as fsWatch } from 'node:fs'
75
+ import { recomputeRecoveryDecision, executeRecovery } from './core/reconcile.ts'
76
+ import { verifyRecovery, recoveryTerminalState } from './core/verify-recovery.ts'
77
+ import { createRecoveryOrchestrator, type RecoveryExecutorFns } from './core/recovery-orchestrator.ts'
78
+ import { redactJournalText, isValidOperationId, isTerminalState, transitionJournalState, JournalStore, type OperationJournal } from './core/journal.ts'
79
+ import { RunRegistry, type RunState } from './core/run-registry.ts'
80
+ import { registerModelTools } from './core/model-tools.ts'
81
+ import { computeConsultReport, type ConsultSourceRef, type ConsultSourceData, type MigratabilityResult } from './core/migration-consult.ts'
82
+ import { readExportZipSource, buildLocalSnapshotSource, buildProfileSource } from './core/consult-source.ts'
83
+ import { makeMsg, msgOf, zhMsg } from './core/messages.ts'
84
+ import type { MsgFunc } from './core/messages.ts'
85
+ import {
86
+ cleanupAbortedInstall, hasDshBundlePatch, installErrorFor, installSpecFor, listInstalledPlugins,
87
+ resolveProfileDir, resolveProfileNameFromArgv, readProfileManifest, runDshPlugin, validateProfileName,
88
+ } from './core/plugin-cli.ts'
89
+ import type {
90
+ ConfigAdapter, CredentialsFacade, FileSystemFacade, HostContext, ImportDecisions,
91
+ ImportPlan, NamespaceInfo, PatchFileFacade, PlanItem, PlanItemKind, PluginInfo, PluginsFacade,
92
+ SettingsFacade, Snapshot, WorkspaceFacade,
93
+ } from './core/types.ts'
94
+ import { ImportNotConfirmedError, ImportUserSkippedError } from './core/types.ts'
95
+ import { createAdapters, USER_PATCH_FILE } from './adapters/index.ts'
96
+ import { createLocalPluginPackHook } from './core/local-plugin-host.ts'
97
+ import { createEncryptionProvider, decryptCredentials, decryptArchive, SecurityError, encryptArchive, isArchiveBlob, verifyEncryptedBlob } from './security/index.ts'
98
+ import { createHardenedZipParser } from './security/zip-security.ts'
99
+ import { atomicCopyFile, atomicWriteFile } from './utils/atomic-write.ts'
100
+ import { EnvironmentLockManager, runWithMutationLock, EnvironmentLockUnavailableError, type MutationLockContext } from './utils/env-lock.ts'
101
+ import { activeProxySummary } from './utils/proxy.ts'
102
+ import { listRecursiveFollowingLinks } from './utils/recursive-walk.ts'
103
+ import { Phase3Recovery, TransactionRecoveryRequiredError, mapLockStateForStartup } from './core/phase3-host.ts'
104
+ import type { JournalRunContext } from './core/phase3-host.ts'
105
+ import { classifyStartup } from './core/startup-barrier.ts'
106
+ import type { MutationLockPort } from './utils/env-lock.ts'
107
+ import type { RecursiveListing } from './utils/recursive-walk.ts'
108
+ import { GitTransport } from './sync/git/git-transport.ts'
109
+ import { WebDavTransport } from './sync/webdav/webdav-transport.ts'
110
+ import { DeviceFlowStore, GitHubAuthClient, GitHubAuthError } from './sync/github-auth.ts'
111
+ import { SyncEngine } from './sync/sync-engine.ts'
112
+ import type { ApplyItemsReport } from './sync/sync-engine.ts'
113
+ import { SyncSessionStore } from './sync/sync-session.ts'
114
+ import { AutoSyncScheduler } from './sync/autosync-scheduler.ts'
115
+ import { readAllAutosyncConfigs, readAutosyncConfig, writeAutosyncConfig } from './sync/autosync-config.ts'
116
+ import type { AutosyncConfig, AutosyncInterval, AutosyncRunStatus } from './sync/autosync-config.ts'
117
+ import { appendAutosyncEntry, readSyncHistory } from './sync/sync-history.ts'
118
+ import {
119
+ MigrationStore, queryHistory, summarizeHistory, renderExport, parseHistoryQuery,
120
+ type MigrationKind, type MigrationResult, type ReadMigrationResult,
121
+ type StoredMigrationHistoryEntry, MIGRATION_HISTORY_DIR,
122
+ } from './core/migration-history.ts'
123
+ import { loadSyncState, saveSyncState } from './sync/sync-state.ts'
124
+ import {
125
+ readSyncConfig, readSyncConfigFor, readFullSyncConfig, writeSyncConfig, validateRepoUrl, validateWebDavUrl,
126
+ isGitConfig, isWebDavConfig,
127
+ } from './sync/sync-config.ts'
128
+ import type { SyncConfig, FullSyncConfig, SyncTransportType } from './sync/sync-config.ts'
129
+ import {
130
+ defaultSyncSelection, effectiveSections, readAllSyncSelections, readSyncSelection, writeSyncSelection,
131
+ SYNC_SELECTION_SCHEMA_VERSION,
132
+ } from './sync/sync-selection.ts'
133
+ import type { SyncSelection, SyncSelectionMode } from './sync/sync-selection.ts'
134
+ import { readUiPrefs, updateUiPrefs } from './sync/ui-prefs.ts'
135
+ import type { UiPrefsChannel } from './sync/ui-prefs.ts'
136
+ import type { SyncTransport } from './sync/transport.ts'
137
+ import { redact } from './security/redaction.ts'
138
+ import { createConfiguredSecretScanner } from './security/secret-scanner.ts'
139
+ import type { ConfiguredSecretPatterns } from './security/secret-scanner.ts'
140
+ import type { SecretScanner } from './core/types.ts'
141
+ import { sha256Hex } from './utils/hashing.ts'
142
+ import { MANIFEST_FILE, parseManifest } from './schema/manifest.ts'
143
+ import { isFileSection, SECTION_IDS } from './schema/config.ts'
144
+ import { stringifyJsonSafe } from './utils/json.ts'
145
+ import type { Manifest, SectionId, WorkspaceRecord } from './schema/types.ts'
146
+ import { parseZip, zipToBuffer } from './utils/zip.ts'
147
+ import { isSameOrChild, normalizePath } from './utils/paths.ts'
148
+ import { createLogger, type Logger } from './utils/logger.ts'
149
+
150
+ /* ---------------------------------------------------------------- identity */
151
+
152
+ /** Stable cordis plugin name — must match the cordis.patch.yml row id. */
153
+ export const name = 'config-manager'
154
+
155
+ /** Services required before the engine can mount (present in every profile). */
156
+ export const inject = ['settings', 'credentials']
157
+
158
+ /** Plugin version, kept in sync with package.json ("version"). */
159
+ const PLUGIN_VERSION = '0.1.60'
160
+
161
+ /** Plugin own package name — excluded from its own exported plugins list. */
162
+ const PLUGIN_NAME = 'dsh-config-manager'
163
+
164
+ /**
165
+ * Star 引导弹窗指向的 GitHub 仓库(用户引导点 Star 的目标)。
166
+ * 与 package.json 的 repository 字段保持一致;界面不可改(硬编码,参照
167
+ * 「一键上传」目标仓库先例)。仅在 GET /star-prompt 响应中返回,供弹窗按钮跳转。
168
+ */
169
+ const STAR_PROMPT_REPO_URL = 'https://github.com/xiajiajun516/dsh-config-manager'
170
+
171
+ /** 缓存自动清理周期:24 小时(启动即清一次 + 此后每日一次;与 cache-cleaner 保留期独立) */
172
+ const CACHE_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000
173
+
174
+ /**
175
+ * 内置 GitHub OAuth App 的 client_id(「使用 GitHub 登录」device flow 缺省值)。
176
+ * Client ID 是公开标识(GitHub 官方明确非机密):内置后所有安装者开箱即用,
177
+ * 无需各自注册/配置 OAuth App;token 仍按用户私有(各自授权、各自存 credentials)。
178
+ * 插件配置的 githubClientId 优先于本默认值(换自有 App 时覆盖)。
179
+ */
180
+ export const DEFAULT_GITHUB_CLIENT_ID = 'Ov23liq4i7n8UsylGRfb'
181
+
182
+ /** Plugin config (composition entry); the loader applies it as-is. */
183
+ export interface Config {
184
+ /** Master switch; defaults to true. */
185
+ enabled?: boolean
186
+ /** Data root override; defaults to $DSH_HOME/dsh-config-manager. */
187
+ dataDir?: string
188
+ /** 管理的 profile 名(插件依赖读写/安装目标);缺省取启动参数 --profile,再缺省 'web'。 */
189
+ profile?: string
190
+ /**
191
+ * GitHub OAuth App 的 client_id(「使用 GitHub 登录」device flow)。
192
+ * 缺省使用内置 DEFAULT_GITHUB_CLIENT_ID(公开标识,开箱即用);
193
+ * 显式配置可覆盖(换自有 OAuth App 时)。
194
+ */
195
+ githubClientId?: string
196
+ /**
197
+ * GitHub OAuth App 的 client_secret(confidential app 必需;public app 可省略)。
198
+ * 只存在于宿主进程:device flow 轮询时由宿主直接发送给 GitHub,绝不回传浏览器/日志。
199
+ */
200
+ githubClientSecret?: string
201
+ /**
202
+ * pluginFiles 分区:额外白名单文件(相对 ~/.dsh 根的单文件名或子路径)。
203
+ * 与默认白名单(dsh-ssh.json、pet.json)合并;用于精确指定要随导出携带的插件配置文件。
204
+ */
205
+ pluginFiles?: string[]
206
+ /**
207
+ * pluginFiles 分区:约定的插件配置目录(相对 ~/.dsh 根,如 'plugin-config')。
208
+ * 导出时递归收集该目录下所有文件(按相对 ~/.dsh 根的路径写回),实现「往目录放文件即自动随备份携带」。
209
+ */
210
+ pluginFilesDir?: string
211
+ /**
212
+ * F2 个人隐私规则(对齐 dsh-packer config.personalPatterns):个人化敏感字段名 /
213
+ * 引用字段 / 值形状模式,由部署者注入(个人昵称、本机用户名等),不进开源代码。
214
+ * 未配置时扫描器行为与默认完全一致。
215
+ */
216
+ personalPatterns?: ConfiguredSecretPatterns
217
+ }
218
+
219
+ /* ---------------------------------------------------------------- constants */
220
+
221
+ /** Route family — must match the browser half's CONFIG_MANAGER_API exactly. */
222
+ const API = {
223
+ // P2-⑫:导出前只读预览(不落盘 ZIP;返回各分区 counts + 估算大小)
224
+ // P1-⑧:快照管理(手动删除 + 置顶豁免自动清理)
225
+ // m-backup-schedule:定时全量备份(读/存 backup-schedule.json + 立即执行一次)
226
+ // m-backup-files:导出产物管理(列出 exports/*.zip + 删除;下载复用 /download)
227
+ // Phase 7:迁移前咨询(只读健康评分 + 建议;POST,loopback fence)
228
+ // m-sync-ui:远程同步(Git 私有仓库通道)
229
+ syncStatus: '/api/dsh-config-manager/sync/status',
230
+ syncPush: '/api/dsh-config-manager/sync/push',
231
+ syncPull: '/api/dsh-config-manager/sync/pull',
232
+ // m-github-oauth:GitHub OAuth device flow 登录(start → 展示授权码 → poll → token 入库)
233
+ syncGithubStart: '/api/dsh-config-manager/sync/github/start',
234
+ syncGithubPoll: '/api/dsh-config-manager/sync/github/poll',
235
+ syncGithubCancel: '/api/dsh-config-manager/sync/github/cancel',
236
+ // m-sync-github-valid:校验已存 token 是否有效(决定「已登录」→ 隐藏登录区块)
237
+ syncGithubValidate: '/api/dsh-config-manager/sync/github/validate',
238
+ // P2:同步历史 / 自动应用 / 一键回滚
239
+ syncHistory: '/api/dsh-config-manager/sync/history',
240
+ syncRollback: '/api/dsh-config-manager/sync/rollback',
241
+ // m-sync-v2:一键同步(差异确认会话)+ 自动同步 + 历史快照
242
+ syncSnapshotsList: '/api/dsh-config-manager/sync/snapshots-list',
243
+ syncSync: '/api/dsh-config-manager/sync/sync',
244
+ syncApplyItems: '/api/dsh-config-manager/sync/apply-items',
245
+ syncCancel: '/api/dsh-config-manager/sync/cancel',
246
+ syncAutosync: '/api/dsh-config-manager/sync/autosync',
247
+ // m-sync-selection:同步分区选择持久化(默认/高级模式 + 勾选分区;自动同步共用)
248
+ syncSelection: '/api/dsh-config-manager/sync/selection',
249
+ // m-sync-config:同步通道配置保存(UI 表单自动保存 /「保存配置」按钮;凭据写 DSH credentials)
250
+ syncConfig: '/api/dsh-config-manager/sync/config',
251
+ // m-self:插件 UI 偏好(如上次选择的同步通道;ui-prefs.json,随 self 分区进备份)
252
+ syncUiPrefs: '/api/dsh-config-manager/sync/ui-prefs',
253
+ // m-star-prompt:Star 引导弹窗状态(复用 ui-prefs.json;GET 读 + POST 局部更新)
254
+ // 版本更新内容弹窗状态(复用 ui-prefs.json;GET 读 + POST 局部更新)
255
+ // m-market:配置市场(内置单仓库,只读公开仓库:浏览 + 下载 + 安全校验;apply 复用 execute)
256
+ // m-my-configs:「一键上传 / 我的配置」(目标仓库固定 xiajiajun516/dsh-config-market;
257
+ // 登录复用 sync/github/start|poll|cancel,不重复实现;/me/items 401 → 未登录)
258
+ // m-profiles:配置档案(Profile = 一组可切换的配置快照;Save/List/Delete/Rename/Switch/Import)
259
+ // Phase 5:recovery 编排(prefix 路由,内部按 path 分发:status / <opId>/preview|confirm|execute|verify|retry|dismiss)
260
+ // Phase 6:迁移历史审计(统一历史引擎;只读 GET + 导出)
261
+ // Phase 1 P0-1/P0-2:配置生命周期(自动快照 / 撤销 / 重做)与崩溃归因
262
+ // Phase 1 P0-3:启动救援模式(禁用其它插件使 DSH 能启动)
263
+ } as const
264
+
265
+ /**
266
+ * 灾备子系统(Phase 1)总开关 —— 临时下线,待相关缺陷修复后再放出。
267
+ *
268
+ * 置 false 时整个灾备子系统停摆:
269
+ * - 不启动配置变更监听 → 不产生自动快照(也不再刷「配置快照超出上限」告警)
270
+ * - 不写 boot-state → 崩溃归因无观测数据(不影响启动)
271
+ * - /lifecycle /crash /rescue 三条路由一律 503 feature-disabled
272
+ *
273
+ * 与客户端导航入口开关配套:src/client/ConfigManagerSection.tsx 的 SHOW_LIFECYCLE_NAV。
274
+ * 两者都置 true 才是一套完整的灾备功能。
275
+ *
276
+ * 为什么整体下线而不是只停自动快照:自动快照的采集走**全部 adapter**,其中 sessions
277
+ * 分区(历史会话,本机实测 340 MB)远超快照 64 MiB 上限,必然持续失败并刷告警;
278
+ * 在该缺陷修好前,撤销/重做/救援也没有可信的快照基线可用。
279
+ */
280
+ const LIFECYCLE_ENABLED: boolean = false
281
+
282
+ /**
283
+ * 同步 token 的 DSH credentials 引用名(POSIX env-var 形态,满足 CredentialRef 品牌要求)。
284
+ * token 只经 credentialRef 读写(写入由请求体触发,读取在每次 git 网络操作时 resolve),
285
+ * 永不进 repoUrl / argv / commit / 同步文件 / 日志。
286
+ */
287
+ export const SYNC_CREDENTIAL_REF = 'DSH_CONFIG_MANAGER_SYNC_TOKEN'
288
+
289
+ /**
290
+ * WebDAV 通道口令的独立 DSH credentials 引用(与 git token 槽位分离)。
291
+ * 口令只经 credentialRef 读写(写入由请求体触发,读取在每次 WebDAV 网络操作时
292
+ * by WebDavTransport 经注入的 getPassword() resolve),永不进 URL / 请求头 / 日志。
293
+ */
294
+ export const SYNC_WEBDAV_CREDENTIAL_REF = 'DSH_CONFIG_MANAGER_SYNC_WEBDAV_PASSWORD'
295
+
296
+ /** Cap on JSON request bodies (import plans can be large: 4 MB). */
297
+ const MAX_JSON_BODY_BYTES = 4 * 1024 * 1024
298
+
299
+ /** Cap on raw upload bodies (staged to the controlled tmp dir). */
300
+ const MAX_UPLOAD_BYTES = 256 * 1024 * 1024
301
+
302
+ /* ---------------------------------------------------------- loopback fence */
303
+
304
+ /** Loopback literal check plus browser same-origin markers (dsh-ssh's fence). */
305
+ function isLoopbackRequest(request: IncomingMessage): boolean {
306
+ const address = request.socket.remoteAddress
307
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
308
+ const host = request.headers.host
309
+ if (typeof host !== 'string') return false
310
+ let hostUrl: URL
311
+ try {
312
+ hostUrl = new URL(`http://${host}`)
313
+ } catch {
314
+ return false
315
+ }
316
+ if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
317
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
318
+ const origin = request.headers.origin
319
+ if (origin === undefined) return true
320
+ try {
321
+ return new URL(origin).host === hostUrl.host
322
+ } catch {
323
+ return false
324
+ }
325
+ }
326
+
327
+ /* ---------------------------------------------------------------- responses */
328
+
329
+ function writeJson(res: ServerResponse, status: number, body: unknown): void {
330
+ const payload = JSON.stringify(body)
331
+ res.writeHead(status, {
332
+ 'content-type': 'application/json; charset=utf-8',
333
+ 'referrer-policy': 'no-referrer',
334
+ })
335
+ res.end(payload)
336
+ }
337
+
338
+ async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
339
+ const chunks: Buffer[] = []
340
+ let size = 0
341
+ for await (const chunk of req) {
342
+ const buffer = chunk as Buffer
343
+ size += buffer.length
344
+ if (size > MAX_JSON_BODY_BYTES) return undefined
345
+ chunks.push(buffer)
346
+ }
347
+ try {
348
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
349
+ return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
350
+ } catch {
351
+ return undefined
352
+ }
353
+ }
354
+
355
+ /** URL query helper (first value, decoded). */
356
+ function queryParam(url: URL, name: string): string | undefined {
357
+ const value = url.searchParams.get(name)
358
+ return value === null ? undefined : value
359
+ }
360
+
361
+ /** Stream a raw request body to a file, enforcing a byte cap. */
362
+ async function writeRequestBodyToFile(req: IncomingMessage, dest: string, maxBytes: number): Promise<number> {
363
+ const sink = createWriteStream(dest)
364
+ let size = 0
365
+ await new Promise<void>((resolvePromise, reject) => {
366
+ req.on('error', reject)
367
+ sink.on('error', reject)
368
+ req.on('data', (chunk: Buffer) => {
369
+ size += chunk.length
370
+ if (size > maxBytes) {
371
+ sink.destroy()
372
+ req.destroy()
373
+ reject(new Error(`upload body exceeds ${maxBytes} bytes`))
374
+ }
375
+ })
376
+ req.pipe(sink)
377
+ sink.on('finish', () => resolvePromise())
378
+ })
379
+ return size
380
+ }
381
+
382
+ /* -------------------------------------------------------------- dsh version */
383
+
384
+ /** 当前 DSH 应用语言(settings `locale` 命名空间的 preference;缺省 zh)。 */
385
+ function resolveAppLanguage(ctx: Context): 'zh' | 'en' {
386
+ try {
387
+ const descriptors = ctx.settings.describe({ redactSecrets: true })
388
+ const locale = descriptors.find((d) => String(d.ns) === 'locale')
389
+ const pref = (locale?.value as { preference?: unknown } | undefined)?.preference
390
+ return pref === 'en' ? 'en' : 'zh'
391
+ } catch {
392
+ return 'zh'
393
+ }
394
+ }
395
+
396
+ /** Resolve the real DSH version from the profile dependency tree (read-only). */
397
+ function resolveDshVersion(home: string): string {
398
+ const candidates = [
399
+ join(home, 'profiles', 'node_modules', '@deepseek-ai', 'dsh', 'package.json'),
400
+ join(home, 'profiles', 'web', 'node_modules', '@deepseek-ai', 'dsh', 'package.json'),
401
+ ]
402
+ for (const p of candidates) {
403
+ try {
404
+ const parsed = JSON.parse(readFileSync(p, 'utf8')) as { version?: unknown }
405
+ if (typeof parsed.version === 'string' && parsed.version !== '') return parsed.version
406
+ } catch {
407
+ // try the next candidate
408
+ }
409
+ }
410
+ return 'unknown'
411
+ }
412
+
413
+ /* ------------------------------------------------------------ profile name */
414
+
415
+ /** 解析管理的 profile:config.profile → 启动参数 --profile → 'web'。 */
416
+ function resolveProfileName(config?: Config): string {
417
+ const configured = config?.profile
418
+ if (configured !== undefined && configured !== '') return validateProfileName(configured)
419
+ return resolveProfileNameFromArgv()
420
+ }
421
+
422
+ /* ------------------------------------------------------- HostContext facades */
423
+
424
+ /** Optional Cordis service reader (never injected → never blocks the fiber). */
425
+ function readService<T>(ctx: Context, serviceName: string): T | undefined {
426
+ const candidate = ctx.get(serviceName)
427
+ return candidate === null || typeof candidate !== 'object' ? undefined : candidate as T
428
+ }
429
+
430
+ /** Safe settings namespace converter compatible across DSH 0.1.1 and 0.1.2-alpha.x */
431
+ const SETTINGS_NAMESPACE_REGEX = /^[a-z][a-z0-9-]*$/
432
+ function safeSettingsNamespace(namespace: string): any {
433
+ const fn = (dshSettings as Record<string, unknown>).settingsNamespace
434
+ if (typeof fn === 'function') {
435
+ return (fn as (ns: string) => any)(namespace)
436
+ }
437
+ if (!SETTINGS_NAMESPACE_REGEX.test(namespace)) {
438
+ throw new TypeError(`settings namespace "${namespace}" must match ${String(SETTINGS_NAMESPACE_REGEX)}`)
439
+ }
440
+ return namespace
441
+ }
442
+
443
+ /** Safe credential ref converter compatible across DSH 0.1.1 and 0.1.2-alpha.x */
444
+ const CREDENTIAL_REF_REGEX = /^[A-Z_][A-Z0-9_]*$/
445
+ function safeCredentialRef(ref: string): any {
446
+ const fn = (dshCredentials as Record<string, unknown>).credentialRef
447
+ if (typeof fn === 'function') {
448
+ return (fn as (r: string) => any)(ref)
449
+ }
450
+ if (!CREDENTIAL_REF_REGEX.test(ref)) {
451
+ throw new TypeError(`credential ref "${ref}" must match ${String(CREDENTIAL_REF_REGEX)}`)
452
+ }
453
+ return ref
454
+ }
455
+ const credentialRef = safeCredentialRef
456
+
457
+
458
+ /** Settings facade over the real ctx.settings (describe() is namespace-less). */
459
+ class DshSettingsFacade implements SettingsFacade {
460
+ private readonly ctx: Context
461
+
462
+ constructor(ctx: Context) {
463
+ this.ctx = ctx
464
+ }
465
+
466
+ private provider(): SettingsProvider {
467
+ return this.ctx.settings
468
+ }
469
+
470
+ async describe(namespace: string, opts?: { redactSecrets?: boolean }): Promise<NamespaceInfo> {
471
+ const all = this.provider().describe({ redactSecrets: opts?.redactSecrets ?? true })
472
+ const descriptor = all.find((d) => String(d.ns) === namespace)
473
+ if (!descriptor) throw new Error(`namespace not found: ${namespace}`)
474
+ return {
475
+ value: descriptor.value,
476
+ base: descriptor.base,
477
+ revision: descriptor.revision,
478
+ // Real service reports a single applies value; the core contract is an array.
479
+ applies: descriptor.applies === undefined ? undefined : [descriptor.applies],
480
+ secrets: descriptor.secrets ?? [],
481
+ }
482
+ }
483
+
484
+ async replace(namespace: string, value: unknown, expectedRevision?: number): Promise<void> {
485
+ await this.provider().replace(safeSettingsNamespace(namespace), value as object, expectedRevision)
486
+ }
487
+
488
+ async update(namespace: string, patch: unknown, expectedRevision?: number): Promise<void> {
489
+ await this.provider().update(safeSettingsNamespace(namespace), patch as object, expectedRevision)
490
+ }
491
+ }
492
+
493
+ /** Credentials facade over the real ctx.credentials (values never round-trip). */
494
+ class DshCredentialsFacade implements CredentialsFacade {
495
+ private readonly ctx: Context
496
+
497
+ constructor(ctx: Context) {
498
+ this.ctx = ctx
499
+ }
500
+
501
+ async describe(ref: string): Promise<{ configured: boolean; source?: string; writable?: boolean }> {
502
+ const info = await this.ctx.credentials.describe(safeCredentialRef(ref))
503
+ return { configured: info.configured, source: info.source, writable: info.writable }
504
+ }
505
+
506
+ async set(ref: string, value: string): Promise<void> {
507
+ await this.ctx.credentials.set(safeCredentialRef(ref), value)
508
+ }
509
+
510
+ async unset(ref: string): Promise<void> {
511
+ await this.ctx.credentials.unset(safeCredentialRef(ref))
512
+ }
513
+ }
514
+
515
+ /** 包名 → patch 行 id slug(仿 marketplace ensureRow):去 @、非法字符→-、连续-合并、去首尾-。 */
516
+ export function slugOf(name: string): string {
517
+ return name.replace(/^@/, '').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/-{2,}/g, '-').replace(/^-|-$/g, '')
518
+ }
519
+
520
+ /** 某 patch 行(raw)是否激活了指定包名(兼容单行与 insert 块成员)。 */
521
+ export function patchRowActivates(raw: unknown, name: string): boolean {
522
+ if (raw === null || typeof raw !== 'object') return false
523
+ const obj = raw as Record<string, unknown>
524
+ const entries = Array.isArray(obj['insert']) ? obj['insert'] : [obj]
525
+ return entries.some((e) => e !== null && typeof e === 'object' && (e as Record<string, unknown>)['name'] === name)
526
+ }
527
+
528
+ /**
529
+ * 非 bundle 插件安装成功后,幂等补 profile cordis.patch.yml 激活行
530
+ * ({id: pm-<slug>, name: <pkg>},仿 marketplace ensureRow)。bundle 包不写行
531
+ * (CLI 的 reconcile 已维护 dsh.profile.bundles)。
532
+ */
533
+ export async function ensureActivationRow(patchFile: PatchFileFacade, pkgDir: string, pkg: string): Promise<void> {
534
+ if (hasDshBundlePatch(pkgDir)) return
535
+ const lines = await patchFile.readPatchLines(PROFILE_PATCH_FILE)
536
+ if (lines.some((l) => patchRowActivates(l.raw, pkg))) return
537
+ const id = `pm-${slugOf(pkg)}`
538
+ await patchFile.applyPatchChanges(PROFILE_PATCH_FILE, [
539
+ { lineId: id, raw: { id, name: pkg }, action: 'insert' },
540
+ ])
541
+ }
542
+
543
+ /**
544
+ * Plugins facade:官方 dsh plugin CLI 通道(任何 profile 可用)+ profile 文件
545
+ * 实时清单 + 非 bundle 插件激活行幂等补写。不再依赖 web 专用
546
+ * pluginMarketplace / pluginInventory 服务。
547
+ *
548
+ * 导出 + runner 可注入:M5 单测用 mock runner 验证「无 marketplace 时 install
549
+ * 走 CLI 通道」的行为契约,不触发真实子进程;生产路径默认参数不变。
550
+ */
551
+ export class DshPluginsFacade implements PluginsFacade {
552
+ private readonly homeDir: string
553
+ private readonly profile: string
554
+ private readonly patchFile: PatchFileFacade
555
+ private readonly msg: MsgFunc
556
+ private readonly runner: typeof runDshPlugin
557
+
558
+ constructor(
559
+ homeDir: string,
560
+ profile: string,
561
+ patchFile: PatchFileFacade,
562
+ runner: typeof runDshPlugin = runDshPlugin,
563
+ msg: MsgFunc = zhMsg,
564
+ ) {
565
+ this.homeDir = homeDir
566
+ this.profile = profile
567
+ this.patchFile = patchFile
568
+ this.runner = runner
569
+ this.msg = msg
570
+ }
571
+
572
+ async listInstalled(): Promise<PluginInfo[]> {
573
+ return listInstalledPlugins(this.homeDir, this.profile)
574
+ }
575
+
576
+ async install(pkg: string, spec?: string, signal?: AbortSignal): Promise<{ needsRestart: boolean }> {
577
+ const profileDir = resolveProfileDir(this.homeDir, this.profile)
578
+ // 非 registry 来源(github:/git+/file: 等)按来源 spec 安装;registry 包按裸包名装
579
+ // npm 最新版(官方机制)。spec 丢失(旧备份)时退化为裸包名 → pnpm fetch-404,
580
+ // 由 installErrorFor 给出可操作诊断。
581
+ const result = await this.runner(profileDir, this.profile, ['add', installSpecFor(pkg, spec)], undefined, signal)
582
+ // 用户「跳过当前插件」:宿主 kill 了子进程 → 清理半装状态(删依赖行 + 删 node_modules/<pkg>,
583
+ // 防止「package.json 声明了依赖但没装全」导致 DSH 启动失败),再以跳过语义抛错。
584
+ if (result.aborted || (signal !== undefined && signal.aborted)) {
585
+ cleanupAbortedInstall(profileDir, pkg)
586
+ throw new ImportUserSkippedError(this.msg)
587
+ }
588
+ if (result.exitCode !== 0 || result.timedOut) throw installErrorFor(pkg, result)
589
+ // 非 bundle 插件:CLI 只维护 bundles,需补 profile patch 激活行才能加载。
590
+ // 补写失败不吞:包已装但未激活,明确报错并允许重试(幂等补行)。
591
+ try {
592
+ await ensureActivationRow(this.patchFile, join(profileDir, 'node_modules', pkg), pkg)
593
+ } catch (error) {
594
+ const reason = error instanceof Error ? error.message : String(error)
595
+ throw new Error(this.msg('host.activationRowFailed', { pkg, reason }))
596
+ }
597
+ return { needsRestart: true }
598
+ }
599
+ }
600
+
601
+ /** Workspace facade over the real ctx.workspaceRegistry. */
602
+ class DshWorkspaceFacade implements WorkspaceFacade {
603
+ private readonly ctx: Context
604
+ private readonly msg: MsgFunc
605
+
606
+ constructor(ctx: Context, msg: MsgFunc = zhMsg) {
607
+ this.ctx = ctx
608
+ this.msg = msg
609
+ }
610
+
611
+ private registry(): { list(): { id: unknown; path: string; title: string; sessionIds: readonly unknown[]; createdAt: string; updatedAt: string }[]; get(id: unknown): { title: string; setTitle(title: string): Promise<void> } | undefined; create(path: string, title?: string): Promise<unknown>; delete(id: unknown): Promise<boolean> } | undefined {
612
+ return readService(this.ctx, 'workspaceRegistry')
613
+ }
614
+
615
+ async listRecords(): Promise<WorkspaceRecord[]> {
616
+ const registry = this.registry()
617
+ if (!registry) return []
618
+ return registry.list().map((w) => ({
619
+ id: String(w.id),
620
+ path: w.path,
621
+ title: w.title,
622
+ sessionIds: [...w.sessionIds].map(String),
623
+ createdAt: w.createdAt,
624
+ updatedAt: w.updatedAt,
625
+ }))
626
+ }
627
+
628
+ async writeRecord(record: WorkspaceRecord): Promise<void> {
629
+ const registry = this.registry()
630
+ if (!registry) throw new Error(this.msg('host.workspaceUnavailable'))
631
+ const existing = registry.get(record.id as unknown as WorkspaceId)
632
+ if (existing) {
633
+ // DSH API 没有「整体覆盖」写通道:标题可更新;path/会话由 registry 依真实目录维护
634
+ if (record.title !== undefined && existing.title !== record.title) await existing.setTitle(record.title)
635
+ return
636
+ }
637
+ await registry.create(record.path, record.title)
638
+ }
639
+
640
+ async removeRecord(id: string): Promise<void> {
641
+ const registry = this.registry()
642
+ if (!registry) return
643
+ await registry.delete(id as unknown as WorkspaceId)
644
+ }
645
+ }
646
+
647
+ /** Profile 目录内的 patch 文件(非 bundle 插件激活行写入处,marketplace 同款路径)。 */
648
+ const PROFILE_PATCH_FILE = 'cordis.patch.yml'
649
+
650
+ /** Patch-file facade:用户 patch 层($DSH_HOME/cordis.patch.yml)+ profile patch 层
651
+ * ($DSH_HOME/profiles/<name>/cordis.patch.yml),两者都在 home 根内。 */
652
+ class DshPatchFileFacade implements PatchFileFacade {
653
+ private readonly homeDir: string
654
+ private readonly profile: string
655
+ private readonly msg: MsgFunc
656
+
657
+ constructor(homeDir: string, profile: string, msg: MsgFunc = zhMsg) {
658
+ this.homeDir = homeDir
659
+ this.profile = profile
660
+ this.msg = msg
661
+ }
662
+
663
+ private patchPath(file: string): string {
664
+ if (file === USER_PATCH_FILE) return join(this.homeDir, USER_PATCH_FILE)
665
+ if (file === PROFILE_PATCH_FILE) return join(this.homeDir, 'profiles', this.profile, PROFILE_PATCH_FILE)
666
+ throw new Error(this.msg('host.patchUnsupported', { user: USER_PATCH_FILE, profile: PROFILE_PATCH_FILE, file }))
667
+ }
668
+
669
+ async readPatchLines(file: string): Promise<{ lineId: string; raw: unknown }[]> {
670
+ const p = this.patchPath(file)
671
+ let text: string
672
+ try {
673
+ text = await fs.readFile(p, 'utf8')
674
+ } catch {
675
+ return []
676
+ }
677
+ let doc: unknown
678
+ try {
679
+ doc = yaml.load(text)
680
+ } catch {
681
+ return []
682
+ }
683
+ if (!Array.isArray(doc)) return []
684
+ const lines: { lineId: string; raw: unknown }[] = []
685
+ for (const item of doc) {
686
+ if (item === null || typeof item !== 'object') continue
687
+ const obj = item as Record<string, unknown>
688
+ const insert = obj['insert']
689
+ if (Array.isArray(insert)) {
690
+ for (const entry of insert) {
691
+ if (entry === null || typeof entry !== 'object') continue
692
+ const id = (entry as Record<string, unknown>)['id']
693
+ if (typeof id === 'string' && id !== '') lines.push({ lineId: id, raw: entry })
694
+ }
695
+ continue
696
+ }
697
+ const id = obj['id']
698
+ if (typeof id === 'string' && id !== '') lines.push({ lineId: id, raw: obj })
699
+ }
700
+ return lines
701
+ }
702
+
703
+ async applyPatchChanges(
704
+ file: string,
705
+ changes: { lineId: string; raw: unknown; action: 'insert' | 'update' | 'remove' }[],
706
+ ): Promise<void> {
707
+ const p = this.patchPath(file)
708
+
709
+ // 1. Load the current document into an ordered lineId → raw table.
710
+ const rows = new Map<string, unknown>()
711
+ const order: string[] = []
712
+ let doc: unknown
713
+ try {
714
+ doc = yaml.load(await fs.readFile(p, 'utf8'))
715
+ } catch {
716
+ doc = undefined
717
+ }
718
+ if (Array.isArray(doc)) {
719
+ for (const item of doc) {
720
+ if (item === null || typeof item !== 'object') continue
721
+ const obj = item as Record<string, unknown>
722
+ const insert = obj['insert']
723
+ if (Array.isArray(insert)) {
724
+ for (const entry of insert) {
725
+ if (entry === null || typeof entry !== 'object') continue
726
+ const id = (entry as Record<string, unknown>)['id']
727
+ if (typeof id === 'string' && id !== '' && !rows.has(id)) {
728
+ rows.set(id, entry)
729
+ order.push(id)
730
+ }
731
+ }
732
+ continue
733
+ }
734
+ const id = obj['id']
735
+ if (typeof id === 'string' && id !== '' && !rows.has(id)) {
736
+ rows.set(id, obj)
737
+ order.push(id)
738
+ }
739
+ }
740
+ }
741
+
742
+ // 2. Apply the changes.
743
+ for (const change of changes) {
744
+ if (change.action === 'remove') {
745
+ if (rows.delete(change.lineId)) {
746
+ const at = order.indexOf(change.lineId)
747
+ if (at >= 0) order.splice(at, 1)
748
+ }
749
+ } else if (change.action === 'insert' || change.action === 'update') {
750
+ if (!rows.has(change.lineId)) order.push(change.lineId)
751
+ rows.set(change.lineId, change.raw)
752
+ }
753
+ }
754
+
755
+ // 3. Rebuild: every id row is emitted as a top-level row. The loader treats
756
+ // a top-level { id, name } row exactly like an `- insert:` block member
757
+ // (dsh-base patch precedent), so the document stays semantically equal.
758
+ const out: unknown[] = []
759
+ for (const id of order) {
760
+ const raw = rows.get(id)
761
+ if (raw !== undefined) out.push(raw)
762
+ }
763
+ const text = '# rewritten by dsh-config-manager import (original comments not preserved)\n'
764
+ + yaml.dump(out)
765
+ await atomicWriteFile(p, text)
766
+ }
767
+ }
768
+
769
+ /** File facade over $DSH_HOME, confined to the home root. */
770
+ class DshFileSystemFacade implements FileSystemFacade {
771
+ private readonly homeDir: string
772
+ private readonly msg: MsgFunc
773
+
774
+ constructor(homeDir: string, msg: MsgFunc = zhMsg) {
775
+ this.homeDir = homeDir
776
+ this.msg = msg
777
+ }
778
+
779
+ private abs(relPath: string): string {
780
+ const target = resolve(isAbsolute(relPath) ? relPath : join(this.homeDir, relPath))
781
+ if (!isSameOrChild(target, this.homeDir)) throw new Error(this.msg('host.fsPathEscape', { path: relPath }))
782
+ return target
783
+ }
784
+
785
+ async readFile(relPath: string): Promise<Uint8Array> {
786
+ return fs.readFile(this.abs(relPath))
787
+ }
788
+
789
+ async writeFile(relPath: string, data: Uint8Array): Promise<void> {
790
+ // 原子写(Phase 1):同目录 tmp + fsync + rename,覆盖所有经 HostContext.fs 的配置写
791
+ await atomicWriteFile(this.abs(relPath), data)
792
+ }
793
+
794
+ async exists(relPath: string): Promise<boolean> {
795
+ try {
796
+ await fs.access(this.abs(relPath))
797
+ return true
798
+ } catch {
799
+ return false
800
+ }
801
+ }
802
+
803
+ async copy(from: string, to: string): Promise<void> {
804
+ await atomicCopyFile(this.abs(from), this.abs(to))
805
+ }
806
+
807
+ async remove(relPath: string): Promise<void> {
808
+ await fs.rm(this.abs(relPath), { recursive: true, force: true })
809
+ }
810
+
811
+ /** 仅路径列表(既有契约):委托 listRecursiveDetailed,丢弃诊断。 */
812
+ async listRecursive(dir: string): Promise<string[]> {
813
+ return (await this.listRecursiveDetailed(dir)).paths
814
+ }
815
+
816
+ /**
817
+ * 跟随 junction / 符号链接的遍历 + 被跳过链接清单(issue #37)。
818
+ * 实现下沉到 utils/recursive-walk.ts(可用真实临时目录直接单测)。
819
+ */
820
+ async listRecursiveDetailed(dir: string): Promise<RecursiveListing> {
821
+ return listRecursiveFollowingLinks(this.abs(dir), this.homeDir)
822
+ }
823
+
824
+ async mkdir(dir: string): Promise<void> {
825
+ await fs.mkdir(this.abs(dir), { recursive: true })
826
+ }
827
+ }
828
+
829
+ /** The engine's HostContext over real DSH services. */
830
+ class ConfigManagerHostContext implements HostContext {
831
+ readonly platform: string = process.platform
832
+ readonly arch: string = process.arch
833
+ readonly homeDir: string
834
+ readonly dshVersion: string
835
+ readonly profile: string
836
+ readonly log: Logger
837
+ readonly msg: MsgFunc
838
+ /** 应用语言(resolveAppLanguage;导出历史报告 locale 用) */
839
+ readonly language: 'zh' | 'en'
840
+ readonly settings: SettingsFacade
841
+ readonly credentials: CredentialsFacade
842
+ readonly plugins: PluginsFacade
843
+ readonly workspace: WorkspaceFacade
844
+ readonly patchFile: PatchFileFacade
845
+ readonly fs: FileSystemFacade
846
+ /** Phase 2 跨进程环境锁端口(宿主注入;测试 mock 不注入 → 无锁环境) */
847
+ mutationLock?: MutationLockPort
848
+ /** Phase 3 SAFE MODE:注入同步谓词(读内存标志,供 withMutationLock isBlocked 用;env-lock 不识 policy) */
849
+ safeModeIsBlocked?: () => boolean
850
+ /** Phase 3 恢复/事务(JournalStore + reconcile + SAFE MODE + runJournaled)。apply() 注入。 */
851
+ phase3Recovery?: import('./core/phase3-host.ts').Phase3Recovery
852
+
853
+ constructor(ctx: Context, homeDir: string, profile: string) {
854
+ this.homeDir = homeDir
855
+ this.dshVersion = resolveDshVersion(homeDir)
856
+ this.profile = profile
857
+ this.language = resolveAppLanguage(ctx)
858
+ this.msg = makeMsg(this.language)
859
+ const level = process.env.DSH_CONFIG_MANAGER_LOG_LEVEL
860
+ this.log = createLogger({
861
+ level: level === 'debug' || level === 'info' || level === 'warn' || level === 'error' ? level : 'info',
862
+ })
863
+ this.settings = new DshSettingsFacade(ctx)
864
+ this.credentials = new DshCredentialsFacade(ctx)
865
+ this.patchFile = new DshPatchFileFacade(homeDir, profile, this.msg)
866
+ this.plugins = new DshPluginsFacade(homeDir, profile, this.patchFile, undefined, this.msg)
867
+ this.workspace = new DshWorkspaceFacade(ctx, this.msg)
868
+ this.fs = new DshFileSystemFacade(homeDir, this.msg)
869
+ }
870
+ }
871
+
872
+ /* ---------------------------------------------------------------- routes */
873
+
874
+ /** Controlled staging roots guard. */
875
+ function isControlledPath(target: string, roots: string[]): boolean {
876
+ const t = resolve(target)
877
+ return roots.some((root) => isSameOrChild(t, resolve(root)))
878
+ }
879
+
880
+ function dateStamp(d: Date = new Date()): string {
881
+ const y = d.getFullYear()
882
+ const m = String(d.getMonth() + 1).padStart(2, '0')
883
+ const day = String(d.getDate()).padStart(2, '0')
884
+ return `${y}-${m}-${day}`
885
+ }
886
+
887
+ /** Minimal real dependency check (MCP §15): `which`/`where` probe. */
888
+ async function dependencyAvailable(command: string): Promise<boolean> {
889
+ const probe = process.platform === 'win32' ? 'where' : 'which'
890
+ try {
891
+ await promisify(execFile)(probe, [command], { windowsHide: true })
892
+ return true
893
+ } catch {
894
+ return false
895
+ }
896
+ }
897
+
898
+ /** 导出/导入执行超时(ms)。正常导出秒级完成;此上限只兜底「宿主卡死」场景,
899
+ * 让客户端拿到明确错误而不是永远停在进度条。 */
900
+ const ROUTE_TIMEOUT_MS = 5 * 60 * 1000
901
+
902
+ /** WebDAV 单请求超时(ms):慢速 WebDAV(如坚果云限速)上传大快照/读写索引
903
+ * 需要比 git 通道更宽裕的窗口;错误消息会带上实际 ms,便于用户判断。 */
904
+ const WEBDAV_TIMEOUT_MS = 120_000
905
+
906
+ /** 带超时的 Promise:超时以明确错误拒绝(promise 自身由调用方负责,此处只计时)。 */
907
+ async function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
908
+ let timer: ReturnType<typeof setTimeout> | undefined
909
+ const timeout = new Promise<never>((_resolve, reject) => {
910
+ timer = setTimeout(() => reject(new Error(message)), ms)
911
+ })
912
+ try {
913
+ return await Promise.race([promise, timeout])
914
+ } finally {
915
+ if (timer !== undefined) clearTimeout(timer)
916
+ }
917
+ }
918
+
919
+ /** Decrypt an encrypted backup's credentials (in-memory only; undefined when not applicable). */
920
+ async function tryDecryptCredentials(
921
+ zipPath: string,
922
+ password: string | undefined,
923
+ ): Promise<Map<string, string> | undefined> {
924
+ if (password === undefined || password === '') return undefined
925
+ const raw = await fs.readFile(zipPath)
926
+ const archive = parseZip(raw)
927
+ if (!archive.has(MANIFEST_FILE)) return undefined
928
+ let manifest: Manifest
929
+ try {
930
+ manifest = parseManifest(archive.readEntryText(MANIFEST_FILE))
931
+ } catch {
932
+ return undefined
933
+ }
934
+ if (!manifest.security.encrypted || manifest.security.encryption === null) return undefined
935
+ if (!archive.has('security/secrets.enc')) return undefined
936
+ const blob = archive.readEntry('security/secrets.enc')
937
+ const plaintext = await decryptCredentials(blob, manifest.security.encryption, password)
938
+ let parsed: unknown
939
+ try {
940
+ parsed = yaml.load(plaintext)
941
+ } catch {
942
+ return undefined
943
+ }
944
+ const map = new Map<string, string>()
945
+ if (parsed !== null && typeof parsed === 'object') {
946
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
947
+ if (typeof v === 'string' && v !== '') map.set(k, v)
948
+ }
949
+ }
950
+ return map
951
+ }
952
+
953
+ /** 解密错误 → 用户可读文本:BAD_PASSWORD 只报「密码错误」(不泄内部细节),其余原文 */
954
+ function decryptErrorText(error: unknown, msg: MsgFunc): string {
955
+ if (error instanceof SecurityError && error.code === 'BAD_PASSWORD') {
956
+ return msg('import.encryptedPasswordWrong')
957
+ }
958
+ return error instanceof Error ? error.message : String(error)
959
+ }
960
+
961
+ interface RoutesDeps {
962
+ host: ConfigManagerHostContext
963
+ adapters: ConfigAdapter[]
964
+ exportsDir: string
965
+ tmpDir: string
966
+ snapshotsDir: string
967
+ /** m1:导出/导入 run 注册表(跨请求共享,/progress 与 /runs 的单一事实源) */
968
+ runs: RunRegistry
969
+ /** m-sync-ui:同步状态/配置目录($DSH_HOME/dsh-config-manager/sync) */
970
+ syncDir: string
971
+ /** m-market:市场目录($DSH_HOME/dsh-config-manager/market;其下 config/ 与 cache/) */
972
+ /** 插件数据根目录($DSH_HOME/dsh-config-manager;F1 vault 镜像目录 = <dataDir>/vault) */
973
+ dataDir: string
974
+ /** F2 强化 Secret 扫描器(含部署者 personalPatterns);缺省 = 默认扫描器 */
975
+ scanner?: SecretScanner
976
+ /** m-sync-ui:原始 DSH credentials(resolve token / set token / describe 状态) */
977
+ credentials: CredentialProvider
978
+ /** m-github-oauth:GitHub OAuth App 凭据(device flow 必需 client_id;client_secret 可选) */
979
+ githubClientId?: string
980
+ githubClientSecret?: string
981
+ /** Phase 6:迁移历史存储(统一审计史;<dataDir>/migration-history) */
982
+ history: MigrationStore
983
+ }
984
+
985
+ /* -------------------------------------------------- sync 路由(m-sync-ui) */
986
+
987
+ /** 同步路由可预期的请求级错误(status 缺省 400;引擎/传输失败走 500) */
988
+ export class SyncRouteError extends Error {
989
+ readonly status: number
990
+
991
+ constructor(message: string, status: number = 400) {
992
+ super(message)
993
+ this.name = 'SyncRouteError'
994
+ this.status = status
995
+ }
996
+ }
997
+
998
+ /** 同步路由错误统一出口:SyncRouteError 用其 status,其余 500(GitTransport 错误消息已脱敏) */
999
+ export function writeSyncRouteError(res: ServerResponse, error: unknown): void {
1000
+ if (error instanceof SyncRouteError) {
1001
+ writeJson(res, error.status, { error: error.message })
1002
+ return
1003
+ }
1004
+ const message = error instanceof Error ? error.message : String(error)
1005
+ writeJson(res, 500, { error: message })
1006
+ }
1007
+
1008
+ /** parseSyncBody 的凭据写入依赖(只用到 set;测试可注入内存 mock)。 */
1009
+ export interface ParseSyncBodyDeps {
1010
+ credentials: Pick<CredentialProvider, 'set'>
1011
+ }
1012
+
1013
+ /**
1014
+ * 解析同步请求体,按 transport 分支返回归一化的 SyncConfig(可辨识联合,schemaVersion=2)。
1015
+ * 请求体形状(flat,M4 契约):
1016
+ * - git: { transport:'git', repoUrl, token? } —— token 非空写 SYNC_CREDENTIAL_REF;
1017
+ * git 可执行文件固定使用系统 PATH 中的 git(不再接受自定义 gitBin)。
1018
+ * - webdav: { transport:'webdav', url, username?, password? } —— password 非空写 SYNC_WEBDAV_CREDENTIAL_REF。
1019
+ * 返回值不含任何 secret(password/token 只进 credentials,永不回传/落同步文件)。
1020
+ */
1021
+ export async function parseSyncBody(
1022
+ body: Record<string, unknown>,
1023
+ deps: ParseSyncBodyDeps,
1024
+ ): Promise<SyncConfig> {
1025
+ const transport = body['transport'] === 'webdav' ? 'webdav' : 'git'
1026
+ if (transport === 'webdav') {
1027
+ const url = typeof body['url'] === 'string' ? body['url'].trim() : ''
1028
+ if (url === '') throw new SyncRouteError('url is required for webdav')
1029
+ const urlError = validateWebDavUrl(url)
1030
+ if (urlError !== null) throw new SyncRouteError(urlError)
1031
+ const username = typeof body['username'] === 'string' && body['username'] !== '' ? body['username'] : undefined
1032
+ const password = typeof body['password'] === 'string' && body['password'] !== '' ? body['password'] : undefined
1033
+ if (password !== undefined) {
1034
+ try {
1035
+ await deps.credentials.set(credentialRef(SYNC_WEBDAV_CREDENTIAL_REF), password)
1036
+ } catch (error) {
1037
+ const reason = error instanceof Error ? error.message : String(error)
1038
+ throw new SyncRouteError(
1039
+ `WebDAV 口令写入 DSH credentials 失败:${reason}(请在 DSH 凭据管理里配置 ${SYNC_WEBDAV_CREDENTIAL_REF} 后重试)`,
1040
+ )
1041
+ }
1042
+ }
1043
+ return {
1044
+ schemaVersion: 2,
1045
+ transport: 'webdav',
1046
+ webdav: { url, ...(username !== undefined ? { username } : {}) },
1047
+ }
1048
+ }
1049
+ // git 通道(沿用现有逻辑)
1050
+ const repoUrl = typeof body['repoUrl'] === 'string' ? body['repoUrl'].trim() : ''
1051
+ if (repoUrl === '') throw new SyncRouteError('repoUrl is required')
1052
+ const urlError = validateRepoUrl(repoUrl)
1053
+ if (urlError !== null) throw new SyncRouteError(urlError)
1054
+ const token = typeof body['token'] === 'string' && body['token'] !== '' ? body['token'] : undefined
1055
+ if (token !== undefined) {
1056
+ try {
1057
+ await deps.credentials.set(credentialRef(SYNC_CREDENTIAL_REF), token)
1058
+ } catch (error) {
1059
+ const reason = error instanceof Error ? error.message : String(error)
1060
+ throw new SyncRouteError(
1061
+ `token 写入 DSH credentials 失败:${reason}(请在 DSH 凭据管理里配置 ${SYNC_CREDENTIAL_REF} 后重试)`,
1062
+ )
1063
+ }
1064
+ }
1065
+ return {
1066
+ schemaVersion: 2,
1067
+ transport: 'git',
1068
+ git: { repoUrl },
1069
+ }
1070
+ }
1071
+
1072
+ /** 由 SyncConfig 合成 WebDAV 通道 baseUrl(webdav.url,尾部规范化带 '/';git 通道返回 '')。 */
1073
+ export function webdavBaseUrl(cfg: SyncConfig): string {
1074
+ if (!isWebDavConfig(cfg)) return ''
1075
+ return cfg.webdav.url.replace(/\/+$/, '') + '/'
1076
+ }
1077
+
1078
+ /**
1079
+ * 补全 webdav 配置缺失的 username(从持久化配置回填;纯函数,不修改入参)。
1080
+ * 语义与 password 一致:请求未带 username(表单留空/挂载自动加载)→ 沿用已保存的值;
1081
+ * 请求显式带 username → 原样保留(用户新输入优先)。非 webdav / 无持久化 → 原样返回。
1082
+ */
1083
+ export function mergePersistedWebDavUsername(cfg: SyncConfig, persisted: SyncConfig | null): SyncConfig {
1084
+ if (!isWebDavConfig(cfg) || (cfg.webdav.username !== undefined && cfg.webdav.username !== '')) return cfg
1085
+ if (persisted !== null && isWebDavConfig(persisted)
1086
+ && typeof persisted.webdav.username === 'string' && persisted.webdav.username !== '') {
1087
+ return { ...cfg, webdav: { ...cfg.webdav, username: persisted.webdav.username } }
1088
+ }
1089
+ return cfg
1090
+ }
1091
+
1092
+ /**
1093
+ * 解析 push 请求体的分区选择(sections)——「高级/自定义导出」模式负载。
1094
+ * - 缺省 / 非数组 / 空数组 → undefined(= 全部 portable 推荐分区,即「默认/快速导出」模式);
1095
+ * - 元素必须是 knownIds(已知 adapter id)中的非空字符串,非法 → SyncRouteError(不静默吞错);
1096
+ * - 返回去重后的数组(保持原顺序;重复分区不做重复导出)。
1097
+ */
1098
+ export function extractSyncSections(
1099
+ body: Record<string, unknown>,
1100
+ knownIds: ReadonlySet<string>,
1101
+ ): SectionId[] | undefined {
1102
+ const raw = body['sections']
1103
+ if (!Array.isArray(raw) || raw.length === 0) return undefined
1104
+ const out: SectionId[] = []
1105
+ const seen = new Set<string>()
1106
+ for (const item of raw) {
1107
+ if (typeof item !== 'string' || item === '') {
1108
+ throw new SyncRouteError('sections must be an array of non-empty strings')
1109
+ }
1110
+ if (!knownIds.has(item)) {
1111
+ throw new SyncRouteError(`unknown sync section: ${item}`)
1112
+ }
1113
+ if (!seen.has(item)) {
1114
+ seen.add(item)
1115
+ out.push(item as SectionId)
1116
+ }
1117
+ }
1118
+ return out
1119
+ }
1120
+
1121
+ /** 需要人工决策的 PlanItemKind(一键同步 needsReview 判定 + 逐项确认标记)。
1122
+ * 注意:'Install'(安装插件)不在此列 —— 同步拉取差异时插件按「自动安装」处理:
1123
+ * 默认采纳、不逐项展示、无需手动选择(product requirement)。
1124
+ * issue #35:'Warning' 必须**可见**——它承载「本次同步会剔除哪些无法满足的声明」这类
1125
+ * 改变配置语义的信息;此前非决策项默认自动采用且不展示,用户只看到「同步成功」。 */
1126
+ const REVIEW_KINDS: ReadonlySet<PlanItemKind> = new Set([
1127
+ 'Conflict', 'MissingSecret', 'MissingDependency', 'Error', 'PathMapping', 'Warning',
1128
+ ])
1129
+
1130
+ /**
1131
+ * issue #35:会**改变工具链行为**的项 —— 只有 pnpm-workspace.yaml 在本次同步中
1132
+ * 移除了无法满足的 patchedDependencies 声明时,才带 detail。
1133
+ * 这类项此前属「非冲突项 → 自动采用且不展示」,用户即使已知风险也无法否决
1134
+ * (issue #35 正是这条自动采用把目标机 pnpm 弄坏的)。
1135
+ * 现在:进确认列表、可见、可取消;但**默认仍采用**(我们的 sanitize 结果严格更安全,
1136
+ * 默认不采用反而会静默丢掉 allowBuilds / 冷静期配置)。
1137
+ * 注意:与客户端 sync-view.ts 的同名判定必须保持一致(两侧刻意重复,避免跨端 import)。
1138
+ */
1139
+ function isToolchainChangeItem(item: { itemId: string; detail?: string | undefined }): boolean {
1140
+ return item.itemId === 'plugins:pnpm-workspace' && item.detail !== undefined && item.detail !== ''
1141
+ }
1142
+
1143
+ /** 一键同步差异项(client 逐项确认的最小契约;与 sync-api.ts SyncConfirmItem 对齐) */
1144
+ interface SyncConfirmItem {
1145
+ itemId: string
1146
+ adapter: SectionId
1147
+ kind: PlanItemKind
1148
+ description: string
1149
+ /** 变更详情(如插件「当前 1.1 vs 导入 1.6」),与导入恢复向导展示一致 */
1150
+ detail?: string
1151
+ severity: 'info' | 'warning' | 'error'
1152
+ defaultAdopt: boolean
1153
+ adopt: boolean
1154
+ conflict?: { path: string; kind: 'key' | 'file' | 'section'; local?: unknown; remote?: unknown; ancestor?: unknown; diff?: string }
1155
+ target?: { adapter: SectionId; ref: string }
1156
+ }
1157
+
1158
+ /** 把 ImportPlan 投影为逐项可确认的差异项(默认采用 Create/Update/Install;人工项默认不采用)。 */
1159
+ function planToConfirmItems(plan: ImportPlan): SyncConfirmItem[] {
1160
+ return plan.items.map((item) => {
1161
+ const manual = REVIEW_KINDS.has(item.kind)
1162
+ let conflict: SyncConfirmItem['conflict']
1163
+ if (item.kind === 'Conflict') {
1164
+ const c = (item as { conflict?: { path?: string; kind?: string; local?: unknown; remote?: unknown; ancestor?: unknown } }).conflict
1165
+ conflict = {
1166
+ path: c?.path ?? '$',
1167
+ kind: c?.kind === 'file' ? 'file' : c?.kind === 'section' ? 'section' : 'key',
1168
+ ...(c?.local !== undefined ? { local: c.local } : {}),
1169
+ ...(c?.remote !== undefined ? { remote: c.remote } : {}),
1170
+ ...(c?.ancestor !== undefined ? { ancestor: c.ancestor } : {}),
1171
+ }
1172
+ }
1173
+ return {
1174
+ itemId: item.id,
1175
+ adapter: item.adapter,
1176
+ kind: item.kind,
1177
+ description: item.description,
1178
+ detail: item.detail,
1179
+ severity: item.severity,
1180
+ defaultAdopt: !manual,
1181
+ adopt: !manual,
1182
+ ...(conflict !== undefined ? { conflict } : {}),
1183
+ ...(item.target !== undefined ? { target: item.target } : {}),
1184
+ }
1185
+ })
1186
+ }
1187
+
1188
+ /** autosync interval 类型守卫 */
1189
+ function isAutosyncInterval(v: unknown): v is AutosyncInterval {
1190
+ return v === '5m' || v === '15m' || v === '30m' || v === '60m' || v === '6h' || v === '12h' || v === '24h'
1191
+ }
1192
+
1193
+ /** 自动同步状态响应(GET /sync/autosync 与 POST 回填;读盘计算 elapsedMs)。 */
1194
+ async function buildAutosyncStatus(dir: string, channel: SyncTransportType): Promise<AutosyncStatusResponse> {
1195
+ const cfg = await readAutosyncConfig(dir, channel)
1196
+ const elapsedMs = cfg.lastRunAt === undefined || cfg.lastRunAt === ''
1197
+ ? -1
1198
+ : Math.max(0, Date.now() - Date.parse(cfg.lastRunAt))
1199
+ return {
1200
+ enabled: cfg.enabled,
1201
+ interval: cfg.interval,
1202
+ ...(cfg.lastRunAt !== undefined ? { lastRunAt: cfg.lastRunAt } : {}),
1203
+ ...(cfg.lastRunStatus !== undefined ? { lastRunStatus: cfg.lastRunStatus } : {}),
1204
+ ...(cfg.lastRunMessage !== undefined ? { lastRunMessage: cfg.lastRunMessage } : {}),
1205
+ consecutiveFailures: cfg.consecutiveFailures,
1206
+ elapsedMs,
1207
+ ...(cfg.lastRunHistoryId !== undefined ? { lastRunHistoryId: cfg.lastRunHistoryId } : {}),
1208
+ }
1209
+ }
1210
+
1211
+ /** 全部通道的自动同步状态(status 路由一次返回;UI 按当前 tab 取对应通道)。 */
1212
+ async function buildAutosyncStatusByChannel(dir: string): Promise<Record<SyncTransportType, AutosyncStatusResponse>> {
1213
+ const all = await readAllAutosyncConfigs(dir)
1214
+ const build = async (channel: SyncTransportType): Promise<AutosyncStatusResponse> => {
1215
+ const cfg = all[channel]
1216
+ const elapsedMs = cfg.lastRunAt === undefined || cfg.lastRunAt === ''
1217
+ ? -1
1218
+ : Math.max(0, Date.now() - Date.parse(cfg.lastRunAt))
1219
+ return {
1220
+ enabled: cfg.enabled,
1221
+ interval: cfg.interval,
1222
+ ...(cfg.lastRunAt !== undefined ? { lastRunAt: cfg.lastRunAt } : {}),
1223
+ ...(cfg.lastRunStatus !== undefined ? { lastRunStatus: cfg.lastRunStatus } : {}),
1224
+ ...(cfg.lastRunMessage !== undefined ? { lastRunMessage: cfg.lastRunMessage } : {}),
1225
+ consecutiveFailures: cfg.consecutiveFailures,
1226
+ elapsedMs,
1227
+ ...(cfg.lastRunHistoryId !== undefined ? { lastRunHistoryId: cfg.lastRunHistoryId } : {}),
1228
+ }
1229
+ }
1230
+ return { git: await build('git'), webdav: await build('webdav') }
1231
+ }
1232
+
1233
+ /** GET /sync/autosync 响应类型(与 sync-api.ts AutosyncStatusResponse 对齐) */
1234
+ interface AutosyncStatusResponse {
1235
+ enabled: boolean
1236
+ interval: AutosyncInterval
1237
+ lastRunAt?: string
1238
+ lastRunStatus?: AutosyncRunStatus
1239
+ lastRunMessage?: string
1240
+ consecutiveFailures: number
1241
+ elapsedMs: number
1242
+ lastRunHistoryId?: string
1243
+ }
1244
+
1245
+ /* -------------------------------------------------- restore 路由(M4) */
1246
+
1247
+ /** POST /restore 请求体校验(纯函数;snapshotId 拒绝路径分隔符防 join 越界)。 */
1248
+ export type BuildRestoreBodyResult =
1249
+ | { ok: true; value: { snapshotId: string; dryRun: boolean } }
1250
+ | { ok: false; error: string }
1251
+
1252
+ export function buildRestoreBody(body: unknown): BuildRestoreBodyResult {
1253
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
1254
+ return { ok: false, error: 'invalid JSON body' }
1255
+ }
1256
+ const record = body as Record<string, unknown>
1257
+ const snapshotId = record['snapshotId']
1258
+ if (typeof snapshotId !== 'string' || snapshotId === '') {
1259
+ return { ok: false, error: 'snapshotId is required' }
1260
+ }
1261
+ if (snapshotId === '.' || snapshotId === '..' || snapshotId.includes('/') || snapshotId.includes('\\')) {
1262
+ return { ok: false, error: zhMsg('restore.invalidSnapshotId') }
1263
+ }
1264
+ return { ok: true, value: { snapshotId, dryRun: record['dryRun'] === true } }
1265
+ }
1266
+
1267
+ /**
1268
+ * 宿主侧恢复动作执行器(真实执行 restore 计划):
1269
+ * 整文件/文件还原与删除走 ctx.fs(home-relative facade,越界由 facade 再拦一道),
1270
+ * blob 读取与 pre-restore 副本走快照目录(node fs),插件卸载走官方 dsh plugin CLI。
1271
+ */
1272
+ export interface RestoreExecutor {
1273
+ /** 读快照目录内 blob(相对 snapshotDir) */
1274
+ readBlob(blobPath: string): Promise<Uint8Array>
1275
+ /** 把当前 home 文件内容复制到 <snapshotDir>/pre-restore/(覆盖/删除前的双保险) */
1276
+ savePreRestore(relPath: string): Promise<void>
1277
+ existsHome(relPath: string): Promise<boolean>
1278
+ writeHome(relPath: string, data: Uint8Array): Promise<void>
1279
+ removeHome(relPath: string): Promise<void>
1280
+ /** 卸载插件(官方通道);失败返回 { ok:false, message } */
1281
+ uninstallPlugin(name: string): Promise<{ ok: boolean; message?: string }>
1282
+ }
1283
+
1284
+ /**
1285
+ * 按计划执行恢复动作(纯执行器;逐项 try/catch 不拖垮其余),
1286
+ * 返回与 CLI 一致的诚实报告。顺序 = 计划顺序(整文件 → 插件 → file 补偿)。
1287
+ * @param onAction - 每项动作执行回调(宿主路由埋点:更新 RunRegistry 进度;
1288
+ * index/1-based、total=计划动作数、detail=动作描述)
1289
+ */
1290
+ export async function executeRestorePlan(
1291
+ plan: RestorePlan,
1292
+ exec: RestoreExecutor,
1293
+ onAction?: (info: { index: number; total: number; detail: string }) => void,
1294
+ ): Promise<RestoreReport> {
1295
+ const report: RestoreReport = {
1296
+ snapshotId: plan.snapshotId,
1297
+ restored: [],
1298
+ removedPlugins: [],
1299
+ manualHints: [],
1300
+ failed: [],
1301
+ skipped: [],
1302
+ }
1303
+ const total = plan.actions.length
1304
+ let index = 0
1305
+ for (const action of plan.actions) {
1306
+ index += 1
1307
+ onAction?.({ index, total, detail: action.description })
1308
+ try {
1309
+ switch (action.kind) {
1310
+ case 'hostFileRestore':
1311
+ case 'fileRestore': {
1312
+ if (action.target === undefined || action.blobPath === undefined) {
1313
+ throw new Error('恢复动作缺少 target/blobPath')
1314
+ }
1315
+ if (await exec.existsHome(action.target)) await exec.savePreRestore(action.target)
1316
+ await exec.writeHome(action.target, await exec.readBlob(action.blobPath))
1317
+ report.restored.push(action.target)
1318
+ break
1319
+ }
1320
+ case 'hostFileRemove':
1321
+ case 'fileRemove': {
1322
+ if (action.target === undefined) throw new Error('恢复动作缺少 target')
1323
+ if (await exec.existsHome(action.target)) {
1324
+ await exec.savePreRestore(action.target)
1325
+ await exec.removeHome(action.target)
1326
+ }
1327
+ report.restored.push(action.target)
1328
+ break
1329
+ }
1330
+ case 'pluginRemove': {
1331
+ if (action.pluginName === undefined) throw new Error('恢复动作缺少插件名')
1332
+ const result = await exec.uninstallPlugin(action.pluginName)
1333
+ if (result.ok) {
1334
+ report.removedPlugins.push(action.pluginName)
1335
+ } else {
1336
+ report.failed.push({ item: `plugin:${action.pluginName}`, reason: result.message ?? '卸载失败' })
1337
+ }
1338
+ break
1339
+ }
1340
+ case 'credentialHint':
1341
+ report.manualHints.push(action.manualHint ?? action.description)
1342
+ break
1343
+ case 'skip':
1344
+ report.skipped.push(action.description)
1345
+ break
1346
+ default:
1347
+ report.skipped.push(`未知动作 ${String(action.kind)}: ${action.description}`)
1348
+ }
1349
+ } catch (err) {
1350
+ report.failed.push({
1351
+ item: action.target ?? action.pluginName ?? action.description,
1352
+ reason: err instanceof Error ? err.message : String(err),
1353
+ })
1354
+ }
1355
+ }
1356
+ return report
1357
+ }
1358
+
1359
+ /** 宿主 restore 执行器装配:ctx.fs(home-relative)+ 快照目录(node fs)+ runDshPlugin。 */
1360
+ function makeRestoreExecutor(snapshotDir: string, host: HostContext, profile: string): RestoreExecutor {
1361
+ const profileDir = resolveProfileDir(host.homeDir, profile)
1362
+ let seq = 0
1363
+ return {
1364
+ readBlob: async (blobPath) => {
1365
+ const target = resolve(snapshotDir, blobPath)
1366
+ if (!isSameOrChild(target, snapshotDir)) throw new Error(msgOf(host)('host.restoreBlobEscape', { blob: blobPath }))
1367
+ return fs.readFile(target)
1368
+ },
1369
+ savePreRestore: async (relPath) => {
1370
+ const data = await host.fs.readFile(relPath)
1371
+ seq += 1
1372
+ const safe = relPath.replace(/[\\/:*?"<>|]/g, '_')
1373
+ await fs.mkdir(join(snapshotDir, 'pre-restore'), { recursive: true })
1374
+ await fs.writeFile(join(snapshotDir, 'pre-restore', `${String(seq).padStart(4, '0')}-${safe}`), data)
1375
+ },
1376
+ existsHome: (relPath) => host.fs.exists(relPath),
1377
+ writeHome: (relPath, data) => host.fs.writeFile(relPath, data),
1378
+ removeHome: (relPath) => host.fs.remove(relPath),
1379
+ uninstallPlugin: async (name) => {
1380
+ const result = await runDshPlugin(profileDir, profile, ['remove', name])
1381
+ if (result.exitCode === 0) return { ok: true }
1382
+ const output = `${result.stderr}\n${result.stdout}`.trim()
1383
+ const tail = output.split('\n').slice(-8).join('\n') || msgOf(host)('host.restoreNoOutput')
1384
+ return { ok: false, message: msgOf(host)('host.restoreUninstallFailed', { name, code: String(result.exitCode), tail }) }
1385
+ },
1386
+ }
1387
+ }
1388
+
1389
+ /**
1390
+ * 解析「一键上传/我的配置」请求体的 form 字段:仅 { name, description?, categories?, mode? }。
1391
+ * name 必填(非空字符串,trim 后取);description 可选字符串;categories 可选字符串数组;
1392
+ * mode 可选 'migrate' | 'share'(F6 分享模式,非法值忽略→缺省 migrate)。非法 → null(调用方返回 400)。
1393
+ */
1394
+ function parseMeForm(raw: unknown): { name: string; id?: string; description?: string; categories?: string[]; mode?: 'migrate' | 'share' } | null {
1395
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
1396
+ const obj = raw as Record<string, unknown>
1397
+ const name = typeof obj['name'] === 'string' ? obj['name'].trim() : ''
1398
+ if (name === '') return null
1399
+ const form: { name: string; id?: string; description?: string; categories?: string[]; mode?: 'migrate' | 'share' } = { name }
1400
+ // update 模式的可选显式 id(「更新」按钮预填;upload 时省略)
1401
+ const idRaw = obj['id']
1402
+ if (typeof idRaw === 'string' && idRaw.trim() !== '') form.id = idRaw.trim()
1403
+ const description = obj['description']
1404
+ if (typeof description === 'string' && description.trim() !== '') form.description = description.trim()
1405
+ const categoriesRaw = obj['categories']
1406
+ if (Array.isArray(categoriesRaw)) {
1407
+ const categories = categoriesRaw.filter((c): c is string => typeof c === 'string' && c.trim() !== '')
1408
+ if (categories.length > 0) form.categories = categories
1409
+ }
1410
+ // F6 分享模式:仅接受字面量 'share' / 'migrate'(其余忽略 → 缺省 migrate),随 form 透传 MyRepoService
1411
+ if (obj['mode'] === 'share' || obj['mode'] === 'migrate') form.mode = obj['mode']
1412
+ return form
1413
+ }
1414
+
1415
+ /**
1416
+ * GitHub 凭据「缺失或失效」判定(issue #29):`no_token`(credentials 里从未配置 token)与
1417
+ * `unauthorized`(401,token 过期/被撤销)对用户都是同一个「未登录」,必须走同一分支——
1418
+ * 否则 `no_token` 会落到 500,UI 把「未登录」渲染成「登录状态读取失败」的误导性横幅。
1419
+ * 其余分类(network_error / rate_limited / server_error / validation_failed / fork_timeout…)
1420
+ * 是真实故障,仍按 500 暴露,绝不伪装成「未登录」。
1421
+ */
1422
+ export function isGitHubAuthMissing(error: unknown): boolean {
1423
+ return error instanceof GitHubAuthError && (error.code === 'unauthorized' || error.code === 'no_token')
1424
+ }
1425
+
1426
+ /** /status 的插件诊断位(issue #28)。仅回非敏感元信息:目录、profile 名、计数。 */
1427
+ export interface PluginDiagnostics {
1428
+ homeDir: string
1429
+ profile: string
1430
+ /** profile 目录的 package.json 是否可读(不可读 → 清单必然为空) */
1431
+ profileManifestReadable: boolean
1432
+ /** 插件清单来源 = package.json 的 dependencies 里非 in-box 的包 */
1433
+ installedPluginCount: number
1434
+ installedPluginNames: string[]
1435
+ /** dsh.profile.bundles 声明(非空即「替换默认插件栈」) */
1436
+ bundles: string[]
1437
+ }
1438
+
1439
+ /**
1440
+ * 读取插件诊断信息(issue #28):把「插件到底读了哪个目录 / 哪个 profile / 看到什么」变成
1441
+ * 用户可自查的数据——此前只存在于宿主内部,导致「装了插件却识别不到」无从定位。
1442
+ * best-effort:失败不抛出(诊断位缺失不应拖垮 /status)。
1443
+ */
1444
+ async function readPluginDiagnostics(host: HostContext): Promise<Partial<PluginDiagnostics>> {
1445
+ try {
1446
+ const profileDir = resolveProfileDir(host.homeDir, host.profile ?? 'web')
1447
+ const manifest = readProfileManifest(profileDir)
1448
+ const installed = await host.plugins.listInstalled()
1449
+ const bundles = manifest?.dsh?.profile?.bundles
1450
+ return {
1451
+ homeDir: host.homeDir,
1452
+ profile: host.profile ?? 'web',
1453
+ profileManifestReadable: manifest !== null,
1454
+ installedPluginCount: installed.length,
1455
+ installedPluginNames: installed.map((p) => p.name),
1456
+ bundles: Array.isArray(bundles) ? bundles : [],
1457
+ }
1458
+ } catch (err) {
1459
+ host.log.warn(`plugin diagnostics unavailable: ${err instanceof Error ? err.message : String(err)}`)
1460
+ return {}
1461
+ }
1462
+ }
1463
+
1464
+ /** Build the /api/dsh-config-manager route family. */
1465
+ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSyncScheduler; makeSyncEngine: (cfg: SyncConfig) => SyncEngine; lifecycle: ConfigLifecycle } {
1466
+ const { host, adapters, exportsDir, tmpDir, snapshotsDir, runs, syncDir, dataDir, credentials, githubClientId, githubClientSecret, history } = deps
1467
+ /**
1468
+ * Phase 1 P0-1/P0-2:配置生命周期服务(自动快照 / 撤销 / 重做)。
1469
+ *
1470
+ * 与既有 `snapshotsDir`(导入前快照,plan 驱动)**分目录**:那里的快照只登记
1471
+ * 「本次导入将写入的目标」,无法回答「配置整体变没变」;本服务的快照是状态驱动,
1472
+ * 供撤销/重做与自动快照使用。两者保留策略与回放方式都不同,混用会产生错误语义。
1473
+ *
1474
+ * watchFactory 用真 fs.watch 注入(core 侧只依赖抽象 → 测试可完全驱动时序)。
1475
+ * 监听不在此处启动:由 apply() 在「启动 recovery 分类完成且 NORMAL」后启动,
1476
+ * 避免恢复进行中就开拍。
1477
+ */
1478
+ const lifecycle = new ConfigLifecycle({
1479
+ dir: join(dataDir, 'config-snapshots'),
1480
+ adapters,
1481
+ ctx: host,
1482
+ profile: host.profile ?? 'web',
1483
+ applyOrder: APPLY_ORDER,
1484
+ onWarn: (message, detail) => {
1485
+ host.log.warn(`[lifecycle] ${message}${detail !== undefined ? `: ${detail instanceof Error ? detail.message : String(detail)}` : ''}`)
1486
+ },
1487
+ watchFactory: (dir, onEvent) => fsWatch(dir, (eventType, filename) => onEvent(eventType, filename)),
1488
+ })
1489
+
1490
+ const roots = [exportsDir, tmpDir]
1491
+
1492
+ /**
1493
+ * Phase 6:迁移历史 best-effort 追加(写失败不阻断操作,但记录/降级,不静默丢)。
1494
+ * 所有 destructive/migration 结果确定后调用。历史写盘 ms 级,失败仅日志 + 可选告警字段。
1495
+ */
1496
+ const tryAppendHistory = async (
1497
+ raw: { kind: MigrationKind; result: MigrationResult; sections: string[]; operationId?: string; snapshotId?: string; runId?: string; source: 'api' | 'autosync' | 'backup-scheduler' | 'recovery' | 'cli' | 'internal'; summary: string; error?: string },
1498
+ ): Promise<string | undefined> => {
1499
+ try {
1500
+ const res = await history.append(raw)
1501
+ if (!res.ok) {
1502
+ host.log.warn('迁移历史写入失败', { kind: raw.kind, error: res.error })
1503
+ return res.error
1504
+ }
1505
+ return undefined
1506
+ } catch (error) {
1507
+ host.log.warn('迁移历史写入异常', { kind: raw.kind, error: error instanceof Error ? error.message : String(error) })
1508
+ return error instanceof Error ? error.message : String(error)
1509
+ }
1510
+ }
1511
+
1512
+ /**
1513
+ * 从快照目录读取 entries 的 adapter id 集(用于 restore / snapshot-prune 历史 sections)。
1514
+ * 读取失败 → 空数组(best-effort;sections 仅用于审计摘要,不影响功能)。
1515
+ */
1516
+ const snapshotEntrySections = async (snapshotDir: string): Promise<string[]> => {
1517
+ try {
1518
+ const raw = await fs.readFile(join(snapshotDir, 'snapshot.json'), 'utf8')
1519
+ const parsed = JSON.parse(raw) as { entries?: Array<{ adapter?: string }> } | null
1520
+ if (parsed === null || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) return []
1521
+ return Array.from(new Set(parsed.entries.map((e) => e.adapter).filter((s): s is string => typeof s === 'string' && s !== '')))
1522
+ } catch {
1523
+ return []
1524
+ }
1525
+ }
1526
+
1527
+ /**
1528
+ * Phase 6:自动快照保留清理(snapshot-prune)迁移历史(best-effort)。
1529
+ * 由 FileSnapshotStore.prune 经 onPrune 回调触发;fire-and-forget 不阻塞保存。
1530
+ */
1531
+ const tryAppendSnapshotPrune = async (removedIds: string[]): Promise<void> => {
1532
+ if (removedIds.length === 0) return
1533
+ try {
1534
+ await history.append({
1535
+ kind: 'snapshot-prune',
1536
+ result: 'success',
1537
+ sections: [],
1538
+ source: 'api',
1539
+ summary: `自动保留清理删除 ${removedIds.length} 个旧快照`,
1540
+ })
1541
+ } catch (error) {
1542
+ host.log.warn('快照保留清理历史写入失败(best-effort)', { error: error instanceof Error ? error.message : String(error) })
1543
+ }
1544
+ }
1545
+
1546
+ /** m-github-oauth:宿主侧设备码登记表 + auth 客户端(进程生命周期;device_code 只存内存) */
1547
+ const githubFlows = new DeviceFlowStore()
1548
+ const githubAuth = new GitHubAuthClient()
1549
+ const msg = host.msg
1550
+
1551
+ /** 导入 run 的「当前计划项」中止控制器(/execute 登记,/execute/skip 定位 abort)。
1552
+ * 进程生命周期内存登记;同 kind 并发被 RunRegistry 拒绝,单 run 恒只有一个当前项。 */
1553
+ const runAbortControllers = new Map<string, AbortController>()
1554
+
1555
+ /** 已知 adapter id 集合(push 请求体 sections 校验用)。 */
1556
+ const knownSyncSectionIds = new Set(adapters.map((a) => a.id))
1557
+ /** 可同步分区目录(status 回填 UI「高级/自定义导出」勾选列表)。
1558
+ *
1559
+ * 含**全部已挂载分区**(不再按 portability 过滤):改造一取消了 portability 对同步范围的
1560
+ * 限制,所有分区都可勾选;这里若仍只列 portable,mcp/workspaces/credentialsStatus/
1561
+ * pluginFiles/sessions 就永远无法被勾选,用户既看不到也同步不了。
1562
+ * 列表顺序即 adapters 顺序;defaultIncluded 由 UI 用于「推荐分区」默认勾选与计数。 */
1563
+ const syncSectionCatalog = adapters
1564
+ .map((a) => ({ id: a.id, displayName: a.displayName, portability: a.portability, defaultIncluded: a.defaultIncluded }))
1565
+
1566
+ const makeImporter = (): Importer => new Importer({
1567
+ ctx: host,
1568
+ adapters,
1569
+ snapshotStore: new FileSnapshotStore({
1570
+ dir: snapshotsDir,
1571
+ // Phase 4 F3:active/quarantine 未收敛 journal 引用的 snapshot 绝不自动 prune
1572
+ referencedSnapshotIds: () => host.phase3Recovery?.store.listReferencedSnapshotIds() ?? Promise.resolve(new Set<string>()),
1573
+ // Phase 6:自动保留清理 → snapshot-prune 迁移历史(best-effort)
1574
+ onPrune: (removedIds) => { void tryAppendSnapshotPrune(removedIds) },
1575
+ }),
1576
+ parseZipOverride: createHardenedZipParser(),
1577
+ dependencyChecker: dependencyAvailable,
1578
+ msg,
1579
+ })
1580
+
1581
+ /** m-profiles:配置档案管理器(<dataDir>/profiles/<name>/profile.json;切换复用同一快照/回滚管道) */
1582
+ const profiles = new ProfileManager({
1583
+ dataDir,
1584
+ ctx: host,
1585
+ adapters,
1586
+ snapshotStore: new FileSnapshotStore({
1587
+ dir: snapshotsDir,
1588
+ // Phase 4 F3:recovery 引用保护
1589
+ referencedSnapshotIds: () => host.phase3Recovery?.store.listReferencedSnapshotIds() ?? Promise.resolve(new Set<string>()),
1590
+ // Phase 6:自动保留清理 → snapshot-prune 迁移历史(best-effort)
1591
+ onPrune: (removedIds) => { void tryAppendSnapshotPrune(removedIds) },
1592
+ }),
1593
+ })
1594
+
1595
+ /** Fence + method guard (mirrors dsh-ssh). */
1596
+ const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
1597
+ if (!isLoopbackRequest(req)) {
1598
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
1599
+ return false
1600
+ }
1601
+ if (req.method !== method) {
1602
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
1603
+ return false
1604
+ }
1605
+ return true
1606
+ }
1607
+
1608
+ /**
1609
+ * Phase 2 跨进程锁路由门(destructive 公共入口)。
1610
+ * 包裹一个 mutation handler:进入前 acquire GLOBAL 环境锁(无 lock 配置 → 直接放行),
1611
+ * 被另一进程/操作持有(含同进程另一操作)→ 409/423 拒绝;执行后 finally 释放。
1612
+ * 嵌套调用(rollback / applyItems 内部 executeImportPlan)在外层已持锁区域内运行,绝不 reacquire。
1613
+ * Phase 3 SAFE MODE:isBlocked 注入谓词(host.safeModeIsBlocked)被挡 → 423(不执行 destructive)。
1614
+ */
1615
+ const withMutationGate = (
1616
+ op: string,
1617
+ handler: (req: IncomingMessage, res: ServerResponse, lockCtx?: MutationLockContext, journalCtx?: JournalRunContext) => Promise<void>,
1618
+ opts?: { journaled?: boolean; deferredSnapshot?: boolean },
1619
+ ): ((req: IncomingMessage, res: ServerResponse) => Promise<void>) => {
1620
+ return async (req, res) => {
1621
+ try {
1622
+ await runWithMutationLock(host.mutationLock, { op, isBlocked: () => host.safeModeIsBlocked?.() ?? false }, async (lockCtx) => {
1623
+ // Step 3 P0-A:所有被 gate 覆盖的 destructive 路由在已持锁下创建 durable journal
1624
+ // (runJournaled 不 double-acquire、不 release;锁由本 gate 的 finally 释放)。
1625
+ if (host.phase3Recovery !== undefined && (opts?.journaled ?? true) && lockCtx !== null) {
1626
+ await host.phase3Recovery.runJournaled({
1627
+ operationType: op,
1628
+ lockCtx,
1629
+ // Phase 4:生产 snapshot 接线。deferredSnapshot = plan 在 handler 内解析后,
1630
+ // 引擎创建 op-bound snapshot 并 bindSnapshot + markApplying(首个 destructive side effect 前)。
1631
+ deferredSnapshot: opts?.deferredSnapshot ?? false,
1632
+ fn: async (journalCtx) => { await handler(req, res, lockCtx, journalCtx) },
1633
+ })
1634
+ } else {
1635
+ await handler(req, res, lockCtx ?? undefined, undefined)
1636
+ }
1637
+ })
1638
+ } catch (error) {
1639
+ if (error instanceof EnvironmentLockUnavailableError) {
1640
+ // 内部诊断(op/reason)进日志;用户只看到友好文案(error.message 恒为中文友好版,
1641
+ // 不暴露环境锁/op/路径等技术细节)。
1642
+ host.log.warn(`mutation lock blocked: op=${error.op} reason=${error.reason}${error.detail !== undefined ? ` detail=${error.detail}` : ''}`)
1643
+ writeJson(res, 423, { error: error.message, code: 'mutation-locked' })
1644
+ return
1645
+ }
1646
+ // 非 423:若已由 runJournaled 置 SAFE MODE/失败,保持既有错误语义(400/500)
1647
+ if (error instanceof TransactionRecoveryRequiredError) {
1648
+ writeJson(res, 423, { error: error.message, code: 'transaction-recovery-required' })
1649
+ return
1650
+ }
1651
+ throw error
1652
+ }
1653
+ }
1654
+ }
1655
+
1656
+ // ------------------------------------------------- sync 路由装配(m-sync-ui)
1657
+ // 请求级装配:每次 push/pull 从请求体取 repoUrl,token 非空先写入 DSH
1658
+ // credentials(只存值不落盘同步文件/日志),git 网络操作时经 resolve 现取 ——
1659
+ // 与 GitTransport「token 只从注入 provider 读取」的安全契约完全对齐。
1660
+
1661
+ /**
1662
+ * 解析同步请求体并补全缺失字段(委托给导出的 parseSyncBody,便于单测)。
1663
+ * username 回退:webdav 请求体未带 username(如挂载时 snapshotsList 自动加载、
1664
+ * 表单留空后直接同步)时,从持久化 sync-config 回填已保存的 username——
1665
+ * 否则 WebDavTransport 构造会因空 username 抛错,导致「保存过配置仍无法列出快照」。
1666
+ * 语义与 password 一致:留空 = 沿用已保存凭据。
1667
+ */
1668
+ const prepareSync = async (body: Record<string, unknown>): Promise<SyncConfig> => {
1669
+ const cfg = await parseSyncBody(body, { credentials })
1670
+ if (isWebDavConfig(cfg) && (cfg.webdav.username === undefined || cfg.webdav.username === '')) {
1671
+ try {
1672
+ const persisted = await readSyncConfig(syncDir)
1673
+ return mergePersistedWebDavUsername(cfg, persisted)
1674
+ } catch {
1675
+ return cfg // 读失败保持原值(空 username 由 WebDavTransport 构造校验兜底报错)
1676
+ }
1677
+ }
1678
+ return cfg
1679
+ }
1680
+
1681
+ /** 同步分区选择缓存(按通道;sync-selection.json;makeSyncEngine 同步读取用,保存路由更新)。
1682
+ * 缺失通道 = 尚未加载(启动竞态窗口);读取/使用处兜底 defaultSyncSelection。 */
1683
+ const selectionCache: Partial<Record<SyncTransportType, SyncSelection>> = {}
1684
+ void readAllSyncSelections(syncDir).then((all) => {
1685
+ selectionCache.git = all.git
1686
+ selectionCache.webdav = all.webdav
1687
+ }).catch(() => { /* 读失败保持缺省 */ })
1688
+
1689
+ /** 确保指定通道缓存已加载(status/save 路由调用;启动竞态兜底)。 */
1690
+ const ensureSelectionLoaded = async (channel: SyncTransportType): Promise<SyncSelection> => {
1691
+ const cached = selectionCache[channel]
1692
+ if (cached !== undefined) return cached
1693
+ try {
1694
+ const sel = await readSyncSelection(syncDir, channel)
1695
+ selectionCache[channel] = sel
1696
+ return sel
1697
+ } catch {
1698
+ const fallback = defaultSyncSelection()
1699
+ selectionCache[channel] = fallback
1700
+ return fallback
1701
+ }
1702
+ }
1703
+
1704
+ /** 指定通道的分区选择视图({ mode, sections },无 schemaVersion)。 */
1705
+ const selectionView = async (channel: SyncTransportType): Promise<{ mode: SyncSelectionMode; sections: SectionId[] }> => {
1706
+ const sel = await ensureSelectionLoaded(channel)
1707
+ return { mode: sel.mode, sections: sel.sections }
1708
+ }
1709
+
1710
+ /** 全部通道的分区选择视图(status 路由一次返回;UI 按当前 tab 取对应通道)。 */
1711
+ const selectionViewByChannel = async (): Promise<Record<SyncTransportType, { mode: SyncSelectionMode; sections: SectionId[] }>> => {
1712
+ const all = await readAllSyncSelections(syncDir)
1713
+ selectionCache.git = all.git
1714
+ selectionCache.webdav = all.webdav
1715
+ const view = (sel: SyncSelection): { mode: SyncSelectionMode; sections: SectionId[] } =>
1716
+ ({ mode: sel.mode, sections: sel.sections })
1717
+ return { git: view(all.git), webdav: view(all.webdav) }
1718
+ }
1719
+
1720
+ /** 构造 SyncEngine:按 transport 分支构造对应传输(git → GitTransport;webdav → WebDavTransport)。
1721
+ * 同步范围(sections)来自持久化分区选择:advanced 模式 → 只处理勾选分区,
1722
+ * 自动同步(merge/apply/push 全链路)与手动 push 共用此配置。 */
1723
+ const makeSyncEngine = (cfg: SyncConfig): SyncEngine => {
1724
+ let transport: SyncTransport
1725
+ if (isWebDavConfig(cfg)) {
1726
+ transport = new WebDavTransport({
1727
+ baseUrl: webdavBaseUrl(cfg),
1728
+ username: cfg.webdav.username ?? '',
1729
+ credentials: {
1730
+ getPassword: async () => {
1731
+ const resolved = await credentials.resolve(credentialRef(SYNC_WEBDAV_CREDENTIAL_REF))
1732
+ return resolved?.value ?? ''
1733
+ },
1734
+ },
1735
+ // 显式传超时:不依赖默认值,慢速 WebDAV 上传大快照有足够窗口
1736
+ timeoutMs: WEBDAV_TIMEOUT_MS,
1737
+ msg,
1738
+ })
1739
+ } else {
1740
+ transport = new GitTransport({
1741
+ repoUrl: cfg.git.repoUrl,
1742
+ workDir: join(syncDir, 'work'),
1743
+ credentials: {
1744
+ getToken: async () => {
1745
+ const resolved = await credentials.resolve(credentialRef(SYNC_CREDENTIAL_REF))
1746
+ return resolved?.value ?? ''
1747
+ },
1748
+ },
1749
+ msg,
1750
+ })
1751
+ }
1752
+ const channel: SyncTransportType = isWebDavConfig(cfg) ? 'webdav' : 'git'
1753
+ const sections = effectiveSections(selectionCache[channel] ?? defaultSyncSelection())
1754
+ return new SyncEngine({
1755
+ ctx: host,
1756
+ transport,
1757
+ stateDir: syncDir,
1758
+ adapters,
1759
+ importer: makeImporter(),
1760
+ localSnapshotsDir: join(syncDir, 'snapshots'),
1761
+ zipDir: tmpDir,
1762
+ msg,
1763
+ ...(sections === undefined ? {} : { sections }),
1764
+ })
1765
+ }
1766
+
1767
+ /** 一键同步差异确认会话存储(进程内存;/sync/sync 预览 → /sync/apply-items 逐项执行解耦) */
1768
+ const syncSessions = new SyncSessionStore()
1769
+
1770
+ /** 自动同步后台调度器(宿主进程生命周期,不依赖浏览器) */
1771
+ const scheduler = new AutoSyncScheduler({
1772
+ syncDir,
1773
+ host,
1774
+ makeSyncEngine,
1775
+ msg,
1776
+ runs,
1777
+ mutationLock: host.mutationLock,
1778
+ isBlocked: () => host.safeModeIsBlocked?.() ?? false,
1779
+ phase3Recovery: host.phase3Recovery,
1780
+ // Phase 6:autosync 既写 sync-history.json(既有语义),也写统一迁移历史(COMPLETE 不变量)。
1781
+ appendHistoryFn: async (entry) => {
1782
+ await appendAutosyncEntry(syncDir, entry).catch(() => undefined)
1783
+ await history.append({
1784
+ kind: 'autosync',
1785
+ result: entry.status === 'success' ? 'success' : entry.status === 'skipped' ? 'skipped' : 'failed',
1786
+ sections: entry.appliedSections ?? [],
1787
+ source: 'autosync',
1788
+ summary: `自动同步 ${entry.direction}${entry.transport !== undefined ? `(${entry.transport})` : ''}`,
1789
+ error: entry.status === 'failed' ? (entry.error ?? entry.skipReason) : undefined,
1790
+ }).catch(() => undefined)
1791
+ },
1792
+ })
1793
+ // P1-B:调度器不再在 makeRoutes 内同步 start —— 由 apply() 在「启动 recovery 分类完成后、仅 NORMAL」时启动。
1794
+
1795
+ // ============================================================ Phase 5 recovery orchestration
1796
+ // Recovery 路由**禁用 withMutationGate**(避免 double-journal:recovery 复用被恢复 operation 的
1797
+ // 现有 journal,不新建)。mutation 路由只经 withMutationLock(Phase 2 GLOBAL 锁)+ loopback fence,
1798
+ // **不传 isBlocked**(recovery 是解决 SAFE MODE 的机制,若被 SAFE MODE 阻断会死锁)。
1799
+ // 只读路由(status/preview)不持锁。权威 snapshotId 只来自 j.snapshotId(不接受请求体覆盖)。
1800
+ // 编排逻辑在 src/core/recovery-orchestrator.ts(可测纯编排层)。
1801
+ const recoveryOrchestrator = createRecoveryOrchestrator({
1802
+ store: host.phase3Recovery?.store ?? new JournalStore({ transactionsDir: join(dataDir, 'transactions') }),
1803
+ runs,
1804
+ snapshotsDir,
1805
+ host,
1806
+ msg,
1807
+ snapshotExists: async (snapshotId, binding) => {
1808
+ if (host.phase3Recovery === undefined) return false
1809
+ return host.phase3Recovery.recoveryHooks.snapshotExists(snapshotId, binding)
1810
+ },
1811
+ // 动态 getter:环境指纹在 fire-and-forget 启动分类块(initFingerprint)完成后才就绪,
1812
+ // 创建期捕获会拿到 'unknown' 初值 → 后续 recovery API 误判 WRONG_ENVIRONMENT。
1813
+ // 改动态读取保证 API 调用时取到真实指纹(recovery-orchestrator 已改为 getter 语义)。
1814
+ getEnvironmentFingerprint: () => host.phase3Recovery?.recoveryEnvFingerprint ?? 'unknown',
1815
+ // 清除 SAFE MODE:同时重置内存标志(isBlocked 读它)与 durable 标记。
1816
+ // 仅当 recovery 成功且无其他未解决 incident 时由编排器调用(§5.3 / §10.2)。
1817
+ clearSafeMode: async () => {
1818
+ if (host.phase3Recovery !== undefined) await host.phase3Recovery.clearSafeMode()
1819
+ },
1820
+ // issue #31:环境锁只读探测 + 显式回收,供「事故恢复」面板显示/处理**残留锁**。
1821
+ // 残留锁不是 journal(journalId 恒 null、transactions/active 为空),旧面板因此恒空。
1822
+ inspectLockState: async () => {
1823
+ const port = host.mutationLock as EnvironmentLockManager | undefined
1824
+ if (port === undefined) return { state: 'FREE' }
1825
+ const insp = await port.inspectLockState()
1826
+ return { state: insp.state, ...(insp.detail !== undefined ? { detail: insp.detail } : {}) }
1827
+ },
1828
+ recoverStaleLock: async () => {
1829
+ const port = host.mutationLock as EnvironmentLockManager | undefined
1830
+ // 无锁端口(测试/未接线)→ 无可回收对象,诚实拒绝而非谎称成功
1831
+ if (port === undefined) return { ok: false, removed: false, state: 'FREE', detail: 'no lock port configured' }
1832
+ return port.recoverStaleLock()
1833
+ },
1834
+ })
1835
+ /** 构造 recovery 执行器(restore / rollback),供 execute/retry 注入(runId 用于进度埋点)。 */
1836
+ const makeRecoveryExecutors = (runId: string): RecoveryExecutorFns => ({
1837
+ performRestore: async (snapshotId) => {
1838
+ const dir = join(snapshotsDir, snapshotId)
1839
+ const restoreOpts = {
1840
+ snapshotDir: dir, homeDir: host.homeDir, profile: host.profile, settingsPath: undefined, msg,
1841
+ snapshotsRoot: snapshotsDir, environmentFingerprint: host.phase3Recovery?.recoveryEnvFingerprint ?? 'unknown', requireOperationBound: true,
1842
+ }
1843
+ const plan = await planRestore(restoreOpts)
1844
+ const report = await executeRestorePlan(plan, makeRestoreExecutor(dir, host, host.profile), (info) => {
1845
+ runs.update(runId, { section: 'recovery', item: info.index, itemTotal: info.total, detail: info.detail })
1846
+ })
1847
+ return { full: report.failed.length === 0, failed: report.failed.map((f) => f.item) }
1848
+ },
1849
+ performRollback: async (snapshotId) => {
1850
+ const store = new FileSnapshotStore({ dir: snapshotsDir })
1851
+ const snap = await store.load(snapshotId)
1852
+ const report = await performRollback({ ctx: host, snapshot: snap, store, adapters })
1853
+ return { full: report.full, failed: report.failed.map((f) => f.item) }
1854
+ },
1855
+ })
1856
+
1857
+ const routesList: WebRoute[] = [
1858
+ // ------------------------------------------------------------- status
1859
+ // ------------------------------------------------------------- export
1860
+ // ---------------------------------------------------- export-preview
1861
+ // P2-⑫:导出前只读预览(不落盘 ZIP):对选中分区逐个 adapter.export 收集 counts
1862
+ // (与真实导出一致的 secret 剥离,不导出任何值),估算 JSON 载荷大小,返回可展示摘要。
1863
+ // 零写入;loopback fence 必备。
1864
+ // ------------------------------------------------------------ download
1865
+ // -------------------------------------------------------------- upload
1866
+ // ------------------------------------------------------ decrypt-archive
1867
+ // 整体加密备份容器的解锁(只读,零写入到任何配置):用备份密码解密上传的加密容器,
1868
+ // 得到明文 ZIP 写入受控临时目录并返回新 zipPath,供 analyze/plan/execute 引用。
1869
+ // 导出时容器密码与内部 secrets.enc 密码同源(同一 password 派生两层加密),
1870
+ // 因此顺带在明文 ZIP 上解出内部凭据覆盖清单(refs,非值)一并返回——
1871
+ // 导入全程只需输入这一次密码,无需第二个密码校验页面。
1872
+ // 密码仅内存随请求体传入,绝不落盘/落日志;解出的明文 ZIP 亦为临时文件,导入结束后清理。
1873
+ // ------------------------------------------------------------- analyze
1874
+ // ---------------------------------------------------------------- plan
1875
+ // ------------------------------------------------------------ progress
1876
+ // m1:查询单个 run 的实时状态(轮询 / 刷新恢复用;runId 不可猜,走 loopback-only 守卫)
1877
+ // ----------------------------------------------------------------- runs
1878
+ // m1:列出当前活跃(running)的 run(刷新恢复时重新订阅进度用)
1879
+ // ------------------------------------------------------------- execute
1880
+ // -------------------------------------------------- execute/skip
1881
+ // 用户跳过当前计划项(导入中,目前仅插件安装):abort 当前项的中止控制器 → 引擎
1882
+ // 捕获 ImportUserSkippedError 记为 user-skipped,导入继续执行其余项。
1883
+ // ---------------------------------------------------------- snapshots
1884
+ // M4:列出快照元信息(id/createdAt/sourceZip/status/计数,createdAt 倒序)
1885
+ // ------------------------------------------------------------ restore
1886
+ // M4:快照恢复。dryRun=true 只返回动作计划(planRestore,零写入);
1887
+ // 真实执行 = 计划 → 宿主执行器(ctx.fs 整文件/文件还原 + runDshPlugin 卸载插件)
1888
+ // → 与 CLI 一致的诚实报告 { restored/removedPlugins/manualHints/failed/skipped }。
1889
+ //
1890
+ // **并发防护(P1-1)**:真实执行(dryRun=false)经 runs.register('restore') 登记——
1891
+ // 同 kind 已有 running 时抛 RunConflictError → 409 拒绝。这是宿主侧的权威防重
1892
+ // (前端 loading 只是 UX):即使两个 tab / 刷新后重复点击,同一时刻至多一个
1893
+ // restore 在执行(不同快照并发恢复会交错写文件,同快照并发会互相覆盖
1894
+ // pre-restore 双保险备份,都是真实数据风险)。进度经 onAction 埋点更新
1895
+ // RunRegistry(/progress 轮询 + /runs 刷新恢复可见);响应含 runId。
1896
+ // ------------------------------------------- snapshots/delete(P1-⑧)
1897
+ // 手动删除单个快照(危险操作:该导入前回滚点不可恢复)。loopback fence(guard);
1898
+ // 只接受合法快照 id(deleteSnapshot 内防穿越)。与自动保留清理不同:置顶快照
1899
+ // 只能在这里被用户手动删除。
1900
+ // --------------------------------------------- snapshots/pin(P1-⑧)
1901
+ // 置顶/取消置顶快照:置顶快照豁免「最多保留 N 个」的自动清理(只能手动删除)。
1902
+ // 纯元数据写(重写 snapshot.json 的 pinned 字段);loopback fence 必备。
1903
+ // -------------------------------------------------- m-profiles
1904
+ // 配置档案(Profile):保存当前 DSH 配置为多套可切换快照(Work/Personal…)。
1905
+ // 安全:Profile 名严格校验(ProfileManager 内部 isValidProfileName 防穿越);
1906
+ // 切换走「预览 → confirm → 快照 → 分阶段 apply → 失败回滚」与导入同一语义;
1907
+ // Save 复用 adapter.export(天然不含秘密值);全部路由 loopback fence。
1908
+ // -------------------------------------------------- backup-schedule
1909
+ // 定时全量备份设置(GET 读 / PUT 存 sync/backup-schedule.json;无敏感字段):
1910
+ // 保存后重排调度器(reload);恒不含 secret、不加密(与自动同步同语义)。
1911
+ // 与全仓一致:每个方法分支都过 loopback fence(guard)——其他 /api/dsh-config-manager/*
1912
+ // 路由全部首行 guard,新增路由不得遗漏(安全不变量:仅 loopback + 同源可访问)。
1913
+ // ------------------------------------------------- backup-schedule/run
1914
+ // 返回执行结果(status/zip/skipReason/error)+ 最新配置(含 lastRun 状态)。
1915
+ // 同全仓:loopback fence(guard)——远程调用方不得触发宿主写盘操作。
1916
+ // ------------------------------------------------------ backup-files
1917
+ // 导出产物管理(m-backup-files):列出 exports/*.zip(名称/大小/时间/来源,
1918
+ // 时间倒序)+ 删除单个备份文件。下载复用 /download(roots 已含 exportsDir)。
1919
+ // 安全:删除只接受文件名(服务端 basename 校验防路径穿越);恒 loopback guard。
1920
+ // ------------------------------------------------------ consult
1921
+ // Phase 7:迁移前咨询(只读健康评分 + 建议)。POST,loopback fence。
1922
+ // 对 4 种可迁移源(export-zip / local-snapshot / remote-snapshot / profile)生成
1923
+ // 统一咨询报告。**只读**:不写配置/快照/journal;临时 ZIP 用 try/finally 立即清理。
1924
+ // ------------------------------------------------------ sync/status
1925
+ // m-sync-ui:同步状态(通道配置 / 凭据状态 / 上次同步 / 分区数)。只读,无 secret 值。
1926
+ {
1927
+ kind: 'exact',
1928
+ path: API.syncStatus,
1929
+ handler: async (req, res) => {
1930
+ if (!guard(req, res, 'GET')) return
1931
+ try {
1932
+ // 完整双命名空间配置:repoUrl / webdav.url 无论当前通道都回填,
1933
+ // 保证 UI 在 git ↔ webdav 间切换时另一通道的地址不丢失
1934
+ const full = await readFullSyncConfig(syncDir)
1935
+ const state = await loadSyncState(syncDir)
1936
+ const [cred, webdavCred] = await Promise.all([
1937
+ credentials.describe(credentialRef(SYNC_CREDENTIAL_REF)),
1938
+ credentials.describe(credentialRef(SYNC_WEBDAV_CREDENTIAL_REF)),
1939
+ ])
1940
+ const transport: SyncConfig['transport'] = full !== null && full.transport === 'webdav' ? 'webdav' : 'git'
1941
+ // m-self:插件 UI 偏好(上次选择的同步通道;ui-prefs.json,随 self 分区进备份)
1942
+ const uiPrefs = await readUiPrefs(syncDir)
1943
+ // webdav 配置视图(配置过即返回,与当前通道无关:供表单在 git ↔ webdav 切换时回填)
1944
+ const webdav = full?.webdav !== undefined
1945
+ ? {
1946
+ url: full.webdav.url,
1947
+ // username 非敏感可回显,供表单回填
1948
+ username: full.webdav.username,
1949
+ usernameConfigured: typeof full.webdav.username === 'string' && full.webdav.username !== '',
1950
+ passwordConfigured: webdavCred.configured,
1951
+ }
1952
+ : undefined
1953
+ writeJson(res, 200, {
1954
+ ok: true,
1955
+ configured: full !== null,
1956
+ transport,
1957
+ repoUrl: full?.git?.repoUrl,
1958
+ credentialConfigured: cred.configured,
1959
+ credentialWritable: cred.writable === true,
1960
+ // webdav 配置状态(无 secret 值:口令用 passwordConfigured 布尔标记)
1961
+ ...(webdav !== undefined ? { webdav } : {}),
1962
+ lastSyncAt: state.lastSyncAt === '' ? undefined : state.lastSyncAt,
1963
+ sectionCount: Object.keys(state.sections).length,
1964
+ lastTransport: state.transport,
1965
+ // 上次选择的同步通道(磁盘 ui-prefs;UI 回填优先于此,localStorage 仅兜底)
1966
+ lastSyncChannel: uiPrefs.lastSyncChannel,
1967
+ // 可同步分区目录(「高级/自定义导出」勾选列表;只含 portable,无 secret 值)
1968
+ syncSections: syncSectionCatalog,
1969
+ // 当前分区选择(默认/高级模式 + 勾选分区;当前激活通道;UI 回填用,自动同步共用)
1970
+ syncSelection: await selectionView(transport),
1971
+ // 全部通道的分区选择(git/webdav 各自独立;UI 按当前 tab 取对应通道)
1972
+ syncSelectionByChannel: await selectionViewByChannel(),
1973
+ // 自动同步当前状态(当前激活通道;供 UI 顶部开关回填;§3.9)
1974
+ autosync: await buildAutosyncStatus(syncDir, transport),
1975
+ // 全部通道的自动同步状态(git/webdav 各自独立;UI 按当前 tab 取对应通道)
1976
+ autosyncByChannel: await buildAutosyncStatusByChannel(syncDir),
1977
+ })
1978
+ } catch (error) {
1979
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
1980
+ }
1981
+ },
1982
+ },
1983
+ // ------------------------------------------------------ sync/config
1984
+ // m-sync-config:保存同步通道配置(parseSyncBody 校验 + password/token 写 DSH credentials +
1985
+ // writeSyncConfig 落盘)。UI 表单自动保存 /「保存配置」按钮调用;响应为轻量状态视图
1986
+ // (仅凭据布尔,无 secret 值),供 UI 直接刷新徽章而不必重拉 status 覆盖正在编辑的表单。
1987
+ {
1988
+ kind: 'exact',
1989
+ path: API.syncConfig,
1990
+ handler: async (req, res) => {
1991
+ if (!guard(req, res, 'POST')) return
1992
+ const body = await readJsonBody(req)
1993
+ if (body === undefined) {
1994
+ writeJson(res, 400, { error: 'invalid JSON body' })
1995
+ return
1996
+ }
1997
+ try {
1998
+ const syncCfg = await prepareSync(body)
1999
+ await writeSyncConfig(syncDir, syncCfg)
2000
+ const [cred, webdavCred] = await Promise.all([
2001
+ credentials.describe(credentialRef(SYNC_CREDENTIAL_REF)),
2002
+ credentials.describe(credentialRef(SYNC_WEBDAV_CREDENTIAL_REF)),
2003
+ ])
2004
+ writeJson(res, 200, {
2005
+ ok: true,
2006
+ configured: true,
2007
+ transport: syncCfg.transport,
2008
+ credentialConfigured: cred.configured,
2009
+ webdav: isWebDavConfig(syncCfg)
2010
+ ? {
2011
+ usernameConfigured: typeof syncCfg.webdav.username === 'string' && syncCfg.webdav.username !== '',
2012
+ passwordConfigured: webdavCred.configured,
2013
+ }
2014
+ : undefined,
2015
+ })
2016
+ } catch (error) {
2017
+ writeSyncRouteError(res, error)
2018
+ }
2019
+ },
2020
+ },
2021
+ // ------------------------------------------------------ sync/ui-prefs
2022
+ // m-self:保存插件 UI 偏好(当前为上次选择的同步通道;ui-prefs.json,随 self 分区进备份)。
2023
+ // 纯偏好、无 secret;失败仅提示,不阻断同步主流程。
2024
+ // 经 updateUiPrefs 局部合并写:不覆盖其他端点(star-prompt)刚写入的字段。
2025
+ {
2026
+ kind: 'exact',
2027
+ path: API.syncUiPrefs,
2028
+ handler: async (req, res) => {
2029
+ if (!guard(req, res, 'POST')) return
2030
+ const body = await readJsonBody(req)
2031
+ if (body === undefined) {
2032
+ writeJson(res, 400, { error: 'invalid JSON body' })
2033
+ return
2034
+ }
2035
+ try {
2036
+ const channel: UiPrefsChannel | undefined = body['lastSyncChannel'] === 'webdav' ? 'webdav' : body['lastSyncChannel'] === 'git' ? 'git' : undefined
2037
+ await updateUiPrefs(syncDir, { ...(channel !== undefined ? { lastSyncChannel: channel } : {}) })
2038
+ writeJson(res, 200, { ok: true, lastSyncChannel: channel })
2039
+ } catch (error) {
2040
+ writeSyncRouteError(res, error)
2041
+ }
2042
+ },
2043
+ },
2044
+ // ------------------------------------------------------ star-prompt
2045
+ // m-star-prompt:Star 引导弹窗状态(复用 ui-prefs.json;随 self 分区进备份)。
2046
+ // GET → 返回仓库地址 + 弹窗状态(UI 挂载时判定是否展示 / 是否补记首次使用时间);
2047
+ // POST → 局部更新(firstSeenAt / dismissed / clicked 白名单),经 updateUiPrefs
2048
+ // 合并写,不覆盖 sync/ui-prefs 的 lastSyncChannel。纯偏好、无 secret。
2049
+ // ------------------------------------------------------ release-notes-prompt
2050
+ // 版本更新内容弹窗状态(复用 ui-prefs.json;随 self 分区进备份)。
2051
+ // GET → 返回当前插件版本 + 上次已读版本 + 是否永不提示;
2052
+ // POST → 局部更新(lastSeenVersion / dismissed 白名单),经 updateUiPrefs 合并写。
2053
+ // ------------------------------------------------------ sync/push
2054
+ // m-sync-ui:推送(导出 portable 分区 → 提交私有仓库 → 更新 sync-state)。
2055
+ // token 可选:非空先写入 DSH credentials;成功则记忆仓库配置(回填表单用)。
2056
+ // sections 可选(高级/自定义导出):只推送勾选的分区;缺省 = 默认模式全部推荐分区。
2057
+ {
2058
+ kind: 'exact',
2059
+ path: API.syncPush,
2060
+ handler: withMutationGate('sync-push', async (req, res) => {
2061
+ if (!guard(req, res, 'POST')) return
2062
+ const body = await readJsonBody(req)
2063
+ if (body === undefined) {
2064
+ writeJson(res, 400, { error: 'invalid JSON body' })
2065
+ return
2066
+ }
2067
+ try {
2068
+ const syncCfg = await prepareSync(body)
2069
+ const engine = makeSyncEngine(syncCfg)
2070
+ const snapshotId =
2071
+ typeof body['snapshotId'] === 'string' && body['snapshotId'] !== '' ? body['snapshotId'] : undefined
2072
+ const sections = extractSyncSections(body, knownSyncSectionIds)
2073
+ // P0-②:push 前只读预览(body.preview === true → 不写远端,只返回「将推送什么」)
2074
+ const preview = body['preview'] === true
2075
+ // 分支调用以保证 withTimeout 的泛型结果类型正确(SyncPushReport | SyncPushPreview)
2076
+ const report = preview
2077
+ ? await withTimeout(
2078
+ engine.previewPush({ ...(sections === undefined ? {} : { sections }) }),
2079
+ ROUTE_TIMEOUT_MS,
2080
+ msg('host.syncPushTimeout'),
2081
+ )
2082
+ : await withTimeout(
2083
+ engine.push({
2084
+ ...(snapshotId === undefined ? {} : { snapshotId }),
2085
+ ...(sections === undefined ? {} : { sections }),
2086
+ }),
2087
+ ROUTE_TIMEOUT_MS,
2088
+ msg('host.syncPushTimeout'),
2089
+ )
2090
+ await writeSyncConfig(syncDir, syncCfg)
2091
+ writeJson(res, 200, report)
2092
+ } catch (error) {
2093
+ writeSyncRouteError(res, error)
2094
+ }
2095
+ }),
2096
+ },
2097
+ // ------------------------------------------------------ sync/pull
2098
+ // m-sync-ui:拉取差异预览(只读:list/download → 转临时 ZIP → Importer 分析出计划摘要)。
2099
+ // 绝不直接写配置、绝不执行导入(executeImportPlan 由上层按用户确认驱动)。
2100
+ {
2101
+ kind: 'exact',
2102
+ path: API.syncPull,
2103
+ handler: async (req, res) => {
2104
+ if (!guard(req, res, 'POST')) return
2105
+ const body = await readJsonBody(req)
2106
+ if (body === undefined) {
2107
+ writeJson(res, 400, { error: 'invalid JSON body' })
2108
+ return
2109
+ }
2110
+ try {
2111
+ const syncCfg = await prepareSync(body)
2112
+ const engine = makeSyncEngine(syncCfg)
2113
+ // 缺省 replace:明文同步的产品语义是「远端值覆盖本地」,不做 diff/合并
2114
+ const strategy =
2115
+ body['strategy'] === 'merge' || body['strategy'] === 'skipExisting' ? body['strategy'] : 'replace'
2116
+ const snapshotId =
2117
+ typeof body['snapshotId'] === 'string' && body['snapshotId'] !== '' ? body['snapshotId'] : undefined
2118
+ const report = await withTimeout(
2119
+ engine.pull({
2120
+ strategy,
2121
+ ...(snapshotId === undefined ? {} : { snapshotId }),
2122
+ }),
2123
+ ROUTE_TIMEOUT_MS,
2124
+ msg('host.syncPullTimeout'),
2125
+ )
2126
+ await writeSyncConfig(syncDir, syncCfg)
2127
+ writeJson(res, 200, report)
2128
+ } catch (error) {
2129
+ writeSyncRouteError(res, error)
2130
+ }
2131
+ },
2132
+ },
2133
+ // -------------------------------------------------- sync/github/start
2134
+ // m-github-oauth:发起 GitHub OAuth device flow。请求 GitHub 取设备码,宿主登记
2135
+ // (flowId → device_code 只存内存),返回 UI 展示用的 user_code + 授权页 URL。
2136
+ // client_id 来自插件配置;未配置时给出可操作指引(不会凭空认证)。
2137
+ {
2138
+ kind: 'exact',
2139
+ path: API.syncGithubStart,
2140
+ handler: async (req, res) => {
2141
+ if (!guard(req, res, 'POST')) return
2142
+ if (githubClientId === undefined || githubClientId === '') {
2143
+ writeJson(res, 400, {
2144
+ error: msg('host.githubMissingClientId'),
2145
+ })
2146
+ return
2147
+ }
2148
+ try {
2149
+ const started = await githubAuth.startDeviceFlow(githubClientId)
2150
+ const flowId = DeviceFlowStore.newFlowId()
2151
+ githubFlows.set(flowId, {
2152
+ deviceCode: started.deviceCode,
2153
+ clientId: githubClientId,
2154
+ clientSecret: githubClientSecret,
2155
+ interval: started.interval,
2156
+ expiresAt: Date.now() + started.expiresIn * 1000,
2157
+ })
2158
+ // device_code 绝不回传;只回 UI 需要的展示信息
2159
+ writeJson(res, 200, {
2160
+ flowId,
2161
+ userCode: started.userCode,
2162
+ verificationUri: started.verificationUri,
2163
+ expiresIn: started.expiresIn,
2164
+ interval: started.interval,
2165
+ })
2166
+ } catch (error) {
2167
+ writeSyncRouteError(res, error)
2168
+ }
2169
+ },
2170
+ },
2171
+ // -------------------------------------------------- sync/github/poll
2172
+ // m-github-oauth:轮询授权结果。凭 flowId 取回宿主登记的 device_code → GitHub 换 token
2173
+ // → 成功则立即写入 DSH credentials(SYNC_CREDENTIAL_REF,与手动 token 同槽),
2174
+ // token 绝不回传浏览器;pending 返回下次轮询延迟;终止态(denied/expired/error)清理登记。
2175
+ {
2176
+ kind: 'exact',
2177
+ path: API.syncGithubPoll,
2178
+ handler: async (req, res) => {
2179
+ if (!guard(req, res, 'POST')) return
2180
+ const body = await readJsonBody(req)
2181
+ const flowId = typeof body?.['flowId'] === 'string' ? body['flowId'] : ''
2182
+ if (flowId === '') {
2183
+ writeJson(res, 400, { error: 'flowId is required' })
2184
+ return
2185
+ }
2186
+ const flow = githubFlows.get(flowId)
2187
+ if (flow === undefined) {
2188
+ writeJson(res, 400, { error: msg('host.githubFlowGone') })
2189
+ return
2190
+ }
2191
+ try {
2192
+ const result = await githubAuth.pollForToken({
2193
+ clientId: flow.clientId,
2194
+ deviceCode: flow.deviceCode,
2195
+ clientSecret: flow.clientSecret,
2196
+ interval: flow.interval,
2197
+ })
2198
+ if (result.status === 'success' && result.accessToken !== undefined) {
2199
+ await credentials.set(credentialRef(SYNC_CREDENTIAL_REF), result.accessToken)
2200
+ githubFlows.delete(flowId)
2201
+ host.log.info('GitHub OAuth 登录成功(token 已写入 DSH credentials)')
2202
+ writeJson(res, 200, { status: 'success', credentialConfigured: true })
2203
+ return
2204
+ }
2205
+ if (result.status === 'pending') {
2206
+ writeJson(res, 200, { status: 'pending', pollDelayMs: result.pollDelayMs })
2207
+ return
2208
+ }
2209
+ // 终止态:清理登记,把状态 + 可展示消息回给 UI(不含任何秘密)
2210
+ githubFlows.delete(flowId)
2211
+ writeJson(res, 200, {
2212
+ status: result.status,
2213
+ ...(result.message !== undefined ? { message: result.message } : {}),
2214
+ ...(result.errorCode !== undefined ? { errorCode: result.errorCode } : {}),
2215
+ })
2216
+ } catch (error) {
2217
+ writeSyncRouteError(res, error)
2218
+ }
2219
+ },
2220
+ },
2221
+ // -------------------------------------------------- sync/github/cancel
2222
+ // m-github-oauth:取消登录流程(丢弃宿主侧 device_code 登记,零副作用)。
2223
+ {
2224
+ kind: 'exact',
2225
+ path: API.syncGithubCancel,
2226
+ handler: async (req, res) => {
2227
+ if (!guard(req, res, 'POST')) return
2228
+ const body = await readJsonBody(req)
2229
+ const flowId = typeof body?.['flowId'] === 'string' ? body['flowId'] : ''
2230
+ if (flowId === '') {
2231
+ writeJson(res, 400, { error: 'flowId is required' })
2232
+ return
2233
+ }
2234
+ githubFlows.delete(flowId)
2235
+ writeJson(res, 200, { ok: true })
2236
+ },
2237
+ },
2238
+ // -------------------------------------------------- sync/github/validate
2239
+ // m-sync-github-valid:校验 SYNC_CREDENTIAL_REF 中已存 token 是否有效(GET /user),
2240
+ // 供 UI 判定「是否已登录」→ 已登录隐藏 GitHub 登录区块、token 失效则重新展示。
2241
+ // 只回布尔 + 登录名(非敏感),token 值绝不回传;仅 401(无效/过期)→ valid:false,
2242
+ // 其余错误(网络/限流)向上抛,由 UI 兜底(不误判登出)。
2243
+ {
2244
+ kind: 'exact',
2245
+ path: API.syncGithubValidate,
2246
+ handler: async (req, res) => {
2247
+ if (!guard(req, res, 'POST')) return
2248
+ try {
2249
+ let configured = false
2250
+ let valid = false
2251
+ let login: string | undefined
2252
+ const resolved = await credentials.resolve(credentialRef(SYNC_CREDENTIAL_REF))
2253
+ const token = resolved?.value ?? ''
2254
+ configured = token !== ''
2255
+ if (configured) {
2256
+ try {
2257
+ const user = await githubAuth.getUser(token)
2258
+ valid = true
2259
+ login = user.login
2260
+ } catch (error) {
2261
+ // 仅 401(token 无效/过期)→ 视为未登录;其余错误(网络/限流)向上抛
2262
+ if (!(error instanceof GitHubAuthError && error.code === 'unauthorized')) throw error
2263
+ }
2264
+ }
2265
+ writeJson(res, 200, {
2266
+ ok: true,
2267
+ configured,
2268
+ valid,
2269
+ ...(login !== undefined ? { login } : {}),
2270
+ })
2271
+ } catch (error) {
2272
+ writeJson(res, 500, { error: redact(error instanceof Error ? error.message : String(error)) })
2273
+ }
2274
+ },
2275
+ },
2276
+ // ------------------------------------------------------ sync/history
2277
+ // 列出本地快照目录的 manifest.json(id/createdAt/sectionHashes/transport)。
2278
+ {
2279
+ kind: 'exact',
2280
+ path: API.syncHistory,
2281
+ handler: async (req, res) => {
2282
+ if (!guard(req, res, 'GET')) return
2283
+ try {
2284
+ const localDir = join(syncDir, 'snapshots')
2285
+ const entries = await fs.readdir(localDir).catch(() => [])
2286
+ const rows: Array<{ id: string; createdAt: string; sectionCount: number; reviewCount: number; transport?: string }> = []
2287
+ for (const name of entries) {
2288
+ const dir = join(localDir, name)
2289
+ const stat = await fs.stat(dir).catch(() => null)
2290
+ if (!stat?.isDirectory()) continue
2291
+ const manifestPath = join(dir, 'manifest.json')
2292
+ const raw = await fs.readFile(manifestPath, 'utf8').catch(() => null)
2293
+ if (raw === null) continue
2294
+ try {
2295
+ const m = JSON.parse(raw) as { id?: unknown; createdAt?: unknown; sectionHashes?: unknown; manifest?: { transport?: unknown } }
2296
+ if (typeof m.id !== 'string' || typeof m.createdAt !== 'string') continue
2297
+ const sectionCount = m.sectionHashes && typeof m.sectionHashes === 'object'
2298
+ ? Object.keys(m.sectionHashes as Record<string, unknown>).length
2299
+ : 0
2300
+ // 触发通道(push/apply 落盘时写入各快照 manifest.transport;旧快照为 undefined)
2301
+ const transport = m.manifest && typeof m.manifest === 'object' && typeof m.manifest.transport === 'string'
2302
+ ? m.manifest.transport
2303
+ : undefined
2304
+ rows.push({ id: m.id, createdAt: m.createdAt, sectionCount, reviewCount: 0, ...(transport !== undefined ? { transport } : {}) })
2305
+ } catch { /* skip malformed */ }
2306
+ }
2307
+ // reviewCount 恒 0:待审队列(sync-review-queue.json)已随合并逻辑一并删除,
2308
+ // 保留该字段仅为与客户端契约兼容。
2309
+ rows.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))
2310
+ // 合并自动同步执行记录(sync-history.json)
2311
+ const hist = await readSyncHistory(syncDir)
2312
+ const merged = [
2313
+ ...rows.map((r) => ({ ...r, kind: 'apply' as const })),
2314
+ ...hist.autosyncEntries.map((e) => ({
2315
+ id: e.createdAt,
2316
+ createdAt: e.createdAt,
2317
+ kind: 'autosync' as const,
2318
+ autosync: e,
2319
+ })),
2320
+ ].sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))
2321
+ writeJson(res, 200, { entries: merged })
2322
+ } catch (error) {
2323
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
2324
+ }
2325
+ },
2326
+ },
2327
+ // ------------------------------------------------------ sync/snapshots-list
2328
+ // m-sync-v2:远端历史快照列表(供「选择历史快照」下拉)。
2329
+ {
2330
+ kind: 'exact',
2331
+ path: API.syncSnapshotsList,
2332
+ handler: async (req, res) => {
2333
+ if (!guard(req, res, 'POST')) return
2334
+ const body = await readJsonBody(req)
2335
+ if (body === undefined) {
2336
+ writeJson(res, 400, { error: 'invalid JSON body' })
2337
+ return
2338
+ }
2339
+ try {
2340
+ const syncCfg = await prepareSync(body)
2341
+ const engine = makeSyncEngine(syncCfg)
2342
+ const metas = await withTimeout(
2343
+ engine.listSnapshots(),
2344
+ ROUTE_TIMEOUT_MS,
2345
+ msg('host.syncPullTimeout'),
2346
+ )
2347
+ const snapshots = [...metas]
2348
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))
2349
+ .map((m) => ({
2350
+ id: m.id,
2351
+ createdAt: m.createdAt,
2352
+ sectionCount: m.manifest.sectionIds.length,
2353
+ platform: m.manifest.platform,
2354
+ dshVersion: m.manifest.dshVersion,
2355
+ }))
2356
+ const state = await loadSyncState(syncDir)
2357
+ writeJson(res, 200, { ok: true, snapshots, currentSnapshotId: state.lastSnapshotId === '' ? undefined : state.lastSnapshotId })
2358
+ } catch (error) {
2359
+ writeSyncRouteError(res, error)
2360
+ }
2361
+ },
2362
+ },
2363
+ // ------------------------------------------------------ sync/sync
2364
+ // m-sync-v2:一键同步第一步 —— 拉取 → 差异确认会话(内存登记临时 ZIP + ImportPlan)。
2365
+ {
2366
+ kind: 'exact',
2367
+ path: API.syncSync,
2368
+ handler: async (req, res) => {
2369
+ if (!guard(req, res, 'POST')) return
2370
+ const body = await readJsonBody(req)
2371
+ if (body === undefined) {
2372
+ writeJson(res, 400, { error: 'invalid JSON body' })
2373
+ return
2374
+ }
2375
+ try {
2376
+ const syncCfg = await prepareSync(body)
2377
+ const engine = makeSyncEngine(syncCfg)
2378
+ const snapshotId = typeof body['snapshotId'] === 'string' && body['snapshotId'] !== '' ? body['snapshotId'] : undefined
2379
+ const preview = await withTimeout(
2380
+ engine.preview({ ...(snapshotId === undefined ? {} : { snapshotId }) }),
2381
+ ROUTE_TIMEOUT_MS,
2382
+ msg('host.syncPullTimeout'),
2383
+ )
2384
+ if (!preview.ok || preview.plan === null || preview.analysis === null) {
2385
+ writeJson(res, 200, { ok: false, syncSessionId: '', snapshotId: preview.snapshotId, items: [], needsReview: false, compatibility: 'unsupported', message: preview.message ?? '同步预览失败' })
2386
+ return
2387
+ }
2388
+ const syncSessionId = syncSessions.set({
2389
+ zipPath: preview.zipPath,
2390
+ plan: preview.plan,
2391
+ analysis: preview.analysis,
2392
+ snapshotId: preview.snapshotId,
2393
+ config: syncCfg,
2394
+ })
2395
+ const items = planToConfirmItems(preview.plan)
2396
+ const needsReview = items.some((i) => REVIEW_KINDS.has(i.kind) || isToolchainChangeItem(i))
2397
+ || preview.analysis.pathIssues.length > 0
2398
+ writeJson(res, 200, {
2399
+ ok: true,
2400
+ syncSessionId,
2401
+ snapshotId: preview.snapshotId,
2402
+ items,
2403
+ needsReview,
2404
+ compatibility: preview.analysis.compatibility,
2405
+ })
2406
+ } catch (error) {
2407
+ writeSyncRouteError(res, error)
2408
+ }
2409
+ },
2410
+ },
2411
+ // ------------------------------------------------------ sync/apply-items
2412
+ // m-sync-v2:一键同步第二步 —— 按用户对差异项的逐项决策执行导入。
2413
+ {
2414
+ kind: 'exact',
2415
+ path: API.syncApplyItems,
2416
+ handler: withMutationGate('sync-apply', async (req, res, lockCtx, journalCtx) => {
2417
+ if (!guard(req, res, 'POST')) return
2418
+ const body = await readJsonBody(req)
2419
+ if (body === undefined) {
2420
+ writeJson(res, 400, { error: 'invalid JSON body' })
2421
+ return
2422
+ }
2423
+ try {
2424
+ const syncSessionId = typeof body['syncSessionId'] === 'string' ? body['syncSessionId'] : ''
2425
+ const session = syncSessions.get(syncSessionId)
2426
+ if (session === undefined) {
2427
+ writeJson(res, 400, { error: '同步会话不存在或已过期,请重新拉取预览' })
2428
+ return
2429
+ }
2430
+ const adoptions = Array.isArray(body['adoptions']) ? body['adoptions'] : []
2431
+ // 构造子计划(仅含采纳项)
2432
+ const byId = new Map<string, { adopt: boolean; resolution?: string }>()
2433
+ for (const a of adoptions as Array<Record<string, unknown>>) {
2434
+ if (typeof a?.['itemId'] !== 'string') continue
2435
+ byId.set(a['itemId'], { adopt: a['adopt'] === true, resolution: typeof a['resolution'] === 'string' ? a['resolution'] : undefined })
2436
+ }
2437
+ // 构造子计划(仅含采纳项)。同步冲突决策 useRemote → 核心 importer 的
2438
+ // useImported(item 转成 Update,applyOne 才会真正写远端值),
2439
+ // keepLocal/skip 从子计划剔除(keepCurrent/skip 语义:不写)。
2440
+ // 与导入恢复向导(ConflictList keepCurrent/useImported)的决策语义完全一致。
2441
+ const subItems: PlanItem[] = session.plan.items.flatMap((item) => {
2442
+ const d = byId.get(item.id)
2443
+ if (d === undefined || !d.adopt) return []
2444
+ // Conflict 项必须有 resolution;keepLocal/skip 不写入本地 → 剔除
2445
+ if (item.kind === 'Conflict') {
2446
+ if (d.resolution === undefined) throw new SyncRouteError(`冲突项 ${item.id} 必须提供 resolution(useRemote/keepLocal/skip)`)
2447
+ if (d.resolution === 'keepLocal' || d.resolution === 'skip') return []
2448
+ // useRemote → 转成 Update 计划项(镜像 analyzer.applyItemResolution 的
2449
+ // useImported 分支),applyOne 才会把远端值真正写进本地。
2450
+ const c = (item as { conflict?: { itemId?: string } }).conflict
2451
+ return [{
2452
+ ...item,
2453
+ kind: 'Update' as const,
2454
+ severity: 'info' as const,
2455
+ conflict: { itemId: c?.itemId ?? item.id, resolution: 'useImported' as const },
2456
+ } as PlanItem]
2457
+ }
2458
+ return [item]
2459
+ })
2460
+ const subPlan: ImportPlan = {
2461
+ ...session.plan,
2462
+ items: subItems,
2463
+ }
2464
+ // 消费会话(同一 session 只允许一次 apply-items)
2465
+ syncSessions.delete(syncSessionId)
2466
+ let engine: SyncEngine
2467
+ let report: ApplyItemsReport
2468
+ try {
2469
+ engine = makeSyncEngine(session.config)
2470
+ report = await engine.applyItems(session.zipPath, subPlan, {
2471
+ onItem: (info) => { /* 进度可选:runs 已由 applyItems 内部处理 */ },
2472
+ snapshotBinding: journalCtx,
2473
+ })
2474
+ } finally {
2475
+ // 用完再清理临时 ZIP(此前在 applyItems 读取前就删除 → ENOENT:无法读取备份文件)
2476
+ await fs.rm(dirname(session.zipPath), { recursive: true, force: true }).catch(() => { /* 尽力清理临时 ZIP */ })
2477
+ }
2478
+ const historyError = await tryAppendHistory({
2479
+ kind: 'sync-apply',
2480
+ result: report.ok ? 'success' : 'failed',
2481
+ sections: Array.isArray(report.applied) ? report.applied.filter((s): s is string => typeof s === 'string') : subItems.map((i) => (i as { adapter?: string }).adapter).filter((s): s is string => typeof s === 'string' && s !== ''),
2482
+ operationId: journalCtx?.operationId,
2483
+ snapshotId: report.restoreId ?? undefined,
2484
+ source: 'api',
2485
+ summary: `一键同步应用:${(Array.isArray(report.applied) ? report.applied.length : subItems.length)} 项${report.rolledBack === true ? '(已回滚)' : ''}`,
2486
+ error: report.ok ? undefined : '同步应用未完全成功',
2487
+ })
2488
+ writeJson(res, 200, historyError === undefined ? {
2489
+ ok: report.ok,
2490
+ applied: report.applied,
2491
+ skipped: subItems.map((i) => i.id),
2492
+ needsRestart: report.needsRestart === true,
2493
+ warnings: report.warnings,
2494
+ restoreId: report.restoreId,
2495
+ rolledBack: report.rolledBack,
2496
+ failed: report.failed,
2497
+ result: report.result,
2498
+ } : {
2499
+ ok: report.ok,
2500
+ applied: report.applied,
2501
+ skipped: subItems.map((i) => i.id),
2502
+ needsRestart: report.needsRestart === true,
2503
+ warnings: report.warnings,
2504
+ restoreId: report.restoreId,
2505
+ rolledBack: report.rolledBack,
2506
+ failed: report.failed,
2507
+ result: report.result,
2508
+ historyWriteError: historyError,
2509
+ })
2510
+ } catch (error) {
2511
+ writeSyncRouteError(res, error)
2512
+ }
2513
+ }, { deferredSnapshot: true }),
2514
+ },
2515
+ // ------------------------------------------------------ sync/cancel
2516
+ // m-sync-v2:取消 / 清理差异确认会话(丢弃临时 ZIP,零副作用)。
2517
+ {
2518
+ kind: 'exact',
2519
+ path: API.syncCancel,
2520
+ handler: async (req, res) => {
2521
+ if (!guard(req, res, 'POST')) return
2522
+ const body = await readJsonBody(req)
2523
+ if (body === undefined) {
2524
+ writeJson(res, 400, { error: 'invalid JSON body' })
2525
+ return
2526
+ }
2527
+ try {
2528
+ const syncSessionId = typeof body['syncSessionId'] === 'string' ? body['syncSessionId'] : ''
2529
+ if (syncSessionId !== '') {
2530
+ const session = syncSessions.get(syncSessionId)
2531
+ if (session !== undefined) {
2532
+ await fs.rm(dirname(session.zipPath), { recursive: true, force: true }).catch(() => { /* 尽力清理临时 ZIP */ })
2533
+ }
2534
+ syncSessions.delete(syncSessionId)
2535
+ }
2536
+ writeJson(res, 200, { ok: true })
2537
+ } catch (error) {
2538
+ writeSyncRouteError(res, error)
2539
+ }
2540
+ },
2541
+ },
2542
+ // ------------------------------------------------------ sync/autosync
2543
+ // m-sync-v2:自动同步配置读写(按通道:git/webdav 各自的开关 + 间隔 + 启动阈值 + 状态)。
2544
+ // GET = 读全部通道状态({ git, webdav });POST = 写指定通道(body.transport,缺省 git)。
2545
+ // 同一路径注册为一个 exact 路由(方法内部分发),避免 webserver 对重复 exact 路径报错。
2546
+ {
2547
+ kind: 'exact',
2548
+ path: API.syncAutosync,
2549
+ handler: async (req, res) => {
2550
+ if (req.method === 'GET') {
2551
+ if (!guard(req, res, 'GET')) return
2552
+ try {
2553
+ writeJson(res, 200, await buildAutosyncStatusByChannel(syncDir))
2554
+ } catch (error) {
2555
+ writeSyncRouteError(res, error)
2556
+ }
2557
+ return
2558
+ }
2559
+ if (!guard(req, res, 'POST')) return
2560
+ const body = await readJsonBody(req)
2561
+ if (body === undefined) {
2562
+ writeJson(res, 400, { error: 'invalid JSON body' })
2563
+ return
2564
+ }
2565
+ try {
2566
+ // 按通道读写:git/webdav 各自的自动同步配置与运行状态独立(缺省 git 兜底)
2567
+ const channel: SyncTransportType = body['transport'] === 'webdav' ? 'webdav' : 'git'
2568
+ const cfg = await readAutosyncConfig(syncDir, channel)
2569
+ if (typeof body['enabled'] === 'boolean') cfg.enabled = body['enabled']
2570
+ if (typeof body['interval'] === 'string' && isAutosyncInterval(body['interval'])) cfg.interval = body['interval']
2571
+ if (typeof body['startupMinIntervalMs'] === 'number' && Number.isFinite(body['startupMinIntervalMs']) && body['startupMinIntervalMs'] > 0) {
2572
+ cfg.startupMinIntervalMs = body['startupMinIntervalMs']
2573
+ }
2574
+ await writeAutosyncConfig(syncDir, channel, cfg)
2575
+ if (scheduler) scheduler.reload().catch(() => { /* 尽力而为 */ })
2576
+ writeJson(res, 200, await buildAutosyncStatus(syncDir, channel))
2577
+ } catch (error) {
2578
+ writeSyncRouteError(res, error)
2579
+ }
2580
+ },
2581
+ },
2582
+ // ------------------------------------------------------ sync/selection
2583
+ // m-sync-selection:保存同步分区选择(按通道:git/webdav 各自的模式 + 勾选分区)。
2584
+ // 持久化到 sync-selection.json;自动同步调度器与手动 push 共用(makeSyncEngine 注入)。
2585
+ // sections 元素必须是可同步(portable)分区 id;mode 非法 → 回退 default。
2586
+ {
2587
+ kind: 'exact',
2588
+ path: API.syncSelection,
2589
+ handler: async (req, res) => {
2590
+ if (!guard(req, res, 'POST')) return
2591
+ const body = await readJsonBody(req)
2592
+ if (body === undefined) {
2593
+ writeJson(res, 400, { error: 'invalid JSON body' })
2594
+ return
2595
+ }
2596
+ try {
2597
+ // 按通道读写:git/webdav 各自的模式与分区勾选独立(缺省 git 兜底)
2598
+ const channel: SyncTransportType = body['transport'] === 'webdav' ? 'webdav' : 'git'
2599
+ const mode: SyncSelectionMode = body['mode'] === 'advanced' ? 'advanced' : 'default'
2600
+ const rawSections = Array.isArray(body['sections']) ? body['sections'] : []
2601
+ // 目录已含全部分区(不再按 portability 过滤),变量名不沿用 portable*
2602
+ const catalogSectionIds = new Set(syncSectionCatalog.map((s) => s.id))
2603
+ for (const s of rawSections) {
2604
+ if (typeof s !== 'string' || s === '') {
2605
+ writeJson(res, 400, { error: 'sections must be an array of non-empty strings' })
2606
+ return
2607
+ }
2608
+ if (!catalogSectionIds.has(s as SectionId)) {
2609
+ writeJson(res, 400, { error: `unknown sync section: ${s}` })
2610
+ return
2611
+ }
2612
+ }
2613
+ const next: SyncSelection = {
2614
+ schemaVersion: SYNC_SELECTION_SCHEMA_VERSION,
2615
+ mode,
2616
+ sections: [...new Set(rawSections as string[])] as SectionId[],
2617
+ }
2618
+ await writeSyncSelection(syncDir, channel, next)
2619
+ selectionCache[channel] = next
2620
+ writeJson(res, 200, { ok: true, transport: channel, mode: next.mode, sections: next.sections })
2621
+ } catch (error) {
2622
+ writeSyncRouteError(res, error)
2623
+ }
2624
+ },
2625
+ },
2626
+ // ------------------------------------------------------ sync/rollback
2627
+ // P2:UI 一键回滚入口(按 apply 返回的 restoreId 调用 backup→rollback)。
2628
+ {
2629
+ kind: 'exact',
2630
+ path: API.syncRollback,
2631
+ handler: withMutationGate('sync-rollback', async (req, res) => {
2632
+ if (!guard(req, res, 'POST')) return
2633
+ const body = await readJsonBody(req)
2634
+ if (body === undefined) {
2635
+ writeJson(res, 400, { error: 'invalid JSON body' })
2636
+ return
2637
+ }
2638
+ try {
2639
+ const restoreId = typeof body['restoreId'] === 'string' ? body['restoreId'] : ''
2640
+ if (restoreId === '') {
2641
+ writeJson(res, 400, { error: 'restoreId required' })
2642
+ return
2643
+ }
2644
+ const store = new FileSnapshotStore({ dir: join(syncDir, 'snapshots') })
2645
+ const snap = await store.load(restoreId)
2646
+ const report = await performRollback({ ctx: host, snapshot: snap, store, adapters })
2647
+ const historyError = await tryAppendHistory({
2648
+ kind: 'rollback',
2649
+ result: 'success',
2650
+ sections: [],
2651
+ snapshotId: restoreId,
2652
+ source: 'api',
2653
+ summary: `一键同步回滚(${restoreId})`,
2654
+ })
2655
+ writeJson(res, 200, historyError === undefined ? { ok: true, full: report.full } : { ok: true, full: report.full, historyWriteError: historyError })
2656
+ } catch (error) {
2657
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
2658
+ }
2659
+ }),
2660
+ },
2661
+ // ---------------------------------------------------- market/status
2662
+ // 内置单市场(只读、不可编辑):恒返回内置仓库摘要。无 add/remove —— 市场绑定内置仓库。
2663
+ // ---------------------------------------------------- market/refresh
2664
+ // ---------------------------------------------------- market/browse
2665
+ // ---------------------------------------------------- market/download
2666
+ // 拉取 manifest + config.zip → 安全校验(§6)→ valid 则落受控临时区 + dry-run 分析/计划。
2667
+ // 真正落盘由用户确认后走既有 POST /execute(zipPath + plan)。零写入到确认。
2668
+ // ---------------------------------------------------- market/prepare
2669
+ // 发布向导:由「用户上传的配置 zip + 用户填写元数据」生成市场条目包
2670
+ // (L2 manifest + config.zip SHA-256 + sections),供 UI 展示/复制与引导推送。
2671
+ // 零写入配置:只在受控临时区生成发布目录;插件不做任何 git 写操作、不持有凭据。
2672
+ // ---------------------------------------------------- me/status
2673
+ // 「一键上传 / 我的配置」登录状态:resolve SYNC_CREDENTIAL_REF token → GET /user。
2674
+ // 401 → loggedIn:false(未登录);token 值不出模块外,只回传 login 用户名。
2675
+ // ---------------------------------------------------- me/upload
2676
+ // 一键上传:zipPath 必须来自受控上传临时区(复用 /market/prepare 规则);
2677
+ // form 仅 { name, description?, categories? }(name 必填);元数据全自动由 MyRepoService 生成。
2678
+ // ---------------------------------------------------- me/items
2679
+ // 查看已上传:读用户仓库 index.json + 收录状态(未收录 / PR 待审核 / 已收录)。
2680
+ // 401(token 过期)→ 401 + 脱敏错误,UI 引导重新登录。
2681
+ // ---------------------------------------------------- me/update
2682
+ // 一键更新:同 upload 时序;version 纯自动 +1、id 不变;PR 未合并 force push 更新 / 已合并基于最新 main 重开。
2683
+ // ---------------------------------------------------- me/listing
2684
+ // 查询收录/下架任务状态(结果卡轮询):任务表命中 → 直接返回;未命中 → 回退 GitHub 实况推导;
2685
+ // 无任务且无实况 → 200 null。401(token 过期)→ 401,UI 引导重新登录。
2686
+ // ---------------------------------------------------- me/relist
2687
+ // 重新提交收录(收录失败 / 进程重启丢失后的一键重试):幂等复用已存在 fork/open PR。
2688
+ // ---------------------------------------------------- me/delete
2689
+ // 删除条目:同步删用户仓库索引 + items/<id>/ 文件;已收录 → 后台异步提下架 PR;待审核 → 关闭收录 PR。
2690
+ // ------------------------------------------------------------ history
2691
+ // Phase 6:迁移历史审计(统一历史引擎)。只读 GET:列表(过滤)+ 导出。
2692
+ // loopback fence(guard)与全仓一致——仅同源 + loopback 可访问。
2693
+ // ------------------------------------------------------------ recovery
2694
+ // Phase 5:recovery 编排(prefix 路由,内部按 path 分发)。
2695
+ // 禁用 withMutationGate(避免 double-journal);mutation 路由经 withMutationLock + loopback fence。
2696
+ // ------------------------------------------------------------ Phase 1 P0
2697
+ // 配置生命周期(自动快照 / 撤销 / 重做):prefix 路由,内部按 path 分发。
2698
+ // mutation 动作经 withMutationGate(GLOBAL 锁 + SAFE MODE 闸门);status 只读不加锁。
2699
+ // 崩溃归因(P0-5):只读。上次启动是否异常 + 归因 + 建议动作 + last-good 快照。
2700
+ // 启动救援模式(P0-3):on = 备份三处原件后,把 profile patch 改写成只挂载本插件的最小内容、
2701
+ // 置空 home patch,并把 dsh.profile.bundles 收窄为 DSH 核心(@deepseek-ai/*)与本插件自身
2702
+ // —— 其余用户插件本次启动不挂载(这才是「禁用其它插件」,只中和 patch 层救不了
2703
+ // 「插件代码自己把 DSH 搞挂」);off = 从备份完整还原。两侧都只动
2704
+ // cordis.patch.yml / package.json / state.json,可完全回退。
2705
+ ]
2706
+ return { routes: routesList, scheduler, makeSyncEngine, lifecycle }
2707
+ }
2708
+
2709
+ /* ------------------------------------------------------------------ apply */
2710
+
2711
+ /**
2712
+ * Mount the config-manager engine: host context, adapters, and the
2713
+ * /api/dsh-config-manager routes (when a webServer is present).
2714
+ * @param ctx - host plugin context carrying settings/credentials/webServer.
2715
+ * @param config - resolved plugin config.
2716
+ */
2717
+ export function apply(ctx: Context, config?: Config): void {
2718
+ if (config?.enabled === false) return
2719
+
2720
+ const homeDir = resolveDshHome()
2721
+ const dataDir = config?.dataDir !== undefined && config.dataDir !== ''
2722
+ ? resolve(config.dataDir)
2723
+ : dshHomePath('dsh-config-manager')
2724
+ const exportsDir = join(dataDir, 'exports')
2725
+ const tmpDir = join(dataDir, 'tmp')
2726
+ const snapshotsDir = join(dataDir, 'snapshots')
2727
+ const syncDir = join(dataDir, 'sync')
2728
+ // Phase 6:迁移历史审计目录(统一历史引擎;加入 RESERVED_INTERNAL_PREFIXES 防 F23 投毒链)
2729
+ const historyDir = join(dataDir, MIGRATION_HISTORY_DIR)
2730
+ mkdirSync(exportsDir, { recursive: true })
2731
+ /**
2732
+ * Phase 1 P0-5 崩溃归因:启动即写 boot-state(ok:false),待启动分类完成后翻 ok:true。
2733
+ * 崩溃瞬间不写文件 —— 靠「下一次启动发现上次 ok!==true」归因。上次若有崩溃且尚无归因,
2734
+ * 此处扫一次日志尾部并把 crashReason 持久化(日志会被滚动覆盖,错过就没了)。
2735
+ * best-effort:任何失败都不得影响插件挂载。
2736
+ */
2737
+ const bootStateDir = join(dataDir, 'config-snapshots')
2738
+ const bootBegin = async (): Promise<void> => {
2739
+ try {
2740
+ const prev = await readBootState(bootStateDir)
2741
+ const next = beginBoot(process.pid, prev)
2742
+ if (prev !== null && prev.ok !== true && next.crashReason === null) {
2743
+ try {
2744
+ const alert = computeBootAlert(prev, await readCrashLogTail(await listCandidateLogs(homeDir)))
2745
+ if (alert.crashReason !== null) next.crashReason = alert.crashReason
2746
+ } catch { /* 归因失败不影响启动 */ }
2747
+ }
2748
+ await writeBootState(bootStateDir, next)
2749
+ } catch (error) {
2750
+ host.log.warn('boot-state 写入失败(不影响挂载)', { error: error instanceof Error ? error.message : String(error) })
2751
+ }
2752
+ }
2753
+ // 灾备总开关关闭时不写 boot-state(崩溃归因整体下线,也不留观测残留)。
2754
+ if (LIFECYCLE_ENABLED) void bootBegin()
2755
+ mkdirSync(tmpDir, { recursive: true })
2756
+ mkdirSync(snapshotsDir, { recursive: true })
2757
+ mkdirSync(syncDir, { recursive: true })
2758
+ mkdirSync(historyDir, { recursive: true })
2759
+
2760
+ const host = new ConfigManagerHostContext(ctx, homeDir, resolveProfileName(config))
2761
+ // issue #30:出站代理(插件私有,不改全局)。仅在检测到 HTTP(S)_PROXY 时生效;未配置则完全直连。
2762
+ // 打一条脱敏日志,便于用户确认「插件当前到底走没走代理」(凭据不进日志)。
2763
+ const proxySummary = activeProxySummary()
2764
+ if (proxySummary !== null) {
2765
+ host.log.info(
2766
+ `出站请求经代理 / outbound requests via proxy: http=${proxySummary.http ?? '(none)'} https=${proxySummary.https ?? '(none)'} noProxyEntries=${proxySummary.noProxyEntries}`,
2767
+ )
2768
+ }
2769
+ // Phase 2 跨进程环境锁:全局唯一 GLOBAL EXCLUSIVE MUTATION LOCK(<dataDir>/locks/environment.lock)。
2770
+ // 所有 destructive mutation 入口经 runWithMutationLock(host.mutationLock, …) 获取;跨进程/跨 kind 互斥。
2771
+ // 随插件生命周期停止:停止 heartbeat 并清除本进程持有(release 由各入口 finally 保证;这里无需额外清理)。
2772
+ host.mutationLock = new EnvironmentLockManager({
2773
+ dataDir,
2774
+ op: 'config-manager',
2775
+ target: 'global-mutation',
2776
+ lockVersion: PLUGIN_VERSION,
2777
+ })
2778
+ const envLockManager = host.mutationLock as EnvironmentLockManager
2779
+ // Phase 3:启动 reconcile(只读)+ SAFE MODE。宿主 apply() 为同步 →
2780
+ // ① 先同步探测 durable SAFE MODE 标记(scheduler.start() 前即被阻断),
2781
+ // ② 再异步跑完整只读 reconcile,刷新标志与 durable 标记。不自动 recover stale lock(Rev 3 P1-NEW-2)。
2782
+ // Phase 4 F21/F11:注入真实 snapshotExists 正向校验——journal 引用的 snapshot 存在 + READY +
2783
+ // verified(manifest/blob hash)+ op/env/owner binding 匹配 journal,才视为可回滚的有效 recovery 证据。
2784
+ const phase3Recovery = new Phase3Recovery({
2785
+ dataDir,
2786
+ packageVersion: PLUGIN_VERSION,
2787
+ snapshotExists: async (snapshotId, binding) => {
2788
+ if (snapshotId === null || snapshotId === '') return false
2789
+ if (!isValidSnapshotId(snapshotId)) return false
2790
+ const v = await verifySnapshot(snapshotsDir, snapshotId)
2791
+ if (!v.ok) return false
2792
+ // binding 校验:journal 引用必须与快照双向一致(operationId/ownerInstanceId/environmentFingerprint)
2793
+ const snap = await new FileSnapshotStore({ dir: snapshotsDir }).load(snapshotId).catch(() => null)
2794
+ if (snap === null) return false
2795
+ if (snap.readiness !== 'READY') return false
2796
+ if (binding?.operationId !== undefined && snap.operationId !== binding.operationId) return false
2797
+ if (binding?.ownerInstanceId !== undefined && snap.ownerInstanceId !== binding.ownerInstanceId) return false
2798
+ if (binding?.environmentFingerprint !== undefined && snap.environmentFingerprint !== binding.environmentFingerprint) return false
2799
+ return true
2800
+ },
2801
+ })
2802
+ host.safeModeIsBlocked = () => phase3Recovery.safeModeActive
2803
+ host.phase3Recovery = phase3Recovery
2804
+ phase3Recovery.probeSafeModeSync()
2805
+ if (phase3Recovery.safeModeActive) {
2806
+ host.log.warn('Phase 3 SAFE MODE 激活:存在未恢复的 transaction,destructive 操作被阻断(如需恢复请先显式处理)')
2807
+ }
2808
+ // P1-B:启动 recovery 分类 barrier。调度器(AutoSync/Backup)只在分类完成且 state=NORMAL 时启动。
2809
+ // schedulerGate.start 由 makeRoutes 返回 scheduler 后赋值;apply 为同步,
2810
+ // 故在该异步分类块 await 完成前,schedulerGate.start 通常已就绪。fail-closed:分类抛错 → 不启动调度器。
2811
+ const schedulerGate = { start: null as (() => void) | null }
2812
+ let startupStateResolved = false
2813
+ let shouldStartSchedulers = false
2814
+ void (async () => {
2815
+ try {
2816
+ await phase3Recovery.initFingerprint()
2817
+ const lockInsp = await envLockManager.inspectLockState()
2818
+ // P1-A:启动 barrier 前捕获 crashed stale ownership 证据(environment.lock owner.instanceId),
2819
+ // 并将其作为 expectedOwnershipInstanceId 传入分类 env → 激活 journal↔ownership binding 校验。
2820
+ const staleOwnerId = lockInsp.state === 'STALE_LOCK_DETECTED' || lockInsp.state === 'UNKNOWN_STATE'
2821
+ ? await phase3Recovery.captureStaleOwnershipInstanceId()
2822
+ : null
2823
+ const startupState = classifyStartup({
2824
+ store: phase3Recovery.store,
2825
+ hooks: phase3Recovery.recoveryHooks,
2826
+ env: {
2827
+ environmentFingerprint: phase3Recovery.recoveryEnvFingerprint,
2828
+ isLiveOwner: async () => false,
2829
+ ...(staleOwnerId ? { expectedOwnershipInstanceId: staleOwnerId } : {}),
2830
+ },
2831
+ lockState: mapLockStateForStartup(lockInsp.state),
2832
+ })
2833
+ const { state } = await startupState.classify()
2834
+ startupStateResolved = true
2835
+ phase3Recovery.safeModeActive = phase3Recovery.safeModeActive || ['RECOVERY_REQUIRED', 'NEEDS_ATTENTION', 'UNKNOWN_STATE'].includes(state.kind)
2836
+ shouldStartSchedulers = (state.kind === 'NORMAL')
2837
+ if (state.kind === 'RECOVERY_REQUIRED' || state.kind === 'NEEDS_ATTENTION') {
2838
+ host.log.warn(`Phase 3 ${state.kind}:上次 destructive operation 崩溃残留,需显式恢复;destructive 调度器未启动(read-only host 存活)`)
2839
+ } else if (shouldStartSchedulers && schedulerGate.start !== null) {
2840
+ schedulerGate.start()
2841
+ }
2842
+ // Phase 1 P0-5:启动分类已得出结论 → 本次启动判定为成功(推进 lastGoodAt)。
2843
+ // 灾备总开关关闭时不写 boot-state。
2844
+ if (LIFECYCLE_ENABLED) {
2845
+ try {
2846
+ await writeBootState(bootStateDir, markBootOk((await readBootState(bootStateDir)) ?? beginBoot(process.pid, null)))
2847
+ } catch (error) {
2848
+ host.log.warn('boot-state 标记成功失败', { error: error instanceof Error ? error.message : String(error) })
2849
+ }
2850
+ }
2851
+ } catch (err) {
2852
+ // fail-closed:inspectStartup 抛错不默认 NORMAL → 不启动调度器(read-only host 存活)
2853
+ startupStateResolved = true
2854
+ shouldStartSchedulers = false
2855
+ // fail-closed 也是「本次启动成功了」:host 存活(read-only),不该被判为崩溃。
2856
+ if (LIFECYCLE_ENABLED) {
2857
+ try {
2858
+ await writeBootState(bootStateDir, markBootOk((await readBootState(bootStateDir)) ?? beginBoot(process.pid, null)))
2859
+ } catch { /* best-effort */ }
2860
+ }
2861
+ host.log.warn('Phase 3 启动 reconcile 失败(fail-closed:destructive 调度器不启动)', {
2862
+ error: err instanceof Error ? err.message : String(err),
2863
+ })
2864
+ }
2865
+ })()
2866
+ // 缓存自动清理:启动即清一次 + 每 24h 定时清一次。
2867
+ // 只清「可重建/一次性」缓存与临时文件(tmp 暂存、exports 导出副本、market cache/work),
2868
+ // 保留期内的文件不删(供刷新恢复导入/下载等窗口继续消费);snapshots 与 sync 属用户数据/安全网不动。
2869
+ // 尽力而为:任何失败仅记日志,不影响插件挂载与其他功能。
2870
+ const runCacheCleanup = (): void => {
2871
+ void cleanupCaches({
2872
+ tmpDir,
2873
+ exportsDir,
2874
+ })
2875
+ .then((report) => {
2876
+ if (report.removed > 0) {
2877
+ host.log.info('缓存自动清理完成', { removed: report.removed, freedBytes: report.freedBytes })
2878
+ }
2879
+ })
2880
+ .catch((error) => {
2881
+ host.log.warn('缓存自动清理失败', { error: error instanceof Error ? error.message : String(error) })
2882
+ })
2883
+ }
2884
+ runCacheCleanup()
2885
+ const cacheCleanupTimer = setInterval(runCacheCleanup, CACHE_CLEANUP_INTERVAL_MS)
2886
+ ctx.effect(() => () => clearInterval(cacheCleanupTimer), 'config-manager: cache cleanup scheduler')
2887
+ // self 分区目录(相对 ~/.dsh 根):dataDir 在 homeDir 下 → 用相对路径挂载 self adapter;
2888
+ // 自定义 dataDir 位于 ~/.dsh 之外时 Host fs 门面无法覆盖(confined to home root),
2889
+ // 不挂 self 分区并告警(其余分区不受影响)。
2890
+ const selfRel = relative(homeDir, dataDir)
2891
+ const selfDir = !selfRel.startsWith('..') && !isAbsolute(selfRel) && selfRel !== '' ? selfRel : ''
2892
+ if (selfDir === '') {
2893
+ host.log.warn(`dataDir 不在 ~/.dsh 之下(${dataDir}),self 分区(插件自身配置备份)不挂载`)
2894
+ }
2895
+ const adapters = createAdapters({
2896
+ // Namespace list = everything the settings service has registered.
2897
+ namespaces: async () => (await ctx.settings.describe({ redactSecrets: true })).map((d) => String(d.ns)),
2898
+ // Sessions 分区默认关(含敏感内容):挂载 adapter 供 Custom Export 显式勾选(§3.3/§15)。
2899
+ includeSessions: true,
2900
+ // 导出 plugins 分区时不列本插件自身,避免备份中的自引用条目。
2901
+ selfPluginName: PLUGIN_NAME,
2902
+ // pluginFiles 扩展:额外白名单文件 + 约定配置目录(都相对 ~/.dsh 根),支持导出更多插件配置。
2903
+ pluginFiles: config?.pluginFiles,
2904
+ pluginFilesDir: config?.pluginFilesDir,
2905
+ // self 分区:插件自身配置(sync-*.json / market-config.json / ui-prefs.json);'' = 不挂载
2906
+ selfDir,
2907
+ // T1:本地源(link:/file:)插件打包 —— 这些 spec 指向本机路径,换机后必然不可达,
2908
+ // 曾导致插件被静默丢失。导出时用 npm pack 把源码包一并放进备份,
2909
+ // 导入时用解包出的绝对路径重写 spec(见 adapters/plugins.ts 的 applyItem)。
2910
+ localPluginPack: createLocalPluginPackHook({ homeDir, dataDir }),
2911
+ })
2912
+
2913
+ host.log.info('config-manager 已挂载', {
2914
+ homeDir,
2915
+ dataDir,
2916
+ dshVersion: host.dshVersion,
2917
+ adapters: adapters.map((a) => a.id),
2918
+ })
2919
+
2920
+ // Phase 6:迁移历史引擎(统一审计史;per-file append-only 存储于 <dataDir>/migration-history)
2921
+ const historyStore = new MigrationStore({ dir: historyDir })
2922
+ const runs = new RunRegistry({ msg: host.msg })
2923
+ const secretScanner = createConfiguredSecretScanner(config?.personalPatterns)
2924
+ const { routes, scheduler, makeSyncEngine, lifecycle } = makeRoutes({
2925
+ host,
2926
+ adapters,
2927
+ exportsDir,
2928
+ tmpDir,
2929
+ snapshotsDir,
2930
+ runs,
2931
+ syncDir,
2932
+ dataDir,
2933
+ // F2:部署者 personalPatterns → 强化 secret 扫描器(未配置 = 默认行为)。
2934
+ // 该扫描器实现了 scanText(文件类分区文本级扫描,G-09 只告警不改写)——
2935
+ // 换成任何没有 scanText 的扫描器都会静默关闭 G-09,改这里务必先看 exporter.ts 的 scanFileSectionText。
2936
+ scanner: secretScanner, // M1:与同步路径共用同一实例(见上方 secretScanner 构造)
2937
+ credentials: ctx.credentials,
2938
+ githubClientId: config?.githubClientId ?? DEFAULT_GITHUB_CLIENT_ID,
2939
+ githubClientSecret: config?.githubClientSecret,
2940
+ history: historyStore,
2941
+ })
2942
+ // Agent 可调用的模型工具(P0-1):复用 src/core 引擎与同一 makeSyncEngine 来源。
2943
+ // 不依赖 webServer:host 侧能力在无 Web 部署时仍可用;tools 服务未组合时内部守卫跳过。
2944
+ registerModelTools(ctx, {
2945
+ host,
2946
+ adapters,
2947
+ syncDir,
2948
+ makeSyncEngine,
2949
+ })
2950
+ // P1-B:调度器不同步 start —— 由启动 recovery 分类完成后(仅 NORMAL)启动。
2951
+ schedulerGate.start = () => {
2952
+ scheduler.start();
2953
+ // Phase 1 P0-1:配置变更自动快照。放在同一闸门内,确保恢复/事务进行中不开拍。
2954
+ // 灾备总开关关闭时不启动监听(否则后台持续采集全部分区并刷「超出上限」告警)。
2955
+ if (LIFECYCLE_ENABLED) lifecycle.startAutoSnapshot();
2956
+ }
2957
+ // 若启动分类已在此构造完成前解析为 NORMAL(罕见竞态),立即补启动。
2958
+ if (startupStateResolved && shouldStartSchedulers && schedulerGate.start !== null) { schedulerGate.start(); }
2959
+ // 自动同步调度器随插件生命周期停止:插件重载/卸载时清理定时器,
2960
+ // 避免旧调度器残留导致重复后台同步。
2961
+ ctx.effect(() => () => scheduler.stop(), 'config-manager: autosync scheduler')
2962
+ // Phase 1 P0-1:停止配置变更监听(dispose 后不再产生自动快照)。
2963
+ ctx.effect(() => () => lifecycle.dispose(), 'config-manager: config lifecycle watcher')
2964
+ const webServer = readService<WebServer>(ctx, 'webServer')
2965
+ if (webServer === undefined) {
2966
+ host.log.warn('webServer 服务不可用:跳过 /api/dsh-config-manager 路由注册(引擎能力仍可用)')
2967
+ return
2968
+ }
2969
+ ctx.effect(() => {
2970
+ const disposers = routes.map((route) => webServer.register(route))
2971
+ return () => {
2972
+ for (const dispose of disposers) dispose()
2973
+ }
2974
+ }, 'config-manager: routes')
2975
+ }