dsh-config-manager 0.1.78 → 0.1.79

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 (290) hide show
  1. package/lib/adapters/credentials.d.ts +6 -2
  2. package/lib/adapters/credentials.js +19 -3
  3. package/lib/adapters/file-collection.js +2 -1
  4. package/lib/adapters/mcp.d.ts +48 -2
  5. package/lib/adapters/mcp.js +265 -3
  6. package/lib/adapters/messages.d.ts +3 -0
  7. package/lib/adapters/messages.js +7 -0
  8. package/lib/adapters/plugin-files.js +2 -1
  9. package/lib/adapters/plugins.js +105 -20
  10. package/lib/adapters/pnpm-workspace.d.ts +32 -0
  11. package/lib/adapters/pnpm-workspace.js +113 -2
  12. package/lib/adapters/prompts.d.ts +6 -2
  13. package/lib/adapters/prompts.js +17 -2
  14. package/lib/adapters/providers.d.ts +19 -2
  15. package/lib/adapters/providers.js +59 -3
  16. package/lib/adapters/settings.d.ts +6 -2
  17. package/lib/adapters/settings.js +13 -2
  18. package/lib/adapters/skills.d.ts +25 -1
  19. package/lib/adapters/skills.js +60 -0
  20. package/lib/adapters/ui.d.ts +6 -2
  21. package/lib/adapters/ui.js +13 -2
  22. package/lib/adapters/units.d.ts +18 -0
  23. package/lib/adapters/units.js +39 -0
  24. package/lib/adapters/workspaces.js +2 -1
  25. package/lib/client.d.ts +400 -112
  26. package/lib/client.js +2845 -1524
  27. package/lib/core/backup.js +20 -0
  28. package/lib/core/mcp-connector-store.d.ts +159 -0
  29. package/lib/core/mcp-connector-store.js +445 -0
  30. package/lib/core/messages-core.d.ts +2 -0
  31. package/lib/core/messages-core.js +4 -0
  32. package/lib/core/messages.d.ts +20 -0
  33. package/lib/core/messages.js +44 -0
  34. package/lib/core/phase3-host.js +18 -1
  35. package/lib/core/plugin-cli.d.ts +22 -2
  36. package/lib/core/plugin-cli.js +22 -3
  37. package/lib/core/recovery-orchestrator.d.ts +3 -1
  38. package/lib/core/recovery-orchestrator.js +75 -16
  39. package/lib/core/rollback-activity.d.ts +89 -0
  40. package/lib/core/rollback-activity.js +350 -0
  41. package/lib/core/rollback-ledger.d.ts +0 -9
  42. package/lib/core/rollback-ledger.js +37 -0
  43. package/lib/core/rollback-merge.d.ts +22 -2
  44. package/lib/core/rollback-merge.js +63 -13
  45. package/lib/core/rollback-point.d.ts +93 -0
  46. package/lib/core/rollback-point.js +420 -101
  47. package/lib/core/rollback-undo-journal.d.ts +123 -0
  48. package/lib/core/rollback-undo-journal.js +517 -0
  49. package/lib/core/self-update.js +2 -4
  50. package/lib/core/text-fidelity.d.ts +89 -0
  51. package/lib/core/text-fidelity.js +176 -0
  52. package/lib/core/types.d.ts +12 -3
  53. package/lib/core/update-check.js +2 -4
  54. package/lib/core/validator.d.ts +9 -0
  55. package/lib/core/validator.js +12 -0
  56. package/lib/foreign/bundle.js +42 -3
  57. package/lib/foreign/claude-code.js +358 -26
  58. package/lib/foreign/claude-sessions.d.ts +51 -8
  59. package/lib/foreign/claude-sessions.js +111 -13
  60. package/lib/foreign/read-claude-code.d.ts +33 -4
  61. package/lib/foreign/read-claude-code.js +446 -65
  62. package/lib/foreign/read-claude-plugins.d.ts +36 -0
  63. package/lib/foreign/read-claude-plugins.js +403 -0
  64. package/lib/foreign/registry.js +25 -2
  65. package/lib/foreign/truth-table.js +6 -4
  66. package/lib/foreign/types.d.ts +170 -2
  67. package/lib/index.d.ts +14 -5
  68. package/lib/index.js +60 -16
  69. package/lib/market/git-file-writer.js +7 -1
  70. package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
  71. package/lib/profiles/dsh-profile-launcher.js +58 -1
  72. package/lib/profiles/dsh-profile-shared.d.ts +8 -2
  73. package/lib/profiles/dsh-profile-shared.js +8 -2
  74. package/lib/routes/foreign.js +8 -1
  75. package/lib/routes/index.d.ts +1 -1
  76. package/lib/routes/kit.d.ts +2 -2
  77. package/lib/routes/sync.js +173 -9
  78. package/lib/schema/manifest.d.ts +6 -1
  79. package/lib/schema/manifest.js +55 -0
  80. package/lib/schema/types.d.ts +47 -2
  81. package/lib/sync/autosync-config.d.ts +73 -0
  82. package/lib/sync/autosync-config.js +89 -0
  83. package/lib/sync/autosync-scheduler.d.ts +80 -6
  84. package/lib/sync/autosync-scheduler.js +263 -40
  85. package/lib/sync/gist/gist-transport.d.ts +15 -1
  86. package/lib/sync/gist/gist-transport.js +66 -5
  87. package/lib/sync/gist/messages.d.ts +3 -0
  88. package/lib/sync/gist/messages.js +7 -0
  89. package/lib/sync/git/git-transport.d.ts +42 -3
  90. package/lib/sync/git/git-transport.js +146 -3
  91. package/lib/sync/merge.d.ts +47 -14
  92. package/lib/sync/merge.js +242 -222
  93. package/lib/sync/messages.d.ts +8 -0
  94. package/lib/sync/messages.js +19 -0
  95. package/lib/sync/risk.d.ts +19 -2
  96. package/lib/sync/risk.js +10 -3
  97. package/lib/sync/s3/messages.d.ts +6 -0
  98. package/lib/sync/s3/messages.js +13 -0
  99. package/lib/sync/s3/s3-transport.d.ts +9 -1
  100. package/lib/sync/s3/s3-transport.js +78 -6
  101. package/lib/sync/sync-engine.d.ts +193 -4
  102. package/lib/sync/sync-engine.js +450 -37
  103. package/lib/sync/sync-history.d.ts +11 -0
  104. package/lib/sync/sync-history.js +20 -0
  105. package/lib/sync/sync-state.d.ts +43 -0
  106. package/lib/sync/sync-state.js +44 -0
  107. package/lib/sync/sync-watcher.d.ts +76 -0
  108. package/lib/sync/sync-watcher.js +202 -0
  109. package/lib/sync/text-merge.d.ts +85 -0
  110. package/lib/sync/text-merge.js +513 -0
  111. package/lib/sync/transport.d.ts +48 -0
  112. package/lib/sync/transport.js +12 -0
  113. package/lib/sync/watch-scope.d.ts +38 -0
  114. package/lib/sync/watch-scope.js +95 -0
  115. package/lib/sync/webdav/webdav-transport.d.ts +16 -2
  116. package/lib/sync/webdav/webdav-transport.js +128 -11
  117. package/lib/ui/foreign-view.js +10 -0
  118. package/lib/ui/i18n.d.ts +8 -0
  119. package/lib/ui/i18n.js +20 -0
  120. package/lib/ui/rollback-view.d.ts +12 -0
  121. package/lib/ui/rollback-view.js +2 -0
  122. package/lib/ui/sync-settings-view.d.ts +47 -0
  123. package/lib/ui/sync-settings-view.js +24 -0
  124. package/lib/ui/types.d.ts +21 -0
  125. package/lib/utils/env-lock.d.ts +1 -0
  126. package/lib/utils/env-lock.js +7 -4
  127. package/package.json +6 -6
  128. package/src/adapters/credentials.ts +22 -4
  129. package/src/adapters/file-collection.ts +2 -1
  130. package/src/adapters/mcp.test.ts +235 -1
  131. package/src/adapters/mcp.ts +276 -5
  132. package/src/adapters/messages.ts +7 -0
  133. package/src/adapters/plugin-files.ts +2 -1
  134. package/src/adapters/plugins.test.ts +176 -2
  135. package/src/adapters/plugins.ts +107 -21
  136. package/src/adapters/pnpm-workspace.test.ts +89 -1
  137. package/src/adapters/pnpm-workspace.ts +127 -2
  138. package/src/adapters/prompts.ts +20 -3
  139. package/src/adapters/providers.ts +62 -4
  140. package/src/adapters/settings.ts +16 -3
  141. package/src/adapters/skills.test.ts +141 -0
  142. package/src/adapters/skills.ts +58 -2
  143. package/src/adapters/ui.ts +16 -3
  144. package/src/adapters/units-entries.test.ts +190 -0
  145. package/src/adapters/units.ts +50 -0
  146. package/src/adapters/workspaces.ts +2 -1
  147. package/src/client/about/PluginUpdateDialog.tsx +4 -2
  148. package/src/client/about/ReleaseNotesDialog.tsx +4 -2
  149. package/src/client/chat/RollbackReviewDialog.tsx +3 -2
  150. package/src/client/common/Skeleton.tsx +32 -0
  151. package/src/client/common/routes.ts +9 -0
  152. package/src/client/config-manager.module.css +321 -11
  153. package/src/client/home/HomePanel.tsx +14 -6
  154. package/src/client/import/ForeignImportView.tsx +3 -1
  155. package/src/client/import/import-wizard-steps.tsx +2 -1
  156. package/src/client/library/ArtifactRow.tsx +12 -2
  157. package/src/client/library/LibraryActions.tsx +6 -3
  158. package/src/client/library/LibraryPanel.tsx +21 -3
  159. package/src/client/library/RestorePlanView.tsx +4 -2
  160. package/src/client/locales.ts +3 -0
  161. package/src/client/recovery/RecoveryPanel.tsx +54 -7
  162. package/src/client/recovery/RollbackCard.tsx +7 -2
  163. package/src/client/recovery/RollbackDiffPane.tsx +3 -2
  164. package/src/client/recovery/recovery-locales.ts +20 -2
  165. package/src/client/recovery/recovery-view.test.ts +39 -1
  166. package/src/client/recovery/recovery-view.ts +23 -1
  167. package/src/client/run-store.test.ts +1 -1
  168. package/src/client/run-store.ts +4 -0
  169. package/src/client/sync/AutosyncCard.tsx +49 -6
  170. package/src/client/sync/MergeModal.tsx +219 -0
  171. package/src/client/sync/MergePreviewView.tsx +306 -0
  172. package/src/client/sync/SyncActivityBar.tsx +31 -0
  173. package/src/client/sync/SyncConfirmView.tsx +2 -1
  174. package/src/client/sync/SyncPanel.tsx +130 -87
  175. package/src/client/sync/SyncProbeRow.tsx +162 -0
  176. package/src/client/sync/SyncSettingsView.test.ts +27 -2
  177. package/src/client/sync/WorkDirCard.tsx +44 -6
  178. package/src/client/sync/history-model.ts +4 -1
  179. package/src/client/sync/merge-view.test.ts +234 -0
  180. package/src/client/sync/merge-view.ts +371 -0
  181. package/src/client/sync/probe-view.test.ts +90 -0
  182. package/src/client/sync/probe-view.ts +63 -0
  183. package/src/client/sync/remote-device-view.test.ts +35 -0
  184. package/src/client/sync/remote-device-view.ts +25 -0
  185. package/src/client/sync/sync-api.ts +212 -2
  186. package/src/client/sync/sync-entry.test.ts +78 -0
  187. package/src/client/sync/sync-entry.ts +53 -0
  188. package/src/client/sync/sync-locales.ts +234 -2
  189. package/src/client/sync/sync-view.test.ts +18 -1
  190. package/src/client/sync/sync-view.ts +20 -0
  191. package/src/core/backup.test.ts +41 -0
  192. package/src/core/backup.ts +20 -0
  193. package/src/core/mcp-connector-store.test.ts +265 -0
  194. package/src/core/mcp-connector-store.ts +516 -0
  195. package/src/core/messages-core.ts +4 -0
  196. package/src/core/messages.ts +46 -0
  197. package/src/core/phase3-host.ts +18 -1
  198. package/src/core/phase3-production-integration.test.ts +4 -0
  199. package/src/core/plugin-cli.test.ts +26 -0
  200. package/src/core/plugin-cli.ts +30 -4
  201. package/src/core/recovery-orchestrator.test.ts +104 -5
  202. package/src/core/recovery-orchestrator.ts +74 -17
  203. package/src/core/rollback-activity.test.ts +311 -0
  204. package/src/core/rollback-activity.ts +367 -0
  205. package/src/core/rollback-cross-session.test.ts +694 -0
  206. package/src/core/rollback-interrupted.test.ts +137 -0
  207. package/src/core/rollback-ledger.ts +32 -0
  208. package/src/core/rollback-merge.test.ts +130 -0
  209. package/src/core/rollback-merge.ts +78 -14
  210. package/src/core/rollback-point.ts +468 -91
  211. package/src/core/rollback-undo-journal.test.ts +508 -0
  212. package/src/core/rollback-undo-journal.ts +571 -0
  213. package/src/core/self-update.ts +2 -5
  214. package/src/core/text-fidelity.test.ts +189 -0
  215. package/src/core/text-fidelity.ts +197 -0
  216. package/src/core/types.ts +11 -3
  217. package/src/core/update-check.ts +2 -5
  218. package/src/core/validator.ts +14 -0
  219. package/src/foreign/bundle-origin.test.ts +265 -0
  220. package/src/foreign/bundle.ts +45 -5
  221. package/src/foreign/claude-code.test.ts +268 -0
  222. package/src/foreign/claude-code.ts +406 -25
  223. package/src/foreign/claude-plugins.test.ts +234 -0
  224. package/src/foreign/claude-sessions.test.ts +141 -0
  225. package/src/foreign/claude-sessions.ts +179 -23
  226. package/src/foreign/file-budget.test.ts +4 -1
  227. package/src/foreign/read-claude-code.test.ts +320 -0
  228. package/src/foreign/read-claude-code.ts +490 -71
  229. package/src/foreign/read-claude-plugins.ts +465 -0
  230. package/src/foreign/registry.ts +25 -2
  231. package/src/foreign/session-ir.test.ts +65 -0
  232. package/src/foreign/truth-table.test.ts +81 -0
  233. package/src/foreign/truth-table.ts +6 -3
  234. package/src/foreign/types.ts +162 -2
  235. package/src/index.facade.test.ts +26 -4
  236. package/src/index.ts +76 -19
  237. package/src/market/git-file-writer.test.ts +4 -0
  238. package/src/market/git-file-writer.ts +7 -1
  239. package/src/profiles/dsh-profile-launcher.test.ts +68 -3
  240. package/src/profiles/dsh-profile-launcher.ts +56 -1
  241. package/src/profiles/dsh-profile-shared.ts +8 -2
  242. package/src/routes/foreign.ts +8 -1
  243. package/src/routes/index.ts +1 -1
  244. package/src/routes/kit.ts +2 -2
  245. package/src/routes/sync.ts +176 -9
  246. package/src/schema/manifest-schema.test.ts +87 -2
  247. package/src/schema/manifest.ts +57 -1
  248. package/src/schema/types.ts +45 -2
  249. package/src/sync/autosync-config.test.ts +73 -1
  250. package/src/sync/autosync-config.ts +124 -0
  251. package/src/sync/autosync-risk-tier.test.ts +38 -2
  252. package/src/sync/autosync-scheduler.test.ts +264 -12
  253. package/src/sync/autosync-scheduler.ts +296 -44
  254. package/src/sync/gist/gist-conditional-write.test.ts +223 -0
  255. package/src/sync/gist/gist-transport.ts +73 -7
  256. package/src/sync/gist/messages.ts +7 -0
  257. package/src/sync/git/git-probe.test.ts +80 -0
  258. package/src/sync/git/git-transport.test.ts +185 -0
  259. package/src/sync/git/git-transport.ts +178 -5
  260. package/src/sync/merge.test.ts +188 -55
  261. package/src/sync/merge.ts +282 -224
  262. package/src/sync/messages.ts +19 -0
  263. package/src/sync/risk.ts +29 -5
  264. package/src/sync/s3/messages.ts +13 -0
  265. package/src/sync/s3/s3-conditional-write.test.ts +330 -0
  266. package/src/sync/s3/s3-transport.ts +87 -9
  267. package/src/sync/sync-device-identity.test.ts +267 -0
  268. package/src/sync/sync-engine.test.ts +379 -0
  269. package/src/sync/sync-engine.ts +535 -36
  270. package/src/sync/sync-history.test.ts +16 -1
  271. package/src/sync/sync-history.ts +26 -0
  272. package/src/sync/sync-state.test.ts +64 -0
  273. package/src/sync/sync-state.ts +79 -0
  274. package/src/sync/sync-watcher.test.ts +197 -0
  275. package/src/sync/sync-watcher.ts +246 -0
  276. package/src/sync/text-merge.test.ts +358 -0
  277. package/src/sync/text-merge.ts +554 -0
  278. package/src/sync/transport.ts +57 -0
  279. package/src/sync/watch-scope.test.ts +122 -0
  280. package/src/sync/watch-scope.ts +120 -0
  281. package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
  282. package/src/sync/webdav/webdav-transport.ts +137 -14
  283. package/src/ui/foreign-view.test.ts +34 -0
  284. package/src/ui/foreign-view.ts +10 -0
  285. package/src/ui/i18n.ts +20 -0
  286. package/src/ui/rollback-view.ts +14 -0
  287. package/src/ui/sync-settings-view.test.ts +28 -1
  288. package/src/ui/sync-settings-view.ts +43 -0
  289. package/src/ui/types.ts +22 -0
  290. package/src/utils/env-lock.ts +8 -5
