@interactive-inc/claude-funnel 0.4.1 → 0.8.0

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 (349) hide show
  1. package/README.md +233 -111
  2. package/dist/bin.js +1417 -0
  3. package/dist/gateway/daemon.js +513 -0
  4. package/dist/highlights-eq9cgrbb.scm +604 -0
  5. package/dist/highlights-ghv9g403.scm +205 -0
  6. package/dist/highlights-hk7bwhj4.scm +284 -0
  7. package/dist/highlights-r812a2qc.scm +150 -0
  8. package/dist/highlights-x6tmsnaa.scm +115 -0
  9. package/dist/injections-73j83es3.scm +27 -0
  10. package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  11. package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
  12. package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  13. package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  14. package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
  15. package/lib/bin.ts +78 -0
  16. package/lib/{modules → cli}/router/to-request.ts +13 -20
  17. package/lib/cli/routes/channels.$channel.connectors.$connector.rename.$newName.ts +27 -0
  18. package/lib/cli/routes/channels.$channel.connectors.$connector.request.ts +40 -0
  19. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.add.$id.ts +41 -0
  20. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.remove.$id.ts +22 -0
  21. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.ts +23 -0
  22. package/lib/cli/routes/channels.$channel.connectors.$connector.ts +26 -0
  23. package/lib/cli/routes/channels.$channel.connectors.add.$connector.ts +92 -0
  24. package/lib/cli/routes/channels.$channel.connectors.remove.$connector.ts +22 -0
  25. package/lib/cli/routes/channels.$channel.connectors.set.$connector.ts +63 -0
  26. package/lib/cli/routes/channels.$channel.connectors.ts +26 -0
  27. package/lib/cli/routes/channels.$channel.rename.$newName.ts +22 -0
  28. package/lib/cli/routes/channels.$channel.set.delivery.$mode.ts +34 -0
  29. package/lib/cli/routes/channels.$channel.ts +34 -0
  30. package/lib/cli/routes/channels.add.$channel.ts +33 -0
  31. package/lib/cli/routes/channels.remove.$channel.ts +20 -0
  32. package/lib/cli/routes/channels.ts +39 -0
  33. package/lib/cli/routes/claude.ts +69 -0
  34. package/lib/cli/routes/gateway.listeners.ts +41 -0
  35. package/lib/cli/routes/gateway.logs.ts +123 -0
  36. package/lib/{routes/gateway/restart.ts → cli/routes/gateway.restart.ts} +20 -5
  37. package/lib/cli/routes/gateway.run.ts +41 -0
  38. package/lib/cli/routes/gateway.start.ts +50 -0
  39. package/lib/cli/routes/gateway.status.ts +19 -0
  40. package/lib/cli/routes/gateway.stop.ts +32 -0
  41. package/lib/cli/routes/gateway.ts +55 -0
  42. package/lib/cli/routes/index.ts +202 -0
  43. package/lib/cli/routes/profiles.$profile.as-default.ts +22 -0
  44. package/lib/cli/routes/profiles.$profile.rename.$newName.ts +22 -0
  45. package/lib/cli/routes/profiles.$profile.run.ts +36 -0
  46. package/lib/cli/routes/profiles.add.$profile.ts +46 -0
  47. package/lib/cli/routes/profiles.remove.$profile.ts +20 -0
  48. package/lib/cli/routes/profiles.set.$profile.ts +46 -0
  49. package/lib/cli/routes/profiles.ts +40 -0
  50. package/lib/cli/routes/status.ts +93 -0
  51. package/lib/cli/routes/update.ts +27 -0
  52. package/lib/connectors/connector-config-schema.ts +16 -0
  53. package/lib/connectors/connector-factory.ts +94 -0
  54. package/lib/connectors/connector-listener.ts +20 -0
  55. package/lib/{modules/connectors/funnel-discord-adapter.ts → connectors/discord-adapter.ts} +6 -11
  56. package/lib/{modules/connectors → connectors}/discord-connector-schema.ts +4 -1
  57. package/lib/connectors/discord-listener.ts +111 -0
  58. package/lib/{modules/connectors/funnel-gh-adapter.ts → connectors/gh-adapter.ts} +3 -6
  59. package/lib/{modules/connectors → connectors}/gh-connector-schema.ts +4 -1
  60. package/lib/{modules/connectors/funnel-gh-listener.ts → connectors/gh-listener.ts} +57 -22
  61. package/lib/{modules/connectors → connectors}/match-cron.ts +10 -4
  62. package/lib/connectors/schedule-connector-schema.ts +33 -0
  63. package/lib/connectors/schedule-listener.ts +207 -0
  64. package/lib/connectors/schedule-state-store.ts +54 -0
  65. package/lib/connectors/slack-adapter.ts +36 -0
  66. package/lib/{modules/connectors → connectors}/slack-connector-schema.ts +4 -1
  67. package/lib/{modules/connectors/funnel-slack-event-processor.ts → connectors/slack-event-processor.ts} +15 -9
  68. package/lib/{modules/connectors/funnel-slack-listener.ts → connectors/slack-listener.ts} +39 -14
  69. package/lib/engine/channels/channels.ts +520 -0
  70. package/lib/{modules/claude/funnel-claude.ts → engine/claude/claude.ts} +47 -62
  71. package/lib/engine/claude/gateway-controller.ts +4 -0
  72. package/lib/{modules/fs/funnel-file-system.ts → engine/fs/file-system.ts} +9 -0
  73. package/lib/{modules/fs/memory-funnel-file-system.ts → engine/fs/memory-file-system.ts} +20 -3
  74. package/lib/{modules/fs/node-funnel-file-system.ts → engine/fs/node-file-system.ts} +14 -2
  75. package/lib/{modules/http/memory-funnel-http-client.ts → engine/http/memory-http-client.ts} +1 -5
  76. package/lib/{modules/http/node-funnel-http-client.ts → engine/http/node-http-client.ts} +1 -5
  77. package/lib/engine/id/id-generator.ts +7 -0
  78. package/lib/engine/id/memory-id-generator.ts +20 -0
  79. package/lib/engine/id/node-id-generator.ts +7 -0
  80. package/lib/engine/logger/logger.ts +11 -0
  81. package/lib/engine/logger/memory-logger.ts +28 -0
  82. package/lib/engine/logger/node-logger.ts +49 -0
  83. package/lib/engine/logger/noop-logger.ts +9 -0
  84. package/lib/engine/mcp/channel-server.ts +204 -0
  85. package/lib/{modules/mcp/funnel-mcp.ts → engine/mcp/mcp.ts} +29 -10
  86. package/lib/{modules/process/memory-funnel-process-runner.ts → engine/process/memory-process-runner.ts} +1 -1
  87. package/lib/{modules/process/node-funnel-process-runner.ts → engine/process/node-process-runner.ts} +12 -21
  88. package/lib/{modules/process/funnel-process-runner.ts → engine/process/process-runner.ts} +5 -0
  89. package/lib/engine/profiles/profile-channel-checker.ts +7 -0
  90. package/lib/engine/profiles/profiles.ts +126 -0
  91. package/lib/{modules/settings/mock-funnel-settings-reader.ts → engine/settings/mock-settings-reader.ts} +4 -3
  92. package/lib/{modules/settings/funnel-settings-reader.ts → engine/settings/settings-reader.ts} +1 -1
  93. package/lib/engine/settings/settings-schema.ts +46 -0
  94. package/lib/engine/settings/settings-store.ts +110 -0
  95. package/lib/engine/time/clock.ts +15 -0
  96. package/lib/engine/time/memory-clock.ts +26 -0
  97. package/lib/engine/time/node-clock.ts +7 -0
  98. package/lib/funnel.ts +148 -56
  99. package/lib/gateway/auth-middleware.ts +44 -0
  100. package/lib/gateway/broadcaster.ts +319 -0
  101. package/lib/gateway/daemon.ts +47 -0
  102. package/lib/gateway/factory.ts +10 -0
  103. package/lib/gateway/funnel-event-store.ts +155 -0
  104. package/lib/gateway/gateway-server.ts +414 -0
  105. package/lib/gateway/gateway-token.ts +79 -0
  106. package/lib/{modules/gateway/funnel-gateway.ts → gateway/gateway.ts} +70 -27
  107. package/lib/{modules/gateway → gateway}/kill-competing-slack-gateways.ts +7 -3
  108. package/lib/gateway/listener-supervisor.ts +339 -0
  109. package/lib/gateway/listeners-client.ts +128 -0
  110. package/lib/gateway/resolve-daemon-script.ts +26 -0
  111. package/lib/gateway/routes/channels.connectors.call.ts +39 -0
  112. package/lib/gateway/routes/health.ts +13 -0
  113. package/lib/gateway/routes/index.ts +24 -0
  114. package/lib/gateway/routes/listeners.list.ts +6 -0
  115. package/lib/gateway/routes/listeners.restart.ts +15 -0
  116. package/lib/gateway/routes/listeners.start.ts +15 -0
  117. package/lib/gateway/routes/listeners.stop.ts +15 -0
  118. package/lib/gateway/routes/route-deps.ts +11 -0
  119. package/lib/gateway/routes/status.ts +15 -0
  120. package/lib/gateway/routes/validator.ts +17 -0
  121. package/lib/index.ts +50 -92
  122. package/lib/logger/leuco-human-file-writer.ts +65 -0
  123. package/lib/logger/leuco-human-logger.ts +98 -0
  124. package/lib/logger/leuco-human-record.ts +16 -0
  125. package/lib/logger/leuco-human-stdout-writer.ts +26 -0
  126. package/lib/logger/leuco-human-writer.ts +14 -0
  127. package/lib/logger/leuco-logger-memory-sink.ts +67 -0
  128. package/lib/logger/leuco-logger-record.ts +13 -0
  129. package/lib/logger/leuco-logger-sink.ts +33 -0
  130. package/lib/logger/leuco-logger-sqlite-sink.ts +355 -0
  131. package/lib/logger/leuco-logger.ts +135 -0
  132. package/lib/tui/app.tsx +357 -0
  133. package/lib/tui/components/add-row.tsx +18 -0
  134. package/lib/tui/components/brand.tsx +27 -0
  135. package/lib/tui/components/card.tsx +44 -0
  136. package/lib/tui/components/detail-bar.tsx +46 -0
  137. package/lib/tui/components/editable-field.tsx +33 -0
  138. package/lib/tui/components/empty-state.tsx +11 -0
  139. package/lib/tui/components/gateway-status.tsx +66 -0
  140. package/lib/tui/components/keymap.tsx +29 -0
  141. package/lib/tui/components/menu-item.tsx +73 -0
  142. package/lib/tui/components/menu.tsx +26 -0
  143. package/lib/tui/components/panel-header.tsx +22 -0
  144. package/lib/tui/components/readonly-field.tsx +18 -0
  145. package/lib/tui/components/section-header.tsx +25 -0
  146. package/lib/tui/components/selection-accent.tsx +32 -0
  147. package/lib/tui/components/session-item.tsx +33 -0
  148. package/lib/tui/components/session-list.tsx +33 -0
  149. package/lib/tui/components/ui/hascii/accordion-item.tsx +88 -0
  150. package/lib/tui/components/ui/hascii/accordion.tsx +96 -0
  151. package/lib/tui/components/ui/hascii/alert-dialog.tsx +43 -0
  152. package/lib/tui/components/ui/hascii/badge.tsx +51 -0
  153. package/lib/tui/components/ui/hascii/breadcrumb.tsx +58 -0
  154. package/lib/tui/components/ui/hascii/button.tsx +194 -0
  155. package/lib/tui/components/ui/hascii/card-content.tsx +14 -0
  156. package/lib/tui/components/ui/hascii/card-description.tsx +13 -0
  157. package/lib/tui/components/ui/hascii/card-footer.tsx +14 -0
  158. package/lib/tui/components/ui/hascii/card-header.tsx +14 -0
  159. package/lib/tui/components/ui/hascii/card-title.tsx +13 -0
  160. package/lib/tui/components/ui/hascii/card.tsx +27 -0
  161. package/lib/tui/components/ui/hascii/checkbox.tsx +65 -0
  162. package/lib/tui/components/ui/hascii/command.tsx +159 -0
  163. package/lib/tui/components/ui/hascii/dialog-content.tsx +14 -0
  164. package/lib/tui/components/ui/hascii/dialog-description.tsx +13 -0
  165. package/lib/tui/components/ui/hascii/dialog-footer.tsx +14 -0
  166. package/lib/tui/components/ui/hascii/dialog-header.tsx +14 -0
  167. package/lib/tui/components/ui/hascii/dialog-title.tsx +13 -0
  168. package/lib/tui/components/ui/hascii/dialog.tsx +27 -0
  169. package/lib/tui/components/ui/hascii/file-tree.tsx +142 -0
  170. package/lib/tui/components/ui/hascii/focus-group.tsx +62 -0
  171. package/lib/tui/components/ui/hascii/form-item.tsx +43 -0
  172. package/lib/tui/components/ui/hascii/input-otp.tsx +86 -0
  173. package/lib/tui/components/ui/hascii/input.tsx +130 -0
  174. package/lib/tui/components/ui/hascii/pagination.tsx +105 -0
  175. package/lib/tui/components/ui/hascii/progress.tsx +28 -0
  176. package/lib/tui/components/ui/hascii/select.tsx +131 -0
  177. package/lib/tui/components/ui/hascii/separator.tsx +35 -0
  178. package/lib/tui/components/ui/hascii/sidebar-content.tsx +23 -0
  179. package/lib/tui/components/ui/hascii/sidebar-header.tsx +14 -0
  180. package/lib/tui/components/ui/hascii/sidebar-menu-item.tsx +67 -0
  181. package/lib/tui/components/ui/hascii/sidebar.tsx +24 -0
  182. package/lib/tui/components/ui/hascii/skeleton.tsx +60 -0
  183. package/lib/tui/components/ui/hascii/slider.tsx +91 -0
  184. package/lib/tui/components/ui/hascii/snackbar.tsx +75 -0
  185. package/lib/tui/components/ui/hascii/sparkline.tsx +53 -0
  186. package/lib/tui/components/ui/hascii/spinner.tsx +47 -0
  187. package/lib/tui/components/ui/hascii/stepper.tsx +54 -0
  188. package/lib/tui/components/ui/hascii/switch.tsx +66 -0
  189. package/lib/tui/components/ui/hascii/table.tsx +95 -0
  190. package/lib/tui/components/ui/hascii/tabs.tsx +59 -0
  191. package/lib/tui/components/ui/hascii/toggle-group-item.tsx +45 -0
  192. package/lib/tui/components/ui/hascii/toggle-group.tsx +99 -0
  193. package/lib/tui/components/ui/hascii/tree.tsx +104 -0
  194. package/lib/tui/components/view-shell.tsx +44 -0
  195. package/lib/tui/filter-input.tsx +33 -0
  196. package/lib/tui/hooks/hascii/use-pressable.ts +54 -0
  197. package/lib/tui/parse-comma-list.ts +14 -0
  198. package/lib/tui/profile-launcher.tsx +61 -0
  199. package/lib/tui/scrollbar-options.ts +19 -0
  200. package/lib/tui/sidebar.tsx +50 -0
  201. package/lib/tui/theme.ts +40 -0
  202. package/lib/tui/tui.tsx +20 -0
  203. package/lib/tui/types.ts +38 -0
  204. package/lib/tui/unique-name.ts +18 -0
  205. package/lib/tui/use-event-stream.ts +133 -0
  206. package/lib/tui/use-snapshot.ts +99 -0
  207. package/lib/tui/utils/hascii/form-item-context.tsx +23 -0
  208. package/lib/tui/utils/hascii/input-focus-context.tsx +31 -0
  209. package/lib/tui/utils/hascii/theme-context.tsx +26 -0
  210. package/lib/tui/utils/hascii/theme.ts +176 -0
  211. package/lib/tui/views/channels-view.tsx +108 -0
  212. package/lib/tui/views/connectors-view.tsx +164 -0
  213. package/lib/tui/views/events-view.tsx +160 -0
  214. package/lib/tui/views/listeners-view.tsx +80 -0
  215. package/lib/tui/views/profiles-view.tsx +152 -0
  216. package/package.json +51 -34
  217. package/lib/modules/channels/channel-connector-ref-updater.ts +0 -4
  218. package/lib/modules/channels/funnel-channels.ts +0 -155
  219. package/lib/modules/connectors/connector-config-schema.ts +0 -16
  220. package/lib/modules/connectors/connector-existence-checker.ts +0 -3
  221. package/lib/modules/connectors/funnel-callable-connector-store.ts +0 -9
  222. package/lib/modules/connectors/funnel-connector-listener.ts +0 -5
  223. package/lib/modules/connectors/funnel-connector-stores.ts +0 -24
  224. package/lib/modules/connectors/funnel-connector-type-store.ts +0 -24
  225. package/lib/modules/connectors/funnel-connectors.ts +0 -145
  226. package/lib/modules/connectors/funnel-discord-listener.ts +0 -65
  227. package/lib/modules/connectors/funnel-discord-store.ts +0 -84
  228. package/lib/modules/connectors/funnel-gh-store.ts +0 -84
  229. package/lib/modules/connectors/funnel-json-connector-store.ts +0 -100
  230. package/lib/modules/connectors/funnel-schedule-listener.ts +0 -124
  231. package/lib/modules/connectors/funnel-schedule-store.ts +0 -178
  232. package/lib/modules/connectors/funnel-slack-adapter.ts +0 -31
  233. package/lib/modules/connectors/funnel-slack-store.ts +0 -86
  234. package/lib/modules/connectors/migrate-legacy-connectors.ts +0 -77
  235. package/lib/modules/connectors/schedule-connector-schema.ts +0 -18
  236. package/lib/modules/connectors/schedule-last-fired-store.ts +0 -48
  237. package/lib/modules/gateway/daemon.ts +0 -207
  238. package/lib/modules/gateway/funnel-broadcaster.ts +0 -37
  239. package/lib/modules/gateway/funnel-event-logger.ts +0 -59
  240. package/lib/modules/logger.ts +0 -26
  241. package/lib/modules/mcp/channel-server.ts +0 -76
  242. package/lib/modules/profiles/funnel-profiles.ts +0 -123
  243. package/lib/modules/profiles/profile-channel-checker.ts +0 -3
  244. package/lib/modules/profiles/profile-channel-ref-updater.ts +0 -3
  245. package/lib/modules/repos/funnel-repositories.ts +0 -107
  246. package/lib/modules/schedule/funnel-schedule.ts +0 -34
  247. package/lib/modules/settings/funnel-settings-store.ts +0 -56
  248. package/lib/modules/settings/settings-schema.ts +0 -33
  249. package/lib/modules/tui/app.tsx +0 -44
  250. package/lib/modules/tui/tui.tsx +0 -13
  251. package/lib/routes/channels/add.help.ts +0 -3
  252. package/lib/routes/channels/add.ts +0 -21
  253. package/lib/routes/channels/connectors-attach.help.ts +0 -3
  254. package/lib/routes/channels/connectors-attach.ts +0 -17
  255. package/lib/routes/channels/connectors-detach.help.ts +0 -3
  256. package/lib/routes/channels/connectors-detach.ts +0 -17
  257. package/lib/routes/channels/group.help.ts +0 -16
  258. package/lib/routes/channels/group.ts +0 -22
  259. package/lib/routes/channels/remove.help.ts +0 -3
  260. package/lib/routes/channels/remove.ts +0 -17
  261. package/lib/routes/channels/rename.help.ts +0 -5
  262. package/lib/routes/channels/rename.ts +0 -17
  263. package/lib/routes/channels/routes.ts +0 -19
  264. package/lib/routes/channels/show.help.ts +0 -1
  265. package/lib/routes/channels/show.ts +0 -26
  266. package/lib/routes/claude/claude.help.ts +0 -16
  267. package/lib/routes/claude/claude.ts +0 -76
  268. package/lib/routes/claude/routes.ts +0 -4
  269. package/lib/routes/connectors/add.help.ts +0 -28
  270. package/lib/routes/connectors/add.ts +0 -64
  271. package/lib/routes/connectors/group.help.ts +0 -14
  272. package/lib/routes/connectors/group.ts +0 -18
  273. package/lib/routes/connectors/remove.help.ts +0 -3
  274. package/lib/routes/connectors/remove.ts +0 -17
  275. package/lib/routes/connectors/rename.help.ts +0 -5
  276. package/lib/routes/connectors/rename.ts +0 -17
  277. package/lib/routes/connectors/routes.ts +0 -23
  278. package/lib/routes/connectors/schedules-add.help.ts +0 -11
  279. package/lib/routes/connectors/schedules-add.ts +0 -33
  280. package/lib/routes/connectors/schedules-group.help.ts +0 -1
  281. package/lib/routes/connectors/schedules-group.ts +0 -38
  282. package/lib/routes/connectors/schedules-remove.help.ts +0 -3
  283. package/lib/routes/connectors/schedules-remove.ts +0 -17
  284. package/lib/routes/connectors/set.help.ts +0 -8
  285. package/lib/routes/connectors/set.ts +0 -72
  286. package/lib/routes/connectors/show.help.ts +0 -1
  287. package/lib/routes/connectors/show.ts +0 -41
  288. package/lib/routes/gateway/group.help.ts +0 -15
  289. package/lib/routes/gateway/group.ts +0 -28
  290. package/lib/routes/gateway/logs.help.ts +0 -13
  291. package/lib/routes/gateway/logs.ts +0 -100
  292. package/lib/routes/gateway/restart.help.ts +0 -10
  293. package/lib/routes/gateway/routes.ts +0 -18
  294. package/lib/routes/gateway/run.help.ts +0 -12
  295. package/lib/routes/gateway/run.ts +0 -35
  296. package/lib/routes/gateway/start.help.ts +0 -15
  297. package/lib/routes/gateway/start.ts +0 -32
  298. package/lib/routes/gateway/status.help.ts +0 -9
  299. package/lib/routes/gateway/status.ts +0 -28
  300. package/lib/routes/gateway/stop.help.ts +0 -8
  301. package/lib/routes/gateway/stop.ts +0 -21
  302. package/lib/routes/profiles/add.help.ts +0 -3
  303. package/lib/routes/profiles/add.ts +0 -33
  304. package/lib/routes/profiles/group.help.ts +0 -16
  305. package/lib/routes/profiles/group.ts +0 -25
  306. package/lib/routes/profiles/launch.help.ts +0 -4
  307. package/lib/routes/profiles/launch.ts +0 -36
  308. package/lib/routes/profiles/remove.help.ts +0 -3
  309. package/lib/routes/profiles/remove.ts +0 -17
  310. package/lib/routes/profiles/rename.help.ts +0 -5
  311. package/lib/routes/profiles/rename.ts +0 -17
  312. package/lib/routes/profiles/routes.ts +0 -18
  313. package/lib/routes/profiles/set.help.ts +0 -5
  314. package/lib/routes/profiles/set.ts +0 -32
  315. package/lib/routes/repos/add.help.ts +0 -6
  316. package/lib/routes/repos/add.ts +0 -20
  317. package/lib/routes/repos/group.help.ts +0 -11
  318. package/lib/routes/repos/group.ts +0 -18
  319. package/lib/routes/repos/remove.help.ts +0 -3
  320. package/lib/routes/repos/remove.ts +0 -17
  321. package/lib/routes/repos/rename.help.ts +0 -5
  322. package/lib/routes/repos/rename.ts +0 -17
  323. package/lib/routes/repos/routes.ts +0 -17
  324. package/lib/routes/repos/set.help.ts +0 -5
  325. package/lib/routes/repos/set.ts +0 -21
  326. package/lib/routes/repos/show.help.ts +0 -1
  327. package/lib/routes/repos/show.ts +0 -19
  328. package/lib/routes/request/discord-help.ts +0 -9
  329. package/lib/routes/request/discord.help.ts +0 -19
  330. package/lib/routes/request/discord.ts +0 -65
  331. package/lib/routes/request/group.help.ts +0 -15
  332. package/lib/routes/request/group.ts +0 -9
  333. package/lib/routes/request/routes.ts +0 -14
  334. package/lib/routes/request/slack-help.ts +0 -9
  335. package/lib/routes/request/slack.help.ts +0 -19
  336. package/lib/routes/request/slack.ts +0 -61
  337. package/lib/routes/status/routes.ts +0 -4
  338. package/lib/routes/status/status.help.ts +0 -6
  339. package/lib/routes/status/status.ts +0 -77
  340. package/lib/routes/update/routes.ts +0 -4
  341. package/lib/routes/update/update.help.ts +0 -5
  342. package/lib/routes/update/update.ts +0 -21
  343. package/lib/routes.ts +0 -40
  344. /package/lib/{factory.ts → cli/factory.ts} +0 -0
  345. /package/lib/{modules → cli}/router/query-to-cli-args.ts +0 -0
  346. /package/lib/{modules → cli}/router/validator.ts +0 -0
  347. /package/lib/{modules/connectors/funnel-connector-adapter.ts → connectors/connector-adapter.ts} +0 -0
  348. /package/lib/{modules/connectors/funnel-discord-event-processor.ts → connectors/discord-event-processor.ts} +0 -0
  349. /package/lib/{modules/http/funnel-http-client.ts → engine/http/http-client.ts} +0 -0
