catherd-cli 1.4.0 → 2.0.0-beta.20261022113309

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 (320) hide show
  1. package/README.md +188 -169
  2. package/THIRD_PARTY_NOTICES.md +217 -35
  3. package/catalog/jev.json +27 -1
  4. package/catalog/models.json +56 -239
  5. package/package.json +6 -8
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/commands/catherd.md +2 -2
  10. package/plugin/skills/catherd/SKILL.md +97 -356
  11. package/plugin/skills/catherd-setup/SKILL.md +21 -20
  12. package/src/adapters/all.ts +0 -4
  13. package/src/adapters/backend.ts +19 -4
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/codex/events.ts +10 -0
  16. package/src/adapters/codex/index.ts +17 -5
  17. package/src/adapters/cursor/home.ts +1 -1
  18. package/src/adapters/cursor/index.ts +10 -4
  19. package/src/adapters/opencode/index.ts +38 -1
  20. package/src/cli.ts +27 -27
  21. package/src/domain/agents.ts +3 -1
  22. package/src/domain/bars.ts +33 -107
  23. package/src/domain/blocked.ts +72 -0
  24. package/src/domain/branch.ts +51 -0
  25. package/src/domain/brief.ts +106 -0
  26. package/src/domain/catalog.ts +69 -313
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +19 -29
  29. package/src/domain/errors.ts +6 -2
  30. package/src/domain/fail-lines.ts +26 -0
  31. package/src/domain/failover.ts +37 -75
  32. package/src/domain/findings.ts +47 -0
  33. package/src/domain/gate-brief.ts +53 -0
  34. package/src/domain/hints.ts +13 -2
  35. package/src/domain/ids.ts +13 -2
  36. package/src/domain/jev.ts +47 -2
  37. package/src/domain/judge.ts +124 -0
  38. package/src/domain/lane.ts +118 -3
  39. package/src/domain/milestone-memory.ts +53 -0
  40. package/src/domain/notice.ts +32 -8
  41. package/src/domain/notify.ts +32 -0
  42. package/src/domain/plan-doc.ts +198 -0
  43. package/src/domain/plan-lint.ts +141 -0
  44. package/src/domain/profile-rules.ts +175 -138
  45. package/src/domain/profile.ts +140 -63
  46. package/src/domain/record.ts +68 -3
  47. package/src/domain/reply.ts +29 -0
  48. package/src/domain/role-prompts.ts +182 -56
  49. package/src/domain/role-scope.ts +76 -0
  50. package/src/domain/role-tools.ts +34 -0
  51. package/src/domain/roles.ts +2 -0
  52. package/src/domain/route.ts +90 -0
  53. package/src/domain/rules.ts +33 -0
  54. package/src/domain/run-events.ts +298 -0
  55. package/src/domain/run-machine.ts +668 -0
  56. package/src/domain/run-state.ts +1310 -0
  57. package/src/domain/scorecard.ts +197 -0
  58. package/src/domain/select.ts +229 -72
  59. package/src/domain/slopalytics.ts +72 -0
  60. package/src/domain/state.ts +5 -0
  61. package/src/domain/util.ts +22 -0
  62. package/src/domain/workspace.ts +130 -0
  63. package/src/entry/answer-command.ts +39 -0
  64. package/src/entry/catalog-command.ts +24 -165
  65. package/src/entry/check-bin.ts +17 -0
  66. package/src/entry/cli-kit.ts +18 -2
  67. package/src/entry/daemon-bin.ts +22 -0
  68. package/src/entry/deps.ts +2 -0
  69. package/src/entry/doctor-command.ts +20 -2
  70. package/src/entry/glyphs.ts +15 -2
  71. package/src/entry/host-arg.ts +28 -0
  72. package/src/entry/init-command.ts +20 -69
  73. package/src/entry/knowledge-command.ts +121 -5
  74. package/src/entry/lock-command.ts +103 -8
  75. package/src/entry/mcp/handshake.ts +6 -2
  76. package/src/entry/mcp/role-bin.ts +20 -0
  77. package/src/entry/mcp/role-server.ts +134 -0
  78. package/src/entry/mcp/server.ts +85 -21
  79. package/src/entry/mcp/session-tools.ts +149 -0
  80. package/src/entry/panel/openapi.ts +189 -0
  81. package/src/entry/panel/server.ts +410 -0
  82. package/src/entry/panel-command.ts +68 -0
  83. package/src/entry/pause-command.ts +67 -0
  84. package/src/entry/profile-command.ts +33 -18
  85. package/src/entry/role-cli-command.ts +159 -0
  86. package/src/entry/runs-command.ts +81 -27
  87. package/src/entry/scorecard-command.ts +187 -0
  88. package/src/entry/supervise-bin.ts +1 -1
  89. package/src/entry/supervise-command.ts +30 -3
  90. package/src/entry/workspace-command.ts +44 -0
  91. package/src/infra/boot-lock.ts +25 -0
  92. package/src/infra/delivery.ts +3 -0
  93. package/src/infra/dispatch-dir.ts +2 -0
  94. package/src/infra/env.ts +11 -3
  95. package/src/infra/git.ts +70 -0
  96. package/src/infra/heavy-lock.ts +133 -6
  97. package/src/infra/host-probe.ts +16 -0
  98. package/src/infra/launch.ts +63 -7
  99. package/src/infra/lock-activity.ts +88 -0
  100. package/src/infra/login-env.ts +48 -0
  101. package/src/infra/memory.ts +45 -0
  102. package/src/infra/ntfy.ts +40 -0
  103. package/src/infra/open-url.ts +42 -0
  104. package/src/infra/paths.ts +37 -2
  105. package/src/infra/role-mcp.ts +69 -0
  106. package/src/infra/slopalytics.ts +30 -0
  107. package/src/infra/store.ts +6 -1
  108. package/src/infra/supervisor.ts +107 -16
  109. package/src/services/admission.ts +133 -31
  110. package/src/services/backends.ts +4 -6
  111. package/src/services/budget.ts +26 -6
  112. package/src/services/capture.ts +1 -22
  113. package/src/services/catalog-service.ts +155 -254
  114. package/src/services/credentials.ts +15 -26
  115. package/src/services/daemon.ts +181 -0
  116. package/src/services/dispatch-service.ts +209 -17
  117. package/src/services/dispatches.ts +30 -2
  118. package/src/services/doctor-access.ts +8 -3
  119. package/src/services/doctor-backends.ts +1 -1
  120. package/src/services/doctor-checks.ts +47 -0
  121. package/src/services/doctor-docker.ts +221 -0
  122. package/src/services/doctor-role-mcp.ts +65 -0
  123. package/src/services/doctor.ts +72 -15
  124. package/src/services/end-push.ts +157 -0
  125. package/src/services/failover-rungs.ts +109 -0
  126. package/src/services/finalize.ts +24 -2
  127. package/src/services/gate-env.ts +217 -0
  128. package/src/services/gate-service.ts +231 -45
  129. package/src/services/global-install.ts +46 -13
  130. package/src/services/jev-service.ts +2 -4
  131. package/src/services/lane-edit.ts +154 -0
  132. package/src/services/lane-service.ts +291 -55
  133. package/src/services/milestone-check.ts +433 -0
  134. package/src/services/milestone-tree.ts +170 -0
  135. package/src/services/milestones.ts +167 -29
  136. package/src/services/notifier.ts +124 -4
  137. package/src/services/notify-service.ts +26 -0
  138. package/src/services/orchestrator-wait.ts +82 -0
  139. package/src/services/panel-setup.ts +116 -0
  140. package/src/services/pause.ts +161 -0
  141. package/src/services/peek.ts +79 -17
  142. package/src/services/plan-input.ts +151 -0
  143. package/src/services/ports.ts +60 -5
  144. package/src/services/preflight.ts +232 -24
  145. package/src/services/profile-service.ts +31 -3
  146. package/src/services/profile-store.ts +18 -6
  147. package/src/services/protocol.ts +123 -25
  148. package/src/services/provenance.ts +34 -89
  149. package/src/services/questions.ts +24 -0
  150. package/src/services/reconcile.ts +33 -1
  151. package/src/services/reentry.ts +7 -6
  152. package/src/services/role-access.ts +27 -0
  153. package/src/services/routing-service.ts +161 -42
  154. package/src/services/run-briefs.ts +477 -0
  155. package/src/services/run-client.ts +217 -0
  156. package/src/services/run-effects.ts +837 -0
  157. package/src/services/run-events-store.ts +174 -0
  158. package/src/services/run-evidence.ts +1 -1
  159. package/src/services/run-pin.ts +125 -0
  160. package/src/services/run-read.ts +221 -0
  161. package/src/services/run-report.ts +176 -0
  162. package/src/services/run-service.ts +204 -23
  163. package/src/services/run-store.ts +159 -18
  164. package/src/services/scorecard-metrics.ts +86 -0
  165. package/src/services/scorecard.ts +382 -0
  166. package/src/services/scratch.ts +60 -0
  167. package/src/services/sessions.ts +3 -1
  168. package/src/services/state.ts +21 -0
  169. package/src/services/summary.ts +76 -12
  170. package/src/services/tree-path.ts +27 -0
  171. package/src/services/verifier-brief.ts +23 -0
  172. package/src/services/verifier-step.ts +48 -0
  173. package/src/services/workspace-admission.ts +259 -0
  174. package/src/services/workspace-service.ts +259 -0
  175. package/src/services/workspace-store.ts +95 -0
  176. package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
  177. package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
  178. package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
  179. package/web/dist/assets/Modal-Dxc5so53.js +1 -0
  180. package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
  181. package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
  182. package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
  183. package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
  184. package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
  185. package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
  186. package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
  187. package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
  188. package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
  189. package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
  190. package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
  191. package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
  192. package/web/dist/assets/drawer-Du3cdlok.js +1 -0
  193. package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
  194. package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
  195. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  196. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  197. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  198. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  199. package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  200. package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  201. package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  202. package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  203. package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  204. package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  205. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  206. package/web/dist/assets/index-B6GiwDkh.js +11 -0
  207. package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
  208. package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
  209. package/web/dist/assets/index-C5IbylvM.css +2 -0
  210. package/web/dist/assets/index-C5IbylvM.css.br +0 -0
  211. package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
  212. package/web/dist/assets/label-DFSYBc62.js +1 -0
  213. package/web/dist/assets/label-DFSYBc62.js.br +0 -0
  214. package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
  215. package/web/dist/assets/link-D8EcKZUt.js +1 -0
  216. package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
  217. package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
  218. package/web/dist/assets/login-DaN5HPPW.js +1 -0
  219. package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
  220. package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
  221. package/web/dist/assets/query-Cu--ccT3.js +1 -0
  222. package/web/dist/assets/questions-shoO9OVj.js +1 -0
  223. package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
  224. package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
  225. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
  226. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
  227. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
  228. package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  229. package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
  230. package/web/dist/assets/runs-C3rg2zZc.js +1 -0
  231. package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
  232. package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
  233. package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
  234. package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
  235. package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
  236. package/web/dist/assets/table-BOxXhqS4.js +1 -0
  237. package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
  238. package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
  239. package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
  240. package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
  241. package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
  242. package/web/dist/assets/time--9kSQbbp.js +1 -0
  243. package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
  244. package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
  245. package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
  246. package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
  247. package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
  248. package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
  249. package/web/dist/index.html +15 -0
  250. package/catalog/ATTRIBUTION.md +0 -36
  251. package/catalog/scores.json +0 -3202
  252. package/catalog/sources.json +0 -97
  253. package/src/adapters/antigravity/events.ts +0 -131
  254. package/src/adapters/antigravity/home.ts +0 -46
  255. package/src/adapters/antigravity/index.ts +0 -320
  256. package/src/adapters/antigravity/models.ts +0 -37
  257. package/src/adapters/grok/events.ts +0 -112
  258. package/src/adapters/grok/home.ts +0 -132
  259. package/src/adapters/grok/index.ts +0 -339
  260. package/src/adapters/grok/models.ts +0 -48
  261. package/src/domain/calibration.ts +0 -125
  262. package/src/domain/sources.ts +0 -144
  263. package/src/entry/mcp/dispatch-tools.ts +0 -52
  264. package/src/entry/mcp/lane-tools.ts +0 -84
  265. package/src/entry/mcp/protocol-tools.ts +0 -57
  266. package/src/entry/mcp/run-tools.ts +0 -148
  267. package/src/entry/mcp/setup-tools.ts +0 -108
  268. package/src/entry/tui/commands.ts +0 -563
  269. package/src/entry/tui/effects.ts +0 -381
  270. package/src/entry/tui/fixtures.ts +0 -637
  271. package/src/entry/tui/profile-edits.ts +0 -146
  272. package/src/entry/tui/profile-tree.ts +0 -429
  273. package/src/entry/tui/providers/app.tsx +0 -162
  274. package/src/entry/tui/providers/boundary.tsx +0 -57
  275. package/src/entry/tui/providers/data.tsx +0 -160
  276. package/src/entry/tui/providers/keymap.tsx +0 -166
  277. package/src/entry/tui/providers/root.tsx +0 -45
  278. package/src/entry/tui/providers/theme.tsx +0 -17
  279. package/src/entry/tui/providers/toast.tsx +0 -77
  280. package/src/entry/tui/run.tsx +0 -114
  281. package/src/entry/tui/state.ts +0 -373
  282. package/src/entry/tui/stories.ts +0 -114
  283. package/src/entry/tui/text.ts +0 -130
  284. package/src/entry/tui/theme.ts +0 -140
  285. package/src/entry/tui/views/app.tsx +0 -208
  286. package/src/entry/tui/views/dialogs.tsx +0 -41
  287. package/src/entry/tui/views/profile-actions.ts +0 -331
  288. package/src/entry/tui/views/profiles.tsx +0 -520
  289. package/src/entry/tui/views/runs.tsx +0 -573
  290. package/src/entry/tui/views/save-dialog.tsx +0 -340
  291. package/src/entry/tui/views/status.tsx +0 -209
  292. package/src/entry/tui/widgets/chrome.tsx +0 -114
  293. package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
  294. package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
  295. package/src/entry/tui/widgets/dialog-select.tsx +0 -212
  296. package/src/entry/tui/widgets/dialog.tsx +0 -68
  297. package/src/entry/tui/widgets/line.tsx +0 -92
  298. package/src/entry/tui/widgets/list.tsx +0 -197
  299. package/src/entry/tui/widgets/spinner.tsx +0 -49
  300. package/src/entry/tui/widgets/toast.tsx +0 -62
  301. package/src/infra/sources/arena.ts +0 -87
  302. package/src/infra/sources/artificial-analysis.ts +0 -112
  303. package/src/infra/sources/cache.ts +0 -124
  304. package/src/infra/sources/csv.ts +0 -38
  305. package/src/infra/sources/epoch.ts +0 -82
  306. package/src/infra/sources/http.ts +0 -75
  307. package/src/infra/sources/litellm.ts +0 -58
  308. package/src/infra/sources/models-dev.ts +0 -59
  309. package/src/infra/sources/openrouter-endpoints.ts +0 -52
  310. package/src/infra/sources/openrouter-models.ts +0 -60
  311. package/src/infra/sources/rows.ts +0 -38
  312. package/src/infra/sources/vectara.ts +0 -69
  313. package/src/infra/sources/zip.ts +0 -48
  314. package/src/services/catalog-refresh.ts +0 -201
  315. package/src/services/doctor-sources.ts +0 -76
  316. package/src/services/runs-page.ts +0 -255
  317. package/src/services/source-derive.ts +0 -374
  318. package/src/services/source-sync.ts +0 -305
  319. package/src/services/standins.ts +0 -204
  320. package/src/services/treat-likes.ts +0 -97