@@ -0,0 +1,571 @@
1
+ /**
2
+ * 回退前像 journal:**崩溃可检测、可证明安全重放**的前像日志。
3
+ *
4
+ * 背景(已确认缺陷):`rollback-point.ts` 的 UndoStager 把前像写成 `<dir>/N.bin`,既没有「路径 → 前像」
5
+ * 索引,也没有完成标记。进程崩溃后 `rollback-ledger.ts` 的 cleanupOrphanTempFiles 只按「pid 已死」
6
+ * 把整个 undo 目录删掉 ⇒ 工作区被留在半回退状态:**无人知晓、也无法恢复**。本模块提供修复所需的原语。
7
+ *
8
+ * 提供的原语(纯模块,不接触引擎;零运行时依赖:node: 内置 + 仓内 utils):
9
+ * - `UndoJournalWriter`:先落前像 bin、再 append 一行 JSON 索引并 fsync(顺序不可交换);
10
+ * 条目全部落盘后写 `done` 完成标记(原子 rename)。目录形态沿用既有实现
11
+ * `<root>/undo/<pid>-<ts36>-<8hex>/`。
12
+ * - `readUndoJournal`:读回快照;**尾行被截断**(崩在 append 中途)绝不抛异常,只置 `truncatedTail`。
13
+ * - `findInterruptedUndos`:只认「目录名形态 + 有 journal + 无 done + pid 已死」的中断残留。
14
+ * - `replayUndoJournal`:**逆序**写回,且只有当目标**当前内容确实等于**该条记录记录的 after 镜像时
15
+ * 才写(删除动作 ⇒ 当前必须不存在);否则计入 `skipped` —— 崩溃期间被用户/别的进程改过的文件
16
+ * **绝不覆盖**。
17
+ *
18
+ * 崩溃安全为什么成立(写入协议的排序理由):
19
+ * 1) 先写 bin、再记索引 ⇒ 崩在两者之间只留一个**未被任何索引引用**的 bin,重放按索引工作,不受影响;
20
+ * 2) 每条索引 = 一次 append + fsync ⇒ 崩在写行中途只可能伤到最后一行,前面的行逐字完整;
21
+ * 3) 完成后写 done ⇒「有 journal 无 done + pid 已死」就是「被中断」的信号。
22
+ *
23
+ * 所有返回给调用方的失败/跳过都是**逐条**文本 `<path>: <reason>`,绝不静默丢弃。
24
+ */
25
+ import fs from 'node:fs/promises'
26
+ import type { FileHandle } from 'node:fs/promises'
27
+ import path from 'node:path'
28
+
29
+ import { atomicWriteFile } from '../utils/atomic-write.ts'
30
+ import { isENOENT, isRecord } from '../utils/guards.ts'
31
+ import { sha256Hex } from '../utils/hashing.ts'
32
+
33
+ /* ---------------------------------------------------------------------- 常量 */
34
+
35
+ /** 索引文件名:JSONL,一行一条记录 */
36
+ export const UNDO_JOURNAL_FILE_NAME = 'journal.jsonl'
37
+ /** 完成标记文件名(原子写入);存在 = 这次回退的前像已**完整**落盘 */
38
+ export const UNDO_DONE_FILE_NAME = 'done'
39
+
40
+ /**
41
+ * 前像暂存子目录名(与 `rollback-ledger.ts` 的 `ROLLBACK_UNDO_DIR_NAME` 同值)。
42
+ * 刻意**不 import**:ledger 将来很可能反过来 import 本模块(清理前先查 journal),互相 import 会成环。
43
+ */
44
+ const UNDO_SUBDIR_NAME = 'undo'
45
+ /**
46
+ * 目录名形态 `<pid>-<ts36>-<8hex>`(与 rollback-ledger.ts 的 UNDO_DIR_RE 同源,**改动必须两处同步**)。
47
+ * pid 必须在最前:中断判定要读它。
48
+ */
49
+ const UNDO_DIR_RE = /^(\d+)-[0-9a-z]+-[0-9a-f]{8}$/
50
+ /** 前像文件名形态:只允许 `<seq>.bin`(防篡改 journal 把读引到目录外) */
51
+ const UNDO_BIN_RE = /^\d+\.bin$/
52
+ /** 找最后一个换行符时的反向分块大小(截断残缺尾行用;内存有界) */
53
+ const SCAN_CHUNK_BYTES = 64 * 1024
54
+
55
+ /* ---------------------------------------------------------------------- 类型 */
56
+
57
+ /** journal 里一条记录:索引(path/after 镜像)+ 前像引用(bin/bytes/sha256) */
58
+ export interface UndoJournalEntry {
59
+ /** 本进程内的记录序号(0 起,一次 record 一条;bin 名 = `<seq>.bin`) */
60
+ seq: number
61
+ /** 回退动作作用的**绝对**路径 */
62
+ path: string
63
+ /** 前像文件名(相对 undo 目录);null = 回退前该路径不存在 */
64
+ bin: string | null
65
+ /** 前像字节数 */
66
+ bytes: number
67
+ /** 前像 sha256;bin === null 时恒 null */
68
+ sha256: string | null
69
+ /**
70
+ * **回退动作写入**的内容 sha256(删除动作为 null)。
71
+ * 重放的唯一安全判据:当前内容 sha256 === 该值才敢写回。
72
+ */
73
+ afterSha256: string | null
74
+ /** true = 该路径被回退动作删除(重放要求「当前仍不存在」) */
75
+ afterDeleted: boolean
76
+ /** 记录时间戳(注入的 now()) */
77
+ at: number
78
+ }
79
+
80
+ export interface UndoJournalSnapshot {
81
+ entries: UndoJournalEntry[]
82
+ /** true = 文件最后一段是崩溃写了一半的行(已丢弃,绝不因此抛错) */
83
+ truncatedTail: boolean
84
+ }
85
+
86
+ export interface InterruptedUndo {
87
+ dir: string
88
+ pid: number
89
+ entries: UndoJournalEntry[]
90
+ truncatedTail: boolean
91
+ }
92
+
93
+ /** 回退动作的 after 镜像:写入了某内容 / 删除了该路径 */
94
+ export type UndoAfter = { sha256: string } | { deleted: true }
95
+
96
+ export interface ReplayHooks {
97
+ /** 读当前内容;返回 null = 该路径当前不存在(缺省用 fs.readFile,ENOENT 视为 null) */
98
+ readCurrent?: (target: string) => Promise<Uint8Array | null>
99
+ /** 写回前像 */
100
+ write: (target: string, bytes: Uint8Array) => Promise<void>
101
+ }
102
+
103
+ export interface UndoJournalWriterOptions {
104
+ /** 时间戳来源(测试可注入;缺省 Date.now) */
105
+ now?: () => number
106
+ }
107
+
108
+ /* --------------------------------------------------------------- 写入端 */
109
+
110
+ /**
111
+ * 前像 journal 写入端。
112
+ *
113
+ * 生命周期:`create()` → 逐条 `record()` →(全部落盘)`finish()` 写完成标记 → `dispose()` 关句柄。
114
+ * 中断(未 finish)留在盘上的目录就是崩溃证据;调用方用 `findInterruptedUndos` + `replayUndoJournal` 收尾。
115
+ */
116
+ export class UndoJournalWriter {
117
+ /** 前像目录(`<root>/undo/<pid>-<ts36>-<8hex>`) */
118
+ readonly dir: string
119
+ private readonly journalPath: string
120
+ private readonly now: () => number
121
+ private handle: FileHandle | null
122
+ private seq = 0
123
+ /** 已成功 fsync 的 journal 字节数:写失败时回截到这里,保证不留半行 */
124
+ private committedBytes = 0
125
+ private disposed = false
126
+
127
+ private constructor(
128
+ dir: string,
129
+ journalPath: string,
130
+ handle: FileHandle,
131
+ committedBytes: number,
132
+ seq: number,
133
+ now: () => number,
134
+ ) {
135
+ this.dir = dir
136
+ this.journalPath = journalPath
137
+ this.handle = handle
138
+ this.committedBytes = committedBytes
139
+ this.seq = seq
140
+ this.now = now
141
+ }
142
+
143
+ /**
144
+ * 打开(或续写)一个前像目录。目录建不出来 / journal 打不开 ⇒ 抛错,调用方必须**零写入失败**。
145
+ */
146
+ static async create(dir: string, options: UndoJournalWriterOptions = {}): Promise<UndoJournalWriter> {
147
+ await fs.mkdir(dir, { recursive: true })
148
+ const journalPath = path.join(dir, UNDO_JOURNAL_FILE_NAME)
149
+ // 续写前先截掉上次崩溃留下的半行:否则新行会接在残句后面,把「残句 + 新行」粘成一行垃圾
150
+ await trimTornJournalTail(journalPath)
151
+ // 续写时序号必须接在已有条目之后,否则 bin 名会重号、覆盖上一条前像
152
+ let seq = 0
153
+ const existing = await readUndoJournal(dir)
154
+ if (existing !== null) {
155
+ for (const entry of existing.entries) seq = Math.max(seq, entry.seq + 1)
156
+ }
157
+ let committedBytes = 0
158
+ try {
159
+ committedBytes = (await fs.stat(journalPath)).size
160
+ } catch {
161
+ committedBytes = 0
162
+ }
163
+ const handle = await fs.open(journalPath, 'a+')
164
+ return new UndoJournalWriter(dir, journalPath, handle, committedBytes, seq, options.now ?? Date.now)
165
+ }
166
+
167
+ /**
168
+ * 记一条:先落前像 bin、再 append 索引行并 fsync。
169
+ *
170
+ * @param before 前像字节;null = 回退前该路径**不存在**
171
+ * @param after 回退动作的 after 镜像(写入了什么 / 删除了)
172
+ */
173
+ async record(targetPath: string, before: Uint8Array | null, after: UndoAfter): Promise<void> {
174
+ const handle = this.requireHandle()
175
+ // 路径在**记录那一刻**就必须绝对可解析:宁可当场失败,也不写入一条重放时无法安全落地的索引
176
+ if (!isJournalablePath(targetPath)) {
177
+ throw new Error('undo journal path must be absolute and non-empty: ' + JSON.stringify(targetPath))
178
+ }
179
+ const mirror = normalizeAfter(after)
180
+ const seq = this.seq
181
+ let bin: string | null = null
182
+ let bytes = 0
183
+ let sha256: string | null = null
184
+ if (before !== null) {
185
+ bin = String(seq) + '.bin'
186
+ bytes = before.byteLength
187
+ sha256 = sha256Hex(before)
188
+ // 顺序不可交换:bin 失败 ⇒ 一行索引都不写(journal 仍是此前完整的旧内容)
189
+ await fs.writeFile(path.join(this.dir, bin), before)
190
+ }
191
+ const line = JSON.stringify({
192
+ seq,
193
+ path: targetPath,
194
+ bin,
195
+ bytes,
196
+ sha256,
197
+ afterSha256: mirror.afterSha256,
198
+ afterDeleted: mirror.afterDeleted,
199
+ at: this.now(),
200
+ }) + '\n'
201
+ const prior = this.committedBytes
202
+ try {
203
+ await handle.write(line)
204
+ await handle.sync()
205
+ } catch (error) {
206
+ // 写盘失败(磁盘满 / 句柄坏)⇒ 回截到写入前长度,**绝不留半行 JSON**;回截失败也不掩盖原始错误
207
+ await this.truncateTo(prior)
208
+ throw error
209
+ }
210
+ this.committedBytes = prior + Buffer.byteLength(line, 'utf8')
211
+ this.seq = seq + 1
212
+ }
213
+
214
+ /** 条目全部落盘后写 done 标记(原子 rename):标记存在 = 这份前像完整,崩溃清理不得当残留删掉。 */
215
+ async finish(): Promise<void> {
216
+ this.requireHandle()
217
+ const marker = JSON.stringify({ pid: process.pid, finishedAt: this.now() }) + '\n'
218
+ await atomicWriteFile(path.join(this.dir, UNDO_DONE_FILE_NAME), marker)
219
+ }
220
+
221
+ /** 关句柄(幂等 + best-effort);**不删目录** —— 删不删由调用方按 done/重放结果决定。 */
222
+ async dispose(): Promise<void> {
223
+ if (this.disposed) return
224
+ this.disposed = true
225
+ const handle = this.handle
226
+ this.handle = null
227
+ if (handle !== null) {
228
+ try {
229
+ await handle.close()
230
+ } catch {
231
+ /* best-effort:句柄已经坏了也不该把调用方带崩 */
232
+ }
233
+ }
234
+ }
235
+
236
+ private requireHandle(): FileHandle {
237
+ if (this.disposed || this.handle === null) {
238
+ throw new Error('undo journal writer already disposed: ' + this.journalPath)
239
+ }
240
+ return this.handle
241
+ }
242
+
243
+ /** 回截到 length(best-effort):只用于「append + fsync 失败」后的自愈,绝不掩盖原始写错误。 */
244
+ private async truncateTo(length: number): Promise<void> {
245
+ const handle = this.handle
246
+ if (handle === null) return
247
+ try {
248
+ await handle.truncate(length)
249
+ await handle.sync()
250
+ } catch {
251
+ /* best-effort */
252
+ }
253
+ }
254
+ }
255
+
256
+ /* --------------------------------------------------------------- 读取端 */
257
+
258
+ /**
259
+ * 读回 journal 快照。`null` = 该目录**本来就没有** journal(ENOENT)。
260
+ *
261
+ * 崩溃容忍:文件最后一段(可能是写了一半的行)解析失败 ⇒ 丢弃它、`truncatedTail = true`,
262
+ * **绝不抛异常**。中间段坏掉不是 append 能造成的形状(篡改/损坏)⇒ 跳过且不算截断。
263
+ */
264
+ export async function readUndoJournal(dir: string): Promise<UndoJournalSnapshot | null> {
265
+ const journalPath = path.join(dir, UNDO_JOURNAL_FILE_NAME)
266
+ let text: string
267
+ try {
268
+ text = await fs.readFile(journalPath, 'utf8')
269
+ } catch (error) {
270
+ // 只有「本来就没有 journal」才是 null;读不出来(EACCES/EISDIR…)如实抛,绝不假装「没有中断回退」
271
+ if (isENOENT(error)) return null
272
+ throw error
273
+ }
274
+ const segments = text.split('\n')
275
+ const entries: UndoJournalEntry[] = []
276
+ let truncatedTail = false
277
+ for (let i = 0; i < segments.length; i += 1) {
278
+ const raw = segments[i] ?? ''
279
+ if (raw === '') continue
280
+ let parsed: unknown
281
+ try {
282
+ parsed = JSON.parse(raw)
283
+ } catch {
284
+ if (i === segments.length - 1) truncatedTail = true
285
+ continue
286
+ }
287
+ const entry = asJournalEntry(parsed)
288
+ if (entry !== null) entries.push(entry)
289
+ }
290
+ return { entries, truncatedTail }
291
+ }
292
+
293
+ /**
294
+ * 找中断的回退前像目录。
295
+ *
296
+ * @param root 既可以是**暂存根目录**(其直接子目录就是 `<pid>-<ts36>-<8hex>`),
297
+ * 也可以是上层 dataRoot(此时会自动再看 `<root>/undo`);两种调用口径都支持。
298
+ * @param isAlive 进程存活探测(可注入,便于测试);缺省 `process.kill(pid, 0)`。
299
+ *
300
+ * 判据(严格):目录名形态匹配 + **有 journal** + **无 done** + **pid 已死**。
301
+ * 读不出 journal 的目录跳过(判不准就不报,绝不猜);结果按 dir 升序稳定排序。
302
+ */
303
+ export async function findInterruptedUndos(
304
+ root: string,
305
+ isAlive: (pid: number) => boolean = defaultIsProcessAlive,
306
+ ): Promise<InterruptedUndo[]> {
307
+ const bases: string[] = [root]
308
+ const nested = path.join(root, UNDO_SUBDIR_NAME)
309
+ if (path.resolve(nested) !== path.resolve(root)) bases.push(nested)
310
+ const seen = new Set<string>()
311
+ const out: InterruptedUndo[] = []
312
+ for (const base of bases) {
313
+ let names: string[]
314
+ try {
315
+ names = await fs.readdir(base)
316
+ } catch {
317
+ continue
318
+ }
319
+ for (const name of names) {
320
+ const m = UNDO_DIR_RE.exec(name)
321
+ if (m === null) continue
322
+ const pid = Number(m[1])
323
+ if (!Number.isSafeInteger(pid) || pid <= 0) continue
324
+ if (isAlive(pid)) continue
325
+ const dir = path.join(base, name)
326
+ if (seen.has(dir)) continue
327
+ const snapshot = await readJournalSafe(dir)
328
+ if (snapshot === null) continue
329
+ if (await pathExists(path.join(dir, UNDO_DONE_FILE_NAME))) continue
330
+ seen.add(dir)
331
+ out.push({ dir, pid, entries: snapshot.entries, truncatedTail: snapshot.truncatedTail })
332
+ }
333
+ }
334
+ out.sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0))
335
+ return out
336
+ }
337
+
338
+ /* --------------------------------------------------------------- 重放端 */
339
+
340
+ /**
341
+ * 重放一份中断的前像(**逆序**:同一路径被记两次时,最后写的是最早状态)。
342
+ *
343
+ * 安全判据(**没有证明就绝不写盘**):
344
+ * - `afterDeleted` ⇒ 目标当前必须不存在(`readCurrent` 返回 null),否则 skipped;
345
+ * - 否则 `sha256(current)` 必须 === `afterSha256`,否则 skipped;
346
+ * - after 信息缺失/不可判(老行/损坏行,`afterSha256 === null`)⇒ skipped。
347
+ *
348
+ * 失败一律逐条进 `failed`(绝不静默):`unsafe-path` / `unsafe-bin` / `before-checksum-mismatch` /
349
+ * `read-current-failed: …` / 写入或删除的真实错误。读当前态失败属**保守失败**(该条不写)。
350
+ */
351
+ export async function replayUndoJournal(
352
+ undo: InterruptedUndo,
353
+ hooks: ReplayHooks,
354
+ ): Promise<{ restored: number; skipped: string[]; failed: string[] }> {
355
+ let restored = 0
356
+ const skipped: string[] = []
357
+ const failed: string[] = []
358
+ const readCurrent = hooks.readCurrent ?? readCurrentDefault
359
+ for (const entry of [...undo.entries].reverse()) {
360
+ const target = safeReplayTarget(entry.path)
361
+ if (target === null) {
362
+ failed.push(entry.path + ': unsafe-path')
363
+ continue
364
+ }
365
+ let current: Uint8Array | null
366
+ try {
367
+ current = await readCurrent(target)
368
+ } catch (error) {
369
+ failed.push(target + ': read-current-failed: ' + errorMessage(error))
370
+ continue
371
+ }
372
+ const blocked = afterStateMismatch(entry, current)
373
+ if (blocked !== null) {
374
+ skipped.push(target + ': ' + blocked)
375
+ continue
376
+ }
377
+ try {
378
+ if (entry.bin === null) {
379
+ // 前像 = 「回退前不存在」⇒ 回退动作就是把它删掉(force:已经不在了也算成功)
380
+ await fs.rm(target, { force: true })
381
+ restored += 1
382
+ continue
383
+ }
384
+ const binPath = safeBinPath(undo.dir, entry.bin)
385
+ if (binPath === null) {
386
+ failed.push(target + ': unsafe-bin')
387
+ continue
388
+ }
389
+ const bytes = await fs.readFile(binPath)
390
+ if (entry.sha256 !== null && sha256Hex(bytes) !== entry.sha256) {
391
+ failed.push(target + ': before-checksum-mismatch')
392
+ continue
393
+ }
394
+ await hooks.write(target, bytes)
395
+ restored += 1
396
+ } catch (error) {
397
+ failed.push(target + ': ' + errorMessage(error))
398
+ }
399
+ }
400
+ return { restored, skipped, failed }
401
+ }
402
+
403
+ /* --------------------------------------------------------------- 内部 */
404
+
405
+ /** 与记录的 after 镜像比对:返回 null = 当前状态一致,可以安全写回;否则返回跳过原因。 */
406
+ function afterStateMismatch(entry: UndoJournalEntry, current: Uint8Array | null): string | null {
407
+ if (entry.afterDeleted) {
408
+ return current === null ? null : 'after-state-changed: expected-absent'
409
+ }
410
+ if (entry.afterSha256 === null) {
411
+ // 无证明(老行/损坏行):宁可留下可人工处理的中断,也不覆盖用户后来的修改
412
+ return 'after-state-unknown'
413
+ }
414
+ if (current === null) return 'after-state-changed: missing'
415
+ if (sha256Hex(current) !== entry.afterSha256) return 'after-state-changed: sha256-mismatch'
416
+ return null
417
+ }
418
+
419
+ function asJournalEntry(value: unknown): UndoJournalEntry | null {
420
+ if (!isRecord(value)) return null
421
+ const seq = value['seq']
422
+ const target = value['path']
423
+ const bin = value['bin']
424
+ const bytes = value['bytes']
425
+ const sha = value['sha256']
426
+ if (typeof seq !== 'number' || !Number.isInteger(seq) || seq < 0) return null
427
+ if (typeof target !== 'string' || target === '') return null
428
+ if (bin !== null && typeof bin !== 'string') return null
429
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) return null
430
+ if (sha !== null && typeof sha !== 'string') return null
431
+ const afterDeleted = value['afterDeleted'] === true
432
+ const afterRaw = value['afterSha256']
433
+ const afterSha256 = !afterDeleted && typeof afterRaw === 'string' && afterRaw !== '' ? afterRaw : null
434
+ const at = value['at']
435
+ return {
436
+ seq,
437
+ path: target,
438
+ bin,
439
+ bytes,
440
+ sha256: sha,
441
+ afterSha256,
442
+ afterDeleted,
443
+ // 缺/坏时间戳归一为 0:**不影响安全判据**(重放只看 after 镜像),只作诊断
444
+ at: typeof at === 'number' && Number.isFinite(at) && at >= 0 ? at : 0,
445
+ }
446
+ }
447
+
448
+ /** 记录期路径校验:绝对 + 非空 + 无 NUL(否则重放时无法安全落地) */
449
+ function isJournalablePath(raw: string): boolean {
450
+ return raw !== '' && !raw.includes('\0') && path.isAbsolute(raw)
451
+ }
452
+
453
+ /**
454
+ * 重放期路径校验:必须绝对且可解析。相对路径会落到进程 CWD(越权)⇒ 拒绝;
455
+ * 归一化后的绝对路径(`path.resolve`)是实际写入目标。
456
+ */
457
+ function safeReplayTarget(raw: string): string | null {
458
+ if (raw === '' || raw.includes('\0')) return null
459
+ if (!path.isAbsolute(raw)) return null
460
+ return path.resolve(raw)
461
+ }
462
+
463
+ /** 前像文件名必须在 undo 目录内且形态为 `<seq>.bin`(防篡改 journal 读目录外文件) */
464
+ function safeBinPath(dir: string, bin: string): string | null {
465
+ if (!UNDO_BIN_RE.test(bin)) return null
466
+ const base = path.resolve(dir)
467
+ const resolved = path.resolve(base, bin)
468
+ return resolved.startsWith(base + path.sep) ? resolved : null
469
+ }
470
+
471
+ function isDeletedAfter(after: UndoAfter): after is { deleted: true } {
472
+ return (after as { deleted?: unknown }).deleted === true
473
+ }
474
+
475
+ function normalizeAfter(after: UndoAfter): { afterDeleted: boolean; afterSha256: string | null } {
476
+ if (isDeletedAfter(after)) return { afterDeleted: true, afterSha256: null }
477
+ const digest: unknown = (after as { sha256?: unknown }).sha256
478
+ if (typeof digest !== 'string' || digest === '') {
479
+ throw new Error('undo journal after image must be { sha256 } or { deleted: true }')
480
+ }
481
+ return { afterDeleted: false, afterSha256: digest }
482
+ }
483
+
484
+ function defaultIsProcessAlive(pid: number): boolean {
485
+ // 判不出来一律当「活着」:宁可留垃圾,也绝不删除/覆盖用户数据
486
+ if (!Number.isInteger(pid) || pid <= 0) return true
487
+ try {
488
+ process.kill(pid, 0)
489
+ return true
490
+ } catch (error) {
491
+ // EPERM = 进程存在但没权限发信号(Windows 上很常见)⇒ 仍然算活着
492
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
493
+ }
494
+ }
495
+
496
+ async function readJournalSafe(dir: string): Promise<UndoJournalSnapshot | null> {
497
+ try {
498
+ return await readUndoJournal(dir)
499
+ } catch {
500
+ return null
501
+ }
502
+ }
503
+
504
+ async function readCurrentDefault(target: string): Promise<Uint8Array | null> {
505
+ try {
506
+ return await fs.readFile(target)
507
+ } catch (error) {
508
+ if (isENOENT(error)) return null
509
+ throw error
510
+ }
511
+ }
512
+
513
+ async function pathExists(target: string): Promise<boolean> {
514
+ try {
515
+ await fs.stat(target)
516
+ return true
517
+ } catch {
518
+ return false
519
+ }
520
+ }
521
+
522
+ /**
523
+ * 截掉 journal 末尾**不完整的那一行**(上次崩溃残留)。
524
+ * 末尾是完整行(以 '\n' 收尾)/ 文件不存在 ⇒ 零写入;找不到任何换行 ⇒ 清零。
525
+ */
526
+ async function trimTornJournalTail(file: string): Promise<void> {
527
+ let size = 0
528
+ try {
529
+ size = (await fs.stat(file)).size
530
+ } catch {
531
+ return
532
+ }
533
+ if (size === 0) return
534
+ const handle = await fs.open(file, 'r+')
535
+ try {
536
+ if (await endsWithNewline(handle, size)) return
537
+ const cut = await lastNewlineOffset(handle, size)
538
+ await handle.truncate(cut)
539
+ await handle.sync()
540
+ } finally {
541
+ await handle.close()
542
+ }
543
+ }
544
+
545
+ async function endsWithNewline(handle: FileHandle, size: number): Promise<boolean> {
546
+ const last = Buffer.alloc(1)
547
+ await handle.read(last, 0, 1, size - 1)
548
+ return last[0] === 0x0a
549
+ }
550
+
551
+ /** 反向分块找最后一个 '\n',返回其**之后**的偏移(无换行则 0);内存与文件大小无关 */
552
+ async function lastNewlineOffset(handle: FileHandle, size: number): Promise<number> {
553
+ let end = size
554
+ while (end > 0) {
555
+ const start = Math.max(0, end - SCAN_CHUNK_BYTES)
556
+ const buf = Buffer.allocUnsafe(end - start)
557
+ await handle.read(buf, 0, buf.length, start)
558
+ const idx = buf.lastIndexOf(0x0a)
559
+ if (idx >= 0) return start + idx + 1
560
+ end = start
561
+ }
562
+ return 0
563
+ }
564
+
565
+ function errorMessage(error: unknown): string {
566
+ if (error instanceof Error) {
567
+ const code = (error as NodeJS.ErrnoException).code
568
+ return typeof code === 'string' && code !== '' ? code + ' ' + error.message : error.message
569
+ }
570
+ return String(error)
571
+ }
@@ -17,7 +17,7 @@
17
17
  * ⑥ 失败必须如实:exitCode≠0 / 超时 / spawn 失败 → 复用 installErrorFor 的可操作诊断;