@@ -0,0 +1,204 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js"
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
6
+ import {
7
+ CallToolRequestSchema,
8
+ ListToolsRequestSchema,
9
+ } from "@modelcontextprotocol/sdk/types.js"
10
+ import { FUNNEL_MCP_NAME } from "@/engine/mcp/mcp"
11
+ import { settingsSchema } from "@/engine/settings/settings-schema"
12
+
13
+ const GATEWAY_BASE_URL = process.env.FUNNEL_GATEWAY_URL ?? "http://localhost:9742"
14
+ const GATEWAY_WS_URL = `${GATEWAY_BASE_URL.replace(/^http/, "ws")}/ws`
15
+ const RECONNECT_DELAY = 1000
16
+ const MAX_RECONNECT_DELAY = 10000
17
+ const SETTINGS_PATH = join(homedir(), ".funnel", "settings.json")
18
+ const TOOL_CONNECTOR_TYPES = new Set(["slack", "gh", "discord"])
19
+
20
+ const readGatewayToken = (): string | null => {
21
+ const fromEnv = process.env.FUNNEL_GATEWAY_TOKEN
22
+
23
+ if (fromEnv && fromEnv.length > 0) return fromEnv
24
+
25
+ const path = join(homedir(), ".funnel", "gateway.token")
26
+
27
+ if (!existsSync(path)) return null
28
+
29
+ const value = readFileSync(path, "utf-8").trim()
30
+
31
+ return value.length > 0 ? value : null
32
+ }
33
+
34
+ const readChannelConnectors = (
35
+ channelId: string,
36
+ ): { channelName: string; connectors: { name: string; type: string }[] } | null => {
37
+ if (!existsSync(SETTINGS_PATH)) return null
38
+
39
+ const raw = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"))
40
+ const parsed = settingsSchema.safeParse(raw)
41
+
42
+ if (!parsed.success) return null
43
+
44
+ const channel = parsed.data.channels.find((c) => c.id === channelId)
45
+
46
+ if (!channel) return null
47
+
48
+ const connectors = channel.connectors
49
+ .filter((c) => TOOL_CONNECTOR_TYPES.has(c.type))
50
+ .map((c) => ({ name: c.name, type: c.type }))
51
+
52
+ return { channelName: channel.name, connectors }
53
+ }
54
+
55
+ const usageHintForType = (type: string): string => {
56
+ if (type === "slack") {
57
+ return "Slack Web API. method=POST path=chat.postMessage body={channel,text,thread_ts?}"
58
+ }
59
+
60
+ if (type === "discord") {
61
+ return "Discord REST API. method=POST path=/channels/<id>/messages body={content,...}"
62
+ }
63
+
64
+ if (type === "gh") {
65
+ return "GitHub REST via gh CLI. method=POST path=repos/owner/repo/issues/N/comments body={body}"
66
+ }
67
+
68
+ return "Generic adapter call."
69
+ }
70
+
71
+ export const startChannelServer = async (): Promise<void> => {
72
+ const channelId = process.env.FUNNEL_CHANNEL_ID
73
+ const channel = channelId ? readChannelConnectors(channelId) : null
74
+ const token = readGatewayToken()
75
+
76
+ const server = new Server(
77
+ { name: FUNNEL_MCP_NAME, version: "1.0.0" },
78
+ {
79
+ capabilities: {
80
+ experimental: { "claude/channel": {} },
81
+ tools: {},
82
+ },
83
+ instructions: [
84
+ `Events arrive inside <channel source="${FUNNEL_MCP_NAME}"> tags. Use meta.event_type to discriminate.`,
85
+ "",
86
+ "To reply or act, call the connector tool exposed by this MCP (one tool per connector configured on this channel). Each tool takes { method, path, body } matching the underlying adapter's CallInput.",
87
+ ].join("\n"),
88
+ },
89
+ )
90
+
91
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
92
+ const tools = (channel?.connectors ?? []).map((c) => ({
93
+ name: c.name,
94
+ description: `Call the "${c.name}" (${c.type}) connector. ${usageHintForType(c.type)}`,
95
+ inputSchema: {
96
+ type: "object" as const,
97
+ properties: {
98
+ method: { type: "string", description: "HTTP verb or API method (e.g. POST, chat.postMessage)" },
99
+ path: { type: "string", description: "API path or method name (adapter-specific)" },
100
+ body: { type: "object", description: "Request body / params (adapter-specific)" },
101
+ },
102
+ required: ["method", "path"],
103
+ },
104
+ }))
105
+
106
+ return { tools }
107
+ })
108
+
109
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
110
+ if (!channel) {
111
+ throw new Error("FUNNEL_CHANNEL_ID is not set or channel not found in settings.json")
112
+ }
113
+
114
+ const connectorName = request.params.name
115
+ const args = (request.params.arguments ?? {}) as Record<string, unknown>
116
+ const method = typeof args.method === "string" ? args.method : ""
117
+ const path = typeof args.path === "string" ? args.path : ""
118
+ const body = args.body ?? {}
119
+
120
+ if (!method || !path) {
121
+ throw new Error("`method` and `path` are required")
122
+ }
123
+
124
+ const url = `${GATEWAY_BASE_URL}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(connectorName)}/call`
125
+ const headers: Record<string, string> = { "content-type": "application/json" }
126
+
127
+ if (token) headers.authorization = `Bearer ${token}`
128
+
129
+ const res = await fetch(url, {
130
+ method: "POST",
131
+ headers,
132
+ body: JSON.stringify({ method, path, body }),
133
+ })
134
+
135
+ const text = await res.text()
136
+
137
+ if (!res.ok) {
138
+ throw new Error(`gateway call failed (${res.status}): ${text}`)
139
+ }
140
+
141
+ return {
142
+ content: [{ type: "text", text }],
143
+ }
144
+ })
145
+
146
+ const transport = new StdioServerTransport()
147
+
148
+ await server.connect(transport)
149
+
150
+ if (!channelId) return
151
+
152
+ const baseUrl = `${GATEWAY_WS_URL}?channel=${encodeURIComponent(channelId)}`
153
+ const protocols = token ? [`funnel.token.${token}`] : undefined
154
+ let reconnectDelay = RECONNECT_DELAY
155
+ let lastOffset = 0
156
+
157
+ const connect = () => {
158
+ const sinceQuery = lastOffset > 0 ? `&since=${lastOffset}` : ""
159
+ const wsUrl = `${baseUrl}${sinceQuery}`
160
+ const ws = new WebSocket(wsUrl, protocols)
161
+
162
+ ws.addEventListener("open", () => {
163
+ reconnectDelay = RECONNECT_DELAY
164
+ process.stderr.write(`funnel: connected (${wsUrl})\n`)
165
+ })
166
+
167
+ ws.addEventListener("message", async (event) => {
168
+ try {
169
+ const payload = JSON.parse(String(event.data))
170
+ const eventType = payload.meta?.event_type ?? "unknown"
171
+
172
+ if (typeof payload.offset === "number" && payload.offset > lastOffset) {
173
+ lastOffset = payload.offset
174
+ }
175
+
176
+ process.stderr.write(`funnel: received event (${eventType})\n`)
177
+
178
+ await server.notification({
179
+ method: "notifications/claude/channel",
180
+ params: {
181
+ content: payload.content,
182
+ meta: payload.meta,
183
+ },
184
+ })
185
+ } catch (error) {
186
+ process.stderr.write(
187
+ `funnel: error: ${error instanceof Error ? error.message : String(error)}\n`,
188
+ )
189
+ }
190
+ })
191
+
192
+ ws.addEventListener("close", () => {
193
+ process.stderr.write(`funnel: disconnected, reconnecting in ${reconnectDelay}ms\n`)
194
+ setTimeout(connect, reconnectDelay)
195
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
196
+ })
197
+
198
+ ws.addEventListener("error", () => {
199
+ // close handler will reconnect
200
+ })
201
+ }
202
+
203
+ connect()
204
+ }
@@ -1,18 +1,22 @@
1
1
  import { join } from "node:path"