@@ -0,0 +1,410 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { join, resolve, sep } from "node:path";
4
+ import type { ServerWebSocket } from "bun";
5
+ import { z } from "zod";
6
+ import { CatherdError, errorMessage, isCatherdError } from "../../domain/errors.ts";
7
+ import { describeEvent } from "../../domain/run-events.ts";
8
+ import { ROLES, type Role } from "../../domain/roles.ts";
9
+ import { openUrl, writeRedirectPage } from "../../infra/open-url.ts";
10
+ import { VERSION } from "../../infra/version.ts";
11
+ import { setRoleRungs, setupView } from "../../services/panel-setup.ts";
12
+ import type { Deps } from "../../services/ports.ts";
13
+ import { answerUnit, cancelRun } from "../../services/run-client.ts";
14
+ import { listRunViews, parkedQuestions, runTimeline, runView, tailEvents } from "../../services/run-read.ts";
15
+ import { readEvents } from "../../services/run-events-store.ts";
16
+ import { findRun } from "../../services/run-store.ts";
17
+ import {
18
+ AnswerBodySchema,
19
+ ErrorBodySchema,
20
+ ListRunsQuerySchema,
21
+ LoginBodySchema,
22
+ OPERATIONS,
23
+ type Operation,
24
+ type OperationId,
25
+ QuestionsQuerySchema,
26
+ RungsBodySchema,
27
+ } from "./openapi.ts";
28
+
29
+ export const PANEL_COOKIE = "catherd_panel";
30
+
31
+ export interface PanelOptions {
32
+ deps: Deps;
33
+ repo: string | null;
34
+ token: string;
35
+ port: number;
36
+ distDir: string;
37
+ open: boolean;
38
+ /** What opens the sign-in page: a test replaces it. */
39
+ opener?: (target: string) => void;
40
+ pollMs?: number;
41
+ }
42
+ export interface Panel {
43
+ url: string;
44
+ port: number;
45
+ stop(): Promise<void>;
46
+ }
47
+
48
+ /** 32 random bytes, base64url: the token a `catherd panel` run accepts on its login page. */
49
+ export const newToken = (): string => randomBytes(32).toString("base64url");
50
+
51
+ const SECURITY_HEADERS = {
52
+ "Content-Security-Policy":
53
+ "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'",
54
+ "X-Frame-Options": "DENY",
55
+ "Referrer-Policy": "no-referrer",
56
+ };
57
+
58
+ const STATUS: Record<string, number> = {
59
+ E_INPUT_INVALID: 400,
60
+ unauthorized: 401,
61
+ forbidden: 403,
62
+ E_RUN_NOT_FOUND: 404,
63
+ not_found: 404,
64
+ E_RUN_NOT_LIVE: 409,
65
+ };
66
+
67
+ const errorResponse = (code: string, message: string, fix?: string): Response =>
68
+ Response.json(
69
+ ErrorBodySchema.parse({
70
+ error: {
71
+ message,
72
+ type: code,
73
+ code,
74
+ ...(fix === undefined ? {} : { fix }),
75
+ },
76
+ }),
77
+ { status: STATUS[code] ?? 500 },
78
+ );
79
+
80
+ const digest = (s: string): Buffer => createHash("sha256").update(s).digest();
81
+ const sameToken = (a: string, b: string): boolean => timingSafeEqual(digest(a), digest(b));
82
+
83
+ const cookieOf = (req: Request): string | undefined =>
84
+ req.headers
85
+ .get("cookie")
86
+ ?.split(";")
87
+ .map((c) => c.trim().split("="))
88
+ .find(([k]) => k === PANEL_COOKIE)?.[1];
89
+
90
+ /** The first problem of a request, as the 400's message: the field's path, then what is wrong with it. */
91
+ const issueText = (e: z.ZodError): string => {
92
+ const i = e.issues[0];
93
+ return i ? `${i.path.length ? `${i.path.join(".")}: ` : ""}${i.message}` : "invalid request";
94
+ };
95
+
96
+ interface Call {
97
+ deps: Deps;
98
+ repo: string | null;
99
+ params: Record<string, string>;
100
+ query: unknown;
101
+ body: unknown;
102
+ }
103
+
104
+ const page = <T>(all: T[], q: { limit: number; offset: number }) => ({
105
+ items: all.slice(q.offset, q.offset + q.limit),
106
+ total: all.length,
107
+ });
108
+ const has = (hay: string[], needle: string | undefined): boolean =>
109
+ needle === undefined || hay.some((h) => h.toLowerCase().includes(needle.toLowerCase()));
110
+
111
+ const HANDLERS: Record<OperationId, (c: Call) => unknown> = {
112
+ login: () => null,
113
+ logout: () => null,
114
+ getSession: () => ({ authenticated: true }),
115
+ getVersion: () => ({ catherd: VERSION }),
116
+ getSetup: (c) => setupView(c.deps, c.repo),
117
+ setRoleRungs: (c) => {
118
+ const role = c.params.role as Role;
119
+ if (!ROLES.includes(role))
120
+ throw new CatherdError("E_INPUT_INVALID", `no role named "${role}"`, {
121
+ fix: `one of ${ROLES.join(", ")}`,
122
+ });
123
+ return setRoleRungs(c.deps, c.repo, role, (c.body as z.infer<typeof RungsBodySchema>).rungs);
124
+ },
125
+ // shortcut: reads every run's view per list call, upgrade when a home holds thousands of runs
126
+ listRuns: (c) => {
127
+ const q = c.query as z.infer<typeof ListRunsQuerySchema>;
128
+ const all = listRunViews(c.deps, { limit: 100_000 }).filter(
129
+ (r) => (q.phase === undefined || r.phase === q.phase) && has([r.title, r.id, r.repo], q.q),
130
+ );
131
+ return page(all, q);
132
+ },
133
+ getRun: (c) => runView(c.deps, c.params.id as string),
134
+ // shortcut: reads every event per page, upgrade past ~50k events
135
+ getRunTimeline: (c) => {
136
+ const id = c.params.id as string;
137
+ if (runView(c.deps, id).engine === "1.5") return { items: [], total: 0 };
138
+ return page(
139
+ runTimeline(id, 1, Number.MAX_SAFE_INTEGER).toReversed(),
140
+ c.query as { limit: number; offset: number },
141
+ );
142
+ },
143
+ cancelRun: (c) => cancelRun(c.deps, { run: c.params.id as string }),
144
+ answerUnit: (c) =>
145
+ answerUnit(c.deps, {
146
+ run: c.params.id as string,
147
+ ...(c.body as z.infer<typeof AnswerBodySchema>),
148
+ }),
149
+ listQuestions: (c) => {
150
+ const q = c.query as z.infer<typeof QuestionsQuerySchema>;
151
+ return page(
152
+ parkedQuestions({ run: q.run }).filter((p) => has([p.question, p.unit, p.runTitle], q.q)),
153
+ q,
154
+ );
155
+ },
156
+ };
157
+
158
+ const compile = (op: Operation): RegExp => new RegExp(`^${op.path.replace(/\{(\w+)\}/g, "(?<$1>[^/]+)")}$`);
159
+ const ROUTES = OPERATIONS.map((op) => ({ op, re: compile(op) }));
160
+ const LIVE = /^\/api\/runs\/([^/]+)\/live$/;
161
+
162
+ /** The Host this panel answers to, or null. A browser omits the default port, so port 80 also takes the bare name. */
163
+ export const panelHost = (header: string | null, port: number): string | null => {
164
+ const names = ["127.0.0.1", "localhost"];
165
+ const ok = names.flatMap((n) => (port === 80 ? [n, `${n}:80`] : [`${n}:${port}`]));
166
+ return header !== null && ok.includes(header) ? header : null;
167
+ };
168
+ /** A request without an Origin passes; one with it must come from the page this Host serves. */
169
+ export const panelOriginOk = (origin: string | null, host: string): boolean =>
170
+ origin === null || origin === `http://${host}`;
171
+
172
+ interface SocketData {
173
+ run: string;
174
+ from: number;
175
+ session: string;
176
+ stop?: () => void;
177
+ }
178
+
179
+ /** One Bun process on 127.0.0.1: the built files, the JSON API and a socket per run tailing its events. */
180
+ export function servePanel(o: PanelOptions): Panel {
181
+ const sessions = new Set<string>();
182
+ const sockets = new Set<ServerWebSocket<SocketData>>();
183
+ /** Ends a session and the run sockets it opened: a socket outlives its cookie otherwise. */
184
+ const revoke = (session: string): void => {
185
+ sessions.delete(session);
186
+ for (const ws of sockets) if (ws.data.session === session) ws.close(4401, "signed out");
187
+ };
188
+ const dist = resolve(o.distDir);
189
+ let origin = "";
190
+ let signInPage: ReturnType<typeof writeRedirectPage> | undefined;
191
+ const dropSignInPage = (): void => {
192
+ signInPage?.remove();
193
+ signInPage = undefined;
194
+ };
195
+
196
+ const hostOk = (req: Request): string | null => panelHost(req.headers.get("host"), server.port as number);
197
+ const originOk = (req: Request, host: string): boolean => panelOriginOk(req.headers.get("origin"), host);
198
+ const signedIn = (req: Request): boolean => {
199
+ const c = cookieOf(req);
200
+ return c !== undefined && sessions.has(c);
201
+ };
202
+
203
+ const staticFile = (req: Request, rawPath: string): Response => {
204
+ let path: string;
205
+ try {
206
+ path = decodeURIComponent(rawPath);
207
+ } catch {
208
+ return errorResponse("not_found", "not found");
209
+ }
210
+ const file = resolve(dist, `.${path}`);
211
+ if (file !== dist && !file.startsWith(dist + sep)) return errorResponse("not_found", "not found");
212
+ const served = existsSync(file) && statSync(file).isFile() ? file : join(dist, "index.html");
213
+ const accepts = req.headers.get("accept-encoding") ?? "";
214
+ for (const [enc, ext] of [
215
+ ["br", ".br"],
216
+ ["gzip", ".gz"],
217
+ ] as const)
218
+ if (new RegExp(`\\b${enc}\\b`).test(accepts) && existsSync(served + ext))
219
+ return new Response(Bun.file(served + ext), {
220
+ headers: {
221
+ ...cache(served),
222
+ "Content-Type": Bun.file(served).type,
223
+ "Content-Encoding": enc,
224
+ Vary: "Accept-Encoding",
225
+ },
226
+ });
227
+ return new Response(Bun.file(served), {
228
+ headers: { ...cache(served), Vary: "Accept-Encoding" },
229
+ });
230
+ };
231
+ const cache = (file: string): Record<string, string> => ({
232
+ "Cache-Control": file.startsWith(join(dist, "assets") + sep)
233
+ ? "public, max-age=31536000, immutable"
234
+ : "no-cache",
235
+ });
236
+
237
+ const api = async (req: Request, url: URL, host: string): Promise<Response> => {
238
+ const hit = ROUTES.map((r) => ({ ...r, m: r.re.exec(url.pathname) })).find(
239
+ (r) => r.m && r.op.method === req.method,
240
+ );
241
+ if (!hit?.m) return errorResponse("not_found", "not found");
242
+ const { op } = hit;
243
+ if (!op.public && !signedIn(req)) return errorResponse("unauthorized", "sign in first");
244
+ if (op.method !== "GET" && !originOk(req, host))
245
+ return errorResponse("forbidden", "cross-origin request refused");
246
+ let params: Record<string, string>;
247
+ try {
248
+ params = Object.fromEntries(
249
+ Object.entries(hit.m.groups ?? {}).map(([k, v]) => [k, decodeURIComponent(v)]),
250
+ );
251
+ } catch {
252
+ return errorResponse("E_INPUT_INVALID", "the request path has a malformed escape");
253
+ }
254
+ let query: unknown;
255
+ let body: unknown;
256
+ if (op.query) {
257
+ const r = op.query.safeParse(Object.fromEntries(url.searchParams));
258
+ if (!r.success) return errorResponse("E_INPUT_INVALID", issueText(r.error));
259
+ query = r.data;
260
+ }
261
+ if (op.body) {
262
+ let raw: unknown;
263
+ try {
264
+ raw = await req.json();
265
+ } catch {
266
+ return errorResponse("E_INPUT_INVALID", "the request body is not valid JSON");
267
+ }
268
+ const r = op.body.safeParse(raw);
269
+ if (!r.success) return errorResponse("E_INPUT_INVALID", issueText(r.error));
270
+ body = r.data;
271
+ }
272
+ try {
273
+ if (op.id === "login") {
274
+ if (!sameToken((body as z.infer<typeof LoginBodySchema>).token, o.token))
275
+ return errorResponse("unauthorized", "wrong token");
276
+ const session = randomBytes(32).toString("base64url");
277
+ sessions.add(session);
278
+ dropSignInPage();
279
+ return new Response(null, {
280
+ status: 204,
281
+ headers: {
282
+ "Set-Cookie": `${PANEL_COOKIE}=${session}; HttpOnly; SameSite=Strict; Path=/`,
283
+ },
284
+ });
285
+ }
286
+ if (op.id === "logout") {
287
+ const c = cookieOf(req);
288
+ if (c) revoke(c);
289
+ return new Response(null, {
290
+ status: 204,
291
+ headers: {
292
+ "Set-Cookie": `${PANEL_COOKIE}=; Max-Age=0; HttpOnly; SameSite=Strict; Path=/`,
293
+ },
294
+ });
295
+ }
296
+ const out = await HANDLERS[op.id]({
297
+ deps: o.deps,
298
+ repo: o.repo,
299
+ params,
300
+ query,
301
+ body,
302
+ });
303
+ return op.response === null
304
+ ? new Response(null, { status: 204 })
305
+ : Response.json(op.response.parse(out));
306
+ } catch (e) {
307
+ if (isCatherdError(e)) return errorResponse(e.code, e.message, e.fix);
308
+ return errorResponse("internal", errorMessage(e));
309
+ }
310
+ };
311
+
312
+ const fetch = async (req: Request, srv: Bun.Server<SocketData>): Promise<Response | undefined> => {
313
+ const host = hostOk(req);
314
+ if (host === null) return withHeaders(errorResponse("forbidden", "this panel answers on 127.0.0.1 only"));
315
+ // the raw target, not URL's normalised one: a `%2e%2e` segment must reach the path check
316
+ const target = req.url.replace(/^[a-z]+:\/\/[^/]+/i, "");
317
+ const [rawPath = "/"] = target.split("?");
318
+ const url = new URL(req.url);
319
+ const live = LIVE.exec(url.pathname);
320
+ if (live && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
321
+ const session = cookieOf(req);
322
+ if (session === undefined || !sessions.has(session))
323
+ return withHeaders(errorResponse("unauthorized", "sign in first"));
324
+ if (!originOk(req, host)) return withHeaders(errorResponse("forbidden", "cross-origin socket refused"));
325
+ let id: string;
326
+ try {
327
+ id = decodeURIComponent(live[1] as string);
328
+ } catch {
329
+ return withHeaders(errorResponse("E_INPUT_INVALID", "the request path has a malformed escape"));
330
+ }
331
+ try {
332
+ findRun(id);
333
+ } catch (e) {
334
+ return withHeaders(
335
+ isCatherdError(e)
336
+ ? errorResponse(e.code, e.message, e.fix)
337
+ : errorResponse("internal", errorMessage(e)),
338
+ );
339
+ }
340
+ // no `from`: only what is appended from now on (the page loads the history over HTTP)
341
+ const asked = url.searchParams.get("from");
342
+ const from =
343
+ asked === null
344
+ ? (readEvents(findRun(id)).at(-1)?.seq ?? 0) + 1
345
+ : Math.max(1, Number.parseInt(asked, 10) || 1);
346
+ if (srv.upgrade(req, { data: { run: id, from, session } })) return undefined;
347
+ return withHeaders(errorResponse("E_INPUT_INVALID", "not a websocket upgrade"));
348
+ }
349
+ if (url.pathname.startsWith("/api/")) return withHeaders(await api(req, url, host));
350
+ return withHeaders(staticFile(req, rawPath));
351
+ };
352
+
353
+ const withHeaders = (r: Response): Response => {
354
+ for (const [k, v] of Object.entries(SECURITY_HEADERS)) r.headers.set(k, v);
355
+ return r;
356
+ };
357
+
358
+ let server: Bun.Server<SocketData>;
359
+ try {
360
+ server = Bun.serve<SocketData>({
361
+ hostname: "127.0.0.1",
362
+ port: o.port,
363
+ fetch,
364
+ websocket: {
365
+ open(ws: ServerWebSocket<SocketData>) {
366
+ sockets.add(ws);
367
+ ws.data.stop = tailEvents(
368
+ findRun(ws.data.run),
369
+ ws.data.from,
370
+ (e) =>
371
+ ws.send(
372
+ JSON.stringify({
373
+ seq: e.seq,
374
+ at: e.at,
375
+ type: e.type,
376
+ text: describeEvent(e),
377
+ }),
378
+ ),
379
+ { pollMs: o.pollMs },
380
+ );
381
+ },
382
+ close(ws: ServerWebSocket<SocketData>) {
383
+ sockets.delete(ws);
384
+ ws.data.stop?.();
385
+ },
386
+ // a `ping` frame keeps the tab's socket warm and means nothing
387
+ message() {},
388
+ },
389
+ });
390
+ } catch (e) {
391
+ if (/in use|EADDRINUSE/i.test(errorMessage(e)))
392
+ throw new CatherdError("E_INPUT_INVALID", `port ${o.port} is in use`, {
393
+ fix: "catherd panel --port 0 (any free port)",
394
+ });
395
+ throw e;
396
+ }
397
+ origin = `http://127.0.0.1:${server.port}`;
398
+ if (o.open) {
399
+ signInPage = writeRedirectPage(`${origin}/login#token=${o.token}`);
400
+ (o.opener ?? openUrl)(signInPage.path);
401
+ }
402
+ return {
403
+ url: origin,
404
+ port: server.port as number,
405
+ stop: async () => {
406
+ dropSignInPage();
407
+ await server.stop(true);
408
+ },
409
+ };
410
+ }
@@ -0,0 +1,68 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { defineCommand } from "citty";
4
+ import { CatherdError } from "../domain/errors.ts";
5
+ import { gitToplevel } from "../infra/git.ts";
6
+ import { defaultDeps } from "./deps.ts";
7
+ import { refuseInRole } from "./cli-kit.ts";
8
+ import { HOST_ARG, terminalHost } from "./host-arg.ts";
9
+ import { type Panel, newToken, servePanel } from "./panel/server.ts";
10
+
11
+ /** The seams a test replaces: the server, and what keeps `catherd panel` alive until Ctrl-C. */
12
+ export const panelServer = {
13
+ serve: servePanel as (o: Parameters<typeof servePanel>[0]) => Panel,
14
+ untilStopped: (): Promise<void> =>
15
+ new Promise((done) => {
16
+ for (const sig of ["SIGINT", "SIGTERM"] as const) process.once(sig, () => done());
17
+ }),
18
+ };
19
+
20
+ /** The built panel: `CATHERD_PANEL_DIST`, else the `web/dist` the package ships. */
21
+ export const panelDist = (): string =>
22
+ process.env.CATHERD_PANEL_DIST ?? join(import.meta.dir, "../../web/dist");
23
+
24
+ /** `catherd panel`'s run, and a bare `catherd`'s: serves the panel until Ctrl-C. */
25
+ export async function runPanel(o: { port: number; open: boolean; host?: string }): Promise<void> {
26
+ refuseInRole(process.env, "catherd panel");
27
+ const distDir = panelDist();
28
+ if (!existsSync(join(distDir, "index.html")))
29
+ throw new CatherdError("E_CONFIG_INVALID", "the panel is not built", { fix: "bun run panel:build" });
30
+ const token = newToken();
31
+ const panel = panelServer.serve({
32
+ deps: defaultDeps(terminalHost(o.host)),
33
+ repo: await gitToplevel(process.cwd()),
34
+ token,
35
+ port: o.port,
36
+ distDir,
37
+ open: o.open,
38
+ });
39
+ console.log(`catherd panel: ${panel.url}/login#token=${token}`);
40
+ console.log("Ctrl-C stops it.");
41
+ await panelServer.untilStopped();
42
+ await panel.stop();
43
+ }
44
+
45
+ /** `catherd panel [--port <n>] [--no-open]`: Setup, Runs and Questions in your browser, on 127.0.0.1 only. */
46
+ export const panelCommand = defineCommand({
47
+ meta: {
48
+ name: "panel",
49
+ description: "Serve the panel on 127.0.0.1 and open it in your browser (a bare catherd does the same)",
50
+ },
51
+ args: {
52
+ port: { type: "string", default: "4317", description: "the port (0 picks a free one)" },
53
+ open: {
54
+ type: "boolean",
55
+ default: true,
56
+ description: "open the browser (--no-open prints the link only)",
57
+ },
58
+ ...HOST_ARG,
59
+ },
60
+ async run({ args }) {
61
+ const port = Number(args.port);
62
+ if (!/^\d+$/.test(args.port) || port > 65535)
63
+ throw new CatherdError("E_INPUT_INVALID", `--port takes a number from 0 to 65535, not "${args.port}"`, {
64
+ fix: "catherd panel --port 4317",
65
+ });
66
+ await runPanel({ port, open: args.open, host: args.host });
67
+ },
68
+ });
@@ -0,0 +1,67 @@
1
+ import { defineCommand } from "citty";
2
+ import { CatherdError } from "../domain/errors.ts";
3
+ import {
4
+ pauseLine,
5
+ pauseMachine,
6
+ pauseWorkspace,
7
+ resumeMachine,
8
+ resumeWorkspace,
9
+ } from "../services/pause.ts";
10
+ import { JSON_ARG, printJson, refuseInRole } from "./cli-kit.ts";
11
+
12
+ /** Exactly one of --machine and --workspace: what a pause or resume covers. */
13
+ function scope(args: { machine?: boolean; workspace?: string }, usage: string): "machine" | string {
14
+ if (args.machine === true && args.workspace === undefined) return "machine";
15
+ if (args.machine !== true && args.workspace) return args.workspace;
16
+ throw new CatherdError(
17
+ "E_INPUT_INVALID",
18
+ "name what to pause: --machine or --workspace <id>, one of them",
19
+ {
20
+ fix: usage,
21
+ },
22
+ );
23
+ }
24
+
25
+ const SCOPE_ARGS = {
26
+ machine: { type: "boolean", description: "every run on this machine" },
27
+ workspace: { type: "string", description: "every child of this workspace" },
28
+ } as const;
29
+
30
+ const PAUSE_USAGE = 'catherd pause --machine "the VPN takes the default route"';
31
+ const RESUME_USAGE = "catherd resume --machine";
32
+
33
+ /** Spec 1.5 "Group pause": one blocker, one pause, instead of a park per run and milestone. */
34
+ export const pauseCommand = defineCommand({
35
+ meta: {
36
+ name: "pause",
37
+ description:
38
+ "Pause every run on this machine (--machine) or in a workspace: dispatches are refused until resume",
39
+ },
40
+ args: {
41
+ reason: { type: "positional", required: true, description: "why: what blocks the runs" },
42
+ ...SCOPE_ARGS,
43
+ ...JSON_ARG,
44
+ },
45
+ run({ args }) {
46
+ refuseInRole(process.env, "catherd pause");
47
+ const at = scope(args, PAUSE_USAGE);
48
+ const r =
49
+ at === "machine"
50
+ ? pauseMachine(Date.now(), args.reason)
51
+ : pauseWorkspace(Date.now(), { workspace: at, reason: args.reason });
52
+ if (args.json) return printJson(r);
53
+ console.log(pauseLine(r.pause));
54
+ },
55
+ });
56
+
57
+ export const resumeCommand = defineCommand({
58
+ meta: { name: "resume", description: "Lift a pause on this machine (--machine) or on a workspace" },
59
+ args: { ...SCOPE_ARGS, ...JSON_ARG },
60
+ run({ args }) {
61
+ refuseInRole(process.env, "catherd resume");
62
+ const at = scope(args, RESUME_USAGE);
63
+ const r = at === "machine" ? resumeMachine(Date.now()) : resumeWorkspace(Date.now(), { workspace: at });
64
+ if (args.json) return printJson(r);
65
+ console.log(r.resumed ? `resumed: ${pauseLine(r.resumed)}` : "nothing was paused");
66
+ },
67
+ });