@logictan/dsh-config-manager 0.1.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (429) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/cordis.patch.yml +21 -0
  4. package/lib/adapters/agent-instructions.d.ts +13 -0
  5. package/lib/adapters/agent-instructions.js +46 -0
  6. package/lib/adapters/agent-presets.d.ts +13 -0
  7. package/lib/adapters/agent-presets.js +14 -0
  8. package/lib/adapters/credentials.d.ts +37 -0
  9. package/lib/adapters/credentials.js +122 -0
  10. package/lib/adapters/file-collection.d.ts +15 -0
  11. package/lib/adapters/file-collection.js +127 -0
  12. package/lib/adapters/index.d.ts +57 -0
  13. package/lib/adapters/index.js +55 -0
  14. package/lib/adapters/link-report.d.ts +16 -0
  15. package/lib/adapters/link-report.js +47 -0
  16. package/lib/adapters/mcp.d.ts +28 -0
  17. package/lib/adapters/mcp.js +178 -0
  18. package/lib/adapters/plugin-files.d.ts +18 -0
  19. package/lib/adapters/plugin-files.js +142 -0
  20. package/lib/adapters/plugins.d.ts +41 -0
  21. package/lib/adapters/plugins.js +603 -0
  22. package/lib/adapters/pnpm-workspace.d.ts +26 -0
  23. package/lib/adapters/pnpm-workspace.js +147 -0
  24. package/lib/adapters/prompts.d.ts +30 -0
  25. package/lib/adapters/prompts.js +231 -0
  26. package/lib/adapters/providers.d.ts +34 -0
  27. package/lib/adapters/providers.js +155 -0
  28. package/lib/adapters/self.d.ts +17 -0
  29. package/lib/adapters/self.js +71 -0
  30. package/lib/adapters/sessions.d.ts +14 -0
  31. package/lib/adapters/sessions.js +15 -0
  32. package/lib/adapters/settings.d.ts +28 -0
  33. package/lib/adapters/settings.js +165 -0
  34. package/lib/adapters/skills.d.ts +13 -0
  35. package/lib/adapters/skills.js +14 -0
  36. package/lib/adapters/test-helpers.d.ts +125 -0
  37. package/lib/adapters/test-helpers.js +236 -0
  38. package/lib/adapters/ui.d.ts +27 -0
  39. package/lib/adapters/ui.js +89 -0
  40. package/lib/adapters/workspaces.d.ts +13 -0
  41. package/lib/adapters/workspaces.js +106 -0
  42. package/lib/client.d.ts +1026 -0
  43. package/lib/client.js +10047 -0
  44. package/lib/core/analyzer.d.ts +99 -0
  45. package/lib/core/analyzer.js +934 -0
  46. package/lib/core/backup.d.ts +138 -0
  47. package/lib/core/backup.js +513 -0
  48. package/lib/core/boot-rescue.d.ts +146 -0
  49. package/lib/core/boot-rescue.js +618 -0
  50. package/lib/core/cache-cleaner.d.ts +31 -0
  51. package/lib/core/cache-cleaner.js +95 -0
  52. package/lib/core/config-lifecycle.d.ts +180 -0
  53. package/lib/core/config-lifecycle.js +481 -0
  54. package/lib/core/config-snapshot.d.ts +135 -0
  55. package/lib/core/config-snapshot.js +424 -0
  56. package/lib/core/config-state.d.ts +70 -0
  57. package/lib/core/config-state.js +177 -0
  58. package/lib/core/consult-source.d.ts +58 -0
  59. package/lib/core/consult-source.js +260 -0
  60. package/lib/core/crash-report.d.ts +84 -0
  61. package/lib/core/crash-report.js +317 -0
  62. package/lib/core/exporter.d.ts +68 -0
  63. package/lib/core/exporter.js +375 -0
  64. package/lib/core/ghost-sweep.d.ts +19 -0
  65. package/lib/core/ghost-sweep.js +52 -0
  66. package/lib/core/importer.d.ts +60 -0
  67. package/lib/core/importer.js +29 -0
  68. package/lib/core/index.d.ts +14 -0
  69. package/lib/core/index.js +14 -0
  70. package/lib/core/journal.d.ts +197 -0
  71. package/lib/core/journal.js +418 -0
  72. package/lib/core/local-plugin-host.d.ts +35 -0
  73. package/lib/core/local-plugin-host.js +120 -0
  74. package/lib/core/local-plugin-pack.d.ts +135 -0
  75. package/lib/core/local-plugin-pack.js +278 -0
  76. package/lib/core/messages.d.ts +310 -0
  77. package/lib/core/messages.js +621 -0
  78. package/lib/core/migration-consult.d.ts +117 -0
  79. package/lib/core/migration-consult.js +363 -0
  80. package/lib/core/migration-history.d.ts +153 -0
  81. package/lib/core/migration-history.js +411 -0
  82. package/lib/core/model-tools.d.ts +50 -0
  83. package/lib/core/model-tools.js +148 -0
  84. package/lib/core/msg-types.d.ts +8 -0
  85. package/lib/core/msg-types.js +6 -0
  86. package/lib/core/phase3-child-crash.d.ts +1 -0
  87. package/lib/core/phase3-child-crash.js +87 -0
  88. package/lib/core/phase3-host.d.ts +138 -0
  89. package/lib/core/phase3-host.js +359 -0
  90. package/lib/core/phase3-prod-child.d.ts +1 -0
  91. package/lib/core/phase3-prod-child.js +35 -0
  92. package/lib/core/phase4-crash-child.d.ts +1 -0
  93. package/lib/core/phase4-crash-child.js +103 -0
  94. package/lib/core/plugin-cli.d.ts +150 -0
  95. package/lib/core/plugin-cli.js +497 -0
  96. package/lib/core/reconcile.d.ts +120 -0
  97. package/lib/core/reconcile.js +389 -0
  98. package/lib/core/recovery-orchestrator.d.ts +94 -0
  99. package/lib/core/recovery-orchestrator.js +327 -0
  100. package/lib/core/restore.d.ts +162 -0
  101. package/lib/core/restore.js +703 -0
  102. package/lib/core/rollback.d.ts +12 -0
  103. package/lib/core/rollback.js +145 -0
  104. package/lib/core/run-registry.d.ts +85 -0
  105. package/lib/core/run-registry.js +157 -0
  106. package/lib/core/startup-barrier.d.ts +53 -0
  107. package/lib/core/startup-barrier.js +77 -0
  108. package/lib/core/types.d.ts +515 -0
  109. package/lib/core/types.js +27 -0
  110. package/lib/core/undo.d.ts +73 -0
  111. package/lib/core/undo.js +87 -0
  112. package/lib/core/validator.d.ts +18 -0
  113. package/lib/core/validator.js +77 -0
  114. package/lib/core/verify-recovery.d.ts +21 -0
  115. package/lib/core/verify-recovery.js +256 -0
  116. package/lib/core/watcher.d.ts +101 -0
  117. package/lib/core/watcher.js +219 -0
  118. package/lib/index.d.ts +238 -0
  119. package/lib/index.js +2764 -0
  120. package/lib/migrations/index.d.ts +29 -0
  121. package/lib/migrations/index.js +56 -0
  122. package/lib/migrations/v1-to-v2.d.ts +9 -0
  123. package/lib/migrations/v1-to-v2.js +10 -0
  124. package/lib/profiles/index.d.ts +4 -0
  125. package/lib/profiles/index.js +5 -0
  126. package/lib/profiles/profile-manager.d.ts +106 -0
  127. package/lib/profiles/profile-manager.js +543 -0
  128. package/lib/schema/config.d.ts +28 -0
  129. package/lib/schema/config.js +165 -0
  130. package/lib/schema/index.d.ts +24 -0
  131. package/lib/schema/index.js +26 -0
  132. package/lib/schema/manifest.d.ts +28 -0
  133. package/lib/schema/manifest.js +109 -0
  134. package/lib/schema/tombstones.d.ts +46 -0
  135. package/lib/schema/tombstones.js +64 -0
  136. package/lib/schema/types.d.ts +232 -0
  137. package/lib/schema/types.js +8 -0
  138. package/lib/schema/versions.d.ts +27 -0
  139. package/lib/schema/versions.js +50 -0
  140. package/lib/security/encryption.d.ts +85 -0
  141. package/lib/security/encryption.js +279 -0
  142. package/lib/security/index.d.ts +16 -0
  143. package/lib/security/index.js +17 -0
  144. package/lib/security/integrity.d.ts +30 -0
  145. package/lib/security/integrity.js +78 -0
  146. package/lib/security/redaction.d.ts +26 -0
  147. package/lib/security/redaction.js +95 -0
  148. package/lib/security/secret-scanner.d.ts +86 -0
  149. package/lib/security/secret-scanner.js +404 -0
  150. package/lib/security/vault.d.ts +59 -0
  151. package/lib/security/vault.js +151 -0
  152. package/lib/security/zip-security.d.ts +27 -0
  153. package/lib/security/zip-security.js +154 -0
  154. package/lib/sync/autosync-config.d.ts +39 -0
  155. package/lib/sync/autosync-config.js +151 -0
  156. package/lib/sync/autosync-scheduler.d.ts +139 -0
  157. package/lib/sync/autosync-scheduler.js +564 -0
  158. package/lib/sync/fs.d.ts +18 -0
  159. package/lib/sync/fs.js +49 -0
  160. package/lib/sync/git/git-transport.d.ts +100 -0
  161. package/lib/sync/git/git-transport.js +437 -0
  162. package/lib/sync/github-auth.d.ts +131 -0
  163. package/lib/sync/github-auth.js +264 -0
  164. package/lib/sync/layout.d.ts +43 -0
  165. package/lib/sync/layout.js +203 -0
  166. package/lib/sync/snapshot-json.d.ts +9 -0
  167. package/lib/sync/snapshot-json.js +107 -0
  168. package/lib/sync/sync-config.d.ts +81 -0
  169. package/lib/sync/sync-config.js +281 -0
  170. package/lib/sync/sync-engine.d.ts +269 -0
  171. package/lib/sync/sync-engine.js +591 -0
  172. package/lib/sync/sync-history.d.ts +42 -0
  173. package/lib/sync/sync-history.js +78 -0
  174. package/lib/sync/sync-selection.d.ts +29 -0
  175. package/lib/sync/sync-selection.js +127 -0
  176. package/lib/sync/sync-session.d.ts +40 -0
  177. package/lib/sync/sync-session.js +54 -0
  178. package/lib/sync/sync-state.d.ts +37 -0
  179. package/lib/sync/sync-state.js +108 -0
  180. package/lib/sync/transport.d.ts +75 -0
  181. package/lib/sync/transport.js +55 -0
  182. package/lib/sync/ui-prefs.d.ts +34 -0
  183. package/lib/sync/ui-prefs.js +109 -0
  184. package/lib/sync/webdav/webdav-transport.d.ts +77 -0
  185. package/lib/sync/webdav/webdav-transport.js +451 -0
  186. package/lib/ui/errors.d.ts +19 -0
  187. package/lib/ui/errors.js +81 -0
  188. package/lib/ui/export-flow.d.ts +82 -0
  189. package/lib/ui/export-flow.js +98 -0
  190. package/lib/ui/i18n.d.ts +289 -0
  191. package/lib/ui/i18n.js +581 -0
  192. package/lib/ui/migration-consult-view.d.ts +91 -0
  193. package/lib/ui/migration-consult-view.js +115 -0
  194. package/lib/ui/progress.d.ts +38 -0
  195. package/lib/ui/progress.js +87 -0
  196. package/lib/ui/report.d.ts +23 -0
  197. package/lib/ui/report.js +153 -0
  198. package/lib/ui/test-helpers.d.ts +69 -0
  199. package/lib/ui/test-helpers.js +154 -0
  200. package/lib/ui/types.d.ts +257 -0
  201. package/lib/ui/types.js +21 -0
  202. package/lib/utils/atomic-write.d.ts +88 -0
  203. package/lib/utils/atomic-write.js +399 -0
  204. package/lib/utils/env-lock.d.ts +393 -0
  205. package/lib/utils/env-lock.js +930 -0
  206. package/lib/utils/hashing.d.ts +17 -0
  207. package/lib/utils/hashing.js +41 -0
  208. package/lib/utils/json.d.ts +24 -0
  209. package/lib/utils/json.js +76 -0
  210. package/lib/utils/logger.d.ts +39 -0
  211. package/lib/utils/logger.js +82 -0
  212. package/lib/utils/paths.d.ts +42 -0
  213. package/lib/utils/paths.js +189 -0
  214. package/lib/utils/proxy.d.ts +69 -0
  215. package/lib/utils/proxy.js +455 -0
  216. package/lib/utils/recursive-walk.d.ts +24 -0
  217. package/lib/utils/recursive-walk.js +134 -0
  218. package/lib/utils/zip.d.ts +54 -0
  219. package/lib/utils/zip.js +285 -0
  220. package/package.json +188 -0
  221. package/src/adapters/agent-instructions.ts +48 -0
  222. package/src/adapters/agent-presets.ts +14 -0
  223. package/src/adapters/credentials.test.ts +90 -0
  224. package/src/adapters/credentials.ts +143 -0
  225. package/src/adapters/file-collection.ts +137 -0
  226. package/src/adapters/files.test.ts +353 -0
  227. package/src/adapters/index.ts +94 -0
  228. package/src/adapters/link-report.test.ts +65 -0
  229. package/src/adapters/link-report.ts +61 -0
  230. package/src/adapters/mcp.test.ts +96 -0
  231. package/src/adapters/mcp.ts +185 -0
  232. package/src/adapters/plugin-files.ts +146 -0
  233. package/src/adapters/plugins.test.ts +326 -0
  234. package/src/adapters/plugins.ts +615 -0
  235. package/src/adapters/pnpm-workspace.test.ts +87 -0
  236. package/src/adapters/pnpm-workspace.ts +150 -0
  237. package/src/adapters/prompts.test.ts +110 -0
  238. package/src/adapters/prompts.ts +230 -0
  239. package/src/adapters/providers.test.ts +126 -0
  240. package/src/adapters/providers.ts +173 -0
  241. package/src/adapters/roundtrip.test.ts +190 -0
  242. package/src/adapters/self.test.ts +92 -0
  243. package/src/adapters/self.ts +75 -0
  244. package/src/adapters/sessions.ts +15 -0
  245. package/src/adapters/settings.test.ts +100 -0
  246. package/src/adapters/settings.ts +187 -0
  247. package/src/adapters/skills.ts +14 -0
  248. package/src/adapters/test-helpers.ts +248 -0
  249. package/src/adapters/ui.test.ts +57 -0
  250. package/src/adapters/ui.ts +108 -0
  251. package/src/adapters/workspaces.test.ts +106 -0
  252. package/src/adapters/workspaces.ts +109 -0
  253. package/src/client/ConfigManagerSection.tsx +90 -0
  254. package/src/client/api.ts +66 -0
  255. package/src/client/client-types.ts +18 -0
  256. package/src/client/common/ErrorBanner.tsx +73 -0
  257. package/src/client/common/Icon.tsx +129 -0
  258. package/src/client/common/Modal.tsx +173 -0
  259. package/src/client/common/ToastViewport.tsx +73 -0
  260. package/src/client/common/toast-store.test.ts +192 -0
  261. package/src/client/common/toast-store.ts +181 -0
  262. package/src/client/common/toast-types.ts +7 -0
  263. package/src/client/common/ui.tsx +342 -0
  264. package/src/client/config-manager.module.css +2971 -0
  265. package/src/client/css-modules.d.ts +8 -0
  266. package/src/client/index.ts +65 -0
  267. package/src/client/locales.ts +56 -0
  268. package/src/client/lucide-icons.d.ts +38 -0
  269. package/src/client/run-store.test.ts +223 -0
  270. package/src/client/run-store.ts +372 -0
  271. package/src/client/sync/SyncConfirmView.tsx +379 -0
  272. package/src/client/sync/SyncHistoryView.test.ts +40 -0
  273. package/src/client/sync/SyncHistoryView.tsx +220 -0
  274. package/src/client/sync/SyncSettingsView.tsx +1535 -0
  275. package/src/client/sync/history-model.test.ts +194 -0
  276. package/src/client/sync/history-model.ts +195 -0
  277. package/src/client/sync/sync-api.test.ts +585 -0
  278. package/src/client/sync/sync-api.ts +543 -0
  279. package/src/client/sync/sync-locales.ts +416 -0
  280. package/src/client/sync/sync-push-preview.test.ts +52 -0
  281. package/src/client/sync/sync-view-v2.test.ts +213 -0
  282. package/src/client/sync/sync-view.test.ts +487 -0
  283. package/src/client/sync/sync-view.ts +759 -0
  284. package/src/core/analyzer.ts +1068 -0
  285. package/src/core/backup.ts +605 -0
  286. package/src/core/boot-rescue.test.ts +847 -0
  287. package/src/core/boot-rescue.ts +737 -0
  288. package/src/core/cache-cleaner.test.ts +169 -0
  289. package/src/core/cache-cleaner.ts +121 -0
  290. package/src/core/config-lifecycle.test.ts +727 -0
  291. package/src/core/config-lifecycle.ts +573 -0
  292. package/src/core/config-snapshot.test.ts +369 -0
  293. package/src/core/config-snapshot.ts +522 -0
  294. package/src/core/config-state.test.ts +381 -0
  295. package/src/core/config-state.ts +207 -0
  296. package/src/core/consult-source.test.ts +199 -0
  297. package/src/core/consult-source.ts +293 -0
  298. package/src/core/crash-report.test.ts +380 -0
  299. package/src/core/crash-report.ts +369 -0
  300. package/src/core/exporter.ts +417 -0
  301. package/src/core/ghost-sweep.test.ts +94 -0
  302. package/src/core/ghost-sweep.ts +67 -0
  303. package/src/core/importer.ts +83 -0
  304. package/src/core/index.ts +56 -0
  305. package/src/core/journal.test.ts +257 -0
  306. package/src/core/journal.ts +518 -0
  307. package/src/core/local-plugin-host.ts +152 -0
  308. package/src/core/local-plugin-pack.test.ts +359 -0
  309. package/src/core/local-plugin-pack.ts +363 -0
  310. package/src/core/messages.test.ts +54 -0
  311. package/src/core/messages.ts +664 -0
  312. package/src/core/migration-consult.test.ts +315 -0
  313. package/src/core/migration-consult.ts +494 -0
  314. package/src/core/migration-history.test.ts +319 -0
  315. package/src/core/migration-history.ts +484 -0
  316. package/src/core/model-tools.test.ts +190 -0
  317. package/src/core/model-tools.ts +208 -0
  318. package/src/core/msg-types.ts +10 -0
  319. package/src/core/phase3-child-crash.ts +95 -0
  320. package/src/core/phase3-host.ts +411 -0
  321. package/src/core/phase3-p1.test.ts +212 -0
  322. package/src/core/phase3-p2b-fingerprint.test.ts +189 -0
  323. package/src/core/phase3-prod-child.ts +36 -0
  324. package/src/core/phase3-production-integration.test.ts +286 -0
  325. package/src/core/phase4-crash-child.ts +101 -0
  326. package/src/core/phase4-crash-injection.test.ts +166 -0
  327. package/src/core/plugin-cli.fs.test.ts +184 -0
  328. package/src/core/plugin-cli.test.ts +137 -0
  329. package/src/core/plugin-cli.ts +563 -0
  330. package/src/core/reconcile.test.ts +392 -0
  331. package/src/core/reconcile.ts +498 -0
  332. package/src/core/recovery-orchestrator.test.ts +145 -0
  333. package/src/core/recovery-orchestrator.ts +366 -0
  334. package/src/core/restore-manage.test.ts +94 -0
  335. package/src/core/restore.ts +849 -0
  336. package/src/core/rollback.ts +151 -0
  337. package/src/core/run-progress.test.ts +237 -0
  338. package/src/core/run-registry.test.ts +232 -0
  339. package/src/core/run-registry.ts +209 -0
  340. package/src/core/smoke.test.ts +842 -0
  341. package/src/core/startup-barrier.ts +99 -0
  342. package/src/core/types.ts +542 -0
  343. package/src/core/undo.ts +116 -0
  344. package/src/core/validator.ts +84 -0
  345. package/src/core/verify-recovery.ts +265 -0
  346. package/src/core/watcher.test.ts +326 -0
  347. package/src/core/watcher.ts +263 -0
  348. package/src/index.facade.test.ts +265 -0
  349. package/src/index.sync.test.ts +217 -0
  350. package/src/index.ts +2975 -0
  351. package/src/migrations/index.ts +71 -0
  352. package/src/migrations/v1-to-v2.ts +17 -0
  353. package/src/profiles/index.ts +8 -0
  354. package/src/profiles/profile-manager.test.ts +282 -0
  355. package/src/profiles/profile-manager.ts +630 -0
  356. package/src/schema/config.ts +165 -0
  357. package/src/schema/index.ts +71 -0
  358. package/src/schema/manifest-schema.test.ts +519 -0
  359. package/src/schema/manifest.ts +124 -0
  360. package/src/schema/tombstones.test.ts +131 -0
  361. package/src/schema/tombstones.ts +89 -0
  362. package/src/schema/types.ts +245 -0
  363. package/src/schema/versions.ts +55 -0
  364. package/src/security/encryption.ts +335 -0
  365. package/src/security/index.ts +16 -0
  366. package/src/security/integrity.ts +102 -0
  367. package/src/security/redaction.ts +101 -0
  368. package/src/security/secret-scanner.ts +445 -0
  369. package/src/security/security.test.ts +1215 -0
  370. package/src/security/vault.test.ts +164 -0
  371. package/src/security/vault.ts +198 -0
  372. package/src/security/zip-security.ts +180 -0
  373. package/src/sync/autosync-config.test.ts +147 -0
  374. package/src/sync/autosync-config.ts +179 -0
  375. package/src/sync/autosync-scheduler.test.ts +490 -0
  376. package/src/sync/autosync-scheduler.ts +646 -0
  377. package/src/sync/fs.ts +48 -0
  378. package/src/sync/git/git-transport.test.ts +673 -0
  379. package/src/sync/git/git-transport.ts +491 -0
  380. package/src/sync/github-auth.test.ts +280 -0
  381. package/src/sync/github-auth.ts +356 -0
  382. package/src/sync/layout.test.ts +270 -0
  383. package/src/sync/layout.ts +222 -0
  384. package/src/sync/snapshot-json.test.ts +119 -0
  385. package/src/sync/snapshot-json.ts +128 -0
  386. package/src/sync/sync-config.test.ts +310 -0
  387. package/src/sync/sync-config.ts +299 -0
  388. package/src/sync/sync-engine.test.ts +1074 -0
  389. package/src/sync/sync-engine.ts +754 -0
  390. package/src/sync/sync-history.test.ts +116 -0
  391. package/src/sync/sync-history.ts +120 -0
  392. package/src/sync/sync-selection.test.ts +186 -0
  393. package/src/sync/sync-selection.ts +143 -0
  394. package/src/sync/sync-session.test.ts +143 -0
  395. package/src/sync/sync-session.ts +77 -0
  396. package/src/sync/sync-state.test.ts +164 -0
  397. package/src/sync/sync-state.ts +131 -0
  398. package/src/sync/transport.test.ts +111 -0
  399. package/src/sync/transport.ts +118 -0
  400. package/src/sync/ui-prefs.test.ts +152 -0
  401. package/src/sync/ui-prefs.ts +135 -0
  402. package/src/sync/webdav/webdav-transport.test.ts +871 -0
  403. package/src/sync/webdav/webdav-transport.ts +535 -0
  404. package/src/ui/errors.test.ts +47 -0
  405. package/src/ui/errors.ts +100 -0
  406. package/src/ui/export-flow.test.ts +87 -0
  407. package/src/ui/export-flow.ts +156 -0
  408. package/src/ui/i18n.ts +590 -0
  409. package/src/ui/migration-consult-view.test.ts +151 -0
  410. package/src/ui/migration-consult-view.ts +178 -0
  411. package/src/ui/progress.test.ts +47 -0
  412. package/src/ui/progress.ts +102 -0
  413. package/src/ui/report.test.ts +101 -0
  414. package/src/ui/report.ts +146 -0
  415. package/src/ui/test-helpers.ts +184 -0
  416. package/src/ui/types.ts +325 -0
  417. package/src/utils/atomic-write.test.ts +312 -0
  418. package/src/utils/atomic-write.ts +422 -0
  419. package/src/utils/env-lock.test.ts +999 -0
  420. package/src/utils/env-lock.ts +1086 -0
  421. package/src/utils/hashing.ts +53 -0
  422. package/src/utils/json.ts +77 -0
  423. package/src/utils/logger.ts +114 -0
  424. package/src/utils/paths.ts +176 -0
  425. package/src/utils/proxy.test.ts +465 -0
  426. package/src/utils/proxy.ts +493 -0
  427. package/src/utils/recursive-walk.test.ts +148 -0
  428. package/src/utils/recursive-walk.ts +145 -0
  429. package/src/utils/zip.ts +332 -0
