dsh-config-manager 0.1.71 → 0.1.73

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 (596) hide show
  1. package/README.md +149 -98
  2. package/README.zh-CN.md +245 -88
  3. package/lib/adapters/agent-instructions.js +1 -1
  4. package/lib/adapters/file-collection.js +1 -1
  5. package/lib/adapters/index.d.ts +2 -1
  6. package/lib/adapters/index.js +6 -1
  7. package/lib/adapters/link-report.d.ts +1 -0
  8. package/lib/adapters/link-report.js +27 -3
  9. package/lib/adapters/messages.d.ts +21 -0
  10. package/lib/adapters/messages.js +57 -0
  11. package/lib/adapters/plugin-data.d.ts +114 -0
  12. package/lib/adapters/plugin-data.js +309 -0
  13. package/lib/adapters/plugin-files.js +3 -2
  14. package/lib/adapters/plugins.js +4 -3
  15. package/lib/adapters/prompts.d.ts +1 -0
  16. package/lib/adapters/prompts.js +7 -1
  17. package/lib/adapters/self.d.ts +8 -2
  18. package/lib/adapters/self.js +3 -2
  19. package/lib/adapters/sessions.d.ts +1 -0
  20. package/lib/adapters/sessions.js +4 -2
  21. package/lib/adapters/test-helpers.d.ts +9 -0
  22. package/lib/adapters/test-helpers.js +14 -0
  23. package/lib/adapters/ui.d.ts +2 -2
  24. package/lib/adapters/ui.js +25 -25
  25. package/lib/adapters/workspaces.d.ts +1 -0
  26. package/lib/adapters/workspaces.js +7 -3
  27. package/lib/cli/actions.d.ts +9 -1
  28. package/lib/cli/actions.js +5 -2
  29. package/lib/cli/help.js +23 -10
  30. package/lib/cli/index.d.ts +23 -0
  31. package/lib/cli/index.js +90 -32
  32. package/lib/cli/sessions-inspect.js +3 -1
  33. package/lib/cli/sessions-repair.js +25 -6
  34. package/lib/cli/web/page.d.ts +12 -0
  35. package/lib/cli/web/page.js +21 -12
  36. package/lib/cli/web/routes.js +2 -2
  37. package/lib/cli/web/server.js +4 -4
  38. package/lib/client.d.ts +1442 -494
  39. package/lib/client.js +15326 -8520
  40. package/lib/core/analyzer.d.ts +11 -2
  41. package/lib/core/analyzer.js +31 -8
  42. package/lib/core/backup-plan.d.ts +10 -0
  43. package/lib/core/backup-plan.js +51 -20
  44. package/lib/core/backup-verify.js +24 -23
  45. package/lib/core/backup.d.ts +25 -1
  46. package/lib/core/backup.js +99 -28
  47. package/lib/core/boot-rescue.js +4 -3
  48. package/lib/core/boot-safety.js +2 -1
  49. package/lib/core/command-rollback.d.ts +182 -0
  50. package/lib/core/command-rollback.js +293 -0
  51. package/lib/core/consult-source.js +7 -6
  52. package/lib/core/disk-usage.d.ts +74 -10
  53. package/lib/core/disk-usage.js +67 -4
  54. package/lib/core/engine-locale.d.ts +87 -0
  55. package/lib/core/engine-locale.js +125 -0
  56. package/lib/core/exporter.js +3 -3
  57. package/lib/core/fork-reconcile.d.ts +128 -0
  58. package/lib/core/fork-reconcile.js +150 -0
  59. package/lib/core/journal.js +6 -5
  60. package/lib/core/local-plugin-host.js +3 -2
  61. package/lib/core/local-plugin-pack.js +11 -6
  62. package/lib/core/messages-core.d.ts +294 -0
  63. package/lib/core/messages-core.js +619 -0
  64. package/lib/core/messages.d.ts +61 -2
  65. package/lib/core/messages.js +118 -13
  66. package/lib/core/migration-consult.js +21 -20
  67. package/lib/core/migration-history.js +19 -5
  68. package/lib/core/model-tools.d.ts +0 -22
  69. package/lib/core/model-tools.js +50 -27
  70. package/lib/core/phase3-host.js +30 -5
  71. package/lib/core/plugin-cli.js +18 -19
  72. package/lib/core/plugin-data-registry.d.ts +124 -0
  73. package/lib/core/plugin-data-registry.js +198 -0
  74. package/lib/core/reconcile.d.ts +2 -14
  75. package/lib/core/reconcile.js +87 -17
  76. package/lib/core/recovery-orchestrator.js +19 -18
  77. package/lib/core/reinstall.d.ts +4 -0
  78. package/lib/core/reinstall.js +26 -21
  79. package/lib/core/restore.js +20 -17
  80. package/lib/core/rollback-gate.d.ts +33 -0
  81. package/lib/core/rollback-gate.js +57 -0
  82. package/lib/core/rollback-ignore-rules.d.ts +31 -0
  83. package/lib/core/rollback-ignore-rules.js +140 -0
  84. package/lib/core/rollback-ledger.d.ts +248 -0
  85. package/lib/core/rollback-ledger.js +1282 -0
  86. package/lib/core/rollback-point.d.ts +1391 -0
  87. package/lib/core/rollback-point.js +3503 -0
  88. package/lib/core/rollback.js +6 -4
  89. package/lib/core/run-registry.js +6 -5
  90. package/lib/core/session-health.d.ts +15 -0
  91. package/lib/core/session-meta.d.ts +42 -2
  92. package/lib/core/session-meta.js +140 -1
  93. package/lib/core/session-title.d.ts +28 -0
  94. package/lib/core/session-title.js +185 -0
  95. package/lib/core/startup-barrier.d.ts +24 -0
  96. package/lib/core/startup-barrier.js +22 -0
  97. package/lib/core/transaction-coordinator.js +4 -3
  98. package/lib/core/types.d.ts +37 -1
  99. package/lib/core/validator.js +5 -4
  100. package/lib/core/verify-recovery.js +31 -30
  101. package/lib/foreign/bundle.js +3 -2
  102. package/lib/foreign/continue.js +2 -1
  103. package/lib/foreign/crush.js +2 -1
  104. package/lib/foreign/gemini.js +2 -1
  105. package/lib/foreign/goose.js +2 -1
  106. package/lib/foreign/kilocode.js +2 -1
  107. package/lib/foreign/kimi.js +2 -1
  108. package/lib/foreign/messages.d.ts +15 -0
  109. package/lib/foreign/messages.js +40 -0
  110. package/lib/foreign/mimocode.js +2 -1
  111. package/lib/foreign/openclaw.js +2 -1
  112. package/lib/foreign/opencode.js +2 -1
  113. package/lib/foreign/pi.js +2 -1
  114. package/lib/foreign/qoder.js +2 -1
  115. package/lib/foreign/qwen.js +2 -1
  116. package/lib/foreign/registry.d.ts +1 -1
  117. package/lib/foreign/registry.js +8 -7
  118. package/lib/foreign/teleagent.js +2 -1
  119. package/lib/foreign/workbuddy.js +2 -1
  120. package/lib/foreign/zcode.js +2 -1
  121. package/lib/foreign/zed.js +2 -1
  122. package/lib/index.d.ts +227 -7
  123. package/lib/index.js +799 -54
  124. package/lib/market/git-file-writer.js +20 -19
  125. package/lib/market/github-repos.js +35 -19
  126. package/lib/market/index-parser.js +31 -30
  127. package/lib/market/messages-market.d.ts +138 -0
  128. package/lib/market/messages-market.js +305 -0
  129. package/lib/market/my-repo.d.ts +2 -2
  130. package/lib/market/my-repo.js +32 -29
  131. package/lib/market/prepare.js +18 -15
  132. package/lib/market/security.js +20 -19
  133. package/lib/market/types.js +2 -1
  134. package/lib/market/url.js +2 -1
  135. package/lib/migrations/index.js +6 -5
  136. package/lib/migrations/messages-migrations.d.ts +32 -0
  137. package/lib/migrations/messages-migrations.js +58 -0
  138. package/lib/profiles/dsh-profile-launcher.js +2 -1
  139. package/lib/profiles/dsh-profile-runtime.js +2 -1
  140. package/lib/profiles/messages.d.ts +7 -0
  141. package/lib/profiles/messages.js +27 -0
  142. package/lib/routes/backup.js +18 -2
  143. package/lib/routes/import.js +3 -3
  144. package/lib/routes/index.d.ts +1 -1
  145. package/lib/routes/kit.d.ts +3 -2
  146. package/lib/routes/kit.js +6 -1
  147. package/lib/routes/market.js +1 -1
  148. package/lib/routes/messages.d.ts +43 -0
  149. package/lib/routes/messages.js +102 -0
  150. package/lib/routes/prefs.js +26 -3
  151. package/lib/routes/profiles.js +12 -6
  152. package/lib/routes/recovery.d.ts +21 -0
  153. package/lib/routes/recovery.js +478 -94
  154. package/lib/routes/snapshots.js +51 -4
  155. package/lib/routes/sync.js +79 -17
  156. package/lib/schema/config.d.ts +11 -1
  157. package/lib/schema/config.js +52 -20
  158. package/lib/schema/index.d.ts +8 -6
  159. package/lib/schema/index.js +8 -6
  160. package/lib/schema/manifest.js +19 -18
  161. package/lib/schema/messages.d.ts +47 -0
  162. package/lib/schema/messages.js +110 -0
  163. package/lib/schema/section-registry.d.ts +42 -15
  164. package/lib/schema/section-registry.js +67 -32
  165. package/lib/schema/types.d.ts +1 -1
  166. package/lib/schema/versions.js +6 -5
  167. package/lib/security/encryption.js +16 -15
  168. package/lib/security/integrity.js +8 -7
  169. package/lib/security/messages.d.ts +30 -0
  170. package/lib/security/messages.js +77 -0
  171. package/lib/security/redaction.js +12 -0
  172. package/lib/security/secret-scanner.js +16 -13
  173. package/lib/security/vault.js +2 -1
  174. package/lib/security/zip-security.js +4 -3
  175. package/lib/sync/ancestor.js +2 -1
  176. package/lib/sync/autosync-config.d.ts +11 -0
  177. package/lib/sync/autosync-config.js +12 -0
  178. package/lib/sync/autosync-scheduler.d.ts +11 -1
  179. package/lib/sync/autosync-scheduler.js +115 -76
  180. package/lib/sync/backup-files.js +3 -2
  181. package/lib/sync/backup-scheduler.js +6 -5
  182. package/lib/sync/blob-store.js +2 -1
  183. package/lib/sync/git/git-transport.d.ts +55 -0
  184. package/lib/sync/git/git-transport.js +152 -2
  185. package/lib/sync/github-auth.js +10 -9
  186. package/lib/sync/layout.js +13 -12
  187. package/lib/sync/messages.d.ts +63 -0
  188. package/lib/sync/messages.js +149 -0
  189. package/lib/sync/review-queue.js +6 -5
  190. package/lib/sync/s3/messages.d.ts +7 -0
  191. package/lib/sync/s3/messages.js +16 -0
  192. package/lib/sync/s3/s3-providers.d.ts +4 -1
  193. package/lib/sync/s3/s3-providers.js +5 -5
  194. package/lib/sync/s3/s3-transport.js +1 -1
  195. package/lib/sync/s3/sigv4.js +2 -1
  196. package/lib/sync/snapshot-crypto.js +6 -5
  197. package/lib/sync/snapshot-json.js +3 -2
  198. package/lib/sync/sync-engine.d.ts +58 -22
  199. package/lib/sync/sync-engine.js +119 -48
  200. package/lib/sync/sync-history.js +7 -6
  201. package/lib/sync/sync-selection.d.ts +15 -4
  202. package/lib/sync/sync-selection.js +17 -5
  203. package/lib/sync/ui-prefs.d.ts +22 -0
  204. package/lib/sync/ui-prefs.js +28 -0
  205. package/lib/sync/webdav/webdav-transport.d.ts +1 -0
  206. package/lib/sync/webdav/webdav-transport.js +4 -1
  207. package/lib/ui/artifact-view.js +4 -3
  208. package/lib/ui/commands.d.ts +1 -1
  209. package/lib/ui/commands.js +10 -0
  210. package/lib/ui/diff-view.d.ts +10 -0
  211. package/lib/ui/diff-view.js +17 -0
  212. package/lib/ui/disk-usage-view.d.ts +33 -4
  213. package/lib/ui/disk-usage-view.js +29 -1
  214. package/lib/ui/errors.d.ts +1 -1
  215. package/lib/ui/errors.js +4 -3
  216. package/lib/ui/export-flow.js +26 -20
  217. package/lib/ui/foreign-view.d.ts +5 -1
  218. package/lib/ui/foreign-view.js +26 -2
  219. package/lib/ui/i18n.d.ts +68 -1
  220. package/lib/ui/i18n.js +120 -2
  221. package/lib/ui/import-wizard.d.ts +7 -0
  222. package/lib/ui/import-wizard.js +13 -10
  223. package/lib/ui/progress.d.ts +2 -2
  224. package/lib/ui/progress.js +1 -2
  225. package/lib/ui/report.d.ts +5 -5
  226. package/lib/ui/report.js +6 -6
  227. package/lib/ui/rollback-view.d.ts +815 -0
  228. package/lib/ui/rollback-view.js +738 -0
  229. package/lib/ui/selection-model.d.ts +41 -0
  230. package/lib/ui/selection-model.js +30 -0
  231. package/lib/ui/session-inventory-view.d.ts +97 -3
  232. package/lib/ui/session-inventory-view.js +76 -2
  233. package/lib/ui/session-issue-labels.d.ts +43 -0
  234. package/lib/ui/session-issue-labels.js +48 -0
  235. package/lib/ui/sync-settings-view.d.ts +33 -0
  236. package/lib/ui/sync-settings-view.js +40 -0
  237. package/lib/ui/test-helpers.js +2 -1
  238. package/lib/ui/types.d.ts +8 -3
  239. package/lib/ui/types.js +1 -1
  240. package/lib/utils/atomic-write.js +6 -5
  241. package/lib/utils/bundle-scan.d.ts +11 -1
  242. package/lib/utils/bundle-scan.js +12 -1
  243. package/lib/utils/env-lock.d.ts +10 -0
  244. package/lib/utils/env-lock.js +73 -46
  245. package/lib/utils/json.js +6 -5
  246. package/lib/utils/messages-utils.d.ts +96 -0
  247. package/lib/utils/messages-utils.js +216 -0
  248. package/lib/utils/proxy.js +18 -5
  249. package/lib/utils/recursive-walk.d.ts +22 -0
  250. package/lib/utils/recursive-walk.js +75 -1
  251. package/lib/utils/session-log-repair.js +10 -9
  252. package/lib/utils/zip.js +27 -26
  253. package/package.json +12 -2
  254. package/src/adapters/agent-instructions.ts +1 -1
  255. package/src/adapters/file-collection.test.ts +1 -0
  256. package/src/adapters/file-collection.ts +1 -1
  257. package/src/adapters/files.test.ts +27 -0
  258. package/src/adapters/index.ts +6 -1
  259. package/src/adapters/link-report.test.ts +22 -2
  260. package/src/adapters/link-report.ts +29 -4
  261. package/src/adapters/messages.ts +61 -0
  262. package/src/adapters/plugin-data.test.ts +225 -0
  263. package/src/adapters/plugin-data.ts +351 -0
  264. package/src/adapters/plugin-files.ts +3 -2
  265. package/src/adapters/plugins.ts +4 -3
  266. package/src/adapters/prompts.ts +7 -1
  267. package/src/adapters/self.test.ts +26 -0
  268. package/src/adapters/self.ts +10 -3
  269. package/src/adapters/sessions.ts +4 -2
  270. package/src/adapters/settings.test.ts +38 -1
  271. package/src/adapters/test-helpers.ts +15 -0
  272. package/src/adapters/ui.test.ts +2 -2
  273. package/src/adapters/ui.ts +27 -26
  274. package/src/adapters/workspaces.ts +7 -3
  275. package/src/cli/actions.test.ts +28 -0
  276. package/src/cli/actions.ts +11 -2
  277. package/src/cli/help.ts +23 -10
  278. package/src/cli/index-guards.test.ts +193 -0
  279. package/src/cli/index.ts +106 -31
  280. package/src/cli/sessions-inspect.test.ts +15 -1
  281. package/src/cli/sessions-inspect.ts +3 -1
  282. package/src/cli/sessions-repair.test.ts +84 -0
  283. package/src/cli/sessions-repair.ts +33 -7
  284. package/src/cli/web/console-route-parity.test.ts +115 -0
  285. package/src/cli/web/page.ts +23 -12
  286. package/src/cli/web/routes.ts +2 -1
  287. package/src/cli/web/server.ts +4 -4
  288. package/src/cli/web/web.test.ts +2 -2
  289. package/src/client/ConfigManagerSection.tsx +30 -2
  290. package/src/client/about/AboutPanel.tsx +2 -2
  291. package/src/client/about/PluginUpdateDialog.tsx +1 -1
  292. package/src/client/about/ReleaseNotesDialog.tsx +2 -2
  293. package/src/client/about/about-view.test.ts +13 -3
  294. package/src/client/about/about-view.ts +10 -6
  295. package/src/client/about/release-notes-view.test.ts +14 -4
  296. package/src/client/about/release-notes-view.ts +4 -2
  297. package/src/client/api.ts +18 -6
  298. package/src/client/chat/ChatEntryView.tsx +411 -0
  299. package/src/client/chat/RollbackDiffTab.tsx +108 -0
  300. package/src/client/chat/RollbackFileIcon.tsx +96 -0
  301. package/src/client/chat/RollbackReviewDialog.tsx +607 -0
  302. package/src/client/chat/chat-entry-view.test.ts +1174 -0
  303. package/src/client/chat/chat-entry-view.ts +758 -0
  304. package/src/client/chat/chat-locales.ts +152 -0
  305. package/src/client/chat/chat-node-registration.ts +64 -0
  306. package/src/client/chat/chat-point-window.ts +183 -0
  307. package/src/client/chat/chat-slots.ts +107 -0
  308. package/src/client/chat/rollback-diff-tab.test.ts +105 -0
  309. package/src/client/chat/rollback-diff-tab.ts +174 -0
  310. package/src/client/chat/rollback-entry-view.test.ts +1469 -0
  311. package/src/client/chat/rollback-entry-view.ts +946 -0
  312. package/src/client/chat/sidebar-slots.ts +59 -0
  313. package/src/client/common/ConfirmDialog.tsx +7 -3
  314. package/src/client/common/ContentPicker.tsx +48 -10
  315. package/src/client/common/DiffTable.tsx +133 -0
  316. package/src/client/common/ErrorBanner.tsx +4 -4
  317. package/src/client/common/Icon.tsx +2 -0
  318. package/src/client/common/Modal.tsx +8 -1
  319. package/src/client/common/ProgressBar.tsx +8 -2
  320. package/src/client/common/ReportView.tsx +4 -4
  321. package/src/client/common/RunsCenter.tsx +1 -0
  322. package/src/client/common/client-route-parity.test.ts +84 -0
  323. package/src/client/common/info-hint-guard.test.ts +46 -13
  324. package/src/client/common/picker-lazy-units.test.ts +42 -0
  325. package/src/client/common/plan-text-redaction.test.ts +17 -6
  326. package/src/client/common/progress-view-edge.test.ts +8 -7
  327. package/src/client/common/progress-view.test.ts +9 -9
  328. package/src/client/common/progress-view.ts +2 -2
  329. package/src/client/common/routes.ts +38 -0
  330. package/src/client/common/section-labels.test.ts +1 -0
  331. package/src/client/common/section-labels.ts +2 -0
  332. package/src/client/common/ui.tsx +73 -10
  333. package/src/client/config-manager.module.css +1109 -31
  334. package/src/client/css-scale-guard.test.ts +30 -0
  335. package/src/client/environment/EnvironmentPanel.tsx +11 -4
  336. package/src/client/environment/maintenance/DiskUsageCard.tsx +23 -2
  337. package/src/client/export/ExportView.tsx +13 -8
  338. package/src/client/history/HistoryPanel.tsx +4 -3
  339. package/src/client/history/history-api.ts +2 -2
  340. package/src/client/history/history-locales.ts +5 -1
  341. package/src/client/home/HomePanel.tsx +29 -8
  342. package/src/client/import/ForeignImportView.tsx +4 -5
  343. package/src/client/import/ImportWizardView.tsx +5 -3
  344. package/src/client/import/import-wizard-split.test.ts +3 -1
  345. package/src/client/import/import-wizard-steps.tsx +9 -9
  346. package/src/client/index.ts +232 -3
  347. package/src/client/library/LibraryActions.tsx +32 -7
  348. package/src/client/library/LibraryPanel.tsx +23 -3
  349. package/src/client/library/RestorePlanView.tsx +9 -66
  350. package/src/client/locales.ts +62 -9
  351. package/src/client/lucide-icons.d.ts +1 -0
  352. package/src/client/market/MarketPanel.tsx +13 -2
  353. package/src/client/market/MyConfigsView.tsx +5 -4
  354. package/src/client/market/MyConfigsWizard.tsx +10 -8
  355. package/src/client/market/market-api.test.ts +9 -8
  356. package/src/client/market/market-api.ts +2 -2
  357. package/src/client/market/market-locales.ts +3 -0
  358. package/src/client/market/market-view.test.ts +6 -6
  359. package/src/client/market/market-view.ts +3 -3
  360. package/src/client/market/my-configs-api.ts +2 -2
  361. package/src/client/market/my-configs-view.test.ts +23 -22
  362. package/src/client/market/my-configs-view.ts +8 -8
  363. package/src/client/recovery/IncompleteCopiesSection.tsx +2 -1
  364. package/src/client/recovery/RecoveryPanel.tsx +442 -283
  365. package/src/client/recovery/RollbackCard.tsx +859 -0
  366. package/src/client/recovery/RollbackDiffPane.tsx +69 -0
  367. package/src/client/recovery/RollbackFileChanges.tsx +163 -0
  368. package/src/client/recovery/incident-api.ts +2 -2
  369. package/src/client/recovery/recovery-api.ts +144 -6
  370. package/src/client/recovery/recovery-locales.test.ts +299 -0
  371. package/src/client/recovery/recovery-locales.ts +520 -149
  372. package/src/client/recovery/recovery-panel-layout.test.ts +132 -2
  373. package/src/client/recovery/rollback-enabled.ts +41 -0
  374. package/src/client/recovery/rollback-file-changes.test.ts +114 -0
  375. package/src/client/recovery/session-dialog-mount.test.ts +54 -0
  376. package/src/client/run-store.ts +84 -21
  377. package/src/client/sync/AutosyncCard.tsx +25 -1
  378. package/src/client/sync/ChannelClearConfirmDialog.tsx +6 -3
  379. package/src/client/sync/ChannelConfigDialog.tsx +10 -10
  380. package/src/client/sync/DecryptPasswordCard.tsx +2 -2
  381. package/src/client/sync/SecurityOptionsCard.tsx +27 -17
  382. package/src/client/sync/SyncChannelEntryCard.tsx +14 -2
  383. package/src/client/sync/SyncConfirmView.tsx +46 -8
  384. package/src/client/sync/SyncLogList.tsx +1 -1
  385. package/src/client/sync/SyncPanel.tsx +31 -23
  386. package/src/client/sync/SyncRepositoryPicker.tsx +3 -5
  387. package/src/client/sync/SyncSectionPickerDialog.tsx +11 -2
  388. package/src/client/sync/SyncSettingsView.test.ts +20 -1
  389. package/src/client/sync/UnreadableSnapshotsBanner.tsx +1 -1
  390. package/src/client/sync/WorkDirCard.tsx +112 -0
  391. package/src/client/sync/history-model.test.ts +42 -9
  392. package/src/client/sync/history-model.ts +30 -15
  393. package/src/client/sync/sync-api.test.ts +57 -33
  394. package/src/client/sync/sync-api.ts +80 -6
  395. package/src/client/sync/sync-locales.ts +104 -3
  396. package/src/client/sync/sync-view-v2.test.ts +65 -13
  397. package/src/client/sync/sync-view.test.ts +107 -52
  398. package/src/client/sync/sync-view.ts +124 -41
  399. package/src/core/analyzer.ts +31 -9
  400. package/src/core/backup-plan.test.ts +48 -5
  401. package/src/core/backup-plan.ts +51 -20
  402. package/src/core/backup-verify.ts +24 -23
  403. package/src/core/backup.test.ts +76 -0
  404. package/src/core/backup.ts +105 -29
  405. package/src/core/boot-rescue.ts +4 -3
  406. package/src/core/boot-safety.ts +2 -1
  407. package/src/core/command-rollback.test.ts +536 -0
  408. package/src/core/command-rollback.ts +406 -0
  409. package/src/core/consult-source.ts +7 -6
  410. package/src/core/disk-usage.test.ts +257 -1
  411. package/src/core/disk-usage.ts +134 -14
  412. package/src/core/engine-locale.test.ts +593 -0
  413. package/src/core/engine-locale.ts +175 -0
  414. package/src/core/exporter.ts +3 -3
  415. package/src/core/fork-reconcile.test.ts +279 -0
  416. package/src/core/fork-reconcile.ts +234 -0
  417. package/src/core/journal.ts +6 -5
  418. package/src/core/local-plugin-host.ts +3 -2
  419. package/src/core/local-plugin-pack.ts +11 -6
  420. package/src/core/messages-core.ts +661 -0
  421. package/src/core/messages.ts +125 -17
  422. package/src/core/migration-consult.ts +21 -20
  423. package/src/core/migration-history.ts +19 -5
  424. package/src/core/model-tools.ts +28 -27
  425. package/src/core/phase3-host.ts +21 -5
  426. package/src/core/phase3-p2b-fingerprint.test.ts +22 -0
  427. package/src/core/plugin-cli.ts +18 -19
  428. package/src/core/plugin-data-registry.test.ts +113 -0
  429. package/src/core/plugin-data-registry.ts +261 -0
  430. package/src/core/reconcile.test.ts +117 -0
  431. package/src/core/reconcile.ts +85 -17
  432. package/src/core/recovery-orchestrator.ts +19 -18
  433. package/src/core/reinstall.ts +26 -21
  434. package/src/core/restore.ts +20 -17
  435. package/src/core/rollback-file-diff.test.ts +159 -0
  436. package/src/core/rollback-gate.test.ts +66 -0
  437. package/src/core/rollback-gate.ts +61 -0
  438. package/src/core/rollback-ignore-rules.ts +126 -0
  439. package/src/core/rollback-ledger.test.ts +655 -0
  440. package/src/core/rollback-ledger.ts +1314 -0
  441. package/src/core/rollback-point.test.ts +2853 -0
  442. package/src/core/rollback-point.ts +4340 -0
  443. package/src/core/rollback.ts +5 -3
  444. package/src/core/run-registry.ts +6 -5
  445. package/src/core/session-health.ts +15 -0
  446. package/src/core/session-meta.test.ts +117 -1
  447. package/src/core/session-meta.ts +149 -2
  448. package/src/core/session-title.test.ts +127 -0
  449. package/src/core/session-title.ts +189 -0
  450. package/src/core/startup-barrier.test.ts +69 -0
  451. package/src/core/startup-barrier.ts +27 -0
  452. package/src/core/transaction-coordinator.ts +4 -3
  453. package/src/core/types.ts +37 -1
  454. package/src/core/validator.ts +5 -4
  455. package/src/core/verify-recovery.ts +31 -30
  456. package/src/foreign/bundle.ts +3 -2
  457. package/src/foreign/continue.ts +2 -1
  458. package/src/foreign/crush.ts +2 -1
  459. package/src/foreign/file-budget.test.ts +2 -0
  460. package/src/foreign/gemini.ts +2 -1
  461. package/src/foreign/goose.ts +2 -1
  462. package/src/foreign/kilocode.ts +2 -1
  463. package/src/foreign/kimi.ts +2 -1
  464. package/src/foreign/messages.ts +44 -0
  465. package/src/foreign/mimocode.ts +2 -1
  466. package/src/foreign/openclaw.ts +2 -1
  467. package/src/foreign/opencode.ts +2 -1
  468. package/src/foreign/pi.ts +2 -1
  469. package/src/foreign/qoder.ts +2 -1
  470. package/src/foreign/qwen.ts +2 -1
  471. package/src/foreign/registry.test.ts +1 -1
  472. package/src/foreign/registry.ts +8 -7
  473. package/src/foreign/teleagent.ts +2 -1
  474. package/src/foreign/workbuddy.ts +2 -1
  475. package/src/foreign/zcode.ts +2 -1
  476. package/src/foreign/zed.ts +2 -1
  477. package/src/index.facade.test.ts +340 -1
  478. package/src/index.ts +876 -57
  479. package/src/market/git-file-writer.ts +20 -19
  480. package/src/market/github-repos.test.ts +23 -0
  481. package/src/market/github-repos.ts +37 -19
  482. package/src/market/index-parser.ts +31 -30
  483. package/src/market/messages-market.ts +325 -0
  484. package/src/market/my-repo.ts +32 -29
  485. package/src/market/prepare.ts +18 -15
  486. package/src/market/security.ts +20 -19
  487. package/src/market/types.ts +2 -1
  488. package/src/market/url.ts +2 -1
  489. package/src/migrations/index.ts +6 -5
  490. package/src/migrations/messages-migrations.ts +59 -0
  491. package/src/profiles/dsh-profile-launcher.ts +2 -1
  492. package/src/profiles/dsh-profile-runtime.ts +2 -1
  493. package/src/profiles/messages.ts +31 -0
  494. package/src/routes/backup.ts +18 -2
  495. package/src/routes/import.ts +3 -3
  496. package/src/routes/index.ts +1 -1
  497. package/src/routes/kit.ts +9 -3
  498. package/src/routes/market.ts +1 -1
  499. package/src/routes/messages.ts +106 -0
  500. package/src/routes/prefs.ts +31 -2
  501. package/src/routes/profiles.ts +12 -6
  502. package/src/routes/recovery.test.ts +405 -1
  503. package/src/routes/recovery.ts +477 -94
  504. package/src/routes/snapshots.ts +51 -4
  505. package/src/routes/sync.ts +82 -17
  506. package/src/schema/config.ts +53 -20
  507. package/src/schema/index.ts +7 -5
  508. package/src/schema/manifest-schema.test.ts +4 -4
  509. package/src/schema/manifest.ts +19 -18
  510. package/src/schema/messages.ts +120 -0
  511. package/src/schema/registry.test.ts +28 -7
  512. package/src/schema/section-registry.ts +96 -42
  513. package/src/schema/types.ts +2 -1
  514. package/src/schema/versions.ts +6 -5
  515. package/src/security/encryption.ts +16 -15
  516. package/src/security/integrity.ts +8 -7
  517. package/src/security/messages.ts +87 -0
  518. package/src/security/redaction.test.ts +17 -1
  519. package/src/security/redaction.ts +13 -0
  520. package/src/security/secret-scanner.ts +19 -12
  521. package/src/security/security.test.ts +47 -0
  522. package/src/security/vault.ts +2 -1
  523. package/src/security/zip-security.ts +4 -3
  524. package/src/sync/ancestor.ts +2 -1
  525. package/src/sync/autosync-config.ts +21 -0
  526. package/src/sync/autosync-scheduler.test.ts +63 -7
  527. package/src/sync/autosync-scheduler.ts +118 -78
  528. package/src/sync/backup-files.ts +3 -2
  529. package/src/sync/backup-scheduler.ts +6 -5
  530. package/src/sync/blob-store.ts +2 -1
  531. package/src/sync/git/git-transport.test.ts +170 -8
  532. package/src/sync/git/git-transport.ts +176 -2
  533. package/src/sync/github-auth.ts +10 -9
  534. package/src/sync/layout.ts +13 -12
  535. package/src/sync/messages.ts +153 -0
  536. package/src/sync/review-queue.ts +6 -5
  537. package/src/sync/s3/messages.ts +16 -0
  538. package/src/sync/s3/s3-providers.ts +9 -6
  539. package/src/sync/s3/s3-transport.ts +1 -1
  540. package/src/sync/s3/sigv4.ts +3 -1
  541. package/src/sync/snapshot-crypto.ts +6 -5
  542. package/src/sync/snapshot-json.ts +3 -2
  543. package/src/sync/sync-credentials.test.ts +119 -0
  544. package/src/sync/sync-engine-download-tombstone.test.ts +1 -1
  545. package/src/sync/sync-engine.test.ts +226 -8
  546. package/src/sync/sync-engine.ts +154 -52
  547. package/src/sync/sync-history.ts +7 -6
  548. package/src/sync/sync-selection.test.ts +12 -2
  549. package/src/sync/sync-selection.ts +18 -5
  550. package/src/sync/transport.test.ts +1 -0
  551. package/src/sync/ui-prefs.test.ts +44 -1
  552. package/src/sync/ui-prefs.ts +39 -0
  553. package/src/sync/webdav/webdav-transport.ts +4 -1
  554. package/src/ui/artifact-view.ts +4 -3
  555. package/src/ui/commands.test.ts +1 -0
  556. package/src/ui/commands.ts +11 -1
  557. package/src/ui/diff-view.test.ts +9 -1
  558. package/src/ui/diff-view.ts +22 -0
  559. package/src/ui/disk-usage-view.test.ts +113 -6
  560. package/src/ui/disk-usage-view.ts +63 -7
  561. package/src/ui/errors.ts +4 -3
  562. package/src/ui/export-flow.ts +26 -20
  563. package/src/ui/foreign-view.test.ts +43 -0
  564. package/src/ui/foreign-view.ts +30 -3
  565. package/src/ui/i18n.ts +131 -2
  566. package/src/ui/import-wizard.ts +18 -10
  567. package/src/ui/progress.ts +2 -2
  568. package/src/ui/report.ts +7 -6
  569. package/src/ui/rollback-view.test.ts +836 -0
  570. package/src/ui/rollback-view.ts +1405 -0
  571. package/src/ui/selection-model.test.ts +33 -1
  572. package/src/ui/selection-model.ts +53 -0
  573. package/src/ui/session-inventory-view.test.ts +109 -0
  574. package/src/ui/session-inventory-view.ts +138 -3
  575. package/src/ui/session-issue-labels.test.ts +51 -0
  576. package/src/ui/session-issue-labels.ts +70 -0
  577. package/src/ui/sync-settings-view.test.ts +36 -0
  578. package/src/ui/sync-settings-view.ts +49 -0
  579. package/src/ui/test-helpers.ts +2 -1
  580. package/src/ui/types.ts +10 -4
  581. package/src/utils/atomic-write.ts +6 -5
  582. package/src/utils/bundle-scan.test.ts +2 -2
  583. package/src/utils/bundle-scan.ts +12 -1
  584. package/src/utils/env-lock.ts +83 -49
  585. package/src/utils/json.ts +7 -5
  586. package/src/utils/messages-utils.ts +232 -0
  587. package/src/utils/proxy.test.ts +25 -0
  588. package/src/utils/proxy.ts +19 -5
  589. package/src/utils/recursive-walk.test.ts +43 -0
  590. package/src/utils/recursive-walk.ts +75 -1
  591. package/src/utils/session-log-repair.ts +10 -9
  592. package/src/utils/zip.ts +27 -26
  593. package/lib/core/checkpoint.d.ts +0 -362
  594. package/lib/core/checkpoint.js +0 -1309
  595. package/src/core/checkpoint.test.ts +0 -665
  596. package/src/core/checkpoint.ts +0 -1614
