@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,737 @@
1
+ /**
2
+ * Boot Rescue(启动救援 / SAFE MODE 级兜底)。
3
+ *
4
+ * 背景:DSH 从 profile 树加载插件——
5
+ * - <homeDir>/profiles/<profile>/cordis.patch.yml(profile 补丁层)
6
+ * - <homeDir>/cordis.patch.yml(home 补丁层)
7
+ * - <homeDir>/profiles/<profile>/package.json(dsh.profile.bundles 清单)
8
+ * 一旦装进坏插件或某个 bundle 不再可解析,DSH 可能整个启动失败、连恢复 UI 都进不去。
9
+ * 本模块提供「救援模式」:
10
+ * 进入 → 备份三处原件 → 裁剪不可解析的 bundles(disableUserBundles 时连同**全部非核心
11
+ * bundle** 一起剔除,见 restrictToRescueBundles)→ 把 profile patch 改写成宿主给定的
12
+ * 最小内容 →(默认)把 home patch 置空 → 落状态文件;
13
+ * 退出 → 先校验全部备份存在 → 逐个还原 → 删状态文件。
14
+ *
15
+ * 关键不变量(对应历史事故,勿破坏):
16
+ * 1. `dsh.profile.bundles` 必须**扁平**:`pkg.dsh.profile.bundles = kept`(string[])。
17
+ * 曾有实现写成 `[kept]`(双层包裹 `[[name]]`),DSH 加载器读到非字符串条目后再次启动失败。
18
+ * 2. 幂等:同 home/profile 已激活时 enter 直接返回 already-active,一个字节都不改。
19
+ * 3. 备份先于任何目标写入;备份失败(或原件读失败)→ backup-failed,目标文件保持原样。
20
+ * 4. exit 先校验全部备份存在,任一缺失 → backup-missing 且不做任何还原。
21
+ * 5. package.json 存在但不可解析(或根不是 JSON 对象)→ package-json-invalid,不做任何写入。
22
+ * 6. bundles 无裁剪时**不重写** package.json(避免无意义的重序列化)。
23
+ * 7. 状态里的 homeFingerprint 失效(换机器 / 重建 home / profile 变化)→ 状态自动降级 stale:
24
+ * enterRescueMode 允许覆盖旧痕迹重新进入;exitRescueMode 拒绝照 stale 状态还原(宁可不做)。
25
+ * 指纹输入经 normalizeHomeDir 归一化——同一目录的不同写法(分隔符 / 尾分隔符 / 大小写)
26
+ * 必须得到同一指纹,否则「自己判自己 stale」会让退出救援一个文件都不动。
27
+ *
28
+ * 分层:只依赖 node:* 与 ../utils(core 分层约束),零 DSH 运行时依赖,零适配器耦合。
29
+ * 文案:本模块不产出用户可见文案——失败只给 `code`(+ 英文诊断 message,供日志/测试排查),
30
+ * i18n 由调用方按 code 负责。
31
+ */
32
+ import fs from 'node:fs/promises';
33
+ import path from 'node:path';
34
+
35
+ import { atomicWriteFile } from '../utils/atomic-write.ts';
36
+ import { sha256Hex } from '../utils/hashing.ts';
37
+
38
+ /* ---------------------------------------------------------------- 常量 */
39
+
40
+ /** 默认备份目录(相对 homeDir 的 posix 路径;backupDir 缺省时使用) */
41
+ const DEFAULT_BACKUP_DIR_REL = 'dsh-config-manager/transactions/rescue-backups';
42
+
43
+ /** 状态文件名(位于 backupDir 下,唯一事实源;缺失/损坏即视为未激活) */
44
+ const STATE_FILE = 'state.json';
45
+
46
+ /** 备份文件名(位于 backupDir 下,固定命名;状态里记录其绝对路径) */
47
+ const BACKUP_PROFILE_PATCH = 'profile-patch.yml.orig';
48
+ const BACKUP_HOME_PATCH = 'home-patch.yml.orig';
49
+ const BACKUP_PROFILE_PACKAGE_JSON = 'profile-package.json.orig';
50
+
51
+ /** home patch 置空内容(空补丁层 = 不挂任何行) */
52
+ const EMPTY_PATCH = '[]\n';
53
+
54
+ /* ---------------------------------------------------------------- 路径 */
55
+
56
+ /** 三处被救援模式改写/备份的文件(相对 homeDir 的 posix 路径)。 */
57
+ export interface RescuePaths {
58
+ profilePatch: string; // profiles/<profile>/cordis.patch.yml
59
+ homePatch: string; // cordis.patch.yml
60
+ profilePackageJson: string; // profiles/<profile>/package.json
61
+ }
62
+
63
+ /** 三处文件的 homeDir 相对 posix 路径(profile 名按字面拼接,不做清洗)。 */
64
+ export function rescuePaths(profile: string): RescuePaths {
65
+ return {
66
+ profilePatch: `profiles/${profile}/cordis.patch.yml`,
67
+ homePatch: 'cordis.patch.yml',
68
+ profilePackageJson: `profiles/${profile}/package.json`,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * homeDir 归一化(指纹的输入键)。
74
+ *
75
+ * 为什么必须归一化:指纹原先直接哈希调用方传入的**原始字符串**,同一目录换个写法
76
+ * (`C:/x/.dsh` vs `C:\x\.dsh`,或带尾分隔符 / `.` / `..`)就会算出不同指纹,于是
77
+ * `exitRescueMode` 把**自己的**状态判成 stale 并拒绝还原——一个文件都不动,用户只能
78
+ * 手改文件。从插件 UI 进出时两次都原样传 `host.homeDir` 所以看不出来;从 CLI / 脚本
79
+ * 手动进出(各自拼路径)就会踩到。
80
+ *
81
+ * 规则:先 resolve 成绝对规范路径(消掉分隔符混用、`.` / `..`、尾分隔符),
82
+ * win32 下再折叠大小写(该平台路径大小写不敏感)。
83
+ * `platform` 可注入,使 win32 语义在非 win32 的 CI 上也能被验证。
84
+ */
85
+ export function normalizeHomeDir(homeDir: string, platform: NodeJS.Platform = process.platform): string {
86
+ const impl = platform === 'win32' ? path.win32 : path.posix;
87
+ const resolved = impl.resolve(homeDir);
88
+ return platform === 'win32' ? resolved.toLowerCase() : resolved;
89
+ }
90
+
91
+ /** homeDir|profile 的 sha256 指纹(换机器/重建 home 后用于自动降级失效)。 */
92
+ export function homeFingerprint(homeDir: string, profile: string): string {
93
+ return sha256Hex(`${normalizeHomeDir(homeDir)}|${profile}`);
94
+ }
95
+
96
+ /**
97
+ * 历史指纹形态:直接哈希调用方传入的**原始字符串**(归一化修复前的算法)。
98
+ * 仅用于向后兼容 —— 用户可能停留在「已进入救援」与「升级插件」之间;若只认新指纹,
99
+ * 他们的状态会被误判为 stale 而无法退出救援(正是本 bug 的另一面)。
100
+ */
101
+ function legacyHomeFingerprint(homeDir: string, profile: string): string {
102
+ return sha256Hex(`${homeDir}|${profile}`);
103
+ }
104
+
105
+ /**
106
+ * 记录的指纹是否属于「这个 home + profile」。
107
+ *
108
+ * 认两种形态:归一化后的新指纹,或原样字符串的历史指纹。安全性不受影响 ——
109
+ * 历史指纹要求调用方传入与「进入救援时」完全相同的字符串,按定义就是同一个 home;
110
+ * 换机器 / 重建 home 得到的是不同字符串,依旧判 stale 并拒绝还原。
111
+ */
112
+ function fingerprintMatches(homeDir: string, profile: string, recorded: string): boolean {
113
+ return recorded === homeFingerprint(homeDir, profile) || recorded === legacyHomeFingerprint(homeDir, profile);
114
+ }
115
+
116
+ /** posix 相对路径 → 宿主平台原生绝对路径(Windows 下逐段 join,避免混用分隔符)。 */
117
+ function absFromRel(homeDir: string, relPosix: string): string {
118
+ return path.join(homeDir, ...relPosix.split('/'));
119
+ }
120
+
121
+ /** 默认备份目录:<homeDir>/dsh-config-manager/transactions/rescue-backups */
122
+ function defaultBackupDir(homeDir: string): string {
123
+ return path.join(homeDir, ...DEFAULT_BACKUP_DIR_REL.split('/'));
124
+ }
125
+
126
+ /** 状态里记录的备份路径 → 绝对路径(兼容绝对/相对两种记录形态)。 */
127
+ function resolveBackupPath(backupDir: string, recorded: string): string {
128
+ return path.isAbsolute(recorded) ? recorded : path.join(backupDir, ...recorded.split('/'));
129
+ }
130
+
131
+ /* ---------------------------------------------------------------- 小工具 */
132
+
133
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
134
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
135
+ }
136
+
137
+ function isEnoent(err: unknown): boolean {
138
+ return isPlainObject(err) && err['code'] === 'ENOENT';
139
+ }
140
+
141
+ function errorText(err: unknown): string {
142
+ return err instanceof Error ? err.message : String(err);
143
+ }
144
+
145
+ function decodeText(bytes: Uint8Array): string {
146
+ return new TextDecoder().decode(bytes);
147
+ }
148
+
149
+ /** 读文件:不存在(ENOENT)→ null;其余 IO 错误上抛(调用方判为 backup-failed)。 */
150
+ async function readBytesOrNull(file: string): Promise<Uint8Array | null> {
151
+ try {
152
+ return await fs.readFile(file);
153
+ } catch (err) {
154
+ if (isEnoent(err)) return null;
155
+ throw err;
156
+ }
157
+ }
158
+
159
+ /** 文件是否存在(任何 stat 错误都按「不存在/不可用」处理,保守)。 */
160
+ async function fileExists(file: string): Promise<boolean> {
161
+ try {
162
+ await fs.stat(file);
163
+ return true;
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+
169
+ /** 删除文件;文件不存在视为成功。 */
170
+ async function unlinkIfExists(file: string): Promise<void> {
171
+ try {
172
+ await fs.unlink(file);
173
+ } catch (err) {
174
+ if (!isEnoent(err)) throw err;
175
+ }
176
+ }
177
+
178
+ /** 非字符串条目的可读标签(诊断用,不参与逻辑)。 */
179
+ function describeEntry(value: unknown): string {
180
+ try {
181
+ const json = JSON.stringify(value);
182
+ if (typeof json === 'string') return json;
183
+ } catch {
184
+ /* 循环引用等 → 退化为 String */
185
+ }
186
+ try {
187
+ return String(value);
188
+ } catch {
189
+ return '<unprintable>';
190
+ }
191
+ }
192
+
193
+ /* ---------------------------------------------------------------- bundles 裁剪 */
194
+
195
+ /** bundle 可解析性探测(由宿主注入,core 不做模块解析)。返回 true=可解析。 */
196
+ export type BundleResolver = (name: string) => Promise<boolean> | boolean;
197
+
198
+ export interface SafeBundlesResult {
199
+ kept: string[]; // 可解析、保留
200
+ pruned: { name: string; reason: string }[]; // 被剔除
201
+ }
202
+
203
+ /**
204
+ * 过滤 dsh.profile.bundles:逐个用 resolve() 探测,不可解析的剔除。
205
+ * - 非字符串条目 → pruned,reason='non-string'
206
+ * - resolve 返回 false 或抛错 → pruned,reason='unresolved'
207
+ * - 非数组/缺失 → kept=[], pruned=[], 且 inputWasArray=false
208
+ * 纯函数(async),零 IO。只有严格 `true` 视为可解析(保守:undefined/其他返回值一律剔除)。
209
+ */
210
+ export async function computeSafeBundles(
211
+ bundles: unknown,
212
+ resolve: BundleResolver,
213
+ ): Promise<SafeBundlesResult & { inputWasArray: boolean }> {
214
+ if (!Array.isArray(bundles)) {
215
+ return { kept: [], pruned: [], inputWasArray: false };
216
+ }
217
+ const kept: string[] = [];
218
+ const pruned: { name: string; reason: string }[] = [];
219
+ for (const entry of bundles) {
220
+ if (typeof entry !== 'string') {
221
+ pruned.push({ name: describeEntry(entry), reason: 'non-string' });
222
+ continue;
223
+ }
224
+ let resolvable = false;
225
+ try {
226
+ resolvable = (await resolve(entry)) === true;
227
+ } catch {
228
+ resolvable = false;
229
+ }
230
+ if (resolvable) kept.push(entry);
231
+ else pruned.push({ name: entry, reason: 'unresolved' });
232
+ }
233
+ return { kept, pruned, inputWasArray: true };
234
+ }
235
+
236
+ /** 读取 pkg.dsh.profile.bundles(路径任一层非普通对象 → undefined)。 */
237
+ function readBundlesField(pkg: Record<string, unknown>): unknown {
238
+ const dsh = pkg['dsh'];
239
+ if (!isPlainObject(dsh)) return undefined;
240
+ const profile = dsh['profile'];
241
+ if (!isPlainObject(profile)) return undefined;
242
+ return profile['bundles'];
243
+ }
244
+
245
+ /** 救援模式下必须保留的 bundle 前缀(DSH 核心;剪掉它们 DSH 自身都起不来)。 */
246
+ export const RESCUE_KEEP_BUNDLE_PREFIXES: readonly string[] = ['@deepseek-ai/'];
247
+
248
+ /**
249
+ * 把「可解析的 bundle 清单」进一步收窄到**救援必需项**:DSH 核心(默认 `@deepseek-ai/*`)
250
+ * 与救援插件自身;其余用户 bundle 一律剔除,reason='rescue-disabled'。
251
+ *
252
+ * 为什么需要它:只中和 patch 层 + 剪掉「不可解析」的 bundle,治的是「patch/插件配置把启动
253
+ * 搞挂了」。bundle 只要能解析就会照旧挂载,所以「插件代码自己把 DSH 搞挂」这一类根本救不了。
254
+ * 收窄 bundle 才是「临时禁用除本插件外的全部用户插件」的实现(退出仍按备份逐字节还原)。
255
+ * 纯函数,零 IO。
256
+ */
257
+ export function restrictToRescueBundles(
258
+ kept: readonly string[],
259
+ selfPackageName: string,
260
+ keepPrefixes: readonly string[] = RESCUE_KEEP_BUNDLE_PREFIXES,
261
+ ): SafeBundlesResult {
262
+ const outKept: string[] = [];
263
+ const pruned: { name: string; reason: string }[] = [];
264
+ for (const name of kept) {
265
+ if (name === selfPackageName || keepPrefixes.some((prefix) => name.startsWith(prefix))) outKept.push(name);
266
+ else pruned.push({ name, reason: 'rescue-disabled' });
267
+ }
268
+ return { kept: outKept, pruned };
269
+ }
270
+
271
+ /**
272
+ * 写入 pkg.dsh.profile.bundles(**扁平** string[],绝不做 `[kept]` 二次包裹)。
273
+ * 仅当 dsh / dsh.profile 均为普通对象时成功;返回是否已写入。
274
+ */
275
+ function writeBundlesField(pkg: Record<string, unknown>, bundles: string[]): boolean {
276
+ const dsh = pkg['dsh'];
277
+ if (!isPlainObject(dsh)) return false;
278
+ const profile = dsh['profile'];
279
+ if (!isPlainObject(profile)) return false;
280
+ profile['bundles'] = bundles;
281
+ return true;
282
+ }
283
+
284
+ /* ---------------------------------------------------------------- 状态 */
285
+
286
+ export interface RescueState {
287
+ active: true;
288
+ enteredAt: string;
289
+ profile: string;
290
+ /** homeDir+profile 的指纹(换机器/重建 home 后用于自动降级失效) */
291
+ homeFingerprint: string;
292
+ /**
293
+ * 备份文件绝对路径;null = 原件不存在、未产生备份。
294
+ * profilePatch 例外:类型为 string,原件不存在时取 ''(配合 profilePatchWasAbsent=true 判读)。
295
+ */
296
+ backup: { profilePatch: string; homePatch: string | null; profilePackageJson: string | null };
297
+ prunedBundles: { name: string; reason: string }[];
298
+ /** 进入时 profile patch 是否原本不存在(退出时需删除而非还原) */
299
+ profilePatchWasAbsent: boolean;
300
+ }
301
+
302
+ export type RescueEnterResult =
303
+ | { ok: true; state: RescueState }
304
+ | { ok: false; code: 'already-active' | 'backup-failed' | 'package-json-invalid' | 'write-failed'; message: string };
305
+
306
+ export type RescueExitResult =
307
+ | { ok: true; restored: string[] }
308
+ | { ok: false; code: 'not-active' | 'backup-missing' | 'restore-failed'; message: string };
309
+
310
+ export interface RescueStatus {
311
+ active: boolean;
312
+ /** 状态存在但 homeFingerprint 不匹配 → 已自动降级,不激活 */
313
+ stale: boolean;
314
+ state: RescueState | null;
315
+ }
316
+
317
+ /** `string | null` 严格解析;其他类型 → undefined(判为损坏)。 */
318
+ function asStringOrNull(value: unknown): string | null | undefined {
319
+ if (value === null) return null;
320
+ return typeof value === 'string' ? value : undefined;
321
+ }
322
+
323
+ /** prunedBundles 宽松解析(形状不符的条目丢弃,不因它把整个状态判为损坏)。 */
324
+ function parsePrunedBundles(value: unknown): { name: string; reason: string }[] {
325
+ if (!Array.isArray(value)) return [];
326
+ const out: { name: string; reason: string }[] = [];
327
+ for (const item of value) {
328
+ if (!isPlainObject(item)) continue;
329
+ const name = item['name'];
330
+ const reason = item['reason'];
331
+ if (typeof name === 'string' && typeof reason === 'string') out.push({ name, reason });
332
+ }
333
+ return out;
334
+ }
335
+
336
+ /** 状态文件文本 → RescueState;缺失/损坏/形状不符 → null。 */
337
+ function parseRescueState(text: string): RescueState | null {
338
+ let raw: unknown;
339
+ try {
340
+ raw = JSON.parse(text);
341
+ } catch {
342
+ return null;
343
+ }
344
+ if (!isPlainObject(raw)) return null;
345
+ if (raw['active'] !== true) return null;
346
+ const enteredAt = raw['enteredAt'];
347
+ const profile = raw['profile'];
348
+ const fingerprint = raw['homeFingerprint'];
349
+ if (typeof enteredAt !== 'string' || typeof profile !== 'string' || typeof fingerprint !== 'string') return null;
350
+ const backup = raw['backup'];
351
+ if (!isPlainObject(backup)) return null;
352
+ const backupProfilePatch = asStringOrNull(backup['profilePatch']);
353
+ const backupHomePatch = asStringOrNull(backup['homePatch']);
354
+ const backupPackageJson = asStringOrNull(backup['profilePackageJson']);
355
+ if (typeof backupProfilePatch !== 'string' || backupHomePatch === undefined || backupPackageJson === undefined) return null;
356
+ return {
357
+ active: true,
358
+ enteredAt,
359
+ profile,
360
+ homeFingerprint: fingerprint,
361
+ backup: {
362
+ profilePatch: backupProfilePatch,
363
+ homePatch: backupHomePatch,
364
+ profilePackageJson: backupPackageJson,
365
+ },
366
+ prunedBundles: parsePrunedBundles(raw['prunedBundles']),
367
+ profilePatchWasAbsent: raw['profilePatchWasAbsent'] === true,
368
+ };
369
+ }
370
+
371
+ /** 读状态文件(缺失/损坏 → null)。 */
372
+ async function readRescueState(backupDir: string): Promise<RescueState | null> {
373
+ const statePath = path.join(backupDir, STATE_FILE);
374
+ let text: string;
375
+ try {
376
+ text = await fs.readFile(statePath, 'utf8');
377
+ } catch {
378
+ return null;
379
+ }
380
+ return parseRescueState(text);
381
+ }
382
+
383
+ /**
384
+ * 救援插件自身的挂载描述。
385
+ * - `packageName` 与 profile package.json 的 `dsh.profile.bundles` 条目比对:
386
+ * 命中 → 本插件由 bundle 层挂载,patch 层不得再插入同 id 行(否则 loader 报重复 id)。
387
+ * - `row` 仅在包不在 bundles 时写入(手工 patch 层挂载的场景)。
388
+ */
389
+ export interface RescueSelfMount {
390
+ packageName: string;
391
+ row: { id: string; name: string };
392
+ }
393
+
394
+ /** 救援模式 profile patch 的头注释(说明本文件被谁接管、bundle 层的实际状态)。 */
395
+ const RESCUE_PATCH_HEADER_LINES = [
396
+ '# dsh-config-manager RESCUE MODE',
397
+ '# 本文件(profile patch 层)已被救援模式接管;home patch 同时被置空。',
398
+ ];
399
+
400
+ /** 救援模式 profile patch 的头注释(按 bundle 处理方式给出准确说明,不得夸大能力)。 */
401
+ function rescuePatchHeader(bundlesDisabled: boolean): string {
402
+ const note = bundlesDisabled
403
+ ? '# dsh.profile.bundles 已被收窄为 DSH 核心与救援插件自身:其余用户插件本次启动不挂载。'
404
+ : '# 注意:dsh.profile.bundles 里的插件仍由 bundle 层挂载(本次只裁剪了不可解析的 bundle)。';
405
+ return [...RESCUE_PATCH_HEADER_LINES, note, ''].join('\n');
406
+ }
407
+
408
+ /** 单引号 YAML 标量(按 YAML 规则把 `'` 转义为 `''`)。 */
409
+ function yamlScalar(value: string): string {
410
+ return `'${value.replace(/'/g, "''")}'`;
411
+ }
412
+
413
+ /**
414
+ * 生成救援期间 profile patch 内容。
415
+ *
416
+ * 关键不变量:**本插件已由 bundle 层挂载时,patch 层绝不能再写一行挂载自己**。
417
+ * bundle 层先于 profile patch 层应用,重复 entry id 会让 loader 抛
418
+ * `duplicate loader entry id: <id>`,整棵插件树加载失败 → 救援模式反而把自己锁死
419
+ * (DSH 起不来,UI 里的「退出救援」也点不到)。因此这种情况只写空列表。
420
+ */
421
+ function rescuePatchText(mount: RescueSelfMount, alreadyInBundles: boolean, bundlesDisabled: boolean): string {
422
+ const header = rescuePatchHeader(bundlesDisabled);
423
+ if (alreadyInBundles) return `${header}[]\n`;
424
+ const row = [
425
+ '- insert:',
426
+ ` - id: ${yamlScalar(mount.row.id)}`,
427
+ ` name: ${yamlScalar(mount.row.name)}`,
428
+ '',
429
+ ].join('\n');
430
+ return `${header}${row}`;
431
+ }
432
+
433
+ /* ---------------------------------------------------------------- 进入救援 */
434
+
435
+ export interface RescueEnterOptions {
436
+ homeDir: string;
437
+ profile: string;
438
+ /** 救援插件自身的挂载描述(宿主传入;决定最小 patch 是否需要显式插入本插件行)。 */
439
+ rescueMount: RescueSelfMount;
440
+ /** 是否同时把 home patch 置空(写 '[]\n')。缺省 true。 */
441
+ neutralizeHomePatch?: boolean;
442
+ /** bundle 解析器;缺省 = 不裁剪任何 bundle(保守)。 */
443
+ resolveBundle?: BundleResolver;
444
+ /**
445
+ * 是否连同 bundle 一起收窄(缺省 false = 只剪「不可解析」的)。
446
+ * true → 在可解析的基础上再保留 DSH 核心(keepBundlePrefixes)与救援插件自身,
447
+ * 其余用户 bundle 全部剔除——这才是「禁用其它插件」,见 restrictToRescueBundles。
448
+ */
449
+ disableUserBundles?: boolean;
450
+ /** disableUserBundles=true 时的保留前缀(缺省 RESCUE_KEEP_BUNDLE_PREFIXES) */
451
+ keepBundlePrefixes?: readonly string[];
452
+ /** 备份目录(缺省 <homeDir>/dsh-config-manager/transactions/rescue-backups) */
453
+ backupDir?: string;
454
+ now?: () => Date;
455
+ }
456
+
457
+ /** 进入救援阶段的目标写入计划(回滚复用同一组原件字节)。 */
458
+ interface OriginalSet {
459
+ targetProfilePatch: string;
460
+ targetHomePatch: string;
461
+ targetPackageJson: string;
462
+ origProfilePatch: Uint8Array | null;
463
+ origHomePatch: Uint8Array | null;
464
+ origPackageJson: Uint8Array | null;
465
+ }
466
+
467
+ /** 尽力回滚已改写/已创建的目标;返回失败清单(空 = 全部回滚成功)。 */
468
+ async function rollbackTargets(
469
+ originals: OriginalSet,
470
+ opts: { wrotePackageJson: boolean; wroteHomePatch: boolean },
471
+ ): Promise<string[]> {
472
+ const failures: string[] = [];
473
+ const step = async (label: string, run: () => Promise<void>): Promise<void> => {
474
+ try {
475
+ await run();
476
+ } catch (err) {
477
+ failures.push(`${label}: ${errorText(err)}`);
478
+ }
479
+ };
480
+ await step('profile-patch', async () => {
481
+ if (originals.origProfilePatch !== null) await atomicWriteFile(originals.targetProfilePatch, originals.origProfilePatch);
482
+ else await unlinkIfExists(originals.targetProfilePatch);
483
+ });
484
+ if (opts.wrotePackageJson && originals.origPackageJson !== null) {
485
+ await step('package-json', async () => {
486
+ await atomicWriteFile(originals.targetPackageJson, originals.origPackageJson as Uint8Array);
487
+ });
488
+ }
489
+ if (opts.wroteHomePatch) {
490
+ await step('home-patch', async () => {
491
+ if (originals.origHomePatch !== null) await atomicWriteFile(originals.targetHomePatch, originals.origHomePatch);
492
+ else await unlinkIfExists(originals.targetHomePatch);
493
+ });
494
+ }
495
+ return failures;
496
+ }
497
+
498
+ /** 进入救援模式:备份三处文件 → 裁剪 bundles → 写最小 profile patch → 可能置空 home patch → 写状态文件。 */
499
+ export async function enterRescueMode(opts: RescueEnterOptions): Promise<RescueEnterResult> {
500
+ const homeDir = opts.homeDir;
501
+ const profile = opts.profile;
502
+ const backupDir = opts.backupDir ?? defaultBackupDir(homeDir);
503
+ const fingerprint = homeFingerprint(homeDir, profile);
504
+ const neutralizeHome = opts.neutralizeHomePatch !== false;
505
+ // 缺省保守:不注入解析器时视所有字符串名字为可解析(只剔除非字符串条目)
506
+ const resolveBundle: BundleResolver = opts.resolveBundle ?? (() => true);
507
+ const now = opts.now ?? (() => new Date());
508
+
509
+ const rel = rescuePaths(profile);
510
+ const targetProfilePatch = absFromRel(homeDir, rel.profilePatch);
511
+ const targetHomePatch = absFromRel(homeDir, rel.homePatch);
512
+ const targetPackageJson = absFromRel(homeDir, rel.profilePackageJson);
513
+ const statePath = path.join(backupDir, STATE_FILE);
514
+
515
+ // —— 1. 幂等:同 home/profile 已激活 → 立刻返回,一个字节都不动 ——
516
+ const current = await readRescueState(backupDir);
517
+ if (current !== null && fingerprintMatches(homeDir, profile, current.homeFingerprint)) {
518
+ return {
519
+ ok: false,
520
+ code: 'already-active',
521
+ message: `rescue mode is already active for ${rel.profilePatch} (enteredAt=${current.enteredAt}); nothing was modified`,
522
+ };
523
+ }
524
+ // 指纹不匹配(换机器/重建 home/profile 变化)或状态损坏 → 视为失效残留,允许覆盖重进
525
+
526
+ // —— 2. 读原件(读失败 → backup-failed;备份先于任何目标写入) ——
527
+ let origProfilePatch: Uint8Array | null;
528
+ let origHomePatch: Uint8Array | null;
529
+ let origPackageJson: Uint8Array | null;
530
+ try {
531
+ origProfilePatch = await readBytesOrNull(targetProfilePatch);
532
+ origHomePatch = await readBytesOrNull(targetHomePatch);
533
+ origPackageJson = await readBytesOrNull(targetPackageJson);
534
+ } catch (err) {
535
+ return { ok: false, code: 'backup-failed', message: `cannot read originals for backup: ${errorText(err)}` };
536
+ }
537
+
538
+ // —— 3. 校验 + 计算 package.json 新内容(纯计算,不落盘;非法 → 不做任何写入) ——
539
+ let prunedBundles: { name: string; reason: string }[] = [];
540
+ let nextPackageJsonText: string | null = null;
541
+ // 本插件是否已由 bundle 层挂载(决定 patch 层要不要写挂载行;见 rescuePatchText 注释)
542
+ let selfAlreadyBundled = false;
543
+ if (origPackageJson !== null) {
544
+ let parsed: unknown;
545
+ try {
546
+ parsed = JSON.parse(decodeText(origPackageJson));
547
+ } catch (err) {
548
+ return {
549
+ ok: false,
550
+ code: 'package-json-invalid',
551
+ message: `${rel.profilePackageJson} is not valid JSON, left untouched: ${errorText(err)}`,
552
+ };
553
+ }
554
+ if (!isPlainObject(parsed)) {
555
+ return {
556
+ ok: false,
557
+ code: 'package-json-invalid',
558
+ message: `${rel.profilePackageJson} does not contain a JSON object root, left untouched`,
559
+ };
560
+ }
561
+ // 用「裁剪前」的 bundles 判定:本插件即使因不可解析被裁掉,也不该再由 patch 层重复挂载
562
+ const rawBundles = readBundlesField(parsed);
563
+ selfAlreadyBundled = Array.isArray(rawBundles) && rawBundles.includes(opts.rescueMount.packageName);
564
+ const safe = await computeSafeBundles(rawBundles, resolveBundle);
565
+ // 救援模式(disableUserBundles):在「可解析」之上再收窄到核心 + 自身。
566
+ // 不做这一步的话,bundle 层会把用户插件照旧挂载回来,「禁用其它插件」名不副实。
567
+ const narrowed = opts.disableUserBundles === true
568
+ ? restrictToRescueBundles(safe.kept, opts.rescueMount.packageName, opts.keepBundlePrefixes ?? RESCUE_KEEP_BUNDLE_PREFIXES)
569
+ : { kept: safe.kept, pruned: [] as { name: string; reason: string }[] };
570
+ const finalPruned = [...safe.pruned, ...narrowed.pruned];
571
+ // 无裁剪 → 不重写(保持原字节/原格式);有裁剪 → 扁平写回 kept(绝无 [kept] 二次包裹)
572
+ if (finalPruned.length > 0 && writeBundlesField(parsed, narrowed.kept)) {
573
+ prunedBundles = finalPruned;
574
+ nextPackageJsonText = `${JSON.stringify(parsed, null, 2)}\n`;
575
+ }
576
+ }
577
+
578
+ // —— 4. 备份(任何目标写入之前;失败 → backup-failed,原件保持原样) ——
579
+ const backup: RescueState['backup'] = {
580
+ profilePatch: origProfilePatch === null ? '' : path.join(backupDir, BACKUP_PROFILE_PATCH),
581
+ homePatch: origHomePatch === null ? null : path.join(backupDir, BACKUP_HOME_PATCH),
582
+ profilePackageJson: origPackageJson === null ? null : path.join(backupDir, BACKUP_PROFILE_PACKAGE_JSON),
583
+ };
584
+ try {
585
+ await fs.mkdir(backupDir, { recursive: true });
586
+ if (origProfilePatch !== null) await atomicWriteFile(path.join(backupDir, BACKUP_PROFILE_PATCH), origProfilePatch);
587
+ if (origHomePatch !== null) await atomicWriteFile(path.join(backupDir, BACKUP_HOME_PATCH), origHomePatch);
588
+ if (origPackageJson !== null) await atomicWriteFile(path.join(backupDir, BACKUP_PROFILE_PACKAGE_JSON), origPackageJson);
589
+ } catch (err) {
590
+ return { ok: false, code: 'backup-failed', message: `backup failed, originals untouched: ${errorText(err)}` };
591
+ }
592
+
593
+ const state: RescueState = {
594
+ active: true,
595
+ enteredAt: now().toISOString(),
596
+ profile,
597
+ homeFingerprint: fingerprint,
598
+ backup,
599
+ prunedBundles,
600
+ profilePatchWasAbsent: origProfilePatch === null,
601
+ };
602
+ const originals: OriginalSet = {
603
+ targetProfilePatch,
604
+ targetHomePatch,
605
+ targetPackageJson,
606
+ origProfilePatch,
607
+ origHomePatch,
608
+ origPackageJson,
609
+ };
610
+ const wrotePackageJson = nextPackageJsonText !== null;
611
+
612
+ // —— 5. 写目标:profile patch → package.json → home patch → 状态文件 ——
613
+ // 任一步失败 → 尽力回滚已改动的目标(状态文件最后写,失败时不会被留下)
614
+ try {
615
+ await atomicWriteFile(targetProfilePatch, rescuePatchText(opts.rescueMount, selfAlreadyBundled, opts.disableUserBundles === true));
616
+ if (nextPackageJsonText !== null) await atomicWriteFile(targetPackageJson, nextPackageJsonText);
617
+ if (neutralizeHome) await atomicWriteFile(targetHomePatch, EMPTY_PATCH);
618
+ await atomicWriteFile(statePath, `${JSON.stringify(state, null, 2)}\n`);
619
+ } catch (err) {
620
+ const rollbackFailures = await rollbackTargets(originals, { wrotePackageJson, wroteHomePatch: neutralizeHome });
621
+ const rollbackNote = rollbackFailures.length === 0 ? 'rollback ok' : `rollback incomplete (${rollbackFailures.join('; ')})`;
622
+ return { ok: false, code: 'write-failed', message: `write failed, ${rollbackNote}: ${errorText(err)}` };
623
+ }
624
+
625
+ return { ok: true, state };
626
+ }
627
+
628
+ /* ---------------------------------------------------------------- 退出救援 */
629
+
630
+ /** 退出步骤:restore = 从备份还原;delete = 进入时不存在(本次创建)→ 删除 */
631
+ interface ExitStep {
632
+ rel: string;
633
+ target: string;
634
+ action: 'restore' | 'delete';
635
+ backup: string | null;
636
+ }
637
+
638
+ /** 退出救援模式:先校验全部备份存在,再逐个还原;状态文件删除。 */
639
+ export async function exitRescueMode(opts: { homeDir: string; backupDir?: string }): Promise<RescueExitResult> {
640
+ const homeDir = opts.homeDir;
641
+ const backupDir = opts.backupDir ?? defaultBackupDir(homeDir);
642
+ const statePath = path.join(backupDir, STATE_FILE);
643
+
644
+ let text: string;
645
+ try {
646
+ text = await fs.readFile(statePath, 'utf8');
647
+ } catch {
648
+ return { ok: false, code: 'not-active', message: `no rescue state at ${statePath}` };
649
+ }
650
+ const state = parseRescueState(text);
651
+ if (state === null) {
652
+ return { ok: false, code: 'not-active', message: `rescue state is corrupt/unreadable: ${statePath}` };
653
+ }
654
+ // 指纹不匹配 = 别的 home/profile 的残留状态:拒绝按它改写本 home 的文件(宁可不做)
655
+ if (!fingerprintMatches(homeDir, state.profile, state.homeFingerprint)) {
656
+ return {
657
+ ok: false,
658
+ code: 'not-active',
659
+ message: `rescue state is stale (homeFingerprint mismatch), refusing to touch files: ${statePath}`,
660
+ };
661
+ }
662
+
663
+ const rel = rescuePaths(state.profile);
664
+ const targetProfilePatch = absFromRel(homeDir, rel.profilePatch);
665
+ const targetHomePatch = absFromRel(homeDir, rel.homePatch);
666
+ const targetPackageJson = absFromRel(homeDir, rel.profilePackageJson);
667
+
668
+ const plan: ExitStep[] = [];
669
+ // profile patch:进入时不存在 → 删除本次创建的;否则从备份还原
670
+ if (state.profilePatchWasAbsent) {
671
+ plan.push({ rel: rel.profilePatch, target: targetProfilePatch, action: 'delete', backup: null });
672
+ } else {
673
+ const backup = state.backup.profilePatch === '' ? null : resolveBackupPath(backupDir, state.backup.profilePatch);
674
+ plan.push({ rel: rel.profilePatch, target: targetProfilePatch, action: 'restore', backup });
675
+ }
676
+ // home patch:原件不存在 → 删除本次置空创建的
677
+ if (state.backup.homePatch === null) {
678
+ plan.push({ rel: rel.homePatch, target: targetHomePatch, action: 'delete', backup: null });
679
+ } else {
680
+ plan.push({ rel: rel.homePatch, target: targetHomePatch, action: 'restore', backup: resolveBackupPath(backupDir, state.backup.homePatch) });
681
+ }
682
+ // package.json:备份为 null = 原件不存在,且救援模式从不创建它 → 无需动作
683
+ if (state.backup.profilePackageJson !== null) {
684
+ plan.push({
685
+ rel: rel.profilePackageJson,
686
+ target: targetPackageJson,
687
+ action: 'restore',
688
+ backup: resolveBackupPath(backupDir, state.backup.profilePackageJson),
689
+ });
690
+ }
691
+
692
+ // —— 1. 先校验全部备份存在:任一缺失 → 一个文件都不动 ——
693
+ for (const step of plan) {
694
+ if (step.action !== 'restore') continue;
695
+ const backup = step.backup;
696
+ if (backup === null || backup === '' || !(await fileExists(backup))) {
697
+ return { ok: false, code: 'backup-missing', message: `missing rescue backup for ${step.rel}: ${backup ?? '<none>'}` };
698
+ }
699
+ }
700
+
701
+ // —— 2. 逐个还原 / 删除,最后删状态文件(中途崩溃仍保留状态,可重试还原) ——
702
+ const restored: string[] = [];
703
+ try {
704
+ for (const step of plan) {
705
+ if (step.action === 'restore') {
706
+ const backup = step.backup;
707
+ if (backup === null) throw new Error(`missing backup path for ${step.rel}`);
708
+ const bytes = await fs.readFile(backup);
709
+ await atomicWriteFile(step.target, bytes);
710
+ } else {
711
+ await unlinkIfExists(step.target);
712
+ }
713
+ restored.push(step.rel);
714
+ }
715
+ await fs.unlink(statePath);
716
+ } catch (err) {
717
+ return {
718
+ ok: false,
719
+ code: 'restore-failed',
720
+ message: `restore failed after ${restored.length} step(s): ${errorText(err)}`,
721
+ };
722
+ }
723
+ return { ok: true, restored };
724
+ }
725
+
726
+ /* ---------------------------------------------------------------- 状态查询 */
727
+
728
+ /** 读状态;指纹不匹配时返回 { active:false, stale:true, state }。损坏/缺失 → { active:false, stale:false, state:null }。 */
729
+ export async function rescueModeStatus(opts: { homeDir: string; profile: string; backupDir?: string }): Promise<RescueStatus> {
730
+ const backupDir = opts.backupDir ?? defaultBackupDir(opts.homeDir);
731
+ const state = await readRescueState(backupDir);
732
+ if (state === null) return { active: false, stale: false, state: null };
733
+ if (!fingerprintMatches(opts.homeDir, opts.profile, state.homeFingerprint)) {
734
+ return { active: false, stale: true, state };
735
+ }
736
+ return { active: true, stale: false, state };
737
+ }