@@ -0,0 +1,849 @@
1
+ /**
2
+ * 离线恢复引擎(M2,criterion m2-restore-restores;设计 §8 / 规范 §27)。
3
+ *
4
+ * 与 rollback.ts 的区别:rollback 是导入失败时的自动补偿(在线,经 HostContext
5
+ * 调 DSH 服务);本模块是用户主动的「恢复到导入前状态」——纯离线,零 DSH 运行时
6
+ * 依赖(仅 node 内置 + 本项目零依赖工具),直接操作文件系统与官方 dsh plugin CLI:
7
+ *
8
+ * 1. 宿主整文件还原:hostFileBackups(settings.yaml / settings.json / 用户层与
9
+ * profile 层 cordis.patch.yml)的 blob 写回 $DSH_HOME;快照时不存在、现已出现
10
+ * 的文件删除(restore = 还原到快照那一刻的状态,含移除导入期间新增激活行);
11
+ * 2. pre-restore 双保险:任何覆盖/删除前,把当前文件复制到
12
+ * <snapshotDir>/pre-restore/(可人工反悔);
13
+ * 3. 插件撤销:beforePlugins(导入前基线)与当前已装对比 → 导入期间新增的插件
14
+ * 走官方 `dsh plugin --profile <p> remove <pkg>`(runDshPlugin 通道,零新子进程代码);
15
+ * 4. file 类条目补偿:skills/agentPresets/agentInstructions/pluginFiles/sessions 原文件 blob 写回 /
16
+ * 快照时不存在则删除(与 rollback.ts 语义一致);
17
+ * 5. credentials:DSH 不回读值 → existed=true 只生成 manualHint,绝不自动改写。
18
+ *
19
+ * settingsNamespace / patchLine 条目由宿主整文件还原覆盖(settings.yaml /
20
+ * cordis.patch.yml);workspaceRecord 离线无法经 DSH storages 恢复 → 如实提示。
21
+ * 旧快照(无 beforePlugins / hostFileBackups)兼容:缺基线的部分只提示不动作。
22
+ */
23
+ import fs from 'node:fs/promises';
24
+ import path from 'node:path';
25
+
26
+ import { resolveFileTarget, verifySnapshot } from './backup.ts';
27
+ import { expectedSessionRefs, sweepGhostSessions } from './ghost-sweep.ts';
28
+ import { zhMsg } from './messages.ts';
29
+ import type { MsgFunc } from './messages.ts';
30
+ import {
31
+ classifyDshPluginFailure, readInstalled, resolveProfileDir, runDshPlugin,
32
+ } from './plugin-cli.ts';
33
+ import { parseJsonSafe } from '../utils/json.ts';
34
+ import { atomicCopyFile, atomicWriteFile } from '../utils/atomic-write.ts';
35
+ import type { SectionId } from '../schema/types.ts';
36
+ import type { HostContext, Snapshot, SnapshotStatus } from './types.ts';
37
+
38
+ /* ---------------------------------------------------------------- 类型 */
39
+
40
+ export type RestoreActionKind =
41
+ | 'hostFileRestore' // 宿主整文件 blob → 写回 $DSH_HOME
42
+ | 'hostFileRemove' // 快照时不存在、现已出现的宿主文件 → 删除
43
+ | 'pluginRemove' // 导入期间新增插件 → dsh plugin remove
44
+ | 'fileRestore' // file 类条目 blob → 写回原路径
45
+ | 'fileRemove' // file 类条目快照时不存在 → 删除导入写入的文件
46
+ | 'credentialHint' // 凭据值不可回读 → 人工补录提示(无自动动作)
47
+ | 'skip'; // 无需动作 / 离线无法处理 / 旧快照缺基线
48
+
49
+ export interface RestoreAction {
50
+ kind: RestoreActionKind;
51
+ /** 人类可读描述 */
52
+ description: string;
53
+ /** 目标:相对 $DSH_HOME 的 relPath(hostFile/file 类)或插件名(pluginRemove) */
54
+ target?: string;
55
+ /** hostFileRestore/fileRestore:快照内 blob 相对路径(相对 snapshotDir) */
56
+ blobPath?: string;
57
+ /** pluginRemove:插件包名 */
58
+ pluginName?: string;
59
+ /** credentialHint:人工提示文本 */
60
+ manualHint?: string;
61
+ /** 附加说明(旧快照缺基线 / 离线无法恢复等) */
62
+ detail?: string;
63
+ /** 该动作是否对应 settings 主文件(settingsPath 解析出的文件) */
64
+ isSettings?: boolean;
65
+ }
66
+
67
+ export interface RestorePlan {
68
+ snapshotId: string;
69
+ createdAt: string;
70
+ sourceZip: string;
71
+ actions: RestoreAction[];
72
+ summary: {
73
+ hostFileRestores: number;
74
+ hostFileRemoves: number;
75
+ pluginRemoves: number;
76
+ fileRestores: number;
77
+ fileRemoves: number;
78
+ credentialHints: number;
79
+ skips: number;
80
+ };
81
+ /** beforePlugins 基线是否可用(undefined=旧快照无基线 → 不计划任何插件卸载) */
82
+ pluginBaselineConfirmed: boolean;
83
+ }
84
+
85
+ export interface RestoreOptions {
86
+ /** 快照目录 <snapshotsDir>/<id>/(含 snapshot.json 与 blobs/) */
87
+ snapshotDir: string;
88
+ /** $DSH_HOME(默认 ~/.dsh) */
89
+ homeDir: string;
90
+ /** 管理的 DSH profile 名(插件卸载走 profiles/<profile>) */
91
+ profile: string;
92
+ /** 覆盖 settings 文件路径(默认探测 $DSH_HOME/settings.yaml → settings.json) */
93
+ settingsPath?: string;
94
+ /** 注入式插件卸载器(测试用;缺省走 runDshPlugin 官方通道) */
95
+ pluginUninstaller?: (
96
+ name: string, profileDir: string, profile: string,
97
+ ) => Promise<{ ok: boolean; message?: string }>;
98
+ /** 每项动作执行回调(宿主路由埋点:更新 RunRegistry 进度;index/1-based、total=计划动作数、
99
+ * detail=动作描述。dry-run/planRestore 不触发) */
100
+ onAction?: (info: { index: number; total: number; detail: string }) => void;
101
+ /** 消息翻译器(缺省 zh;宿主按 DSH 应用语言注入) */
102
+ msg?: MsgFunc;
103
+ /**
104
+ * Phase 4 统一恢复校验:快照根目录(<snapshotsDir>,<snapshotDir> 的直接父级)。
105
+ * 提供时,planRestore 顶端强制调用 validateSnapshotForRestore;CORRUPT/INVALID/UNSAFE_PATH/
106
+ * WRONG_ENVIRONMENT 拒绝计划。缺省 = 不强制(兼容旧调用;新入口都应传)。
107
+ */
108
+ snapshotsRoot?: string;
109
+ /** Phase 4 恢复校验时校验环境指纹(不匹配 → WRONG_ENVIRONMENT;缺省不校验)。 */
110
+ environmentFingerprint?: string;
111
+ /**
112
+ * Phase 4 恢复确认策略:缺省允许 LEGACY 显式恢复。若调用方要求「非 operation-bound 一律拒绝」,
113
+ * 可传 'requireOperationBound'。
114
+ */
115
+ requireOperationBound?: boolean;
116
+ }
117
+
118
+ export interface RestoreReport {
119
+ snapshotId: string;
120
+ /** 已还原/已删除的目标(相对 $DSH_HOME) */
121
+ restored: string[];
122
+ /** 已卸载的插件名 */
123
+ removedPlugins: string[];
124
+ /** 需人工处理的提示(凭据补录等) */
125
+ manualHints: string[];
126
+ /** 失败清单 */
127
+ failed: { item: string; reason: string }[];
128
+ /** 跳过项(无动作 / 离线无法处理) */
129
+ skipped: string[];
130
+ /**
131
+ * 幽灵会话(F5 失效归档清理):快照记录的 sessions 分区会话在磁盘 sessions 目录
132
+ * 已无任何文件(备份有记录、磁盘无文件)。DSH 官方无归档会话 API,降级本地校验,
133
+ * 仅供报告、需人工在 DSH 确认清理;无会话分区/无幽灵时为缺省或空数组。
134
+ */
135
+ ghostSessions?: string[];
136
+ }
137
+
138
+ export interface SnapshotMeta {
139
+ id: string;
140
+ createdAt: string;
141
+ sourceZip: string;
142
+ status?: SnapshotStatus;
143
+ entryCount: number;
144
+ hostFileBackupCount: number;
145
+ beforePluginCount: number;
146
+ /** 置顶标记(P1-⑧):置顶快照不参与自动保留清理(最多保留 N 个时的淘汰豁免) */
147
+ pinned?: boolean;
148
+ }
149
+
150
+ /* ------------------------------------------------------------ 工具 */
151
+
152
+ /** 快照目录内读取 snapshot.json(深度保护解析) */
153
+ async function loadSnapshot(snapshotDir: string, msg: MsgFunc): Promise<Snapshot> {
154
+ const parsed = parseJsonSafe(await fs.readFile(path.join(snapshotDir, 'snapshot.json'), 'utf8'));
155
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
156
+ throw new Error(msg('restore.snapshotInvalid', { dir: snapshotDir }));
157
+ }
158
+ return parsed as Snapshot;
159
+ }
160
+
161
+ async function fileExists(p: string): Promise<boolean> {
162
+ try {
163
+ await fs.access(p);
164
+ return true;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ /** 越界防御:abs 必须等于 homeDir 或在 homeDir 之内 */
171
+ function isWithinHome(homeDir: string, abs: string): boolean {
172
+ const root = path.resolve(homeDir);
173
+ return abs === root || abs.startsWith(root + path.sep);
174
+ }
175
+
176
+ /** homeDir 内绝对路径(越界抛错) */
177
+ function homeAbs(homeDir: string, relPath: string, msg: MsgFunc): string {
178
+ const abs = path.resolve(homeDir, relPath);
179
+ if (!isWithinHome(homeDir, abs)) throw new Error(msg('restore.pathEscape', { path: relPath }));
180
+ return abs;
181
+ }
182
+
183
+ /** 快照目录内 blob 绝对路径(防 snapshot.json 里伪造 ../ 越界读) */
184
+ function blobAbs(snapshotDir: string, blobPath: string, msg: MsgFunc): string {
185
+ const abs = path.resolve(snapshotDir, blobPath);
186
+ if (!isWithinHome(snapshotDir, abs)) throw new Error(msg('restore.blobPathEscape', { path: blobPath }));
187
+ return abs;
188
+ }
189
+
190
+ /** 绝对路径 → 相对 $DSH_HOME(跨平台归一为 / 分隔;越界返回空串) */
191
+ function toRelPath(homeDir: string, abs: string): string {
192
+ if (!isWithinHome(homeDir, abs)) return '';
193
+ return path.relative(homeDir, abs).split(path.sep).join('/');
194
+ }
195
+
196
+ /** file 类条目的目标绝对路径(复用 backup.ts 的基准目录规则;仅读 homeDir) */
197
+ function resolveFileAbs(homeDir: string, adapter: SectionId, ref: string): string {
198
+ return resolveFileTarget({ homeDir } as unknown as HostContext, adapter, ref);
199
+ }
200
+
201
+ /** settings 文件 relPath:settingsPath 覆盖优先,否则探测 settings.yaml → settings.json */
202
+ async function resolveSettingsRelPath(homeDir: string, settingsPath?: string): Promise<string | undefined> {
203
+ if (settingsPath !== undefined && settingsPath !== '') {
204
+ const rel = toRelPath(homeDir, path.resolve(homeDir, settingsPath));
205
+ return rel === '' ? undefined : rel;
206
+ }
207
+ for (const cand of ['settings.yaml', 'settings.json']) {
208
+ if (await fileExists(path.join(homeDir, cand))) return cand;
209
+ }
210
+ return undefined;
211
+ }
212
+
213
+ /** 把当前文件复制到 <snapshotDir>/pre-restore/(双保险,命名含序号防碰撞) */
214
+ async function copyToPreRestore(preDir: string, absPath: string, label: string, seq: number): Promise<void> {
215
+ const safe = label.replace(/[\\/:*?"<>|]/g, '_');
216
+ await fs.mkdir(preDir, { recursive: true });
217
+ await atomicCopyFile(absPath, path.join(preDir, `${String(seq).padStart(4, '0')}-${safe}`));
218
+ }
219
+
220
+ /** 默认插件卸载器:官方 dsh plugin remove 通道 + 失败分类诊断 */
221
+ async function defaultPluginUninstaller(
222
+ name: string, profileDir: string, profile: string, msg: MsgFunc,
223
+ ): Promise<{ ok: boolean; message?: string }> {
224
+ const result = await runDshPlugin(profileDir, profile, ['remove', name]);
225
+ if (result.exitCode === 0) return { ok: true };
226
+ const output = `${result.stderr}\n${result.stdout}`.trim();
227
+ const failure = classifyDshPluginFailure(output);
228
+ if (failure !== null) {
229
+ return { ok: false, message: msg('restore.uninstallFailedCode', { name, code: failure.code, message: failure.message }) };
230
+ }
231
+ const tail = output.split('\n').slice(-8).join('\n') || msg('restore.noOutput');
232
+ return { ok: false, message: msg('restore.uninstallFailedExit', { name, code: String(result.exitCode), tail }) };
233
+ }
234
+
235
+ /* -------------------------------------------------- 统一恢复校验(Phase 4 F8/F9/F25) */
236
+
237
+ /**
238
+ * 快照恢复前可信校验的 verdict(统一供 Host API / ModelTools / CLI / sync rollback 消费)。
239
+ *
240
+ * - TRUSTED_OPERATION_SNAPSHOT:READY + manifest 完整 + blob hash 匹配 + 有 op/env/owner binding。
241
+ * 可授权自动/推荐回滚。
242
+ * - TRUSTED_MANUAL_LOCAL:READY + 完整校验通过,但无 Phase4 binding(如用户手动/pinned 快照)。
243
+ * 可显式手动恢复,不可作 Phase3 自动 recovery 证据。
244
+ * - LEGACY_REQUIRES_CONFIRMATION:旧快照(无 manifest / 无 readiness)。结构/路径校验通过即可显式确认恢复,
245
+ * 绝不可自动恢复。
246
+ * - INVALID / CORRUPT / UNSAFE_PATH / WRONG_ENVIRONMENT:拒绝。
247
+ */
248
+ export type RestoreSnapshotVerdict =
249
+ | 'TRUSTED_OPERATION_SNAPSHOT'
250
+ | 'TRUSTED_MANUAL_LOCAL'
251
+ | 'LEGACY_REQUIRES_CONFIRMATION'
252
+ | 'WRONG_ENVIRONMENT'
253
+ | 'CORRUPT'
254
+ | 'INVALID'
255
+ | 'UNSAFE_PATH';
256
+
257
+ export interface RestoreSnapshotValidation {
258
+ verdict: RestoreSnapshotVerdict;
259
+ reason?: string;
260
+ }
261
+
262
+ /** 递归检查快照目录内关键文件非 symlink(F25:拒绝 symlink 化 metadata/blob 写穿)。 */
263
+ async function assertNoSnapshotSymlink(dir: string, rel: string): Promise<string | null> {
264
+ const p = path.join(dir, rel);
265
+ if (!isWithinHome(dir, p)) return `路径越界: ${rel}`;
266
+ try {
267
+ const st = await fs.lstat(p);
268
+ if (st.isSymbolicLink()) return `symlink 化关键文件: ${rel}`;
269
+ return null;
270
+ } catch {
271
+ // 缺失由 verify/logic 处理(此处不判缺失)
272
+ return null;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * 统一恢复前校验(供 planRestore 顶端对所有 restore 类型强制调用):
278
+ * 1. isValidSnapshotId(语法)
279
+ * 2. snapshot.json + manifest 存在、id 匹配(结构)
280
+ * 3. verifySnapshot(磁盘重读:metadataHash + blob hashes + 路径安全)→ CORRUPT
281
+ * 4. symlink 检查(snapshot.json / manifest / blobs)→ UNSAFE_PATH
282
+ * 5. readiness + provenance:
283
+ * - 无 binding 字段(旧快照)→ LEGACY_REQUIRES_CONFIRMATION
284
+ * - 有 manifest/READY 但无 op binding → TRUSTED_MANUAL_LOCAL
285
+ * - 有 READY + manifest + op/env/owner binding → TRUSTED_OPERATION_SNAPSHOT
286
+ * 绝不把「不可证明」当成 trusted。
287
+ */
288
+ export async function validateSnapshotForRestore(
289
+ snapshotDir: string,
290
+ snapshotsRoot: string,
291
+ env?: { environmentFingerprint?: string },
292
+ ): Promise<RestoreSnapshotValidation> {
293
+ const id = path.basename(snapshotDir);
294
+ if (!isValidSnapshotId(id)) return { verdict: 'INVALID', reason: 'snapshotId 非法' };
295
+
296
+ // 目录边界:snapshotDir 必须紧邻 snapshotsRoot 下的一层
297
+ const rel = path.relative(snapshotsRoot, snapshotDir).split(path.sep).join('/');
298
+ if (rel === '' || rel.includes('/') || !isWithinHome(snapshotsRoot, snapshotDir)) {
299
+ return { verdict: 'INVALID', reason: '快照目录越界或非直接子目录' };
300
+ }
301
+
302
+ // 关键文件 symlink 检查(F25)
303
+ for (const f of ['snapshot.json', 'manifest.json']) {
304
+ const s = await assertNoSnapshotSymlink(snapshotDir, f);
305
+ if (s !== null) return { verdict: 'UNSAFE_PATH', reason: s };
306
+ }
307
+
308
+ // F25(Review C 强化):快照目录本身 / blobs 目录若为 (junction) symlink → 拒绝。
309
+ // 真实 Windows junction(目录 reparse point)在 lstat 下 isSymbolicLink===true,但 readdir
310
+ // 只列出【外部目标目录】的普通文件(isSymbolicLink=false)——仅查 blob 子项会漏 junction 逃逸
311
+ // (外部内容经 junction 伪装成本快照 blobs,restore 读侧 fs.readFile 会跟随到外部读取面)。
312
+ const selfDirGuard = await assertNoSnapshotSymlink(snapshotDir, '.');
313
+ if (selfDirGuard !== null) return { verdict: 'UNSAFE_PATH', reason: selfDirGuard };
314
+ const blobsDirGuard = await assertNoSnapshotSymlink(snapshotDir, 'blobs');
315
+ if (blobsDirGuard !== null) return { verdict: 'UNSAFE_PATH', reason: blobsDirGuard };
316
+
317
+ // F25(Review C):blob 目录内任何实体若为 symlink → 拒绝(文档声称覆盖 blobs,须与实现一致)。
318
+ // blob symlink 指向外部文件,restore 读侧 fs.readFile 会跟随;即使内容 hash 恰好匹配,也属越界读取面,拒绝更安全。
319
+ const blobsDir = path.join(snapshotDir, 'blobs');
320
+ const blobEntries = await fs.readdir(blobsDir, { withFileTypes: true }).catch(() => [] as Array<{ name: string; isSymbolicLink(): boolean }>);
321
+ for (const be of blobEntries) {
322
+ if (be.isSymbolicLink()) {
323
+ return { verdict: 'UNSAFE_PATH', reason: `blob 是 symlink: ${be.name}` };
324
+ }
325
+ }
326
+
327
+ // 旧快照检测:无 manifest.json 且 snapshot.json 无 readiness → LEGACY(结构/路径校验已在上面通过,
328
+ // verifySnapshot 需要 manifest → 旧快照不能走完整 verify,需显式确认后仅做基本一致校验)。
329
+ const manifestExists = await fs.access(path.join(snapshotDir, 'manifest.json')).then(() => true).catch(() => false);
330
+ let snapshot: Snapshot | null = null;
331
+ try {
332
+ snapshot = parseJsonSafe(await fs.readFile(path.join(snapshotDir, 'snapshot.json'), 'utf8')) as Snapshot;
333
+ } catch {
334
+ /* verifySnapshot 已报缺失 */
335
+ }
336
+ if (snapshot === null || typeof snapshot !== 'object') return { verdict: 'CORRUPT', reason: 'snapshot.json 无法解析' };
337
+ if (!manifestExists && snapshot.readiness === undefined) {
338
+ return { verdict: 'LEGACY_REQUIRES_CONFIRMATION', reason: '旧快照(无 manifest/READY),需显式确认后恢复' };
339
+ }
340
+
341
+ // 结构 + 完整性(磁盘重读,不信任内存)
342
+ const v = await verifySnapshot(snapshotsRoot, id);
343
+ if (!v.ok) return { verdict: 'CORRUPT', reason: v.reason };
344
+
345
+ // 环境绑定(WRONG_ENVIRONMENT)
346
+ if (env?.environmentFingerprint && snapshot.readiness === 'READY' && snapshot.environmentFingerprint
347
+ && snapshot.environmentFingerprint !== env.environmentFingerprint) {
348
+ return { verdict: 'WRONG_ENVIRONMENT', reason: 'environmentFingerprint 不匹配(可能来自其他机器/安装)' };
349
+ }
350
+
351
+ // provenance 分类
352
+ const hasBinding = snapshot.operationId !== undefined || snapshot.environmentFingerprint !== undefined || snapshot.ownerInstanceId !== undefined;
353
+ if (snapshot.readiness === undefined) {
354
+ return { verdict: 'LEGACY_REQUIRES_CONFIRMATION', reason: '旧快照(无 readiness),需显式确认后恢复' };
355
+ }
356
+ if (!hasBinding) {
357
+ return { verdict: 'TRUSTED_MANUAL_LOCAL', reason: '完整性通过,但非 operation-bound(手动/本地快照)' };
358
+ }
359
+ return { verdict: 'TRUSTED_OPERATION_SNAPSHOT', reason: 'READY + manifest 完整 + op/env/owner binding 匹配' };
360
+ }
361
+
362
+
363
+ /**
364
+ * 生成恢复动作计划(dry-run 预览的唯一入口;零写入):
365
+ * 读快照 + 探测当前文件/已装插件状态,输出按执行顺序排列的动作清单。
366
+ */
367
+ export async function planRestore(opts: RestoreOptions): Promise<RestorePlan> {
368
+ const { snapshotDir, homeDir, profile } = opts;
369
+ const msg = opts.msg ?? zhMsg;
370
+ // Phase 4 统一恢复校验:若提供 snapshotsRoot,则在生成任何动作前校验快照可信度。
371
+ // 所有 restore 入口(Host API / ModelTools / CLI)都应传 snapshotsRoot → 同一验证强度。
372
+ if (opts.snapshotsRoot !== undefined) {
373
+ const val = await validateSnapshotForRestore(snapshotDir, opts.snapshotsRoot, {
374
+ environmentFingerprint: opts.environmentFingerprint,
375
+ });
376
+ const reject = ['INVALID', 'CORRUPT', 'UNSAFE_PATH', 'WRONG_ENVIRONMENT'].includes(val.verdict);
377
+ const rejectNotTrusted = opts.requireOperationBound === true
378
+ && val.verdict !== 'TRUSTED_OPERATION_SNAPSHOT';
379
+ if (reject || rejectNotTrusted) {
380
+ throw new Error(msg('restore.snapshotUntrusted', { verdict: val.verdict, reason: val.reason ?? '' }));
381
+ }
382
+ }
383
+ const snapshot = await loadSnapshot(snapshotDir, msg);
384
+ const settingsRel = await resolveSettingsRelPath(homeDir, opts.settingsPath);
385
+ const actions: RestoreAction[] = [];
386
+
387
+ // 1) 宿主整文件还原(hostFileBackups)
388
+ const hostBackups = snapshot.hostFileBackups ?? [];
389
+ for (const backup of hostBackups) {
390
+ let abs: string;
391
+ try {
392
+ abs = homeAbs(homeDir, backup.relPath, msg);
393
+ } catch {
394
+ actions.push({
395
+ kind: 'skip',
396
+ description: msg('restore.hostSkipEscape', { path: backup.relPath }),
397
+ target: backup.relPath,
398
+ detail: msg('restore.hostSkipEscapeDetail'),
399
+ });
400
+ continue;
401
+ }
402
+ const currentExists = await fileExists(abs);
403
+ if (backup.existed) {
404
+ if (backup.blobPath === '') {
405
+ actions.push({
406
+ kind: 'skip',
407
+ description: msg('restore.hostNoBlobInfo', { path: backup.relPath }),
408
+ target: backup.relPath,
409
+ });
410
+ continue;
411
+ }
412
+ if (!(await fileExists(blobAbs(snapshotDir, backup.blobPath, msg)))) {
413
+ actions.push({
414
+ kind: 'skip',
415
+ description: msg('restore.hostBlobMissing', { path: backup.relPath }),
416
+ target: backup.relPath,
417
+ });
418
+ continue;
419
+ }
420
+ actions.push({
421
+ kind: 'hostFileRestore',
422
+ description: msg('restore.hostRestore', { path: backup.relPath }),
423
+ target: backup.relPath,
424
+ blobPath: backup.blobPath,
425
+ isSettings: backup.relPath === settingsRel,
426
+ detail: currentExists ? msg('restore.backupBeforeWrite') : msg('restore.writeDirect'),
427
+ });
428
+ } else if (currentExists) {
429
+ actions.push({
430
+ kind: 'hostFileRemove',
431
+ description: msg('restore.hostRemove', { path: backup.relPath }),
432
+ target: backup.relPath,
433
+ isSettings: backup.relPath === settingsRel,
434
+ detail: msg('restore.backupBeforeRemove'),
435
+ });
436
+ } else {
437
+ actions.push({
438
+ kind: 'skip',
439
+ description: msg('restore.hostSkipAbsent', { path: backup.relPath }),
440
+ target: backup.relPath,
441
+ });
442
+ }
443
+ }
444
+
445
+ // settings 覆盖提示:settingsPath 指定的文件未被快照登记 → 无法整文件还原
446
+ if (settingsRel !== undefined && !hostBackups.some((b) => b.relPath === settingsRel)) {
447
+ actions.push({
448
+ kind: 'skip',
449
+ description: msg('restore.settingsNotBacked', { path: settingsRel }),
450
+ isSettings: true,
451
+ detail: msg('restore.settingsNotBackedDetail'),
452
+ });
453
+ }
454
+
455
+ // 2) 插件撤销:beforePlugins 基线 vs 当前已装 → 导入期间新增
456
+ const profileDir = resolveProfileDir(homeDir, profile);
457
+ const baseline = snapshot.beforePlugins;
458
+ if (baseline === undefined) {
459
+ actions.push({
460
+ kind: 'skip',
461
+ description: msg('restore.noBaseline'),
462
+ detail: msg('restore.noBaselineDetail'),
463
+ });
464
+ } else {
465
+ const beforeNames = new Set(baseline.map((p) => p.name));
466
+ const added = Object.keys(readInstalled(profileDir)).filter((name) => !beforeNames.has(name));
467
+ for (const name of added) {
468
+ actions.push({
469
+ kind: 'pluginRemove',
470
+ description: msg('restore.pluginRemove', { name }),
471
+ target: name,
472
+ pluginName: name,
473
+ detail: baseline.length === 0 ? msg('restore.pluginRemoveEmptyBaseline') : undefined,
474
+ });
475
+ }
476
+ if (added.length === 0) {
477
+ actions.push({
478
+ kind: 'skip',
479
+ description: msg('restore.noAddedPlugins'),
480
+ });
481
+ }
482
+ }
483
+
484
+ // 3) file 类条目补偿 + credentials 提示(聚合计数供报告)
485
+ let namespaceCount = 0;
486
+ let patchLineCount = 0;
487
+ let workspaceCount = 0;
488
+ let credentialAbsentCount = 0;
489
+ for (const entry of snapshot.entries) {
490
+ switch (entry.kind) {
491
+ case 'file': {
492
+ const rel = toRelPath(homeDir, resolveFileAbs(homeDir, entry.adapter, entry.ref));
493
+ if (rel === '') {
494
+ actions.push({
495
+ kind: 'skip',
496
+ description: msg('restore.fileSkipEscape', { adapter: entry.adapter, ref: entry.ref }),
497
+ target: entry.ref,
498
+ });
499
+ continue;
500
+ }
501
+ const abs = homeAbs(homeDir, rel, msg);
502
+ const currentExists = await fileExists(abs);
503
+ if (entry.existed && entry.copiedTo) {
504
+ if (!(await fileExists(blobAbs(snapshotDir, entry.copiedTo, msg)))) {
505
+ actions.push({
506
+ kind: 'skip',
507
+ description: msg('restore.fileBlobMissing', { path: rel }),
508
+ target: rel,
509
+ });
510
+ continue;
511
+ }
512
+ actions.push({
513
+ kind: 'fileRestore',
514
+ description: msg('restore.fileRestore', { path: rel, adapter: entry.adapter }),
515
+ target: rel,
516
+ blobPath: entry.copiedTo,
517
+ detail: currentExists ? msg('restore.backupBeforeWrite') : msg('restore.writeDirect'),
518
+ });
519
+ } else if (!entry.existed) {
520
+ if (currentExists) {
521
+ actions.push({
522
+ kind: 'fileRemove',
523
+ description: msg('restore.fileRemove', { path: rel, adapter: entry.adapter }),
524
+ target: rel,
525
+ detail: msg('restore.backupBeforeRemove'),
526
+ });
527
+ } else {
528
+ actions.push({
529
+ kind: 'skip',
530
+ description: msg('restore.fileSkipAbsent', { path: rel }),
531
+ target: rel,
532
+ });
533
+ }
534
+ } else {
535
+ actions.push({
536
+ kind: 'skip',
537
+ description: msg('restore.fileNoBlobInfo', { path: rel }),
538
+ target: rel,
539
+ });
540
+ }
541
+ break;
542
+ }
543
+ case 'credential':
544
+ if (entry.existed) {
545
+ actions.push({
546
+ kind: 'credentialHint',
547
+ description: msg('restore.credentialHint', { ref: entry.ref }),
548
+ target: entry.ref,
549
+ manualHint: msg('restore.credentialManualHint', { ref: entry.ref }),
550
+ });
551
+ } else {
552
+ credentialAbsentCount += 1;
553
+ }
554
+ break;
555
+ case 'settingsNamespace':
556
+ namespaceCount += 1;
557
+ break;
558
+ case 'patchLine':
559
+ patchLineCount += 1;
560
+ break;
561
+ case 'workspaceRecord':
562
+ workspaceCount += 1;
563
+ break;
564
+ default:
565
+ break;
566
+ }
567
+ }
568
+
569
+ // 聚合提示:整文件还原覆盖不了的条目如实说明
570
+ const settingsBacked = hostBackups.some((b) => b.relPath === 'settings.yaml' || b.relPath === 'settings.json');
571
+ const patchBacked = hostBackups.some((b) => b.relPath === 'cordis.patch.yml');
572
+ if (namespaceCount > 0 && !settingsBacked) {
573
+ actions.push({
574
+ kind: 'skip',
575
+ description: msg('restore.nsAggregate', { count: String(namespaceCount) }),
576
+ detail: msg('restore.nsAggregateDetail'),
577
+ });
578
+ }
579
+ if (patchLineCount > 0 && !patchBacked) {
580
+ actions.push({
581
+ kind: 'skip',
582
+ description: msg('restore.patchAggregate', { count: String(patchLineCount) }),
583
+ detail: msg('restore.patchAggregateDetail'),
584
+ });
585
+ }
586
+ if (workspaceCount > 0) {
587
+ actions.push({
588
+ kind: 'skip',
589
+ description: msg('restore.workspaceAggregate', { count: String(workspaceCount) }),
590
+ detail: msg('restore.workspaceAggregateDetail'),
591
+ });
592
+ }
593
+ if (credentialAbsentCount > 0) {
594
+ actions.push({
595
+ kind: 'skip',
596
+ description: msg('restore.credentialAbsentAggregate', { count: String(credentialAbsentCount) }),
597
+ });
598
+ }
599
+
600
+ return {
601
+ snapshotId: snapshot.id,
602
+ createdAt: snapshot.createdAt,
603
+ sourceZip: snapshot.sourceZip,
604
+ actions,
605
+ summary: summarizeActions(actions),
606
+ pluginBaselineConfirmed: baseline !== undefined,
607
+ };
608
+ }
609
+
610
+ function summarizeActions(actions: RestoreAction[]): RestorePlan['summary'] {
611
+ const summary: RestorePlan['summary'] = {
612
+ hostFileRestores: 0, hostFileRemoves: 0, pluginRemoves: 0,
613
+ fileRestores: 0, fileRemoves: 0, credentialHints: 0, skips: 0,
614
+ };
615
+ for (const a of actions) {
616
+ switch (a.kind) {
617
+ case 'hostFileRestore': summary.hostFileRestores += 1; break;
618
+ case 'hostFileRemove': summary.hostFileRemoves += 1; break;
619
+ case 'pluginRemove': summary.pluginRemoves += 1; break;
620
+ case 'fileRestore': summary.fileRestores += 1; break;
621
+ case 'fileRemove': summary.fileRemoves += 1; break;
622
+ case 'credentialHint': summary.credentialHints += 1; break;
623
+ case 'skip': summary.skips += 1; break;
624
+ }
625
+ }
626
+ return summary;
627
+ }
628
+
629
+ /* ------------------------------------------------------------ restore */
630
+
631
+ /**
632
+ * 执行恢复:按计划顺序 整文件还原 → 插件卸载 → file 补偿,
633
+ * 覆盖/删除前全部先复制到 <snapshotDir>/pre-restore/(双保险),
634
+ * 逐项 try/catch(单项失败不拖垮其余),输出诚实报告。
635
+ */
636
+ export async function restore(opts: RestoreOptions): Promise<RestoreReport> {
637
+ const plan = await planRestore(opts);
638
+ const { snapshotDir, homeDir, profile } = opts;
639
+ const msg = opts.msg ?? zhMsg;
640
+ const preDir = path.join(snapshotDir, 'pre-restore');
641
+ const profileDir = resolveProfileDir(homeDir, profile);
642
+ const uninstaller = opts.pluginUninstaller
643
+ ?? ((name: string, dir: string, prof: string) => defaultPluginUninstaller(name, dir, prof, msg));
644
+
645
+ const report: RestoreReport = {
646
+ snapshotId: plan.snapshotId,
647
+ restored: [],
648
+ removedPlugins: [],
649
+ manualHints: [],
650
+ failed: [],
651
+ skipped: [],
652
+ };
653
+
654
+ let seq = 0;
655
+ const total = plan.actions.length;
656
+ let actionIndex = 0;
657
+ for (const action of plan.actions) {
658
+ actionIndex += 1;
659
+ opts.onAction?.({ index: actionIndex, total, detail: action.description });
660
+ try {
661
+ switch (action.kind) {
662
+ case 'hostFileRestore': {
663
+ const abs = homeAbs(homeDir, action.target!, msg);
664
+ if (await fileExists(abs)) await copyToPreRestore(preDir, abs, action.target!, ++seq);
665
+ const data = await fs.readFile(blobAbs(snapshotDir, action.blobPath!, msg));
666
+ await atomicWriteFile(abs, data);
667
+ report.restored.push(action.target!);
668
+ break;
669
+ }
670
+ case 'hostFileRemove': {
671
+ const abs = homeAbs(homeDir, action.target!, msg);
672
+ if (await fileExists(abs)) {
673
+ await copyToPreRestore(preDir, abs, action.target!, ++seq);
674
+ await fs.rm(abs, { force: true });
675
+ }
676
+ report.restored.push(action.target!);
677
+ break;
678
+ }
679
+ case 'fileRestore': {
680
+ const abs = homeAbs(homeDir, action.target!, msg);
681
+ if (await fileExists(abs)) await copyToPreRestore(preDir, abs, action.target!, ++seq);
682
+ const data = await fs.readFile(blobAbs(snapshotDir, action.blobPath!, msg));
683
+ await atomicWriteFile(abs, data);
684
+ report.restored.push(action.target!);
685
+ break;
686
+ }
687
+ case 'fileRemove': {
688
+ const abs = homeAbs(homeDir, action.target!, msg);
689
+ if (await fileExists(abs)) {
690
+ await copyToPreRestore(preDir, abs, action.target!, ++seq);
691
+ await fs.rm(abs, { force: true });
692
+ }
693
+ report.restored.push(action.target!);
694
+ break;
695
+ }
696
+ case 'pluginRemove': {
697
+ const result = await uninstaller(action.pluginName!, profileDir, profile);
698
+ if (result.ok) {
699
+ report.removedPlugins.push(action.pluginName!);
700
+ } else {
701
+ report.failed.push({ item: `plugin:${action.pluginName}`, reason: result.message ?? msg('restore.pluginRemoveFailed') });
702
+ }
703
+ break;
704
+ }
705
+ case 'credentialHint':
706
+ report.manualHints.push(action.manualHint ?? action.description);
707
+ break;
708
+ case 'skip':
709
+ report.skipped.push(action.description);
710
+ break;
711
+ default:
712
+ report.skipped.push(msg('restore.unknownAction', { kind: String(action.kind), description: action.description }));
713
+ }
714
+ } catch (err) {
715
+ report.failed.push({
716
+ item: action.target ?? action.pluginName ?? action.description,
717
+ reason: err instanceof Error ? err.message : String(err),
718
+ });
719
+ }
720
+ }
721
+
722
+ // F5 幽灵会话校验(file 类会话条目写回后):快照记录的会话清单 vs 磁盘 sessions 目录
723
+ // 实际文件,报告失效归档(备份有记录、磁盘无文件)。DSH 无归档会话 API,仅报告不
724
+ // 自动清理;校验失败不拖垮已完成的恢复动作,如实记入 failed。
725
+ try {
726
+ const ghostSessions = await sweepRestoredSessions(snapshotDir, homeDir, msg);
727
+ report.ghostSessions = ghostSessions;
728
+ if (ghostSessions.length > 0) {
729
+ report.skipped.push(
730
+ msg('restore.ghostSweepSummary', { count: String(ghostSessions.length), keys: ghostSessions.join(', ') }),
731
+ );
732
+ }
733
+ } catch (err) {
734
+ report.failed.push({ item: 'ghost-sweep', reason: err instanceof Error ? err.message : String(err) });
735
+ }
736
+
737
+ return report;
738
+ }
739
+
740
+ /* ------------------------------------------------------------ 幽灵会话(F5) */
741
+
742
+ /**
743
+ * 磁盘 sessions 目录实际相对路径清单(相对 <homeDir>/sessions;目录缺失视为空)。
744
+ * 用 node fs 递归列举(离线引擎不依赖 HostContext),分隔符由 sessionKeyOf 归一。
745
+ */
746
+ async function listSessionEntries(homeDir: string): Promise<string[]> {
747
+ const base = path.join(homeDir, 'sessions');
748
+ try {
749
+ return await fs.readdir(base, { recursive: true });
750
+ } catch {
751
+ return []; // 目录不存在(无会话分区)→ 空
752
+ }
753
+ }
754
+
755
+ /**
756
+ * 幽灵会话本地校验(F5 降级方案):
757
+ * 快照记录的 sessions 分区 file 条目(existed=true,恢复后应存在)与磁盘
758
+ * <homeDir>/sessions 实际条目对比,返回「备份有记录、磁盘无文件」的失效会话键。
759
+ * DSH 官方无归档会话 API(core/types.ts 未暴露 archivedSessionIds/unarchive),
760
+ * 故不清理 DSH 归档列表,仅报告供人工确认。
761
+ */
762
+ async function sweepRestoredSessions(snapshotDir: string, homeDir: string, msg: MsgFunc): Promise<string[]> {
763
+ const snapshot = await loadSnapshot(snapshotDir, msg);
764
+ const expected = expectedSessionRefs(snapshot.entries ?? []);
765
+ if (expected.length === 0) return [];
766
+ return sweepGhostSessions(expected, await listSessionEntries(homeDir));
767
+ }
768
+
769
+ /* ------------------------------------------------------------ listSnapshots */
770
+
771
+ /**
772
+ * 扫描快照根目录下每个子目录的 snapshot.json,返回按 createdAt 倒序的元信息。
773
+ * 目录缺失/条目损坏自动跳过(不阻断其他快照)。
774
+ */
775
+ export async function listSnapshots(dir: string): Promise<SnapshotMeta[]> {
776
+ let entries;
777
+ try {
778
+ entries = await fs.readdir(dir, { withFileTypes: true });
779
+ } catch {
780
+ return [];
781
+ }
782
+ const metas: SnapshotMeta[] = [];
783
+ for (const entry of entries) {
784
+ if (!entry.isDirectory()) continue;
785
+ try {
786
+ const parsed = parseJsonSafe(await fs.readFile(path.join(dir, entry.name, 'snapshot.json'), 'utf8'));
787
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
788
+ const snapshot = parsed as Snapshot;
789
+ if (typeof snapshot.id !== 'string' || snapshot.id === '') continue;
790
+ metas.push({
791
+ id: snapshot.id,
792
+ createdAt: snapshot.createdAt,
793
+ sourceZip: snapshot.sourceZip,
794
+ status: snapshot.status,
795
+ entryCount: snapshot.entries?.length ?? 0,
796
+ hostFileBackupCount: snapshot.hostFileBackups?.length ?? 0,
797
+ beforePluginCount: snapshot.beforePlugins?.length ?? 0,
798
+ pinned: snapshot.pinned === true,
799
+ });
800
+ } catch {
801
+ // 损坏 / 非快照目录:跳过
802
+ }
803
+ }
804
+ metas.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
805
+ return metas;
806
+ }
807
+
808
+ /* ------------------------------------------------------------ 快照管理(P1-⑧) */
809
+
810
+ /** 快照 id 安全校验(防路径穿越):字母数字 + - _ .,长度 1-80。 */
811
+ export function isValidSnapshotId(id: unknown): id is string {
812
+ return typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(id);
813
+ }
814
+
815
+ /** 删除单个快照(手动删除;P1-⑧)。只接受合法 id(防穿越),目标是快照根下的 <id>/ 目录。
816
+ * 不存在视为成功(幂等)。返回是否实际删除。 */
817
+ export async function deleteSnapshot(snapshotsDir: string, id: string): Promise<boolean> {
818
+ if (!isValidSnapshotId(id)) {
819
+ throw new Error(`非法快照 id: ${JSON.stringify(id)}`);
820
+ }
821
+ const target = path.join(snapshotsDir, id);
822
+ if (!target.startsWith(path.resolve(snapshotsDir) + path.sep)) {
823
+ throw new Error(`快照路径越界: ${id}`);
824
+ }
825
+ try {
826
+ await fs.stat(target);
827
+ } catch {
828
+ return false; // 不存在 → 幂等成功
829
+ }
830
+ await fs.rm(target, { recursive: true, force: true });
831
+ return true;
832
+ }
833
+
834
+ /** 切换置顶状态(P1-⑧):置顶快照豁免自动保留清理。重写 <dir>/<id>/snapshot.json 的 pinned 字段。
835
+ * 只接受合法 id;快照不存在抛错。 */
836
+ export async function setSnapshotPinned(snapshotsDir: string, id: string, pinned: boolean): Promise<boolean> {
837
+ if (!isValidSnapshotId(id)) {
838
+ throw new Error(`非法快照 id: ${JSON.stringify(id)}`);
839
+ }
840
+ const file = path.join(snapshotsDir, id, 'snapshot.json');
841
+ const resolvedRoot = path.resolve(snapshotsDir) + path.sep;
842
+ if (!file.startsWith(resolvedRoot)) {
843
+ throw new Error(`快照路径越界: ${id}`);
844
+ }
845
+ const snapshot = parseJsonSafe(await fs.readFile(file, 'utf8')) as Snapshot;
846
+ snapshot.pinned = pinned;
847
+ await atomicWriteFile(file, JSON.stringify(snapshot, null, 2));
848
+ return pinned;
849
+ }