@@ -0,0 +1,193 @@
1
+ /**
2
+ * CLI 守卫单测(本轮修复的四条纪律,全部是「不能悄悄放过」的行为):
3
+ * ① 参数白名单:verify 不再悄悄接受 --dry-run / --profile / --settings;
4
+ * ② snapshots / restore 的「快照目录读不出来」必须区别于「没有快照」(非 0 退出 + stderr);
5
+ * ③ restore 的第三道写入门:本机有 DSH 在跑 → 拒绝(且 dry-run 不受影响);
6
+ * ④ web 的四个目录从**同一个 root** 派生(--data-dir 尾段命中 snapshots/exports 时上溯一层)。
7
+ */
8
+ import test from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import fs from 'node:fs/promises';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+
14
+ import { parseCli, resolveWebPaths, runCli, type CliIo } from './index.ts';
15
+
16
+ async function withTmp<T>(fn: (dir: string) => Promise<T>): Promise<T> {
17
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-cli-guards-'));
18
+ try {
19
+ return await fn(dir);
20
+ } finally {
21
+ await fs.rm(dir, { recursive: true, force: true });
22
+ }
23
+ }
24
+
25
+ function captureIo(): { io: CliIo; out: string[] } {
26
+ const out: string[] = [];
27
+ const io: CliIo = { log: (s) => out.push(s), error: (s) => out.push(s) };
28
+ return { io, out };
29
+ }
30
+
31
+ /* ---------------------------------------------------------------- ① 参数白名单 */
32
+
33
+ test('G-01 verify 必须拒绝 --dry-run / --profile / --settings(不得悄悄接受并忽略)', () => {
34
+ for (const args of [
35
+ ['verify', '--dry-run'],
36
+ ['verify', '--profile', 'dev'],
37
+ ['verify', '--settings', '/s/settings.yaml'],
38
+ ]) {
39
+ assert.equal(parseCli(args).ok, false, '应拒绝: ' + args.join(' '));
40
+ }
41
+ // --id 是 verify 位置参数的等价写法(见 help 的 verify 段),必须继续可用。
42
+ const byId = parseCli(['verify', '--id', 'backup.zip']);
43
+ assert.ok(byId.ok);
44
+ assert.equal(byId.options.id, 'backup.zip');
45
+ });
46
+
47
+ test('G-02 其它命令也按「真正用到才放行」拒绝 restore 系参数', () => {
48
+ assert.equal(parseCli(['snapshots', '--profile', 'dev']).ok, false, 'snapshots 不用 --profile');
49
+ assert.equal(parseCli(['snapshots', '--settings', '/s']).ok, false, 'snapshots 不用 --settings');
50
+ assert.equal(parseCli(['snapshots', '--dry-run']).ok, false, 'snapshots 不用 --dry-run');
51
+ // 反向:真正用到这些参数的命令必须继续放行(不能为了「统一拒绝」把它们一起打死)。
52
+ assert.ok(parseCli(['reinstall', '--dry-run']).ok, 'reinstall 真的用 --dry-run');
53
+ assert.ok(parseCli(['backup', '--dry-run']).ok, 'backup 真的用 --dry-run');
54
+ assert.ok(parseCli(['import', '--from', 'claude-code', '--dry-run']).ok, 'import 真的用 --dry-run');
55
+ const web = parseCli(['web', '--profile', 'dev']);
56
+ assert.ok(web.ok, 'web 真的用 --profile(救急台「恢复」页的目标档案)');
57
+ assert.equal(web.options.profile, 'dev');
58
+ assert.ok(parseCli(['restore', '--id', 'x', '--dry-run', '--profile', 'dev', '--settings', '/s']).ok, 'restore 全都要');
59
+ });
60
+
61
+ /* ---------------------------------------------------------------- ② 读不到 ≠ 没有 */
62
+
63
+ test('G-03 snapshots:快照目录读不出来 → stderr 说明 + 非 0 退出(绝不显示成「无快照」)', async () => {
64
+ await withTmp(async (tmp) => {
65
+ // 用一个**普通文件**占住快照目录路径:readdir 必失败(ENOTDIR),且不是「目录不存在」。
66
+ const dataDir = path.join(tmp, 'snapshots-as-file');
67
+ await fs.writeFile(dataDir, 'not a directory', 'utf8');
68
+ const { io, out } = captureIo();
69
+ const code = await runCli(['snapshots', '--data-dir', dataDir], io, { DSH_HOME: path.join(tmp, 'home') });
70
+ assert.equal(code, 1, '读不出来必须非 0 退出');
71
+ const text = out.join('\n');
72
+ assert.match(text, /快照目录读不出来/, '必须把事实打到 stderr');
73
+ assert.doesNotMatch(text, /无快照/, '不得显示成「没有快照」');
74
+ });
75
+ });
76
+
77
+ test('G-04 restore:默认挑快照时目录读不出来 → 如实打印 + 非 0 退出', async () => {
78
+ await withTmp(async (tmp) => {
79
+ const dataDir = path.join(tmp, 'snapshots-as-file');
80
+ await fs.writeFile(dataDir, 'not a directory', 'utf8');
81
+ const { io, out } = captureIo();
82
+ const code = await runCli(['restore', '--data-dir', dataDir], io, { DSH_HOME: path.join(tmp, 'home') });
83
+ assert.equal(code, 1);
84
+ assert.match(out.join('\n'), /快照目录读不出来/);
85
+ });
86
+ });
87
+
88
+ test('G-05 没有快照(目录不存在)仍照旧:snapshots 返回 0 并打印「无快照」', async () => {
89
+ await withTmp(async (tmp) => {
90
+ const { io, out } = captureIo();
91
+ const code = await runCli(['snapshots', '--data-dir', path.join(tmp, 'missing')], io, { DSH_HOME: path.join(tmp, 'home') });
92
+ assert.equal(code, 0, '目录不存在 ≠ 读不出来(core 的 listSnapshots 只有 ENOENT 才算没有)');
93
+ assert.match(out.join('\n'), /无快照/);
94
+ });
95
+ });
96
+
97
+ /* ---------------------------------------------------------------- ③ restore 的第三道门 */
98
+
99
+ /** 造一个「可恢复」的最小快照 + 当前 home 文件。 */
100
+ async function seedRestorableSnapshot(tmp: string): Promise<{ homeDir: string; dataDir: string; id: string }> {
101
+ const homeDir = path.join(tmp, 'home');
102
+ const dataDir = path.join(tmp, 'snapshots');
103
+ const id = 'snap-guard-1';
104
+ const dir = path.join(dataDir, id);
105
+ await fs.mkdir(path.join(dir, 'blobs', 'host'), { recursive: true });
106
+ await fs.writeFile(path.join(dir, 'blobs', 'host', 'settings'), 'SNAPSHOT settings\n', 'utf8');
107
+ await fs.writeFile(path.join(dir, 'snapshot.json'), JSON.stringify({
108
+ id, createdAt: '2026-08-14T12:00:00.000Z', sourceZip: 'backup.zip', status: 'done',
109
+ entries: [],
110
+ hostFileBackups: [{ relPath: 'settings.yaml', blobPath: 'blobs/host/settings', existed: true }],
111
+ beforePlugins: [],
112
+ }), 'utf8');
113
+ await fs.mkdir(homeDir, { recursive: true });
114
+ await fs.writeFile(path.join(homeDir, 'settings.yaml'), 'CURRENT settings\n', 'utf8');
115
+ // 心跳落在候选控制根之一(--data-dir 本身)下:pid 用本进程 → 判定为「正在跑」
116
+ await fs.mkdir(path.join(dataDir, 'running'), { recursive: true });
117
+ return { homeDir, dataDir, id };
118
+ }
119
+
120
+ test('G-06 restore:本机有 DSH 在跑 → 拒绝执行(与救急台 checkWriteGates 同源)', async () => {
121
+ await withTmp(async (tmp) => {
122
+ const { homeDir, dataDir, id } = await seedRestorableSnapshot(tmp);
123
+ await fs.writeFile(path.join(dataDir, 'running', 'web.json'), JSON.stringify({
124
+ schemaVersion: 1, name: 'web', pid: process.pid, port: 3099,
125
+ startedAt: new Date().toISOString(), updatedAt: Date.now(),
126
+ }), 'utf8');
127
+
128
+ const { io, out } = captureIo();
129
+ const code = await runCli(['restore', '--id', id, '--data-dir', dataDir], io, { DSH_HOME: homeDir });
130
+ assert.equal(code, 1, 'DSH 在跑时恢复必须被拒绝');
131
+ const text = out.join('\n');
132
+ assert.match(text, /检测到 DSH 正在运行/);
133
+ assert.match(text, /dcm web/, '必须给出「起不来时」的出口');
134
+ assert.equal(
135
+ await fs.readFile(path.join(homeDir, 'settings.yaml'), 'utf8'),
136
+ 'CURRENT settings\n',
137
+ '被拒绝时一个字节都不许改',
138
+ );
139
+ });
140
+ });
141
+
142
+ test('G-07 restore --dry-run:DSH 在跑也照旧只打印计划(门只在真实执行前)', async () => {
143
+ await withTmp(async (tmp) => {
144
+ const { homeDir, dataDir, id } = await seedRestorableSnapshot(tmp);
145
+ await fs.writeFile(path.join(dataDir, 'running', 'web.json'), JSON.stringify({
146
+ schemaVersion: 1, name: 'web', pid: process.pid, port: 3099,
147
+ startedAt: new Date().toISOString(), updatedAt: Date.now(),
148
+ }), 'utf8');
149
+
150
+ const { io, out } = captureIo();
151
+ const code = await runCli(['restore', '--dry-run', '--id', id, '--data-dir', dataDir], io, { DSH_HOME: homeDir });
152
+ assert.equal(code, 0, 'dry-run 零写入,不受写入门影响');
153
+ assert.match(out.join('\n'), /动作计划/);
154
+ assert.equal(await fs.readFile(path.join(homeDir, 'settings.yaml'), 'utf8'), 'CURRENT settings\n');
155
+ });
156
+ });
157
+
158
+ /* ---------------------------------------------------------------- ④ web 目录派生 */
159
+
160
+ test('G-08 resolveWebPaths:快照/导出/锁/数据根四处必须同源(--data-dir 只上溯一次)', () => {
161
+ // 统一先 resolve:Windows 下 '\\root' 是「当前盘符根」,函数内部也会 resolve,避免断言口径不一致。
162
+ const homeDsh = path.resolve(path.sep, 'home', 'dsh');
163
+ const root = path.resolve(path.sep, 'root');
164
+ const env = { DSH_HOME: homeDsh } as Record<string, string | undefined>;
165
+
166
+ // 尾段命中 snapshots:上溯一层当作 root,四个目录都挂在它下面。
167
+ const fromSnapshots = resolveWebPaths({ dataDir: path.join(root, 'snapshots') }, env);
168
+ assert.equal(fromSnapshots.root, root);
169
+ assert.equal(fromSnapshots.dataDir, root);
170
+ assert.equal(fromSnapshots.snapshotsDir, path.join(root, 'snapshots'));
171
+ assert.equal(fromSnapshots.exportsDir, path.join(root, 'exports'), '导出目录也必须上溯(不再挂到 leaf 下)');
172
+ assert.equal(fromSnapshots.locksDir, path.join(root, 'locks'));
173
+
174
+ // exports 同理
175
+ const fromExports = resolveWebPaths({ dataDir: path.join(root, 'exports') }, env);
176
+ assert.equal(fromExports.snapshotsDir, path.join(root, 'snapshots'));
177
+ assert.equal(fromExports.exportsDir, path.join(root, 'exports'));
178
+
179
+ // 直接给数据根
180
+ const fromRoot = resolveWebPaths({ dataDir: root }, env);
181
+ assert.equal(fromRoot.snapshotsDir, path.join(root, 'snapshots'));
182
+ assert.equal(fromRoot.exportsDir, path.join(root, 'exports'));
183
+
184
+ // --data-root 恒为准(即使同时给了 leaf 形态的 --data-dir)
185
+ const other = path.resolve(path.sep, 'other');
186
+ const explicitRoot = resolveWebPaths({ dataRoot: other, dataDir: path.join(root, 'snapshots') }, env);
187
+ assert.equal(explicitRoot.root, other);
188
+ assert.equal(explicitRoot.snapshotsDir, path.join(other, 'snapshots'));
189
+
190
+ // 都没给:缺省根 = $DSH_HOME/dsh-config-manager
191
+ const defaults = resolveWebPaths({}, env);
192
+ assert.equal(defaults.root, path.join(homeDsh, 'dsh-config-manager'));
193
+ });
package/src/cli/index.ts CHANGED
@@ -139,8 +139,23 @@ const BACKUP_ONLY_FLAGS = new Set(['--sections', '--out']);
139
139
  /** backup 明确不支持的 restore 系参数(--dry-run 对 backup 有效,故不在此表) */
