@logictan/dsh-config-manager 0.1.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (429) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/cordis.patch.yml +21 -0
  4. package/lib/adapters/agent-instructions.d.ts +13 -0
  5. package/lib/adapters/agent-instructions.js +46 -0
  6. package/lib/adapters/agent-presets.d.ts +13 -0
  7. package/lib/adapters/agent-presets.js +14 -0
  8. package/lib/adapters/credentials.d.ts +37 -0
  9. package/lib/adapters/credentials.js +122 -0
  10. package/lib/adapters/file-collection.d.ts +15 -0
  11. package/lib/adapters/file-collection.js +127 -0
  12. package/lib/adapters/index.d.ts +57 -0
  13. package/lib/adapters/index.js +55 -0
  14. package/lib/adapters/link-report.d.ts +16 -0
  15. package/lib/adapters/link-report.js +47 -0
  16. package/lib/adapters/mcp.d.ts +28 -0
  17. package/lib/adapters/mcp.js +178 -0
  18. package/lib/adapters/plugin-files.d.ts +18 -0
  19. package/lib/adapters/plugin-files.js +142 -0
  20. package/lib/adapters/plugins.d.ts +41 -0
  21. package/lib/adapters/plugins.js +603 -0
  22. package/lib/adapters/pnpm-workspace.d.ts +26 -0
  23. package/lib/adapters/pnpm-workspace.js +147 -0
  24. package/lib/adapters/prompts.d.ts +30 -0
  25. package/lib/adapters/prompts.js +231 -0
  26. package/lib/adapters/providers.d.ts +34 -0
  27. package/lib/adapters/providers.js +155 -0
  28. package/lib/adapters/self.d.ts +17 -0
  29. package/lib/adapters/self.js +71 -0
  30. package/lib/adapters/sessions.d.ts +14 -0
  31. package/lib/adapters/sessions.js +15 -0
  32. package/lib/adapters/settings.d.ts +28 -0
  33. package/lib/adapters/settings.js +165 -0
  34. package/lib/adapters/skills.d.ts +13 -0
  35. package/lib/adapters/skills.js +14 -0
  36. package/lib/adapters/test-helpers.d.ts +125 -0
  37. package/lib/adapters/test-helpers.js +236 -0
  38. package/lib/adapters/ui.d.ts +27 -0
  39. package/lib/adapters/ui.js +89 -0
  40. package/lib/adapters/workspaces.d.ts +13 -0
  41. package/lib/adapters/workspaces.js +106 -0
  42. package/lib/client.d.ts +1026 -0
  43. package/lib/client.js +10047 -0
  44. package/lib/core/analyzer.d.ts +99 -0
  45. package/lib/core/analyzer.js +934 -0
  46. package/lib/core/backup.d.ts +138 -0
  47. package/lib/core/backup.js +513 -0
  48. package/lib/core/boot-rescue.d.ts +146 -0
  49. package/lib/core/boot-rescue.js +618 -0
  50. package/lib/core/cache-cleaner.d.ts +31 -0
  51. package/lib/core/cache-cleaner.js +95 -0
  52. package/lib/core/config-lifecycle.d.ts +180 -0
  53. package/lib/core/config-lifecycle.js +481 -0
  54. package/lib/core/config-snapshot.d.ts +135 -0
  55. package/lib/core/config-snapshot.js +424 -0
  56. package/lib/core/config-state.d.ts +70 -0
  57. package/lib/core/config-state.js +177 -0
  58. package/lib/core/consult-source.d.ts +58 -0
  59. package/lib/core/consult-source.js +260 -0
  60. package/lib/core/crash-report.d.ts +84 -0
  61. package/lib/core/crash-report.js +317 -0
  62. package/lib/core/exporter.d.ts +68 -0
  63. package/lib/core/exporter.js +375 -0
  64. package/lib/core/ghost-sweep.d.ts +19 -0
  65. package/lib/core/ghost-sweep.js +52 -0
  66. package/lib/core/importer.d.ts +60 -0
  67. package/lib/core/importer.js +29 -0
  68. package/lib/core/index.d.ts +14 -0
  69. package/lib/core/index.js +14 -0
  70. package/lib/core/journal.d.ts +197 -0
  71. package/lib/core/journal.js +418 -0
  72. package/lib/core/local-plugin-host.d.ts +35 -0
  73. package/lib/core/local-plugin-host.js +120 -0
  74. package/lib/core/local-plugin-pack.d.ts +135 -0
  75. package/lib/core/local-plugin-pack.js +278 -0
  76. package/lib/core/messages.d.ts +310 -0
  77. package/lib/core/messages.js +621 -0
  78. package/lib/core/migration-consult.d.ts +117 -0
  79. package/lib/core/migration-consult.js +363 -0
  80. package/lib/core/migration-history.d.ts +153 -0
  81. package/lib/core/migration-history.js +411 -0
  82. package/lib/core/model-tools.d.ts +50 -0
  83. package/lib/core/model-tools.js +148 -0
  84. package/lib/core/msg-types.d.ts +8 -0
  85. package/lib/core/msg-types.js +6 -0
  86. package/lib/core/phase3-child-crash.d.ts +1 -0
  87. package/lib/core/phase3-child-crash.js +87 -0
  88. package/lib/core/phase3-host.d.ts +138 -0
  89. package/lib/core/phase3-host.js +359 -0
  90. package/lib/core/phase3-prod-child.d.ts +1 -0
  91. package/lib/core/phase3-prod-child.js +35 -0
  92. package/lib/core/phase4-crash-child.d.ts +1 -0
  93. package/lib/core/phase4-crash-child.js +103 -0
  94. package/lib/core/plugin-cli.d.ts +150 -0
  95. package/lib/core/plugin-cli.js +497 -0
  96. package/lib/core/reconcile.d.ts +120 -0
  97. package/lib/core/reconcile.js +389 -0
  98. package/lib/core/recovery-orchestrator.d.ts +94 -0
  99. package/lib/core/recovery-orchestrator.js +327 -0
  100. package/lib/core/restore.d.ts +162 -0
  101. package/lib/core/restore.js +703 -0
  102. package/lib/core/rollback.d.ts +12 -0
  103. package/lib/core/rollback.js +145 -0
  104. package/lib/core/run-registry.d.ts +85 -0
  105. package/lib/core/run-registry.js +157 -0
  106. package/lib/core/startup-barrier.d.ts +53 -0
  107. package/lib/core/startup-barrier.js +77 -0
  108. package/lib/core/types.d.ts +515 -0
  109. package/lib/core/types.js +27 -0
  110. package/lib/core/undo.d.ts +73 -0
  111. package/lib/core/undo.js +87 -0
  112. package/lib/core/validator.d.ts +18 -0
  113. package/lib/core/validator.js +77 -0
  114. package/lib/core/verify-recovery.d.ts +21 -0
  115. package/lib/core/verify-recovery.js +256 -0
  116. package/lib/core/watcher.d.ts +101 -0
  117. package/lib/core/watcher.js +219 -0
  118. package/lib/index.d.ts +238 -0
  119. package/lib/index.js +2764 -0
  120. package/lib/migrations/index.d.ts +29 -0
  121. package/lib/migrations/index.js +56 -0
  122. package/lib/migrations/v1-to-v2.d.ts +9 -0
  123. package/lib/migrations/v1-to-v2.js +10 -0
  124. package/lib/profiles/index.d.ts +4 -0
  125. package/lib/profiles/index.js +5 -0
  126. package/lib/profiles/profile-manager.d.ts +106 -0
  127. package/lib/profiles/profile-manager.js +543 -0
  128. package/lib/schema/config.d.ts +28 -0
  129. package/lib/schema/config.js +165 -0
  130. package/lib/schema/index.d.ts +24 -0
  131. package/lib/schema/index.js +26 -0
  132. package/lib/schema/manifest.d.ts +28 -0
  133. package/lib/schema/manifest.js +109 -0
  134. package/lib/schema/tombstones.d.ts +46 -0
  135. package/lib/schema/tombstones.js +64 -0
  136. package/lib/schema/types.d.ts +232 -0
  137. package/lib/schema/types.js +8 -0
  138. package/lib/schema/versions.d.ts +27 -0
  139. package/lib/schema/versions.js +50 -0
  140. package/lib/security/encryption.d.ts +85 -0
  141. package/lib/security/encryption.js +279 -0
  142. package/lib/security/index.d.ts +16 -0
  143. package/lib/security/index.js +17 -0
  144. package/lib/security/integrity.d.ts +30 -0
  145. package/lib/security/integrity.js +78 -0
  146. package/lib/security/redaction.d.ts +26 -0
  147. package/lib/security/redaction.js +95 -0
  148. package/lib/security/secret-scanner.d.ts +86 -0
  149. package/lib/security/secret-scanner.js +404 -0
  150. package/lib/security/vault.d.ts +59 -0
  151. package/lib/security/vault.js +151 -0
  152. package/lib/security/zip-security.d.ts +27 -0
  153. package/lib/security/zip-security.js +154 -0
  154. package/lib/sync/autosync-config.d.ts +39 -0
  155. package/lib/sync/autosync-config.js +151 -0
  156. package/lib/sync/autosync-scheduler.d.ts +139 -0
  157. package/lib/sync/autosync-scheduler.js +564 -0
  158. package/lib/sync/fs.d.ts +18 -0
  159. package/lib/sync/fs.js +49 -0
  160. package/lib/sync/git/git-transport.d.ts +100 -0
  161. package/lib/sync/git/git-transport.js +437 -0
  162. package/lib/sync/github-auth.d.ts +131 -0
  163. package/lib/sync/github-auth.js +264 -0
  164. package/lib/sync/layout.d.ts +43 -0
  165. package/lib/sync/layout.js +203 -0
  166. package/lib/sync/snapshot-json.d.ts +9 -0
  167. package/lib/sync/snapshot-json.js +107 -0
  168. package/lib/sync/sync-config.d.ts +81 -0
  169. package/lib/sync/sync-config.js +281 -0
  170. package/lib/sync/sync-engine.d.ts +269 -0
  171. package/lib/sync/sync-engine.js +591 -0
  172. package/lib/sync/sync-history.d.ts +42 -0
  173. package/lib/sync/sync-history.js +78 -0
  174. package/lib/sync/sync-selection.d.ts +29 -0
  175. package/lib/sync/sync-selection.js +127 -0
  176. package/lib/sync/sync-session.d.ts +40 -0
  177. package/lib/sync/sync-session.js +54 -0
  178. package/lib/sync/sync-state.d.ts +37 -0
  179. package/lib/sync/sync-state.js +108 -0
  180. package/lib/sync/transport.d.ts +75 -0
  181. package/lib/sync/transport.js +55 -0
  182. package/lib/sync/ui-prefs.d.ts +34 -0
  183. package/lib/sync/ui-prefs.js +109 -0
  184. package/lib/sync/webdav/webdav-transport.d.ts +77 -0
  185. package/lib/sync/webdav/webdav-transport.js +451 -0
  186. package/lib/ui/errors.d.ts +19 -0
  187. package/lib/ui/errors.js +81 -0
  188. package/lib/ui/export-flow.d.ts +82 -0
  189. package/lib/ui/export-flow.js +98 -0
  190. package/lib/ui/i18n.d.ts +289 -0
  191. package/lib/ui/i18n.js +581 -0
  192. package/lib/ui/migration-consult-view.d.ts +91 -0
  193. package/lib/ui/migration-consult-view.js +115 -0
  194. package/lib/ui/progress.d.ts +38 -0
  195. package/lib/ui/progress.js +87 -0
  196. package/lib/ui/report.d.ts +23 -0
  197. package/lib/ui/report.js +153 -0
  198. package/lib/ui/test-helpers.d.ts +69 -0
  199. package/lib/ui/test-helpers.js +154 -0
  200. package/lib/ui/types.d.ts +257 -0
  201. package/lib/ui/types.js +21 -0
  202. package/lib/utils/atomic-write.d.ts +88 -0
  203. package/lib/utils/atomic-write.js +399 -0
  204. package/lib/utils/env-lock.d.ts +393 -0
  205. package/lib/utils/env-lock.js +930 -0
  206. package/lib/utils/hashing.d.ts +17 -0
  207. package/lib/utils/hashing.js +41 -0
  208. package/lib/utils/json.d.ts +24 -0
  209. package/lib/utils/json.js +76 -0
  210. package/lib/utils/logger.d.ts +39 -0
  211. package/lib/utils/logger.js +82 -0
  212. package/lib/utils/paths.d.ts +42 -0
  213. package/lib/utils/paths.js +189 -0
  214. package/lib/utils/proxy.d.ts +69 -0
  215. package/lib/utils/proxy.js +455 -0
  216. package/lib/utils/recursive-walk.d.ts +24 -0
  217. package/lib/utils/recursive-walk.js +134 -0
  218. package/lib/utils/zip.d.ts +54 -0
  219. package/lib/utils/zip.js +285 -0
  220. package/package.json +188 -0
  221. package/src/adapters/agent-instructions.ts +48 -0
  222. package/src/adapters/agent-presets.ts +14 -0
  223. package/src/adapters/credentials.test.ts +90 -0
  224. package/src/adapters/credentials.ts +143 -0
  225. package/src/adapters/file-collection.ts +137 -0
  226. package/src/adapters/files.test.ts +353 -0
  227. package/src/adapters/index.ts +94 -0
  228. package/src/adapters/link-report.test.ts +65 -0
  229. package/src/adapters/link-report.ts +61 -0
  230. package/src/adapters/mcp.test.ts +96 -0
  231. package/src/adapters/mcp.ts +185 -0
  232. package/src/adapters/plugin-files.ts +146 -0
  233. package/src/adapters/plugins.test.ts +326 -0
  234. package/src/adapters/plugins.ts +615 -0
  235. package/src/adapters/pnpm-workspace.test.ts +87 -0
  236. package/src/adapters/pnpm-workspace.ts +150 -0
  237. package/src/adapters/prompts.test.ts +110 -0
  238. package/src/adapters/prompts.ts +230 -0
  239. package/src/adapters/providers.test.ts +126 -0
  240. package/src/adapters/providers.ts +173 -0
  241. package/src/adapters/roundtrip.test.ts +190 -0
  242. package/src/adapters/self.test.ts +92 -0
  243. package/src/adapters/self.ts +75 -0
  244. package/src/adapters/sessions.ts +15 -0
  245. package/src/adapters/settings.test.ts +100 -0
  246. package/src/adapters/settings.ts +187 -0
  247. package/src/adapters/skills.ts +14 -0
  248. package/src/adapters/test-helpers.ts +248 -0
  249. package/src/adapters/ui.test.ts +57 -0
  250. package/src/adapters/ui.ts +108 -0
  251. package/src/adapters/workspaces.test.ts +106 -0
  252. package/src/adapters/workspaces.ts +109 -0
  253. package/src/client/ConfigManagerSection.tsx +90 -0
  254. package/src/client/api.ts +66 -0
  255. package/src/client/client-types.ts +18 -0
  256. package/src/client/common/ErrorBanner.tsx +73 -0
  257. package/src/client/common/Icon.tsx +129 -0
  258. package/src/client/common/Modal.tsx +173 -0
  259. package/src/client/common/ToastViewport.tsx +73 -0
  260. package/src/client/common/toast-store.test.ts +192 -0
  261. package/src/client/common/toast-store.ts +181 -0
  262. package/src/client/common/toast-types.ts +7 -0
  263. package/src/client/common/ui.tsx +342 -0
  264. package/src/client/config-manager.module.css +2971 -0
  265. package/src/client/css-modules.d.ts +8 -0
  266. package/src/client/index.ts +65 -0
  267. package/src/client/locales.ts +56 -0
  268. package/src/client/lucide-icons.d.ts +38 -0
  269. package/src/client/run-store.test.ts +223 -0
  270. package/src/client/run-store.ts +372 -0
  271. package/src/client/sync/SyncConfirmView.tsx +379 -0
  272. package/src/client/sync/SyncHistoryView.test.ts +40 -0
  273. package/src/client/sync/SyncHistoryView.tsx +220 -0
  274. package/src/client/sync/SyncSettingsView.tsx +1535 -0
  275. package/src/client/sync/history-model.test.ts +194 -0
  276. package/src/client/sync/history-model.ts +195 -0
  277. package/src/client/sync/sync-api.test.ts +585 -0
  278. package/src/client/sync/sync-api.ts +543 -0
  279. package/src/client/sync/sync-locales.ts +416 -0
  280. package/src/client/sync/sync-push-preview.test.ts +52 -0
  281. package/src/client/sync/sync-view-v2.test.ts +213 -0
  282. package/src/client/sync/sync-view.test.ts +487 -0
  283. package/src/client/sync/sync-view.ts +759 -0
  284. package/src/core/analyzer.ts +1068 -0
  285. package/src/core/backup.ts +605 -0
  286. package/src/core/boot-rescue.test.ts +847 -0
  287. package/src/core/boot-rescue.ts +737 -0
  288. package/src/core/cache-cleaner.test.ts +169 -0
  289. package/src/core/cache-cleaner.ts +121 -0
  290. package/src/core/config-lifecycle.test.ts +727 -0
  291. package/src/core/config-lifecycle.ts +573 -0
  292. package/src/core/config-snapshot.test.ts +369 -0
  293. package/src/core/config-snapshot.ts +522 -0
  294. package/src/core/config-state.test.ts +381 -0
  295. package/src/core/config-state.ts +207 -0
  296. package/src/core/consult-source.test.ts +199 -0
  297. package/src/core/consult-source.ts +293 -0
  298. package/src/core/crash-report.test.ts +380 -0
  299. package/src/core/crash-report.ts +369 -0
  300. package/src/core/exporter.ts +417 -0
  301. package/src/core/ghost-sweep.test.ts +94 -0
  302. package/src/core/ghost-sweep.ts +67 -0
  303. package/src/core/importer.ts +83 -0
  304. package/src/core/index.ts +56 -0
  305. package/src/core/journal.test.ts +257 -0
  306. package/src/core/journal.ts +518 -0
  307. package/src/core/local-plugin-host.ts +152 -0
  308. package/src/core/local-plugin-pack.test.ts +359 -0
  309. package/src/core/local-plugin-pack.ts +363 -0
  310. package/src/core/messages.test.ts +54 -0
  311. package/src/core/messages.ts +664 -0
  312. package/src/core/migration-consult.test.ts +315 -0
  313. package/src/core/migration-consult.ts +494 -0
  314. package/src/core/migration-history.test.ts +319 -0
  315. package/src/core/migration-history.ts +484 -0
  316. package/src/core/model-tools.test.ts +190 -0
  317. package/src/core/model-tools.ts +208 -0
  318. package/src/core/msg-types.ts +10 -0
  319. package/src/core/phase3-child-crash.ts +95 -0
  320. package/src/core/phase3-host.ts +411 -0
  321. package/src/core/phase3-p1.test.ts +212 -0
  322. package/src/core/phase3-p2b-fingerprint.test.ts +189 -0
  323. package/src/core/phase3-prod-child.ts +36 -0
  324. package/src/core/phase3-production-integration.test.ts +286 -0
  325. package/src/core/phase4-crash-child.ts +101 -0
  326. package/src/core/phase4-crash-injection.test.ts +166 -0
  327. package/src/core/plugin-cli.fs.test.ts +184 -0
  328. package/src/core/plugin-cli.test.ts +137 -0
  329. package/src/core/plugin-cli.ts +563 -0
  330. package/src/core/reconcile.test.ts +392 -0
  331. package/src/core/reconcile.ts +498 -0
  332. package/src/core/recovery-orchestrator.test.ts +145 -0
  333. package/src/core/recovery-orchestrator.ts +366 -0
  334. package/src/core/restore-manage.test.ts +94 -0
  335. package/src/core/restore.ts +849 -0
  336. package/src/core/rollback.ts +151 -0
  337. package/src/core/run-progress.test.ts +237 -0
  338. package/src/core/run-registry.test.ts +232 -0
  339. package/src/core/run-registry.ts +209 -0
  340. package/src/core/smoke.test.ts +842 -0
  341. package/src/core/startup-barrier.ts +99 -0
  342. package/src/core/types.ts +542 -0
  343. package/src/core/undo.ts +116 -0
  344. package/src/core/validator.ts +84 -0
  345. package/src/core/verify-recovery.ts +265 -0
  346. package/src/core/watcher.test.ts +326 -0
  347. package/src/core/watcher.ts +263 -0
  348. package/src/index.facade.test.ts +265 -0
  349. package/src/index.sync.test.ts +217 -0
  350. package/src/index.ts +2975 -0
  351. package/src/migrations/index.ts +71 -0
  352. package/src/migrations/v1-to-v2.ts +17 -0
  353. package/src/profiles/index.ts +8 -0
  354. package/src/profiles/profile-manager.test.ts +282 -0
  355. package/src/profiles/profile-manager.ts +630 -0
  356. package/src/schema/config.ts +165 -0
  357. package/src/schema/index.ts +71 -0
  358. package/src/schema/manifest-schema.test.ts +519 -0
  359. package/src/schema/manifest.ts +124 -0
  360. package/src/schema/tombstones.test.ts +131 -0
  361. package/src/schema/tombstones.ts +89 -0
  362. package/src/schema/types.ts +245 -0
  363. package/src/schema/versions.ts +55 -0
  364. package/src/security/encryption.ts +335 -0
  365. package/src/security/index.ts +16 -0
  366. package/src/security/integrity.ts +102 -0
  367. package/src/security/redaction.ts +101 -0
  368. package/src/security/secret-scanner.ts +445 -0
  369. package/src/security/security.test.ts +1215 -0
  370. package/src/security/vault.test.ts +164 -0
  371. package/src/security/vault.ts +198 -0
  372. package/src/security/zip-security.ts +180 -0
  373. package/src/sync/autosync-config.test.ts +147 -0
  374. package/src/sync/autosync-config.ts +179 -0
  375. package/src/sync/autosync-scheduler.test.ts +490 -0
  376. package/src/sync/autosync-scheduler.ts +646 -0
  377. package/src/sync/fs.ts +48 -0
  378. package/src/sync/git/git-transport.test.ts +673 -0
  379. package/src/sync/git/git-transport.ts +491 -0
  380. package/src/sync/github-auth.test.ts +280 -0
  381. package/src/sync/github-auth.ts +356 -0
  382. package/src/sync/layout.test.ts +270 -0
  383. package/src/sync/layout.ts +222 -0
  384. package/src/sync/snapshot-json.test.ts +119 -0
  385. package/src/sync/snapshot-json.ts +128 -0
  386. package/src/sync/sync-config.test.ts +310 -0
  387. package/src/sync/sync-config.ts +299 -0
  388. package/src/sync/sync-engine.test.ts +1074 -0
  389. package/src/sync/sync-engine.ts +754 -0
  390. package/src/sync/sync-history.test.ts +116 -0
  391. package/src/sync/sync-history.ts +120 -0
  392. package/src/sync/sync-selection.test.ts +186 -0
  393. package/src/sync/sync-selection.ts +143 -0
  394. package/src/sync/sync-session.test.ts +143 -0
  395. package/src/sync/sync-session.ts +77 -0
  396. package/src/sync/sync-state.test.ts +164 -0
  397. package/src/sync/sync-state.ts +131 -0
  398. package/src/sync/transport.test.ts +111 -0
  399. package/src/sync/transport.ts +118 -0
  400. package/src/sync/ui-prefs.test.ts +152 -0
  401. package/src/sync/ui-prefs.ts +135 -0
  402. package/src/sync/webdav/webdav-transport.test.ts +871 -0
  403. package/src/sync/webdav/webdav-transport.ts +535 -0
  404. package/src/ui/errors.test.ts +47 -0
  405. package/src/ui/errors.ts +100 -0
  406. package/src/ui/export-flow.test.ts +87 -0
  407. package/src/ui/export-flow.ts +156 -0
  408. package/src/ui/i18n.ts +590 -0
  409. package/src/ui/migration-consult-view.test.ts +151 -0
  410. package/src/ui/migration-consult-view.ts +178 -0
  411. package/src/ui/progress.test.ts +47 -0
  412. package/src/ui/progress.ts +102 -0
  413. package/src/ui/report.test.ts +101 -0
  414. package/src/ui/report.ts +146 -0
  415. package/src/ui/test-helpers.ts +184 -0
  416. package/src/ui/types.ts +325 -0
  417. package/src/utils/atomic-write.test.ts +312 -0
  418. package/src/utils/atomic-write.ts +422 -0
  419. package/src/utils/env-lock.test.ts +999 -0
  420. package/src/utils/env-lock.ts +1086 -0
  421. package/src/utils/hashing.ts +53 -0
  422. package/src/utils/json.ts +77 -0
  423. package/src/utils/logger.ts +114 -0
  424. package/src/utils/paths.ts +176 -0
  425. package/src/utils/proxy.test.ts +465 -0
  426. package/src/utils/proxy.ts +493 -0
  427. package/src/utils/recursive-walk.test.ts +148 -0
  428. package/src/utils/recursive-walk.ts +145 -0
  429. package/src/utils/zip.ts +332 -0
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Phase 1 P0-1 回归:防抖监听 + 回声抑制(watcher.ts)。
3
+ *
4
+ * 全部时序由假定时器驱动 —— 不 sleep、不碰真文件系统,因此不受机器负载影响
5
+ * (本仓库既有套件里有过「负载相关偶发失败」,此处刻意规避同类脆弱性)。
6
+ *
7
+ * 覆盖用户可见契约:
8
+ * - 高频事件合并成一次 flush(一次保存 = 一次快照,不是多次);
9
+ * - 同一文件的 change+rename 双事件去重;
10
+ * - 临时文件 / 隐藏文件不触发(否则编辑器保存会打出成串快照);
11
+ * - **抑制窗口内的自写文件不触发快照**(否则恢复动作会挡住重做);
12
+ * - 抑制窗口结束时清空待发事件(延迟投递也不放行);
13
+ * - EchoRegistry 识别「等于刚写回内容」的回声,真实变更清空登记表;
14
+ * - watch 工厂抛错 / 运行时报错只回调 onError,绝不抛出(否则会炸掉宿主启动)。
15
+ */
16
+ import test from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+
19
+ import {
20
+ DebouncedWatcher, EchoRegistry, groupEventsByDir, hasWatchedBasename, isIgnorableFileName,
21
+ type TimerApi, type WatchFactory, type WatcherEvent,
22
+ } from './watcher.ts';
23
+
24
+ /* ------------------------------------------------------------ 假定时器 */
25
+
26
+ /** 虚拟时钟假定时器:`advance(ms)` 推进时钟并按到期时间执行(与真实 setTimeout 同语义) */
27
+ class FakeTimers implements TimerApi {
28
+ private seq = 0;
29
+ private now = 0;
30
+ private scheduled = new Map<number, { fn: () => void; deadline: number }>();
31
+
32
+ setTimeout(fn: () => void, ms: number): unknown {
33
+ const handle = ++this.seq;
34
+ this.scheduled.set(handle, { fn, deadline: this.now + ms });
35
+ return handle;
36
+ }
37
+
38
+ clearTimeout(handle: unknown): void {
39
+ if (typeof handle === 'number') this.scheduled.delete(handle);
40
+ }
41
+
42
+ /** 推进虚拟时钟 delta 毫秒,按到期顺序执行所有已到点的定时器 */
43
+ advance(delta: number): void {
44
+ this.now += delta;
45
+ for (;;) {
46
+ const due = [...this.scheduled.entries()]
47
+ .filter(([, e]) => e.deadline <= this.now)
48
+ .sort((a, b) => a[1].deadline - b[1].deadline);
49
+ const next = due[0];
50
+ if (next === undefined) break;
51
+ const [handle, entry] = next;
52
+ this.scheduled.delete(handle);
53
+ entry.fn();
54
+ }
55
+ }
56
+
57
+ get pending(): number {
58
+ return this.scheduled.size;
59
+ }
60
+ }
61
+
62
+ /** 可编程 watch 工厂:记录监听目录,允许手动投递事件 */
63
+ class FakeWatchFactory {
64
+ readonly watched: string[] = [];
65
+ readonly closed: string[] = [];
66
+ failOn = new Set<string>();
67
+ private readonly callbacks = new Map<string, (e: string, f: string | null) => void>();
68
+
69
+ readonly factory: WatchFactory = (dir, onEvent) => {
70
+ if (this.failOn.has(dir)) throw new Error(`cannot watch ${dir}`);
71
+ this.watched.push(dir);
72
+ this.callbacks.set(dir, onEvent);
73
+ return { close: () => { this.closed.push(dir); this.callbacks.delete(dir); } };
74
+ };
75
+
76
+ emit(dir: string, filename: string | null, eventType = 'change'): void {
77
+ const cb = this.callbacks.get(dir);
78
+ if (cb === undefined) throw new Error(`目录未被监听: ${dir}`);
79
+ cb(eventType, filename);
80
+ }
81
+ }
82
+
83
+ function setup(opts: { dirs?: string[]; debounceMs?: number; failDirs?: string[] } = {}): {
84
+ watcher: DebouncedWatcher;
85
+ timers: FakeTimers;
86
+ factory: FakeWatchFactory;
87
+ flushes: WatcherEvent[][];
88
+ errors: { dir: string; error: unknown }[];
89
+ } {
90
+ const timers = new FakeTimers();
91
+ const factory = new FakeWatchFactory();
92
+ if (opts.failDirs !== undefined) for (const d of opts.failDirs) factory.failOn.add(d);
93
+ const flushes: WatcherEvent[][] = [];
94
+ const errors: { dir: string; error: unknown }[] = [];
95
+ const watcher = new DebouncedWatcher({
96
+ dirs: opts.dirs ?? ['C:/home/.dsh', 'C:/home/.dsh/profiles/web'],
97
+ debounceMs: opts.debounceMs ?? 1500,
98
+ watchFactory: factory.factory,
99
+ onFlush: (events) => flushes.push(events),
100
+ onError: (dir, error) => errors.push({ dir, error }),
101
+ timers,
102
+ });
103
+ return { watcher, timers, factory, flushes, errors };
104
+ }
105
+
106
+ /* ------------------------------------------------------------ 文件名过滤 */
107
+
108
+ test('isIgnorableFileName:忽略隐藏 / 备份 / 临时 / 版本化临时文件', () => {
109
+ for (const name of ['', '.git', '.DS_Store', 'a.swp', 'x~', 'y.tmp', 'settings.yaml.1234567']) {
110
+ assert.equal(isIgnorableFileName(name), true, `${name} 应被忽略`);
111
+ }
112
+ for (const name of ['settings.yaml', 'cordis.patch.yml', 'package.json', 'a.md']) {
113
+ assert.equal(isIgnorableFileName(name), false, `${name} 不应被忽略`);
114
+ }
115
+ });
116
+
117
+ /* ------------------------------------------------------------ 防抖与合并 */
118
+
119
+ test('start:为每个目录建立监听;stop:全部关闭', () => {
120
+ const { watcher, factory } = setup();
121
+ watcher.start();
122
+ assert.deepEqual(factory.watched, ['C:/home/.dsh', 'C:/home/.dsh/profiles/web']);
123
+ assert.equal(watcher.isRunning, true);
124
+ watcher.stop();
125
+ assert.deepEqual(factory.closed, ['C:/home/.dsh', 'C:/home/.dsh/profiles/web']);
126
+ assert.equal(watcher.isRunning, false);
127
+ });
128
+
129
+ test('高频事件合并成一次 flush(一次保存 = 一次快照)', () => {
130
+ const { watcher, timers, factory, flushes } = setup();
131
+ watcher.start();
132
+ factory.emit('C:/home/.dsh', 'settings.yaml');
133
+ factory.emit('C:/home/.dsh', 'cordis.patch.yml');
134
+ factory.emit('C:/home/.dsh/profiles/web', 'package.json');
135
+ assert.equal(flushes.length, 0, '防抖窗口内不得 flush');
136
+ timers.advance(1500);
137
+ assert.equal(flushes.length, 1, '合并为一次');
138
+ assert.equal(flushes[0]!.length, 3);
139
+ });
140
+
141
+ test('同一文件的 change+rename 双事件去重', () => {
142
+ const { watcher, timers, factory, flushes } = setup();
143
+ watcher.start();
144
+ factory.emit('C:/home/.dsh', 'settings.yaml', 'change');
145
+ factory.emit('C:/home/.dsh', 'settings.yaml', 'rename');
146
+ assert.equal(watcher.pendingCount, 1, '同目录同文件应去重');
147
+ timers.advance(1500);
148
+ assert.equal(flushes[0]!.length, 1);
149
+ });
150
+
151
+ test('防抖窗口被新事件重置(不是固定窗口内的第一次就 flush)', () => {
152
+ const { watcher, timers, factory, flushes } = setup();
153
+ watcher.start();
154
+ factory.emit('C:/home/.dsh', 'settings.yaml');
155
+ timers.advance(1000); // 未到 1500
156
+ factory.emit('C:/home/.dsh', 'package.json');
157
+ timers.advance(1000); // 距上次事件 1000ms,仍未到
158
+ assert.equal(flushes.length, 0, '窗口应被重置');
159
+ timers.advance(600);
160
+ assert.equal(flushes.length, 1);
161
+ });
162
+
163
+ test('临时 / 隐藏文件事件不触发 flush', () => {
164
+ const { watcher, timers, factory, flushes } = setup();
165
+ watcher.start();
166
+ factory.emit('C:/home/.dsh', '.settings.yaml.swp');
167
+ factory.emit('C:/home/.dsh', 'settings.yaml.tmp');
168
+ factory.emit('C:/home/.dsh', 'settings.yaml~');
169
+ timers.advance(5000);
170
+ assert.equal(flushes.length, 0, '不得因编辑器中间产物触发快照');
171
+ assert.equal(timers.pending, 0, '被忽略的事件不应留下定时器');
172
+ });
173
+
174
+ test('filename 为 null 的事件被忽略(fs.watch 会给出 null)', () => {
175
+ const { watcher, timers, factory, flushes } = setup();
176
+ watcher.start();
177
+ factory.emit('C:/home/.dsh', null);
178
+ timers.advance(5000);
179
+ assert.equal(flushes.length, 0);
180
+ });
181
+
182
+ /* ------------------------------------------------------------ 抑制窗口(回声防线 1) */
183
+
184
+ test('抑制窗口内的事件直接丢弃(恢复动作自写文件不得触发快照)', () => {
185
+ const { watcher, timers, factory, flushes } = setup();
186
+ watcher.start();
187
+ watcher.beginSuppress();
188
+ factory.emit('C:/home/.dsh', 'cordis.patch.yml');
189
+ factory.emit('C:/home/.dsh', 'settings.yaml');
190
+ timers.advance(5000);
191
+ assert.equal(flushes.length, 0);
192
+ assert.equal(watcher.pendingCount, 0);
193
+ watcher.endSuppress();
194
+ assert.equal(watcher.suppressDepth, 0);
195
+ });
196
+
197
+ test('endSuppress 清空待发事件:抑制前排队、抑制期间到期的也不放行', () => {
198
+ const { watcher, timers, factory, flushes } = setup();
199
+ watcher.start();
200
+ factory.emit('C:/home/.dsh', 'settings.yaml'); // 先排队
201
+ watcher.beginSuppress(); // 抑制前先压住
202
+ timers.advance(5000); // 定时器到点
203
+ assert.equal(flushes.length, 0, '抑制期间不得 flush');
204
+ watcher.endSuppress();
205
+ timers.advance(5000);
206
+ assert.equal(flushes.length, 0, '抑制结束也不得补发(那是自写文件的回声)');
207
+ assert.equal(timers.pending, 0, '定时器必须被清掉');
208
+ });
209
+
210
+ test('suppressWhile:正常返回并保证退出抑制;抛错也退出', () => {
211
+ const { watcher, factory, timers, flushes } = setup();
212
+ watcher.start();
213
+ const value = watcher.suppressWhile(() => {
214
+ factory.emit('C:/home/.dsh', 'settings.yaml');
215
+ return 42;
216
+ });
217
+ assert.equal(value, 42);
218
+ assert.equal(watcher.suppressDepth, 0);
219
+ timers.advance(5000);
220
+ assert.equal(flushes.length, 0);
221
+ assert.throws(() => watcher.suppressWhile(() => { throw new Error('boom'); }), /boom/);
222
+ assert.equal(watcher.suppressDepth, 0, '异常路径也必须退出抑制');
223
+ });
224
+
225
+ test('suppressWhileAsync:异步恢复期间抑制,结束后真实变更照常触发', async () => {
226
+ const { watcher, factory, timers, flushes } = setup();
227
+ watcher.start();
228
+ await watcher.suppressWhileAsync(async () => {
229
+ factory.emit('C:/home/.dsh', 'settings.yaml');
230
+ await Promise.resolve();
231
+ });
232
+ assert.equal(watcher.suppressDepth, 0);
233
+ timers.advance(5000);
234
+ assert.equal(flushes.length, 0, '抑制期间的自写不得产生快照');
235
+ factory.emit('C:/home/.dsh', 'settings.yaml');
236
+ timers.advance(5000);
237
+ assert.equal(flushes.length, 1, '真实变更必须照常触发');
238
+ });
239
+
240
+ test('endSuppress 在深度为 0 时是空操作(不会把深度压成负数)', () => {
241
+ const { watcher } = setup();
242
+ watcher.endSuppress();
243
+ assert.equal(watcher.suppressDepth, 0);
244
+ });
245
+
246
+ test('嵌套抑制:内层退出不清空,外层退出才清空', () => {
247
+ const { watcher, timers, factory, flushes } = setup();
248
+ watcher.start();
249
+ watcher.beginSuppress();
250
+ watcher.beginSuppress();
251
+ factory.emit('C:/home/.dsh', 'settings.yaml');
252
+ watcher.endSuppress();
253
+ assert.equal(watcher.suppressDepth, 1);
254
+ timers.advance(5000);
255
+ assert.equal(flushes.length, 0, '仍在内层抑制中');
256
+ watcher.endSuppress();
257
+ assert.equal(watcher.suppressDepth, 0);
258
+ assert.equal(timers.pending, 0);
259
+ });
260
+
261
+ /* ------------------------------------------------------------ 错误容错 */
262
+
263
+ test('不可监听的目录只回调 onError,不抛出(否则会炸掉宿主启动)', () => {
264
+ const { watcher, errors, factory } = setup({ failDirs: ['C:/home/.dsh/profiles/web'] });
265
+ watcher.start();
266
+ assert.equal(errors.length, 1);
267
+ assert.equal(errors[0]!.dir, 'C:/home/.dsh/profiles/web');
268
+ assert.deepEqual(factory.watched, ['C:/home/.dsh'], '其余目录照常监听');
269
+ });
270
+
271
+ test('start 幂等重建:重复 start 先关旧监听,不重复订阅', () => {
272
+ const { watcher, factory } = setup({ dirs: ['C:/home/.dsh'] });
273
+ watcher.start();
274
+ watcher.start();
275
+ assert.deepEqual(factory.closed, ['C:/home/.dsh'], '第二次 start 应关闭旧句柄');
276
+ assert.deepEqual(factory.watched, ['C:/home/.dsh', 'C:/home/.dsh']);
277
+ });
278
+
279
+ /* ------------------------------------------------------------ EchoRegistry(回声防线 2) */
280
+
281
+ test('EchoRegistry:内容相同判回声,内容变化判真实变更', () => {
282
+ const reg = new EchoRegistry();
283
+ reg.record('config:settings.yaml', 'h1');
284
+ assert.equal(reg.isEcho('config:settings.yaml', 'h1'), true);
285
+ assert.equal(reg.isEcho('config:settings.yaml', 'h2'), false, '内容变了 → 真实变更');
286
+ assert.equal(reg.isEcho('config:other.yaml', 'h1'), false, '未登记路径 → 非回声');
287
+ assert.equal(reg.size, 1);
288
+ });
289
+
290
+ test('EchoRegistry:真实变更后 clear 整表(防后续合法修改被误判为回声而漏拍)', () => {
291
+ const reg = new EchoRegistry();
292
+ reg.record('config:settings.yaml', 'h1');
293
+ reg.record('config:package.json', 'h2');
294
+ reg.clear();
295
+ assert.equal(reg.size, 0);
296
+ assert.equal(reg.isEcho('config:settings.yaml', 'h1'), false);
297
+ });
298
+
299
+ test('EchoRegistry:has 区分「登记过」与「内容相等」', () => {
300
+ const reg = new EchoRegistry();
301
+ reg.record('k', 'h');
302
+ assert.equal(reg.has('k'), true);
303
+ assert.equal(reg.has('missing'), false);
304
+ assert.equal(reg.isEcho('k', 'different'), false);
305
+ });
306
+
307
+ /* ------------------------------------------------------------ 事件聚合工具 */
308
+
309
+ test('groupEventsByDir:按目录聚合去重', () => {
310
+ const grouped = groupEventsByDir([
311
+ { dir: 'A', filename: 'x' },
312
+ { dir: 'A', filename: 'x' },
313
+ { dir: 'A', filename: 'y' },
314
+ { dir: 'B', filename: 'z' },
315
+ ]);
316
+ assert.deepEqual([...grouped.get('A')!].sort(), ['x', 'y']);
317
+ assert.deepEqual([...grouped.get('B')!], ['z']);
318
+ });
319
+
320
+ test('hasWatchedBasename:跨目录匹配关注的 basename', () => {
321
+ const watched = new Set(['settings.yaml', 'cordis.patch.yml']);
322
+ assert.equal(hasWatchedBasename([{ dir: 'A', filename: 'settings.yaml' }], watched), true);
323
+ assert.equal(hasWatchedBasename([{ dir: 'A', filename: 'C:/x/cordis.patch.yml' }], watched), true);
324
+ assert.equal(hasWatchedBasename([{ dir: 'A', filename: 'unrelated.md' }], watched), false);
325
+ assert.equal(hasWatchedBasename([], watched), false);
326
+ });
@@ -0,0 +1,263 @@
1
+ /**
2
+ * 防抖文件监听 + 回声抑制(Phase 1 P0-1:自动快照)。
3
+ *
4
+ * 为什么把监听器做成 core 模块而不是直接在宿主里调 fs.watch:
5
+ * 1. **可测**:watch 工厂与定时器都由调用方注入,测试用假实现即可驱动时序,
6
+ * 不需要真文件系统、不依赖 sleep,避免既有的「负载相关偶发失败」类脆弱测试;
7
+ * 2. **回声抑制是一等公民**:恢复/回滚动作自己会写文件,若不抑制就会立刻产生
8
+ * 一个「等于刚写回内容」的自动快照,把重做通道堵死。本模块提供两层抑制:
9
+ * `beginSuppress/endSuppress`(写操作窗口内直接丢弃事件)与 `EchoRegistry`
10
+ * (窗口外按内容指纹识别回声,防「延迟投递的事件」漏网)。
11
+ * 3. **平台差异隔离**:Windows 上被监听目录删除/改名时 FSWatcher 会异步抛 EPERM,
12
+ * 不挂 error 处理器会变成未捕获 'error' 事件直接把进程炸掉——本模块统一接管。
13
+ */
14
+ import path from 'node:path';
15
+
16
+ /** 监听句柄(fs.watch 返回值的结构子集,便于测试伪造) */
17
+ export interface WatchHandle {
18
+ close(): void;
19
+ }
20
+
21
+ /** 监听工厂(真实实现注入 fs.watch) */
22
+ export type WatchFactory = (
23
+ dir: string,
24
+ onEvent: (eventType: string, filename: string | null) => void,
25
+ ) => WatchHandle;
26
+
27
+ /** 定时器注入(测试用假实现驱动时序) */
28
+ export interface TimerApi {
29
+ setTimeout(fn: () => void, ms: number): unknown;
30
+ clearTimeout(handle: unknown): void;
31
+ }
32
+
33
+ const defaultTimers: TimerApi = {
34
+ setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
35
+ clearTimeout: (handle) => { globalThis.clearTimeout(handle as ReturnType<typeof globalThis.setTimeout>); },
36
+ };
37
+
38
+ export interface WatcherEvent {
39
+ /** 事件发生的目录(绝对路径) */
40
+ dir: string;
41
+ /** 文件名(fs.watch 可能给 null) */
42
+ filename: string;
43
+ }
44
+
45
+ export interface DebouncedWatcherOptions {
46
+ /** 要监听的目录(绝对路径;不存在的目录由 watchFactory 抛错,本类记 onError 后跳过) */
47
+ dirs: readonly string[];
48
+ /** 防抖窗口(毫秒) */
49
+ debounceMs: number;
50
+ watchFactory: WatchFactory;
51
+ /** 防抖到期后回调(事件已合并;同一目录同一文件去重) */
52
+ onFlush: (events: WatcherEvent[]) => void;
53
+ /** 监听失败 / 运行时 error 回调(不抛;默认静默) */
54
+ onError?: (dir: string, error: unknown) => void;
55
+ timers?: TimerApi;
56
+ }
57
+
58
+ /** 临时文件 / 编辑器残留:默认忽略,避免「保存一次触发多轮快照」 */
59
+ export function isIgnorableFileName(name: string): boolean {
60
+ if (name === '') return true;
61
+ if (name.startsWith('.')) return true; // .git / .DS_Store / .swp 等
62
+ if (name.endsWith('~')) return true; // 编辑器备份
63
+ if (name.endsWith('.tmp')) return true; // 本仓库 atomicWriteFile 的中间产物
64
+ if (name.endsWith('.swp') || name.endsWith('.swo')) return true;
65
+ if (/\.\d{6,}$/.test(name)) return true; // 版本化临时(file.json.1234567)
66
+ return false;
67
+ }
68
+
69
+ /**
70
+ * 防抖监听器:把高频文件事件合并成一次 `onFlush`。
71
+ *
72
+ * 生命周期:`start()` 建立监听;`stop()` 关闭全部监听并清掉待发定时器;
73
+ * `start()` 可在 `stop()` 后重新调用(幂等重建——宿主改配置后需要重建监听集)。
74
+ */
75
+ export class DebouncedWatcher {
76
+ private readonly options: DebouncedWatcherOptions;
77
+ private readonly timers: TimerApi;
78
+ private handles: { dir: string; handle: WatchHandle }[] = [];
79
+ private timer: unknown = null;
80
+ private pending = new Map<string, WatcherEvent>();
81
+ private suppressCount = 0;
82
+ private running = false;
83
+
84
+ constructor(options: DebouncedWatcherOptions) {
85
+ this.options = options;
86
+ this.timers = options.timers ?? defaultTimers;
87
+ }
88
+
89
+ get isRunning(): boolean {
90
+ return this.running;
91
+ }
92
+
93
+ /** 当前抑制深度(>0 表示正在执行会自写文件的操作) */
94
+ get suppressDepth(): number {
95
+ return this.suppressCount;
96
+ }
97
+
98
+ /** 待发事件数(测试与诊断用) */
99
+ get pendingCount(): number {
100
+ return this.pending.size;
101
+ }
102
+
103
+ start(): void {
104
+ this.stop();
105
+ this.running = true;
106
+ for (const dir of this.options.dirs) {
107
+ try {
108
+ const handle = this.options.watchFactory(dir, (_eventType, filename) => {
109
+ this.onEvent(dir, filename);
110
+ });
111
+ this.handles.push({ dir, handle });
112
+ } catch (error) {
113
+ this.options.onError?.(dir, error);
114
+ }
115
+ }
116
+ }
117
+
118
+ stop(): void {
119
+ for (const { handle } of this.handles) {
120
+ try {
121
+ handle.close();
122
+ } catch {
123
+ // 关闭失败不影响其余清理
124
+ }
125
+ }
126
+ this.handles = [];
127
+ if (this.timer !== null) {
128
+ this.timers.clearTimeout(this.timer);
129
+ this.timer = null;
130
+ }
131
+ this.pending.clear();
132
+ this.running = false;
133
+ }
134
+
135
+ /**
136
+ * 进入抑制窗口:期间产生的文件事件**直接丢弃**。
137
+ * 调用方必须在 finally 里 `endSuppress()`,否则自动快照会永久停摆。
138
+ */
139
+ beginSuppress(): void {
140
+ this.suppressCount += 1;
141
+ }
142
+
143
+ /**
144
+ * 退出抑制窗口:深度归零时**顺带清空待发事件**——那些事件正是被抑制的写操作
145
+ * 自己产生的,若放行会立刻产生一个回声快照(挡住重做)。
146
+ */
147
+ endSuppress(): void {
148
+ if (this.suppressDepth === 0) return;
149
+ this.suppressCount -= 1;
150
+ if (this.suppressDepth === 0) {
151
+ this.pending.clear();
152
+ if (this.timer !== null) {
153
+ this.timers.clearTimeout(this.timer);
154
+ this.timer = null;
155
+ }
156
+ }
157
+ }
158
+
159
+ /** 同步便捷包装:抑制窗口内执行 fn,异常也保证退出抑制。 */
160
+ suppressWhile<T>(fn: () => T): T {
161
+ this.beginSuppress();
162
+ try {
163
+ return fn();
164
+ } finally {
165
+ this.endSuppress();
166
+ }
167
+ }
168
+
169
+ /** 异步便捷包装(恢复/回滚等长操作)。 */
170
+ async suppressWhileAsync<T>(fn: () => Promise<T>): Promise<T> {
171
+ this.beginSuppress();
172
+ try {
173
+ return await fn();
174
+ } finally {
175
+ this.endSuppress();
176
+ }
177
+ }
178
+
179
+ private onEvent(dir: string, filename: string | null): void {
180
+ if (this.suppressDepth > 0) return; // 抑制窗口:自写文件不触发快照
181
+ if (filename === null || typeof filename !== 'string') return;
182
+ if (isIgnorableFileName(filename)) return;
183
+ const basename = path.basename(filename);
184
+ if (isIgnorableFileName(basename)) return;
185
+ // 同目录同文件去重(fs.watch 常对一次保存投递 change+rename 两个事件)
186
+ this.pending.set(`${dir}\u0000${filename}`, { dir, filename });
187
+ this.schedule();
188
+ }
189
+
190
+ private schedule(): void {
191
+ if (this.timer !== null) this.timers.clearTimeout(this.timer);
192
+ this.timer = this.timers.setTimeout(() => {
193
+ this.timer = null;
194
+ const events = [...this.pending.values()];
195
+ this.pending.clear();
196
+ if (events.length === 0) return;
197
+ if (this.suppressDepth > 0) return; // 定时器期间被抑制 → 丢弃
198
+ this.options.onFlush(events);
199
+ }, this.options.debounceMs);
200
+ }
201
+ }
202
+
203
+ /**
204
+ * 回声登记表:记录「我们刚刚写回的内容指纹」,用于识别监听器收到的
205
+ * 「恢复动作自己的写」这一回声。
206
+ *
207
+ * 与 `beginSuppress/endSuppress` 的分工:抑制窗口覆盖**写操作进行中**的事件;
208
+ * 本表覆盖**窗口之后才被投递**的延迟事件(macOS / 网络盘 / 杀毒软件扫描后
209
+ * 重写文件都可能造成事件晚到)。两条防线缺一不可——竞品的注释与 v0.4.6
210
+ * CHANGELOG 都记录了「macOS 延迟投递的回声快照挡住 redo」这一实际事故。
211
+ */
212
+ export class EchoRegistry {
213
+ private readonly hashes = new Map<string, string>();
214
+
215
+ /** 登记一次写回:key 建议 `${scope}:${relPath}`(如 `config:cordis.patch.yml`) */
216
+ record(key: string, hash: string): void {
217
+ this.hashes.set(key, hash);
218
+ }
219
+
220
+ /** 是否仍等于我们写回的内容(= 回声,应忽略) */
221
+ isEcho(key: string, hash: string): boolean {
222
+ return this.hashes.get(key) === hash;
223
+ }
224
+
225
+ has(key: string): boolean {
226
+ return this.hashes.has(key);
227
+ }
228
+
229
+ get size(): number {
230
+ return this.hashes.size;
231
+ }
232
+
233
+ /**
234
+ * 清空。
235
+ * 语义:**真实变更一旦发生就清空整表**——否则后续对同一路径的合法修改
236
+ * 只要恰好等于某次历史写回内容就会被误判为回声而漏拍。
237
+ */
238
+ clear(): void {
239
+ this.hashes.clear();
240
+ }
241
+ }
242
+
243
+ /**
244
+ * 把事件批按「目录 → 文件名」聚合,便于调用方按关注目录分类。
245
+ * 返回 Map<dir, Set<filename>>(顺序无关,便于集合判定)。
246
+ */
247
+ export function groupEventsByDir(events: readonly WatcherEvent[]): Map<string, Set<string>> {
248
+ const out = new Map<string, Set<string>>();
249
+ for (const e of events) {
250
+ const set = out.get(e.dir) ?? new Set<string>();
251
+ set.add(e.filename);
252
+ out.set(e.dir, set);
253
+ }
254
+ return out;
255
+ }
256
+
257
+ /** 事件批里是否包含关注的文件名(跨目录;用于「配置目录里出现了被关注的 basename」判定) */
258
+ export function hasWatchedBasename(
259
+ events: readonly WatcherEvent[],
260
+ watched: ReadonlySet<string>,
261
+ ): boolean {
262
+ return events.some((e) => watched.has(path.basename(e.filename)));
263
+ }