18
18
  * 成功也只表示**文件已换新**,必须重启 DSH 才生效(调用方回 needsRestart)。
19
19
  */
20
- import { compareVersionStrings } from './validator.ts'
20
+ import { compareVersionStrings, isConcreteVersion } from './validator.ts'
21
21
  import { installErrorFor } from './plugin-cli.ts'
22
22
  import type { DshPluginResult } from './plugin-cli.ts'
23
23
  import { PLUGIN_NPM_PACKAGE } from './update-check.ts'
@@ -28,9 +28,6 @@ export function selfUpdateSpec(version: string): string {
28
28
  return `${PLUGIN_NPM_PACKAGE}@${version}`
29
29
  }
30
30
 
31
- /** semver 形态判定(与 update-check 同一形态:允许预发布/构建后缀,不允许多余字符)。 */
32
- const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
33
-
34
31
  /** 非 registry 来源的依赖 spec 前缀(与 core/plugin-cli.ts 的安装语义同一口径)。 */
35
32
  const NON_REGISTRY_SPEC = /^(github:|gitlab:|bitbucket:|git\+|file:|link:|workspace:|https?:)/i
36
33
 
@@ -61,7 +58,7 @@ export function planSelfUpdate(input: SelfUpdatePlanInput): SelfUpdatePlan {
61
58
  return { ok: false, code: 'unsupported-profile', error: 'the desktop profile is managed by the desktop app' }
62
59
  }
63
60
  const target = (input.target ?? '').trim()
64
- if (!SEMVER_RE.test(target)) {
61
+ if (!isConcreteVersion(target)) {
65
62
  return { ok: false, code: 'invalid-version', error: `not a valid version: ${target === '' ? '(empty)' : target}` }
66
63
  }
67
64
  if (compareVersionStrings(target, input.current) <= 0) {