140
140
  const BACKUP_REJECTED_FLAGS = new Set(['--id', '--profile', '--settings']);
141
141
 
142
- /** 仅 restore 子命令允许的参数 */
143
- const RESTORE_ONLY_FLAGS = new Set(['--id', '--dry-run', '--profile', '--settings']);
142
+ /**
143
+ * 每个参数**真正被哪些子命令使用**(白名单,而不是「想起来才拒绝某一条」)。
144
+ *
145
+ * 为什么要有这张表:此前只有 `snapshots` 会拒绝 restore 系参数,于是
146
+ * `verify --dry-run / --profile / --settings` 被**悄悄接受并忽略** —— 用户会以为自检跑成了
147
+ * 「只读预演」。纪律是「本命令用不到的参数一律拒绝」,而不是「反正不影响结果就算了」。
148
+ */
149
+ const FLAG_ALLOWED_COMMANDS = new Map<string, ReadonlySet<CliCommand>>([
150
+ // --id:restore 的目标快照;verify 把它当位置参数的等价写法(见 help 的 verify 段)。
151
+ ['--id', new Set<CliCommand>(['restore', 'verify'])],
152
+ // --dry-run:restore(只打印计划)/ reinstall(只打印重装步骤)/ backup(只打印打包清单)。
153
+ ['--dry-run', new Set<CliCommand>(['restore', 'reinstall', 'backup'])],
154
+ // --profile:restore 要恢复的档案;web 用它决定救急台操作哪个档案(两条都真的在用,故放行)。
155
+ ['--profile', new Set<CliCommand>(['restore', 'web'])],
156
+ // --settings:只有 restore 的「覆盖 settings 文件路径」用得到。
157
+ ['--settings', new Set<CliCommand>(['restore'])],
158
+ ]);
144
159
 