2
- import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
3
- import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
2
+ import { z } from "zod"
3
+ import { FunnelFileSystem } from "@/engine/fs/file-system"
4
+ import { NodeFunnelFileSystem } from "@/engine/fs/node-file-system"
4
5
 
5
6
  export const FUNNEL_MCP_COMMAND = "funnel"
6
7
  export const FUNNEL_MCP_NAME = "funnel"
7
8
 
8
- type McpEntry = {
9
- command?: string
10
- args?: string[]
11
- }
9
+ const mcpEntrySchema = z.object({
10
+ command: z.string().optional(),
11
+ args: z.array(z.string()).optional(),
12
+ })
12
13
 
13
- type McpConfig = {
14
- mcpServers?: Record<string, McpEntry>
15
- }
14
+ const mcpConfigSchema = z.object({
15
+ mcpServers: z.record(z.string(), mcpEntrySchema).optional(),
16
+ })
17
+
18
+ type McpEntry = z.infer<typeof mcpEntrySchema>
19
+ type McpConfig = z.infer<typeof mcpConfigSchema>
16
20
 
17
21
  type Deps = {
18
22
  fs?: FunnelFileSystem
@@ -20,6 +24,11 @@ type Deps = {
20
24
 
21
25
  const defaultFs = new NodeFunnelFileSystem()
22
26
 
27
+ /**
28
+ * Installs/uninstalls the funnel MCP entry into a target repository's
29
+ * `.mcp.json`. Detects an existing entry by command match so renaming is
30
+ * preserved across re-installs.
31
+ */
23
32
  export class FunnelMcp {
24
33
  private readonly fs: FunnelFileSystem
25
34
 
@@ -90,13 +99,23 @@ export class FunnelMcp {
90
99
 
91
100
  if (!content) return {}
92
101
 
102
+ let parsed: unknown
103
+
93
104
  try {
94
- return JSON.parse(content) as McpConfig
105
+ parsed = JSON.parse(content)
95
106
  } catch (error) {
96
107
  throw new Error(
97
108
  `invalid .mcp.json (${mcpPath}): ${error instanceof Error ? error.message : String(error)}`,
98
109
  )
99
110
  }
111
+
112
+ const result = mcpConfigSchema.safeParse(parsed)
113
+
114
+ if (!result.success) {
115
+ throw new Error(`invalid .mcp.json (${mcpPath}): ${result.error.message}`)
116
+ }
117
+
118
+ return result.data
100
119
  }
101
120
 
102
121
  private writeConfig(repoPath: string, config: McpConfig): void {
@@ -4,7 +4,7 @@ import {
4
4
  FunnelProcessRunner,
5
5
  type RunOptions,
6
6
  type RunResult,
7
- } from "@/modules/process/funnel-process-runner"
7
+ } from "@/engine/process/process-runner"
8
8
 
9
9
  export type MemoryProcessResponse = {
10
10
  exitCode?: number
@@ -4,12 +4,22 @@ import {
4
4
  FunnelProcessRunner,
5
5
  type RunOptions,
6
6
  type RunResult,
7
- } from "@/modules/process/funnel-process-runner"
7
+ } from "@/engine/process/process-runner"
8
8
 
9
9
  const toEnv = (env?: Record<string, string>): Record<string, string> | undefined => {
10
10
  if (!env) return undefined
11
11
 
12
- return { ...(process.env as Record<string, string>), ...env }
12
+ const merged: Record<string, string> = {}
13
+
14
+ for (const [key, value] of Object.entries(process.env)) {
15
+ if (typeof value === "string") merged[key] = value
16
+ }
17
+
18
+ for (const [key, value] of Object.entries(env)) {
19
+ merged[key] = value
20
+ }
21
+
22
+ return merged
13
23
  }
14
24
 
15
25
  export class NodeFunnelProcessRunner extends FunnelProcessRunner {
@@ -59,25 +69,6 @@ export class NodeFunnelProcessRunner extends FunnelProcessRunner {
59
69
  stdio: ["inherit", "inherit", "inherit"],
60
70
  })
61
71
 
62
- const forward = (signal: "SIGINT" | "SIGTERM") => {
63
- try {
64
- proc.kill(signal)
65
- } catch {
66
- // ignore
67
- }
68
-
69
- setTimeout(() => {
70
- try {
71
- proc.kill("SIGKILL")
72
- } catch {
73
- // ignore
74
- }
75
- }, 3000).unref()
76
- }
77
-
78
- process.on("SIGINT", () => forward("SIGINT"))
79
- process.on("SIGTERM", () => forward("SIGTERM"))
80
-
81
72
  return await proc.exited
82
73
  }
83
74
 
@@ -19,6 +19,11 @@ export type DetachOptions = {
19
19
  env?: Record<string, string>
20
20
  }
21
21
 
22
+ /**
23
+ * Process boundary covering one-shot runs, sync runs, foreground attach, and
24
+ * detached background spawns. Default is NodeFunnelProcessRunner (Bun.spawn);
25
+ * MemoryFunnelProcessRunner records calls and lets tests stub responses.
26
+ */
22
27
  export abstract class FunnelProcessRunner {
23
28
  abstract run(command: string[], options?: RunOptions): Promise<RunResult>
24
29
  abstract runSync(command: string[]): RunResult
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Read-side dependency that lets FunnelChannels ask whether a profile
3
+ * references a given channel id, without depending on FunnelProfiles directly.
4
+ */
5
+ export type ProfileChannelChecker = {
6
+ hasChannelRef(channelId: string): boolean
7
+ }
@@ -0,0 +1,126 @@
1
+ import { FunnelSettingsReader } from "@/engine/settings/settings-reader"
2
+ import type { ProfileConfig } from "@/engine/settings/settings-schema"
3
+
4
+ type Deps = {
5
+ store: FunnelSettingsReader
6
+ }
7
+
8
+ /**
9
+ * Named launch presets for `fnl claude`. Each profile bundles a working
10
+ * directory, a sub-agent name, and the channel id its Claude instance will
11
+ * subscribe to. Implements ProfileChannelChecker so FunnelChannels can refuse
12
+ * to remove a channel that is still referenced.
13
+ *
14
+ * The first entry in the persisted array is treated as the default profile;
15
+ * `asDefault` reorders the array to put a named profile first.
16
+ *
17
+ * `channelId` always stores the channel's stable id (uuid). CLI surfaces
18
+ * resolve channel name → id before calling `add`/`update` here.
19
+ */
20
+ export class FunnelProfiles {
21
+ private readonly store: FunnelSettingsReader
22
+
23
+ constructor(deps: Deps) {
24
+ this.store = deps.store
25
+ Object.freeze(this)
26
+ }
27
+
28
+ list(): ProfileConfig[] {
29
+ return this.store.read().profiles
30
+ }
31
+
32
+ get(name: string): ProfileConfig | null {
33
+ return this.list().find((p) => p.name === name) ?? null
34
+ }
35
+
36
+ getDefault(): ProfileConfig | null {
37
+ return this.list()[0] ?? null
38
+ }
39
+
40
+ add(config: ProfileConfig): void {
41
+ const settings = this.store.read()
42
+
43
+ if (settings.profiles.some((p) => p.name === config.name)) {
44
+ throw new Error(`profile "${config.name}" already exists`)
45
+ }
46
+
47
+ if (!settings.channels.some((c) => c.id === config.channelId)) {
48
+ throw new Error(`channel id "${config.channelId}" not found`)
49
+ }
50
+
51
+ settings.profiles.push(config)
52
+
53
+ this.store.write(settings)
54
+ }
55
+
56
+ remove(name: string): void {
57
+ const settings = this.store.read()
58
+
59
+ const index = settings.profiles.findIndex((p) => p.name === name)
60
+
61
+ if (index < 0) throw new Error(`profile "${name}" not found`)
62
+
63
+ settings.profiles.splice(index, 1)
64
+
65
+ this.store.write(settings)
66
+ }
67
+
68
+ rename(oldName: string, newName: string): void {
69
+ const settings = this.store.read()
70
+
71
+ const profile = settings.profiles.find((p) => p.name === oldName)
72
+
73
+ if (!profile) throw new Error(`profile "${oldName}" not found`)
74
+
75
+ if (settings.profiles.some((p) => p.name === newName)) {
76
+ throw new Error(`profile "${newName}" already exists`)
77
+ }
78
+
79
+ profile.name = newName
80
+
81
+ this.store.write(settings)
82
+ }
83
+
84
+ asDefault(name: string): void {
85
+ const settings = this.store.read()
86
+
87
+ const index = settings.profiles.findIndex((p) => p.name === name)
88
+
89
+ if (index < 0) throw new Error(`profile "${name}" not found`)
90
+
91
+ if (index === 0) return
92
+
93
+ const [profile] = settings.profiles.splice(index, 1)
94
+
95
+ if (!profile) return
96
+
97
+ settings.profiles.unshift(profile)
98
+
99
+ this.store.write(settings)
100
+ }
101
+
102
+ hasChannelRef(channelId: string): boolean {
103
+ return this.store.read().profiles.some((p) => p.channelId === channelId)
104
+ }
105
+
106
+ update(name: string, fields: Partial<Omit<ProfileConfig, "name">>): void {
107
+ const settings = this.store.read()
108
+
109
+ const profile = settings.profiles.find((p) => p.name === name)
110
+
111
+ if (!profile) throw new Error(`profile "${name}" not found`)
112
+
113
+ if (fields.channelId !== undefined) {
114
+ if (!settings.channels.some((c) => c.id === fields.channelId)) {
115
+ throw new Error(`channel id "${fields.channelId}" not found`)
116
+ }
117
+
118
+ profile.channelId = fields.channelId
119
+ }
120
+
121
+ if (fields.path !== undefined) profile.path = fields.path
122
+ if (fields.subAgent !== undefined) profile.subAgent = fields.subAgent
123
+
124
+ this.store.write(settings)
125
+ }
126
+ }
@@ -1,9 +1,10 @@
1
- import { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
2
- import type { Settings } from "@/modules/settings/settings-schema"
1
+ import { FunnelSettingsReader } from "@/engine/settings/settings-reader"
2
+ import { SETTINGS_VERSION } from "@/engine/settings/settings-schema"
3
+ import type { Settings } from "@/engine/settings/settings-schema"
3
4
 
4
5
  export const createSettings = (partial: Partial<Settings> = {}): Settings => ({
6
+ version: SETTINGS_VERSION,
5
7
  channels: [],
6
- repositories: [],
7
8
  profiles: [],
8
9
  ...partial,
9
10
  })
@@ -1,4 +1,4 @@
1
- import type { Settings } from "@/modules/settings/settings-schema"
1
+ import type { Settings } from "@/engine/settings/settings-schema"
2
2
 
3
3
  export abstract class FunnelSettingsReader {
4
4
  abstract read(): Settings
@@ -0,0 +1,46 @@
1
+ import { z } from "zod"
2
+ import { connectorConfigSchema } from "@/connectors/connector-config-schema"
3
+
4
+ /**
5
+ * Routing mode when multiple WS clients are subscribed to the same channel.
6
+ *
7
+ * - `fanout` (default): every connected client receives every event. Right when each
8
+ * subscriber has its own job (e.g., TUI mirrors, distinct Claude profiles each running
9
+ * their own pipeline against the same source).
10
+ * - `exclusive`: each event is delivered to exactly one connected client, picked
11
+ * round-robin per channel. Right when subscribers are interchangeable workers and you
12
+ * want each event handled once. Tap=all clients (TUI dashboard) always receive,
13
+ * regardless of mode, so they can passively observe.
14
+ */
15
+ export const channelDeliveryModeSchema = z.enum(["fanout", "exclusive"])
16
+
17
+ export type ChannelDeliveryMode = z.infer<typeof channelDeliveryModeSchema>
18
+
19
+ export const channelConfigSchema = z.object({
20
+ id: z.string(),
21
+ name: z.string(),
22
+ delivery: channelDeliveryModeSchema.default("fanout"),
23
+ connectors: z.array(connectorConfigSchema).default([]),
24
+ })
25
+
26
+ export type ChannelConfig = z.infer<typeof channelConfigSchema>
27
+
28
+ export const profileConfigSchema = z.object({
29
+ name: z.string(),
30
+ path: z.string(),
31
+ subAgent: z.string(),
32
+ channelId: z.string(),
33
+ })
34
+
35
+ export type ProfileConfig = z.infer<typeof profileConfigSchema>
36
+
37
+ export const SETTINGS_VERSION = 1
38
+
39
+ export const settingsSchema = z.object({
40
+ /** Schema version. New files always write the current version; older files without one are read as v1. */
41
+ version: z.literal(SETTINGS_VERSION).default(SETTINGS_VERSION),
42
+ channels: z.array(channelConfigSchema).default([]),
43
+ profiles: z.array(profileConfigSchema).default([]),
44
+ })
45
+
46
+ export type Settings = z.infer<typeof settingsSchema>
@@ -0,0 +1,110 @@
1
+ import { homedir } from "node:os"
2
+ import { dirname, join } from "node:path"
3
+ import { FunnelFileSystem } from "@/engine/fs/file-system"
4
+ import { NodeFunnelFileSystem } from "@/engine/fs/node-file-system"
5
+ import { FunnelSettingsReader } from "@/engine/settings/settings-reader"
6
+ import { SETTINGS_VERSION, settingsSchema } from "@/engine/settings/settings-schema"
7
+ import type { Settings } from "@/engine/settings/settings-schema"
8
+
9
+ export const FUNNEL_DIR = join(homedir(), ".funnel")
10
+ export const SETTINGS_PATH = join(FUNNEL_DIR, "settings.json")
11
+
12
+ type Deps = {
13
+ path?: string
14
+ fs?: FunnelFileSystem
15
+ }
16
+
17
+ const defaultFs = new NodeFunnelFileSystem()
18
+
19
+ export class FunnelSettingsStore extends FunnelSettingsReader {
20
+ private readonly path: string
21
+ private readonly fs: FunnelFileSystem
22
+
23
+ constructor(deps: Deps = {}) {
24
+ super()
25
+ this.path = deps.path ?? SETTINGS_PATH
26
+ this.fs = deps.fs ?? defaultFs
27
+ Object.freeze(this)
28
+ }
29
+
30
+ read(): Settings {
31
+ if (!this.fs.existsSync(this.path)) {
32
+ return {
33
+ version: SETTINGS_VERSION,
34
+ channels: [],
35
+ profiles: [],
36
+ }
37
+ }
38
+
39
+ const content = this.fs.readFileSync(this.path)
40
+ const parsed: unknown = JSON.parse(content)
41
+
42
+ if (this.looksLikeLegacy(parsed)) {
43
+ throw new Error(
44
+ `legacy settings.json detected at ${this.path}. The schema changed (channel.connectors are now nested objects with ids; profile fields renamed). Migration is intentionally not provided. Back up and remove the old file:\n mv ${this.path} ${this.path}.bak`,
45
+ )
46
+ }
47
+
48
+ if (
49
+ parsed &&
50
+ typeof parsed === "object" &&
51
+ "version" in parsed &&
52
+ parsed.version !== SETTINGS_VERSION
53
+ ) {
54
+ throw new Error(
55
+ `unsupported settings.json version (${this.path}): expected ${SETTINGS_VERSION}, got ${String(parsed.version)}`,
56
+ )
57
+ }
58
+
59
+ const result = settingsSchema.safeParse(parsed)
60
+
61
+ if (!result.success) {
62
+ throw new Error(
63
+ `invalid settings.json (${this.path}): ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")}`,
64
+ )
65
+ }
66
+
67
+ return result.data
68
+ }
69
+
70
+ private looksLikeLegacy(parsed: unknown): boolean {
71
+ if (!parsed || typeof parsed !== "object") return false
72
+
73
+ const obj = parsed as Record<string, unknown>
74
+
75
+ if (Array.isArray(obj.channels)) {
76
+ for (const channel of obj.channels) {
77
+ if (!channel || typeof channel !== "object") continue
78
+ const ch = channel as Record<string, unknown>
79
+
80
+ if (Array.isArray(ch.connectors) && ch.connectors.some((x) => typeof x === "string")) {
81
+ return true
82
+ }
83
+
84
+ if (!("id" in ch) && "name" in ch) return true
85
+ }
86
+ }
87
+
88
+ if (Array.isArray(obj.connectors)) return true
89
+ if (Array.isArray(obj.repositories)) return true
90
+
91
+ if (Array.isArray(obj.profiles)) {
92
+ for (const profile of obj.profiles) {
93
+ if (!profile || typeof profile !== "object") continue
94
+ const p = profile as Record<string, unknown>
95
+
96
+ if ("repository" in p || "envFiles" in p || ("channel" in p && !("channelId" in p))) {
97
+ return true
98
+ }
99
+ }
100
+ }
101
+
102
+ return false
103
+ }
104
+
105
+ write(settings: Settings): void {
106
+ this.fs.mkdirSync(dirname(this.path), { recursive: true })
107
+ const versioned: Settings = { ...settings, version: SETTINGS_VERSION }
108
+ this.fs.writeFileSync(this.path, `${JSON.stringify(versioned, null, 2)}\n`)
109
+ }
110
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Time boundary. Default NodeFunnelClock returns `new Date()`; MemoryFunnelClock
3
+ * is settable and `advance(ms)`-able for deterministic schedule / timeout tests.
4
+ */
5
+ export abstract class FunnelClock {
6
+ abstract now(): Date
7
+
8
+ millis(): number {
9
+ return this.now().getTime()
10
+ }
11
+
12
+ iso(): string {
13
+ return this.now().toISOString()
14
+ }
15
+ }