145
160
  /** 仅 reinstall 子命令允许的参数 */
146
161
  const REINSTALL_ONLY_FLAGS = new Set(['--yes', '--list', '--wipe-config', '--version']);
@@ -349,8 +364,10 @@ export function parseCli(argv: readonly string[]): ParseResult { const command
349
364
  options.json = true;
350
365
  continue;
351
366
  }
352
- if (command === 'snapshots' && RESTORE_ONLY_FLAGS.has(flag)) {
353
- return { ok: false, error: `snapshots 子命令不支持参数 / flag not allowed here: ${flag}` };
367
+ // 本命令用不到的参数一律拒绝(绝不「悄悄接受并忽略」):白名单见 FLAG_ALLOWED_COMMANDS。
368
+ const allowedCommands = FLAG_ALLOWED_COMMANDS.get(flag);
369
+ if (allowedCommands !== undefined && !allowedCommands.has(command)) {
370
+ return { ok: false, error: `${command} 子命令不支持参数 / flag not allowed here: ${flag}` };
354
371
  }
355
372
  if (command !== 'reinstall' && REINSTALL_ONLY_FLAGS.has(flag)) {
356
373
  return { ok: false, error: `${command} 子命令不支持参数 / flag not allowed here: ${flag}` };
@@ -1022,7 +1039,15 @@ export async function runCli(
1022
1039
  const dataDir = lockDataDir;
1023
1040
 
1024
1041
  if (options.command === 'snapshots') {
1025
- const metas = await listSnapshots(dataDir);
1042
+ // 「读不到」≠「没有快照」(core/restore.ts 的 listSnapshots 只有 ENOENT 才算没有):
1043
+ // 目录存在却读不出来时必须把事实打到 stderr 并以非 0 退出,绝不显示成「无快照」。
1044
+ const probe: { unreadable?: string } = {};
1045
+ const metas = await listSnapshots(dataDir, probe);
1046
+ if (probe.unreadable !== undefined) {
1047
+ io.error(probe.unreadable);
1048
+ io.error('读不到 ≠ 没有快照:请检查该目录的权限/形态,或用 --data-dir 指定正确位置。/ unreadable is not the same as no snapshots');
1049
+ return 1;
1050
+ }
1026
1051
  printSnapshots(metas, io);
1027
1052
  return 0;
1028
1053
  }
@@ -1030,7 +1055,14 @@ export async function runCli(
1030
1055
  // restore
1031
1056
  let id = options.id;
1032
1057
  if (id === undefined) {
1033
- const picked = pickDefaultSnapshotId(await listSnapshots(dataDir));
1058
+ // 与 snapshots 同一口径:读不出来时如实打印(restore 的退出语义不变 —— 拿不到候选快照即拒绝执行)。
1059
+ const probe: { unreadable?: string } = {};
1060
+ const picked = pickDefaultSnapshotId(await listSnapshots(dataDir, probe));
1061
+ if (probe.unreadable !== undefined) {
1062
+ io.error(probe.unreadable);
1063
+ io.error('读不到 ≠ 没有快照:恢复拒绝在「不知道有哪些快照」的状态下继续。/ unreadable is not the same as no snapshots; refusing to restore');
1064
+ return 1;
1065
+ }
1034
1066
  if (picked === null) {
1035
1067
  io.error('没有可用快照(无快照或全部已回滚)/ no usable snapshot (none or all rolled-back)');
1036
1068
  return 1;
@@ -1059,7 +1091,8 @@ export async function runCli(
1059
1091
  }
1060
1092
  // Phase 3 SAFE MODE:存在未恢复 transaction → 拒绝 destructive(CLI 不旁路 SAFE MODE)。
1061
1093
  // 候选根与真实 restore 的锁目录/journal 根同源(均为 <root> = dirname(快照目录) 的那一族)。
1062
- const safeMsg2 = checkSafeModeBlocked(resolveControlRoots({ dataRoot: options.dataRoot, dataDir: options.dataDir }, env));
1094
+ const controlRoots = resolveControlRoots({ dataRoot: options.dataRoot, dataDir: options.dataDir }, env);
1095
+ const safeMsg2 = checkSafeModeBlocked(controlRoots);
1063
1096
  if (safeMsg2 !== null) {
1064
1097
  io.error(`拒绝执行:${safeMsg2}`);
1065
1098
  return 1;
@@ -1076,12 +1109,25 @@ export async function runCli(
1076
1109
  // Phase 3 P0-A:CLI restore 也创建 durable journal(关闭 bypass)。
1077
1110
  const recovery = new Phase3Recovery({ dataDir: path.dirname(dataDir), packageVersion: '0.1.0', fingerprintDataDir: path.dirname(dataDir) });
1078
1111
  await recovery.initFingerprint().catch(() => undefined);
1079
- const report = await runWithMutationLock(lock, { op: 'cli-restore', target: id }, async (lockCtx) => {
1080
- if (lockCtx !== null) {
1081
- return (await recovery.runJournaled({ operationType: 'cli-restore', lockCtx, fn: () => restore(restoreOptions) })).result;
1082
- }
1083
- return restore(restoreOptions);
1112
+ const outcome = await runWithMutationLock(lock, { op: 'cli-restore', target: id }, async (lockCtx) => {
1113
+ // 第三道门(与救急台 actions.ts 的 checkWriteGates 同源、同序:SAFE MODE → 残留锁 → DSH 未运行):
1114
+ // 恢复会覆盖/删除 $DSH_HOME 下**正在被实例使用**的文件。放在持有环境锁之后,于是「残留锁」仍先于它报出。
1115
+ const running = await readRunningInstances(controlRoots);
1116
+ if (running.length > 0) return { kind: 'dsh-running' as const, running };
1117
+ return {
1118
+ kind: 'restore' as const,
1119
+ report: lockCtx !== null
1120
+ ? (await recovery.runJournaled({ operationType: 'cli-restore', lockCtx, fn: () => restore(restoreOptions) })).result
1121
+ : await restore(restoreOptions),
1122
+ };
1084
1123
  });
1124
+ if (outcome.kind === 'dsh-running') {
1125
+ io.error('检测到 DSH 正在运行(' + outcome.running.map((i) => i.name + ' pid=' + String(i.pid)).join('、')
1126
+ + '):请先关闭 DSH 再执行恢复 —— 恢复会覆盖/删除 $DSH_HOME 下正在使用的文件。');
1127
+ io.error('如 DSH 已起不来(无法正常关闭),改用本机救急台:dcm web(「恢复」页与 CLI 同一实现,可先停实例/回收残留锁)。');
1128
+ return 1;
1129
+ }
1130
+ const report = outcome.report;
1085
1131
  printReport(report, io);
1086
1132
  return report.failed.length > 0 ? 1 : 0;
1087
1133
  } catch (err) {
@@ -1095,15 +1141,54 @@ export async function runCli(
1095
1141
 
1096
1142
  /* ------------------------------------------------------------ web:离线只读救急台 */
1097
1143
 
1144
+ /** 救急台的目录事实(由 --data-dir / --data-root 统一派生;四处只认这一份)。 */
1145
+ export interface WebPaths {
1146
+ /** 插件数据根(快照/导出/锁都挂在它下面) */
1147
+ root: string
1148
+ /** 与 root 同值(RescuePaths.dataDir 的语义) */
1149
+ dataDir: string
1150
+ snapshotsDir: string
1151
+ exportsDir: string
1152
+ locksDir: string
1153
+ controlRoots: string[]
1154
+ }
1155
+
1156
+ /**
1157
+ * 派生救急台要用的四个目录 —— **root 只算一次**,再同时派生 snapshots / exports / locks / dataDir。
1158
+ *
1159
+ * 为什么必须统一:`--data-dir` 的历史语义是「快照目录 / 导出目录」,尾段命中 `snapshots`/`exports`
1160
+ * 时要**上溯一层**才是数据根。此前只对「那一条」取原值、另一条仍 `path.join(dataDir, ...)`,
1161
+ * 于是 `--data-dir <root>/snapshots` 会派生出 `<root>/snapshots/exports`(半上溯)——
1162
+ * 命令行与网页看到的目录事实立刻分叉。`--data-root` 恒为准。
1163
+ */
1164
+ export function resolveWebPaths(
1165
+ opts: { dataRoot?: string; dataDir?: string } = {},
1166
+ env: Record<string, string | undefined> = process.env,
1167
+ ): WebPaths {
1168
+ const explicitRoot = opts.dataRoot !== undefined && opts.dataRoot !== '' ? path.resolve(opts.dataRoot) : undefined;
1169
+ const explicitDataDir = opts.dataDir !== undefined && opts.dataDir !== '' ? path.resolve(opts.dataDir) : undefined;
1170
+ const root = explicitRoot
1171
+ ?? (explicitDataDir === undefined
1172
+ ? path.join(resolveDshHome(env), 'dsh-config-manager')
1173
+ : ['snapshots', 'exports'].includes(path.basename(explicitDataDir))
1174
+ ? path.dirname(explicitDataDir)
1175
+ : explicitDataDir);
1176
+ return {
1177
+ root,
1178
+ dataDir: root,
1179
+ snapshotsDir: path.join(root, 'snapshots'),
1180
+ exportsDir: path.join(root, 'exports'),
1181
+ locksDir: path.join(root, 'locks'),
1182
+ controlRoots: resolveControlRoots({ dataRoot: explicitRoot, dataDir: explicitDataDir }, env),
1183
+ };
1184
+ }
1185
+
1098
1186
  /**
1099
1187
  * 启动离线救急台(**只读**)。
1100
1188
  *
1101
1189
  * 为什么目录要在这一侧解析:`--data-dir / --data-root` 的既有语义只有本文件的 resolve* 系列说了算——
1102
1190
  * 网页与各子命令必须看到同一份目录事实,否则迟早出现「命令行说没有、网页说有一个」的分叉。
1103
- * 这里统一按**控制面根**派生(`<root>/snapshots` 与 `<root>/exports`)。`--data-dir` 的历史语义是
1104
- * 「快照目录 / 导出目录」,所以尾段命中 `snapshots` / `exports` 时会**上溯一层**再派生
1105
- * (否则 <root>/snapshots 会变成 <root>/snapshots/snapshots,快照列表恒空 —— 验收 F6);
1106
- * `--data-root` 仍恒为准。
1191
+ * 目录派生统一走 `resolveWebPaths()`(`--data-dir` 尾段命中 snapshots/exports 时上溯一层;`--data-root` 恒为准)。
1107
1192
  *
1108
1193
  * 服务端只绑 127.0.0.1,端口缺省 0(内核分配);启动后打印带一次性 token 的 URL 并打开浏览器。
1109
1194
  */
@@ -1113,21 +1198,8 @@ async function runWeb(
1113
1198
  env: Record<string, string | undefined>,
1114
1199
  ): Promise<number> {
1115
1200
  const homeDir = options.home ?? resolveDshHome(env);
1116
- const controlRoots = resolveControlRoots({ dataRoot: options.dataRoot, dataDir: options.dataDir }, env);
1117
- const dataDir = controlRoots[0] ?? path.join(homeDir, 'dsh-config-manager');
1118
- // --data-dir 的历史语义是「快照目录 / 导出目录」:用户按该语义传 <root>/snapshots 时,
1119
- // 不能把它本身当作数据根(否则会派生出 <root>/snapshots/snapshots,真实快照必然看不见 —— 验收 F6)。
1120
- // 尾段命中 snapshots / exports 就上溯一层;显式 --data-root 仍以它为准。
1121
- const resolvedSnapshotsDir = options.dataRoot === undefined
1122
- && options.dataDir !== undefined
1123
- && ['snapshots', 'exports'].includes(path.basename(path.resolve(options.dataDir)))
1124
- ? path.resolve(options.dataDir)
1125
- : path.join(dataDir, 'snapshots');
1126
- const snapshotsDir = resolvedSnapshotsDir;
1127
- const exportsDir = options.dataDir !== undefined && path.basename(path.resolve(options.dataDir)) === 'exports'
1128
- ? path.resolve(options.dataDir)
1129
- : path.join(dataDir, 'exports');
1130
- const locksDir = resolveRecoverLocksDir({ dataRoot: options.dataRoot, dataDir: options.dataDir }, env);
1201
+ const { dataDir, snapshotsDir, exportsDir, locksDir, controlRoots } =
1202
+ resolveWebPaths({ dataRoot: options.dataRoot, dataDir: options.dataDir }, env);
1131
1203
  // 懒加载:http 服务端代码不进其他子命令(尤其 reinstall/restore 的救急路径)的启动链路
1132
1204
  const { startConsoleServer } = await import('./web/server.ts');
1133
1205
  const server = await startConsoleServer({
@@ -1139,6 +1211,9 @@ async function runWeb(
1139
1211
  locksDir,
1140
1212
  controlRoots,
1141
1213
  profile: options.profile,
1214
+ // 离线导出的 manifest.exporter.version 用**真实 CLI 版本**(与 backup 同一口径),
1215
+ // 不再写死 'rescue-console' —— 那会让产物声称一个不存在的导出器版本。
1216
+ exporterVersion: CLI_EXPORTER_VERSION,
1142
1217
  },
1143
1218
  version: CLI_EXPORTER_VERSION,
1144
1219
  port: options.port ?? 0,
@@ -82,9 +82,23 @@ test('T6 CLI:--json 给机器可读结果(readOnly 声明 + summary + rows
82
82
  const { lines, io } = collect();
83
83
  const code = await runSessionsInspect({ home, action: 'list', json: true }, io);
84
84
  assert.equal(code, 0);
85
- const parsed = JSON.parse(lines.join('\n')) as { readOnly: boolean; rows: unknown[]; summary: { total: number } };
85
+ const parsed = JSON.parse(lines.join('\n')) as { ok: boolean; readOnly: boolean; rows: unknown[]; summary: { total: number } };
86
86
  assert.equal(parsed.readOnly, true, '输出必须声明只读');
87
+ assert.equal(parsed.ok, true, '无阻断问题 → ok 与退出码(0)同源');
87
88
  assert.equal(parsed.summary.total, 1);
88
89
  assert.equal(parsed.rows.length, 1);
89
90
  });
90
91
  });
92
+
93
+ test('T6 CLI:--json 的 ok 与退出码同源(有不可加载会话时为 false,不再恒 true)', { skip: !CAPABLE }, async () => {
94
+ await withTmp(async (home) => {
95
+ const dir = path.join(home, 'sessions', KEY, 'broken');
96
+ await fs.mkdir(dir, { recursive: true });
97
+ await fs.writeFile(path.join(dir, 'session.jsonl.zstd'), Buffer.from('not zstd'));
98
+ const { lines, io } = collect();
99
+ const code = await runSessionsInspect({ home, action: 'doctor', json: true }, io);
100
+ assert.equal(code, 1, '存在不可加载会话 → 退出码 1');
101
+ const parsed = JSON.parse(lines.join('\n')) as { ok: boolean };
102
+ assert.equal(parsed.ok, false, 'ok 必须与退出码同源(此前恒 true,机器消费者只会信错一个)');
103
+ });
104
+ });
@@ -67,7 +67,9 @@ export async function runSessionsInspect(options: SessionsInspectOptions, io: Se
67
67
  const blocking = result.summary.bySeverity.blocksStartup + result.summary.bySeverity.unloadable;
68
68
  if (options.json) {
69
69
  io.log(JSON.stringify({
70
- ok: true,
70
+ // ok 与退出码**同源**(0 ⟺ 无阻断级问题):此前恒 true,存在不可加载会话时
71
+ // CI 会看到 ok:true 而退出码 1 —— 两个字段互相矛盾,机器消费者只会信错一个。
72
+ ok: blocking === 0,
71
73
  readOnly: true,
72
74
  sessionsDir: result.sessionsDir,
73
75
  summary: { ...result.summary, untested: result.untested, unreadableEntries: result.unreadableEntries },
@@ -0,0 +1,84 @@
1
+ /**
2
+ * CLI 会话修复(`sessions repair`)的「读不到 ≠ 没有会话」回归。
3
+ *
4
+ * 修前:会话根**存在但读不出来**(ENOTDIR / EACCES…)时 scanSessions 只返回空列表,
5
+ * 于是打印「无需修复」+ exit 0 —— 把扫描失败说成了「没问题」,--json 还会回 ok:true。
6
+ */
7
+ import test from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import fs from 'node:fs/promises';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ import { runSessionsRepair } from './sessions-repair.ts';
14
+
15
+ interface Collected {
16
+ lines: string[];
17
+ errors: string[];
18
+ io: { log: (line: string) => void; error: (line: string) => void };
19
+ }
20
+
21
+ function collect(): Collected {
22
+ const lines: string[] = [];
23
+ const errors: string[] = [];
24
+ return { lines, errors, io: { log: (l) => lines.push(l), error: (l) => errors.push(l) } };
25
+ }
26
+
27
+ async function withTmp<T>(fn: (dir: string) => Promise<T>): Promise<T> {
28
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'cm-cli-repair-'));
29
+ try {
30
+ return await fn(dir);
31
+ } finally {
32
+ await fs.rm(dir, { recursive: true, force: true });
33
+ }
34
+ }
35
+
36
+ const base = { fix: false, maps: [] as string[], json: false };
37
+
38
+ test('RP-01 会话根存在但读不出来 → exit 1,明说「读不到 ≠ 没有会话」,绝不打印「无需修复」', async () => {
39
+ await withTmp(async (home) => {
40
+ // 用普通文件占住 <home>/sessions:exists() 为真,readdir 必失败(ENOTDIR)。
41
+ await fs.writeFile(path.join(home, 'sessions'), 'not a directory', 'utf8');
42
+ const { lines, errors, io } = collect();
43
+ const code = await runSessionsRepair({ home, ...base }, io);
44
+ assert.equal(code, 1, '读不出来必须非 0');
45
+ const text = lines.join('\n') + '\n' + errors.join('\n');
46
+ assert.match(text, /读不到/, '必须明说读不到');
47
+ assert.match(text, /读不到 ≠ 没有会话/);
48
+ assert.doesNotMatch(lines.join('\n'), /无需修复/, '不得把扫描失败说成「无需修复」');
49
+ });
50
+ });
51
+
52
+ test('RP-02 同一情形下 --json 不得回 ok:true', async () => {
53
+ await withTmp(async (home) => {
54
+ await fs.writeFile(path.join(home, 'sessions'), 'not a directory', 'utf8');
55
+ const { lines, io } = collect();
56
+ const code = await runSessionsRepair({ home, ...base, json: true }, io);
57
+ assert.equal(code, 1);
58
+ assert.equal(lines.length, 1, '--json 必须给出唯一一段机器可读输出');
59
+ const parsed = JSON.parse(lines[0]!) as { ok: boolean; rootReadable: boolean; error?: string };
60
+ assert.equal(parsed.ok, false, '读不出来时 ok 必须为 false');
61
+ assert.equal(parsed.rootReadable, false);
62
+ assert.equal(typeof parsed.error, 'string');
63
+ assert.doesNotMatch(lines[0]!, /"ok": true/);
64
+ });
65
+ });
66
+
67
+ test('RP-03 会话根存在且可读但为空 → 仍照旧「无需修复」+ exit 0(不误伤正常路径)', async () => {
68
+ await withTmp(async (home) => {
69
+ await fs.mkdir(path.join(home, 'sessions'), { recursive: true });
70
+ const { lines, io } = collect();
71
+ const code = await runSessionsRepair({ home, ...base }, io);
72
+ assert.equal(code, 0);
73
+ assert.match(lines.join('\n'), /无需修复/);
74
+ });
75
+ });
76
+
77
+ test('RP-04 会话根不存在 → exit 1 且提示用 --home 指对位置(既有语义不变)', async () => {
78
+ await withTmp(async (home) => {
79
+ const { errors, io } = collect();
80
+ const code = await runSessionsRepair({ home, ...base }, io);
81
+ assert.equal(code, 1);
82
+ assert.match(errors.join('\n'), /找不到会话根目录/);
83
+ });
84
+ });
@@ -67,19 +67,31 @@ export function parseRepairMaps(maps: readonly string[]): { mappings: PathMappin
67
67
  return { mappings, errors };
68
68
  }
69
69
 
70
- /** 扫描 <home>/sessions:projectKey 段 → 会话目录 → 首帧 cwd。任何读不出的都如实记为「无法判定」。 */
71
- async function scanSessions(sessionsRoot: string, io: SessionsRepairIo): Promise<ScannedSession[]> {
70
+ /** 一次扫描的结果:会话清单 + **会话根本身是否读得出来**(后者与「没有会话」是两件事)。 */
71
+ interface ScanResult {
72
+ sessions: ScannedSession[];
73
+ /** false = readdir 会话根失败(ENOTDIR / EACCES…):此时 sessions 必为空,但**不等于没有会话** */
74
+ rootReadable: boolean;
75
+ }
76
+
77
+ /**
78
+ * 扫描 <home>/sessions:projectKey 段 → 会话目录 → 首帧 cwd。任何读不出的都如实记为「无法判定」。
79
+ *
80
+ * 返回值里必须带 rootReadable:会话根存在但读不出来时,空列表只能说明「扫描失败」,
81
+ * 调用方据此拒绝给出「无需修复」+ exit 0(读不到 ≠ 无会话 —— 验收 F2 同口径)。
82
+ */
83
+ async function scanSessions(sessionsRoot: string, io: SessionsRepairIo): Promise<ScanResult> {
72
84
  const out: ScannedSession[] = [];
73
85
  let projectDirs: import('node:fs').Dirent[];
74
86
  try {
75
87
  projectDirs = await fs.readdir(sessionsRoot, { withFileTypes: true });
76
88
  } catch (error) {
77
89
  // 会话根不是目录 / 读不出来(ENOTDIR、EACCES…):绝不能把裸异常抛给调用方
78
- // (救急台会因此吐一段 Node 原始 JSON 500 —— 验收 F3)。如实报错并返回空扫描结果。
90
+ // (救急台会因此吐一段 Node 原始 JSON 500 —— 验收 F3)。如实报错并标记「根不可读」。
79
91
  const code = (error as { code?: string }).code;
80
92
  io.error('会话根读不出来 / sessions root not readable(' + String(code ?? 'unknown') + '): ' + sessionsRoot);
81
93
  io.error(error instanceof Error ? error.message : String(error));
82
- return out;
94
+ return { sessions: out, rootReadable: false };
83
95
  }
84
96
  for (const entry of projectDirs) {
85
97
  if (!entry.isDirectory()) continue;
@@ -112,7 +124,7 @@ async function scanSessions(sessionsRoot: string, io: SessionsRepairIo): Promise
112
124
  });
113
125
  }
114
126
  }
115
- return out;
127
+ return { sessions: out, rootReadable: true };
116
128
  }
117
129
 
118
130
  /** 隔离目录:同一轮修复一个时间戳目录;**只搬不删**(用户可自行处理)。 */
@@ -137,8 +149,22 @@ export async function runSessionsRepair(options: SessionsRepairOptions, io: Sess
137
149
  for (const error of parsed.errors) io.error(error);
138
150
  if (parsed.errors.length > 0) return 1;
139
151
 
140
- const scanned = await scanSessions(sessionsRoot, io);
141
- const plan = planSessionRepair(scanned, {
152
+ const scan = await scanSessions(sessionsRoot, io);
153
+ if (!scan.rootReadable) {
154
+ // 「读不到」≠「没有会话」:根存在但读不出来时,空扫描结果只说明**扫描失败** ——
155
+ // 绝不能给出「无需修复」+ exit 0(那等于把「读不到」说成「没问题」),--json 也不得回 ok:true。
156
+ const message = '会话根存在但读不出来:读不到 ≠ 没有会话 / sessions root exists but is unreadable; '
157
+ + 'unreadable is not the same as no sessions: ' + sessionsRoot;
158
+ io.error(message);
159
+ if (options.json) {
160
+ io.log(JSON.stringify({
161
+ ok: false, rootReadable: false, dryRun: options.fix !== true,
162
+ home: options.home, root: sessionsRoot, error: message,
163
+ }, null, 2));
164
+ }
165
+ return 1;
166
+ }
167
+ const plan = planSessionRepair(scan.sessions, {
142
168
  mappings: parsed.mappings,
143
169
  ...(options.keep !== undefined ? { keep: options.keep } : {}),
144
170
  });