@asrunjs/asrun 0.0.0-stage → 0.5.2

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 (305) hide show
  1. package/.claude/commands/explore-case.md +18 -0
  2. package/.claude/commands/gen-spec.md +16 -0
  3. package/.claude/commands/heal-spec.md +26 -0
  4. package/.claude/commands/make-case.md +11 -0
  5. package/.claude/settings.json +5 -0
  6. package/.claude/skills/exploring-cases/SKILL.md +146 -0
  7. package/.claude/skills/exploring-cases/references/assertion-targets.md +103 -0
  8. package/.claude/skills/exploring-cases/references/handoff-card.js +128 -0
  9. package/.claude/skills/exploring-cases/references/open-questions.md +116 -0
  10. package/.claude/skills/exploring-cases/references/readiness.md +146 -0
  11. package/.claude/skills/exploring-cases/references/user-handoff.md +157 -0
  12. package/.claude/skills/generating-specs/SKILL.md +219 -0
  13. package/.claude/skills/generating-specs/references/conventions.md +148 -0
  14. package/.claude/skills/making-cases/SKILL.md +72 -0
  15. package/.claude/skills/making-cases/references/gaps.md +36 -0
  16. package/.claude/skills/making-cases/references/normalizing.md +39 -0
  17. package/.claude/skills/making-cases/references/splitting.md +28 -0
  18. package/.claude/skills/making-cases/references/writes-and-email.md +37 -0
  19. package/AGENTS.md +159 -0
  20. package/CLAUDE.md +2 -0
  21. package/LICENSE +21 -0
  22. package/README.md +511 -2
  23. package/bin/asrun.mjs +49 -0
  24. package/dist/lib/account-lock.d.ts +6 -0
  25. package/dist/lib/account-lock.js +52 -0
  26. package/dist/lib/assertions.d.ts +14 -0
  27. package/dist/lib/assertions.js +39 -0
  28. package/dist/lib/baselines.d.ts +43 -0
  29. package/dist/lib/baselines.js +108 -0
  30. package/dist/lib/build-artifacts.d.ts +8 -0
  31. package/dist/lib/build-artifacts.js +10 -0
  32. package/dist/lib/case-doc.d.ts +311 -0
  33. package/dist/lib/case-doc.js +1117 -0
  34. package/dist/lib/case-id.d.ts +11 -0
  35. package/dist/lib/case-id.js +17 -0
  36. package/dist/lib/case-ops.d.ts +26 -0
  37. package/dist/lib/case-ops.js +110 -0
  38. package/dist/lib/cases.d.ts +16 -0
  39. package/dist/lib/cases.js +51 -0
  40. package/dist/lib/cli.d.ts +10 -0
  41. package/dist/lib/cli.js +18 -0
  42. package/dist/lib/command.d.ts +24 -0
  43. package/dist/lib/command.js +90 -0
  44. package/dist/lib/downloads.d.ts +42 -0
  45. package/dist/lib/downloads.js +84 -0
  46. package/dist/lib/email.d.ts +10 -0
  47. package/dist/lib/email.js +19 -0
  48. package/dist/lib/ffmpeg.d.ts +8 -0
  49. package/dist/lib/ffmpeg.js +50 -0
  50. package/dist/lib/fixtures.d.ts +40 -0
  51. package/dist/lib/fixtures.js +149 -0
  52. package/dist/lib/login-card.d.ts +22 -0
  53. package/dist/lib/login-card.js +183 -0
  54. package/dist/lib/node-cli.d.ts +15 -0
  55. package/dist/lib/node-cli.js +36 -0
  56. package/dist/lib/paths.d.ts +25 -0
  57. package/dist/lib/paths.js +88 -0
  58. package/dist/lib/pdf.d.ts +71 -0
  59. package/dist/lib/pdf.js +140 -0
  60. package/dist/lib/profiles.d.ts +62 -0
  61. package/dist/lib/profiles.js +174 -0
  62. package/dist/lib/ready.d.ts +36 -0
  63. package/dist/lib/ready.js +53 -0
  64. package/dist/lib/records.d.ts +33 -0
  65. package/dist/lib/records.js +61 -0
  66. package/dist/lib/run-filters.d.ts +19 -0
  67. package/dist/lib/run-filters.js +38 -0
  68. package/dist/lib/run-summary.d.ts +90 -0
  69. package/dist/lib/run-summary.js +35 -0
  70. package/dist/lib/session.d.ts +30 -0
  71. package/dist/lib/session.js +111 -0
  72. package/dist/lib/summary-reporter.d.ts +16 -0
  73. package/dist/lib/summary-reporter.js +146 -0
  74. package/dist/lib/targets.d.ts +19 -0
  75. package/dist/lib/targets.js +26 -0
  76. package/dist/lib/ui-build.d.ts +10 -0
  77. package/dist/lib/ui-build.js +30 -0
  78. package/dist/lib/values.d.ts +29 -0
  79. package/dist/lib/values.js +85 -0
  80. package/dist/lib/write-gate.d.ts +41 -0
  81. package/dist/lib/write-gate.js +129 -0
  82. package/dist/playwright.config.d.ts +2 -0
  83. package/dist/playwright.config.js +92 -0
  84. package/dist/scripts/build.d.ts +1 -0
  85. package/dist/scripts/build.js +21 -0
  86. package/dist/scripts/check-cases.d.ts +1 -0
  87. package/dist/scripts/check-cases.js +149 -0
  88. package/dist/scripts/cleanup.d.ts +1 -0
  89. package/dist/scripts/cleanup.js +25 -0
  90. package/dist/scripts/cli.d.ts +1 -0
  91. package/dist/scripts/cli.js +28 -0
  92. package/dist/scripts/doctor.d.ts +1 -0
  93. package/dist/scripts/doctor.js +296 -0
  94. package/dist/scripts/explore.d.ts +1 -0
  95. package/dist/scripts/explore.js +75 -0
  96. package/dist/scripts/go.mjs +188 -0
  97. package/dist/scripts/heal-evidence.mjs +143 -0
  98. package/dist/scripts/init.d.ts +22 -0
  99. package/dist/scripts/init.js +332 -0
  100. package/dist/scripts/lint-specs.d.ts +1 -0
  101. package/dist/scripts/lint-specs.js +141 -0
  102. package/dist/scripts/login.d.ts +1 -0
  103. package/dist/scripts/login.js +75 -0
  104. package/dist/scripts/new-case.d.ts +1 -0
  105. package/dist/scripts/new-case.js +19 -0
  106. package/dist/scripts/render-pdf.d.ts +1 -0
  107. package/dist/scripts/render-pdf.js +44 -0
  108. package/dist/scripts/report.d.ts +1 -0
  109. package/dist/scripts/report.js +14 -0
  110. package/dist/scripts/review-baselines.d.ts +1 -0
  111. package/dist/scripts/review-baselines.js +30 -0
  112. package/dist/scripts/run.d.ts +1 -0
  113. package/dist/scripts/run.js +423 -0
  114. package/dist/scripts/status.d.ts +1 -0
  115. package/dist/scripts/status.js +31 -0
  116. package/dist/scripts/ui-build.d.ts +1 -0
  117. package/dist/scripts/ui-build.js +7 -0
  118. package/dist/scripts/ui-dev.d.ts +1 -0
  119. package/dist/scripts/ui-dev.js +3 -0
  120. package/dist/scripts/ui.d.ts +1 -0
  121. package/dist/scripts/ui.js +13 -0
  122. package/dist/ui/dev.d.ts +1 -0
  123. package/dist/ui/dev.js +36 -0
  124. package/dist/ui/server/actions.d.ts +95 -0
  125. package/dist/ui/server/actions.js +365 -0
  126. package/dist/ui/server/ai-models.d.ts +3 -0
  127. package/dist/ui/server/ai-models.js +347 -0
  128. package/dist/ui/server/ai-stream.d.ts +66 -0
  129. package/dist/ui/server/ai-stream.js +408 -0
  130. package/dist/ui/server/app.d.ts +4 -0
  131. package/dist/ui/server/app.js +395 -0
  132. package/dist/ui/server/cases.d.ts +23 -0
  133. package/dist/ui/server/cases.js +170 -0
  134. package/dist/ui/server/files.d.ts +5 -0
  135. package/dist/ui/server/files.js +67 -0
  136. package/dist/ui/server/index.d.ts +1 -0
  137. package/dist/ui/server/index.js +33 -0
  138. package/dist/ui/server/jobs.d.ts +80 -0
  139. package/dist/ui/server/jobs.js +317 -0
  140. package/dist/ui/server/profiles.d.ts +35 -0
  141. package/dist/ui/server/profiles.js +371 -0
  142. package/dist/ui/server/watch-files.d.ts +4 -0
  143. package/dist/ui/server/watch-files.js +36 -0
  144. package/dist/ui/shared/ai-commands.d.ts +88 -0
  145. package/dist/ui/shared/ai-commands.js +177 -0
  146. package/dist/ui/shared/case-id.d.ts +1 -0
  147. package/dist/ui/shared/case-id.js +1 -0
  148. package/dist/ui/shared/jobs.d.ts +6 -0
  149. package/dist/ui/shared/jobs.js +7 -0
  150. package/dist/ui/shared/product.d.ts +3 -0
  151. package/dist/ui/shared/product.js +3 -0
  152. package/dist/ui/shared/run-filters.d.ts +1 -0
  153. package/dist/ui/shared/run-filters.js +1 -0
  154. package/dist/ui/shared/site-address.d.ts +6 -0
  155. package/dist/ui/shared/site-address.js +11 -0
  156. package/dist/ui/shared/types.d.ts +371 -0
  157. package/dist/ui/shared/types.js +1 -0
  158. package/docs/agents/framework-dev.md +79 -0
  159. package/docs/theme-guide.html +922 -0
  160. package/package.json +109 -3
  161. package/templates/.claude/commands/explore-case.md +22 -0
  162. package/templates/.claude/commands/gen-spec.md +21 -0
  163. package/templates/.claude/commands/heal-spec.md +31 -0
  164. package/templates/.claude/commands/make-case.md +13 -0
  165. package/templates/.claude/settings.json +5 -0
  166. package/templates/.mcp.json +8 -0
  167. package/templates/AGENTS.md +64 -0
  168. package/templates/CLAUDE.md +1 -0
  169. package/templates/cases/_template.md +57 -0
  170. package/templates/gitignore +13 -0
  171. package/templates/opencode.jsonc +16 -0
  172. package/templates/package.json +31 -0
  173. package/templates/playwright.config.ts +3 -0
  174. package/templates/pnpm-workspace.yaml +5 -0
  175. package/templates/profiles.yaml +42 -0
  176. package/templates/seed.spec.ts +9 -0
  177. package/templates/tsconfig.json +14 -0
  178. package/ui/dist/apple-touch-icon.png +0 -0
  179. package/ui/dist/assets/apl-gfMMiTNf.js +1 -0
  180. package/ui/dist/assets/asciiarmor-C_eisQCp.js +1 -0
  181. package/ui/dist/assets/asn1-DCl_8MCW.js +1 -0
  182. package/ui/dist/assets/asterisk-BEn0Cwpx.js +1 -0
  183. package/ui/dist/assets/brainfuck-D5EjA2JK.js +1 -0
  184. package/ui/dist/assets/clike-xvONpD0Z.js +1 -0
  185. package/ui/dist/assets/clojure-BflJGmDX.js +1 -0
  186. package/ui/dist/assets/cmake-84nbDH0I.js +1 -0
  187. package/ui/dist/assets/cobol-DHLQUbN7.js +1 -0
  188. package/ui/dist/assets/coffeescript-De_zI4J0.js +1 -0
  189. package/ui/dist/assets/commonlisp-DgAKBe0r.js +1 -0
  190. package/ui/dist/assets/crystal-Bl6YKCG_.js +1 -0
  191. package/ui/dist/assets/css-CnaYoQib.js +1 -0
  192. package/ui/dist/assets/cypher-p9eYesGn.js +1 -0
  193. package/ui/dist/assets/d-DWkQyNnU.js +1 -0
  194. package/ui/dist/assets/diff-ChtP43wD.js +1 -0
  195. package/ui/dist/assets/dist-BGbe0-Aa.js +1 -0
  196. package/ui/dist/assets/dist-Ba-HC662.js +2 -0
  197. package/ui/dist/assets/dist-BtMLMthJ.js +1 -0
  198. package/ui/dist/assets/dist-Bw5-uxs9.js +1 -0
  199. package/ui/dist/assets/dist-BzulKuec.js +1 -0
  200. package/ui/dist/assets/dist-C-Oyo1ZT.js +1 -0
  201. package/ui/dist/assets/dist-C8MdhqgG.js +1 -0
  202. package/ui/dist/assets/dist-CG1pHn5m.js +1 -0
  203. package/ui/dist/assets/dist-CgIQYY1o.js +1 -0
  204. package/ui/dist/assets/dist-CmpteMYY.js +9 -0
  205. package/ui/dist/assets/dist-DDJNvivM.js +1 -0
  206. package/ui/dist/assets/dist-DF3F_I8N.js +1 -0
  207. package/ui/dist/assets/dist-DSy3dVf4.js +11 -0
  208. package/ui/dist/assets/dist-Dlorc3Sx.js +1 -0
  209. package/ui/dist/assets/dist-Dq6sW5Ur.js +1 -0
  210. package/ui/dist/assets/dist-Dr9crbBz.js +13 -0
  211. package/ui/dist/assets/dist-jjxQutEm.js +1 -0
  212. package/ui/dist/assets/dockerfile-DJkHQgkl.js +1 -0
  213. package/ui/dist/assets/dtd-DTF72YNO.js +1 -0
  214. package/ui/dist/assets/dylan-S7ucR4pR.js +1 -0
  215. package/ui/dist/assets/ebnf-D4c0_ac3.js +1 -0
  216. package/ui/dist/assets/ecl-BVIeWmwY.js +1 -0
  217. package/ui/dist/assets/eiffel-BEjRio4Q.js +1 -0
  218. package/ui/dist/assets/elm-Cshzl8qu.js +1 -0
  219. package/ui/dist/assets/erlang-BV0c4Hdn.js +1 -0
  220. package/ui/dist/assets/factor-LKH5gN8L.js +1 -0
  221. package/ui/dist/assets/fcl-ClOhbFR7.js +1 -0
  222. package/ui/dist/assets/forth-D6RzAl64.js +1 -0
  223. package/ui/dist/assets/fortran-Bt6PBEDR.js +1 -0
  224. package/ui/dist/assets/gas-BHEdbvp9.js +1 -0
  225. package/ui/dist/assets/gherkin-oBAE_ms0.js +1 -0
  226. package/ui/dist/assets/groovy-CUrWKPia.js +1 -0
  227. package/ui/dist/assets/haskell-BRsoo5mP.js +1 -0
  228. package/ui/dist/assets/haxe-DjVOVvNP.js +1 -0
  229. package/ui/dist/assets/http-CLVgA2GD.js +1 -0
  230. package/ui/dist/assets/idl-BUZw3wgd.js +1 -0
  231. package/ui/dist/assets/index-CTmnF0Wp.css +2 -0
  232. package/ui/dist/assets/index-P_Yo0ZZn.js +338 -0
  233. package/ui/dist/assets/javascript-lS2r2ifg.js +1 -0
  234. package/ui/dist/assets/julia-CQfgDRfP.js +1 -0
  235. package/ui/dist/assets/livescript-BXxG0Yva.js +1 -0
  236. package/ui/dist/assets/lua-DPCEkfD_.js +1 -0
  237. package/ui/dist/assets/mathematica-u8YMmbU0.js +1 -0
  238. package/ui/dist/assets/mbox-BEMVcqjs.js +1 -0
  239. package/ui/dist/assets/mirc-C9zpzAZU.js +1 -0
  240. package/ui/dist/assets/mllike-B45xgp2S.js +1 -0
  241. package/ui/dist/assets/modelica-CqHI_q-D.js +1 -0
  242. package/ui/dist/assets/mscgen-qgSLujhx.js +1 -0
  243. package/ui/dist/assets/mumps-BSzj6Xyz.js +1 -0
  244. package/ui/dist/assets/nginx-C-ORUuei.js +1 -0
  245. package/ui/dist/assets/nsis-viPvXFiq.js +1 -0
  246. package/ui/dist/assets/ntriples-CzMBulkp.js +1 -0
  247. package/ui/dist/assets/octave-D2Q8cUp1.js +1 -0
  248. package/ui/dist/assets/oz-BwTct_5T.js +1 -0
  249. package/ui/dist/assets/pascal-Dp_KdFgX.js +1 -0
  250. package/ui/dist/assets/perl-BkSkiI9m.js +1 -0
  251. package/ui/dist/assets/pig-AF4Hwhju.js +1 -0
  252. package/ui/dist/assets/powershell-D8WpQPaX.js +1 -0
  253. package/ui/dist/assets/properties-C357ku5U.js +1 -0
  254. package/ui/dist/assets/protobuf-D1ij_kNL.js +1 -0
  255. package/ui/dist/assets/pug-BqCHZ5xT.js +1 -0
  256. package/ui/dist/assets/puppet-BZyAS7I4.js +1 -0
  257. package/ui/dist/assets/python-B_1Jd8HM.js +1 -0
  258. package/ui/dist/assets/q-B9NhS7L_.js +1 -0
  259. package/ui/dist/assets/r-CxDmU1I4.js +1 -0
  260. package/ui/dist/assets/rpm-gpDK5sbt.js +1 -0
  261. package/ui/dist/assets/ruby-ChfNwlCY.js +1 -0
  262. package/ui/dist/assets/sas-C2OyD2Y6.js +1 -0
  263. package/ui/dist/assets/scheme-D9qYN0V5.js +1 -0
  264. package/ui/dist/assets/shell-DwuoZtxw.js +1 -0
  265. package/ui/dist/assets/sieve-tLWDfEkZ.js +1 -0
  266. package/ui/dist/assets/simple-mode-DXiLM1-G.js +1 -0
  267. package/ui/dist/assets/smalltalk-BLp5-jwC.js +1 -0
  268. package/ui/dist/assets/solr-B78wSQJQ.js +1 -0
  269. package/ui/dist/assets/sparql-Dz-mlCAC.js +1 -0
  270. package/ui/dist/assets/spreadsheet-CTIncYxj.js +1 -0
  271. package/ui/dist/assets/sql-DuQMFDvy.js +1 -0
  272. package/ui/dist/assets/stex-DNzd62Q_.js +1 -0
  273. package/ui/dist/assets/stylus-BOVgKnng.js +1 -0
  274. package/ui/dist/assets/swift-DKB6_1j6.js +1 -0
  275. package/ui/dist/assets/tcl-BGfruO1u.js +1 -0
  276. package/ui/dist/assets/textile-C4jVoHG1.js +1 -0
  277. package/ui/dist/assets/tiddlywiki-VlUiK86J.js +1 -0
  278. package/ui/dist/assets/tiki-CvUEFusT.js +1 -0
  279. package/ui/dist/assets/toml-BPTmHmyx.js +1 -0
  280. package/ui/dist/assets/troff-C2dAgh7P.js +1 -0
  281. package/ui/dist/assets/ttcn-PPSTk1ow.js +1 -0
  282. package/ui/dist/assets/ttcn-cfg-2b4Gb5sE.js +1 -0
  283. package/ui/dist/assets/turtle-Cx3rYX2g.js +1 -0
  284. package/ui/dist/assets/vb-BthdM_4N.js +1 -0
  285. package/ui/dist/assets/vbscript-D9f6rSsU.js +1 -0
  286. package/ui/dist/assets/velocity-CqftRsjg.js +1 -0
  287. package/ui/dist/assets/verilog-CjI9Y6RI.js +1 -0
  288. package/ui/dist/assets/vhdl-kS471soi.js +1 -0
  289. package/ui/dist/assets/webidl-D5jiJvOU.js +1 -0
  290. package/ui/dist/assets/xquery-BKJx4mf9.js +1 -0
  291. package/ui/dist/assets/yacas-CXXgtw0p.js +1 -0
  292. package/ui/dist/assets/z80-Co3PbHdE.js +1 -0
  293. package/ui/dist/favicon.ico +0 -0
  294. package/ui/dist/favicon.svg +11 -0
  295. package/ui/dist/help/ai-tools.png +0 -0
  296. package/ui/dist/help/case-explore.png +0 -0
  297. package/ui/dist/help/case-generate.png +0 -0
  298. package/ui/dist/help/cases-memo.png +0 -0
  299. package/ui/dist/help/profile-form.png +0 -0
  300. package/ui/dist/help/profiles-login.png +0 -0
  301. package/ui/dist/help/reports.png +0 -0
  302. package/ui/dist/help/run-tests.png +0 -0
  303. package/ui/dist/index.html +23 -0
  304. package/ui/dist/logos/claude.svg +1 -0
  305. package/ui/dist/logos/opencode.svg +1 -0
@@ -0,0 +1,395 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Hono } from 'hono';
4
+ import { streamSSE } from 'hono/streaming';
5
+ import { z } from 'zod';
6
+ import { listBaselines, snapshotsDir } from '../../lib/baselines.js';
7
+ import { listEnvs } from '../../lib/profiles.js';
8
+ import { listSuites } from '../../lib/cases.js';
9
+ import { FRAMEWORK_ROOT, ROOT } from '../../lib/paths.js';
10
+ import { ActionError, buildJobSpec, planRunCases, previewRun, previewSchema, startJobSchema } from './actions.js';
11
+ import { addProfile, deleteProfile, editProfile, forgetSession, listProfileRows, ProfileError, verifyProfiles } from './profiles.js';
12
+ import { CaseError, caseImpact, createCase, deleteCase, isValidCaseId, listCaseIds, listCases, openSpec, readCase, readSpec, renameCase, writeCase, writeSpec, } from './cases.js';
13
+ import { resolveInside, sendFile } from './files.js';
14
+ import { JobConflictError, jobs } from './jobs.js';
15
+ import { listAiModels } from './ai-models.js';
16
+ import { fileEvents } from './watch-files.js';
17
+ const REPORTS_DIR = path.join(ROOT, 'reports');
18
+ // Built by `prepack` and shipped with the package; the framework repository builds it with `pnpm ui`.
19
+ const WEB_DIST = path.join(FRAMEWORK_ROOT, 'ui', 'dist');
20
+ // `2026-10-27T16-47-54` (before environments existed, always production) or `2026-10-27T16-47-54-staging`.
21
+ const REPORT_ID = /^(latest|\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(-[a-z0-9]+(-[a-z0-9]+)*)?)$/;
22
+ const BASELINE_FILE = /^[\w.@-]+\.png$/i;
23
+ const CSRF_HEADER = 'x-asrun-ui';
24
+ function readSummary(id) {
25
+ const file = resolveInside(REPORTS_DIR, path.join(id, 'summary.json'));
26
+ if (!file)
27
+ return null;
28
+ try {
29
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function createApp({ port }) {
36
+ const app = new Hono();
37
+ const allowedHosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
38
+ const allowedOrigins = new Set([...allowedHosts].map((host) => `http://${host}`));
39
+ // Vite dev server proxies /api to us and keeps its own origin.
40
+ const devOrigin = process.env.ASRUN_UI_DEV_ORIGIN;
41
+ if (devOrigin)
42
+ allowedOrigins.add(devOrigin);
43
+ // This server can start local processes, so only accept requests that really come from our own pages.
44
+ app.use('*', async (c, next) => {
45
+ const host = c.req.header('host') ?? '';
46
+ if (!allowedHosts.has(host) && !(devOrigin && new URL(devOrigin).host === host)) {
47
+ return c.text('Forbidden host', 403);
48
+ }
49
+ const origin = c.req.header('origin');
50
+ if (origin && !allowedOrigins.has(origin))
51
+ return c.text('Forbidden origin', 403);
52
+ if (!['GET', 'HEAD'].includes(c.req.method)) {
53
+ if (c.req.header(CSRF_HEADER) !== '1')
54
+ return c.text('Missing CSRF header', 403);
55
+ }
56
+ await next();
57
+ });
58
+ app.onError((error, c) => {
59
+ if (error instanceof ActionError)
60
+ return c.json({ error: error.message }, 400);
61
+ if (error instanceof JobConflictError)
62
+ return c.json({ error: error.message }, 409);
63
+ if (error instanceof ProfileError)
64
+ return c.json({ error: error.message }, error.status);
65
+ if (error instanceof CaseError)
66
+ return c.json({ error: error.message }, error.status);
67
+ if (error instanceof z.ZodError)
68
+ return c.json({ error: 'Invalid request body.' }, 400);
69
+ console.error(error);
70
+ return c.json({ error: error instanceof Error ? error.message : 'Internal error' }, 500);
71
+ });
72
+ const api = new Hono();
73
+ api.get('/info', (c) => {
74
+ let suites = [];
75
+ try {
76
+ suites = listSuites();
77
+ }
78
+ catch {
79
+ // A case with broken front matter; the cases page shows it.
80
+ }
81
+ return c.json({ root: ROOT, envs: listEnvs(), suites, caseIds: listCaseIds() });
82
+ });
83
+ api.get('/profiles', (c) => c.json(listProfileRows()));
84
+ const profileFields = {
85
+ name: z.string().max(100),
86
+ env: z.string().max(40),
87
+ baseURL: z.string().max(200),
88
+ account: z.string().max(200).optional(),
89
+ tags: z.array(z.string().max(40)).max(20).optional(),
90
+ startPath: z.string().max(300).optional(),
91
+ sessionCheckPath: z.string().max(200).optional(),
92
+ // Optional keys: an omitted one keeps its default. The ranges are checked by lib/profiles.ts, so the
93
+ // form and a hand-edited profiles.yaml fail with the same message.
94
+ sessionCheck: z
95
+ .object({
96
+ loggedOutUrl: z.string().max(300).optional(),
97
+ loggedInSelector: z.string().max(300).optional(),
98
+ loggedOutSelector: z.string().max(300).optional(),
99
+ timeoutMs: z.number().int().optional(),
100
+ })
101
+ .optional(),
102
+ email: z.boolean().optional(),
103
+ };
104
+ api.post('/profiles', async (c) => {
105
+ const body = z.object({ id: z.string().max(60), ...profileFields }).parse(await c.req.json());
106
+ return c.json(addProfile(body), 201);
107
+ });
108
+ // A running job read profiles.yaml at its start and uses the session files while it runs, so a profile
109
+ // cannot change underneath it.
110
+ const assertProfileIdle = (id) => {
111
+ const busy = jobs
112
+ .list()
113
+ .find((job) => job.status === 'running' &&
114
+ (job.kind === 'run' ||
115
+ job.kind === 'ai' ||
116
+ (job.profile === id && (job.kind === 'signin' || job.kind === 'visit'))));
117
+ if (busy)
118
+ throw new ProfileError(`Wait for "${busy.command}" to finish first.`, 409);
119
+ };
120
+ // The id is fixed: commands, session files and baseline names use it. Changing the site address logs the profile out.
121
+ api.put('/profiles/:id', async (c) => {
122
+ const id = c.req.param('id');
123
+ assertProfileIdle(id);
124
+ const body = z.object(profileFields).parse(await c.req.json());
125
+ return c.json(editProfile({ ...body, id }));
126
+ });
127
+ // Removes the entry from profiles.yaml and deletes the saved login. Never reads the session file.
128
+ api.delete('/profiles/:id', (c) => {
129
+ const id = c.req.param('id');
130
+ assertProfileIdle(id);
131
+ deleteProfile(id);
132
+ return c.json(listProfileRows());
133
+ });
134
+ api.post('/profiles/verify', async (c) => {
135
+ const body = z
136
+ .object({ profile: z.string().optional(), env: z.string().optional() })
137
+ .parse(await c.req.json().catch(() => ({})));
138
+ void verifyProfiles({ ids: body.profile ? [body.profile] : undefined, env: body.env });
139
+ // Give verifyProfiles a tick to mark the profiles as "verifying".
140
+ await new Promise((resolve) => setTimeout(resolve, 20));
141
+ return c.json(listProfileRows());
142
+ });
143
+ // Forgets the saved login of one profile. Never reads it: the file is only deleted.
144
+ api.post('/profiles/logout', async (c) => {
145
+ const { profile: id } = z.object({ profile: z.string().min(1).max(60) }).parse(await c.req.json());
146
+ assertProfileIdle(id);
147
+ forgetSession(id);
148
+ return c.json(listProfileRows());
149
+ });
150
+ api.get('/cases', (c) => c.json(listCases()));
151
+ api.post('/cases', async (c) => {
152
+ const { id } = z.object({ id: z.string() }).parse(await c.req.json());
153
+ return c.json(await createCase(id), 201);
154
+ });
155
+ // A case id may contain one `/` (`documents/landing-page`), so the id is the whole rest of the path.
156
+ api.get('/case-impact/:id{.+}', (c) => c.json(caseImpact(c.req.param('id'))));
157
+ // Moving or deleting files under a running agent or test run would break it.
158
+ const assertNoRunningWork = () => {
159
+ const busy = jobs.list().find((job) => job.status === 'running' && (job.kind === 'ai' || job.kind === 'run'));
160
+ if (busy)
161
+ throw new CaseError(`Wait for "${busy.command}" to finish first.`, 409);
162
+ };
163
+ api.post('/case-rename', async (c) => {
164
+ const body = z.object({ id: z.string().max(200), to: z.string().max(200) }).parse(await c.req.json());
165
+ assertNoRunningWork();
166
+ return c.json(renameCase(body.id, body.to));
167
+ });
168
+ api.get('/specs/:id{.+}', (c) => c.json(readSpec(c.req.param('id'))));
169
+ api.put('/specs/:id{.+}', async (c) => {
170
+ const body = z.object({ content: z.string(), mtimeMs: z.number() }).parse(await c.req.json());
171
+ return c.json(writeSpec(c.req.param('id'), body.content, body.mtimeMs));
172
+ });
173
+ // Only the spec of a known case, never a path from the browser.
174
+ api.post('/spec-open', async (c) => {
175
+ const { id } = z.object({ id: z.string().max(200) }).parse(await c.req.json());
176
+ await openSpec(id);
177
+ return c.json({ ok: true });
178
+ });
179
+ api.get('/cases/:id{.+}', (c) => c.json(readCase(c.req.param('id'))));
180
+ api.put('/cases/:id{.+}', async (c) => {
181
+ const body = z.object({ content: z.string(), mtimeMs: z.number() }).parse(await c.req.json());
182
+ return c.json(writeCase(c.req.param('id'), body.content, body.mtimeMs));
183
+ });
184
+ api.delete('/cases/:id{.+}', (c) => {
185
+ assertNoRunningWork();
186
+ return c.json(deleteCase(c.req.param('id')));
187
+ });
188
+ // The screenshot baselines of one case, with what each must show. Read-only.
189
+ api.get('/baselines', (c) => {
190
+ const caseId = c.req.query('case') ?? '';
191
+ if (!isValidCaseId(caseId) || !listCaseIds().includes(caseId))
192
+ return c.json({ error: 'Unknown case.' }, 404);
193
+ const items = listBaselines([caseId]).map(({ caseId, file, profile, env, changed, mtimeMs, targetId, card, problem }) => ({
194
+ caseId,
195
+ file,
196
+ profile,
197
+ env,
198
+ changed,
199
+ mtimeMs,
200
+ targetId,
201
+ card,
202
+ problem,
203
+ }));
204
+ return c.json(items);
205
+ });
206
+ // One baseline image. Only a .png that sits directly in the snapshot folder of a known case.
207
+ app.get('/baseline-files', (c) => {
208
+ const caseId = c.req.query('case') ?? '';
209
+ const file = c.req.query('file') ?? '';
210
+ if (!isValidCaseId(caseId) || !listCaseIds().includes(caseId) || !BASELINE_FILE.test(file))
211
+ return c.notFound();
212
+ const dir = snapshotsDir(caseId);
213
+ if (!fs.existsSync(dir))
214
+ return c.notFound();
215
+ const image = resolveInside(dir, file);
216
+ return image ? sendFile(c, image) : c.notFound();
217
+ });
218
+ // A reference screenshot of a target card: the case names it as a project-relative path under `.explore/`, which is
219
+ // local state and git-ignored, so the file may be missing on this machine. Only images, only inside `.explore/`.
220
+ const REFERENCE_IMAGE = /\.(png|jpe?g|webp)$/i;
221
+ app.get('/explore-files', (c) => {
222
+ const file = (c.req.query('file') ?? '').replace(/\\/g, '/');
223
+ if (!file.startsWith('.explore/') || file.includes('..') || !REFERENCE_IMAGE.test(file))
224
+ return c.notFound();
225
+ const base = path.join(ROOT, '.explore');
226
+ if (!fs.existsSync(base))
227
+ return c.notFound();
228
+ const image = resolveInside(base, file.slice('.explore/'.length));
229
+ return image ? sendFile(c, image) : c.notFound();
230
+ });
231
+ api.get('/reports', (c) => {
232
+ if (!fs.existsSync(REPORTS_DIR))
233
+ return c.json([]);
234
+ const latestTarget = resolveInside(REPORTS_DIR, 'latest');
235
+ const reports = fs
236
+ .readdirSync(REPORTS_DIR)
237
+ .filter((name) => REPORT_ID.test(name) && name !== 'latest')
238
+ .filter((name) => fs.existsSync(path.join(REPORTS_DIR, name, 'html', 'index.html')))
239
+ .sort()
240
+ .reverse()
241
+ .map((id) => {
242
+ const summary = readSummary(id);
243
+ return {
244
+ id,
245
+ // Runs from before environments existed have no env in the id or the summary; they all ran against production.
246
+ env: summary?.env ?? /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)$/.exec(id)?.[1] ?? null,
247
+ createdAt: new Date(id.slice(0, 10) + 'T' + id.slice(11, 19).replace(/-/g, ':') + 'Z').toISOString(),
248
+ isLatest: latestTarget === path.join(fs.realpathSync(REPORTS_DIR), id),
249
+ counts: summary?.counts ?? null,
250
+ };
251
+ });
252
+ return c.json(reports);
253
+ });
254
+ // Per-test results with the error already split into a headline and technical details.
255
+ api.get('/reports/:id/summary', (c) => {
256
+ const id = c.req.param('id');
257
+ if (!REPORT_ID.test(id))
258
+ return c.json({ error: 'Unknown report.' }, 404);
259
+ const summary = readSummary(id);
260
+ return summary ? c.json(summary) : c.json({ error: 'This run has no summary.' }, 404);
261
+ });
262
+ api.post('/runs/preview', async (c) => c.json(previewRun(previewSchema.parse(await c.req.json()))));
263
+ api.post('/runs/plan', async (c) => c.json(await planRunCases(previewSchema.parse(await c.req.json()))));
264
+ // The models the chosen AI CLI can be run with here (read from the CLI or its configuration, never from a list
265
+ // this app ships). `?refresh=1` reads them again instead of using the short cache.
266
+ api.get('/ai/models', async (c) => {
267
+ const tool = c.req.query('tool') === 'claude' ? 'claude' : 'opencode';
268
+ return c.json(await listAiModels(tool, c.req.query('refresh') === '1'));
269
+ });
270
+ api.get('/jobs', (c) => c.json(jobs.list()));
271
+ api.post('/jobs', async (c) => {
272
+ const request = startJobSchema.parse(await c.req.json());
273
+ return c.json(jobs.start(buildJobSpec(request)), 201);
274
+ });
275
+ api.post('/jobs/:id/input', async (c) => {
276
+ const { text } = z.object({ text: z.string().max(100) }).parse(await c.req.json());
277
+ return c.json({ ok: jobs.write(c.req.param('id'), text) });
278
+ });
279
+ api.post('/jobs/:id/cancel', (c) => c.json({ ok: jobs.cancel(c.req.param('id')) }));
280
+ // Status changes of any job, so every open page can refresh what depends on them.
281
+ api.get('/events', (c) => streamSSE(c, async (stream) => {
282
+ let open = true;
283
+ const listener = (info) => {
284
+ if (open)
285
+ void stream.writeSSE({ event: 'job', data: JSON.stringify(info) }).catch(() => { });
286
+ };
287
+ const filesListener = (change) => {
288
+ if (open)
289
+ void stream.writeSSE({ event: 'files', data: JSON.stringify(change) }).catch(() => { });
290
+ };
291
+ jobs.events.on('job', listener);
292
+ fileEvents.on('files', filesListener);
293
+ stream.onAbort(() => {
294
+ open = false;
295
+ jobs.events.off('job', listener);
296
+ fileEvents.off('files', filesListener);
297
+ });
298
+ while (open) {
299
+ await stream.writeSSE({ event: 'ping', data: '' });
300
+ await stream.sleep(15_000);
301
+ }
302
+ }));
303
+ // Output of one job. Reconnecting with Last-Event-ID replays only what was missed.
304
+ api.get('/jobs/:id/events', (c) => {
305
+ const record = jobs.get(c.req.param('id'));
306
+ if (!record)
307
+ return c.json({ error: 'Unknown job.' }, 404);
308
+ const lastId = Number(c.req.header('last-event-id') ?? c.req.query('after') ?? -1);
309
+ return streamSSE(c, async (stream) => {
310
+ const queue = [];
311
+ let wake = null;
312
+ let aborted = false;
313
+ const notify = () => wake?.();
314
+ const onChunk = (chunk) => {
315
+ queue.push({ event: 'chunk', id: String(chunk.seq), data: JSON.stringify(chunk) });
316
+ notify();
317
+ };
318
+ const onStatus = (info) => {
319
+ queue.push({ event: 'status', data: JSON.stringify(info) });
320
+ notify();
321
+ };
322
+ // Replay and subscribe in one synchronous step so no chunk can fall in between.
323
+ for (const chunk of record.chunks)
324
+ if (chunk.seq > lastId)
325
+ onChunk(chunk);
326
+ record.emitter.on('chunk', onChunk);
327
+ record.emitter.on('status', onStatus);
328
+ if (record.info.status !== 'running')
329
+ onStatus(record.info);
330
+ stream.onAbort(() => {
331
+ aborted = true;
332
+ notify();
333
+ });
334
+ try {
335
+ let done = false;
336
+ while (!aborted && !done) {
337
+ while (queue.length > 0) {
338
+ const message = queue.shift();
339
+ await stream.writeSSE(message);
340
+ if (message.event === 'status' && JSON.parse(message.data).status !== 'running')
341
+ done = true;
342
+ }
343
+ if (done || aborted)
344
+ break;
345
+ await new Promise((resolve) => {
346
+ wake = resolve;
347
+ setTimeout(resolve, 15_000);
348
+ });
349
+ wake = null;
350
+ }
351
+ }
352
+ finally {
353
+ record.emitter.off('chunk', onChunk);
354
+ record.emitter.off('status', onStatus);
355
+ }
356
+ });
357
+ });
358
+ api.all('*', (c) => c.json({ error: 'Not found' }, 404));
359
+ app.route('/api', api);
360
+ // The Playwright HTML report, same origin so it can be embedded in an iframe.
361
+ app.get('/report-files/:id', (c) => c.redirect(`/report-files/${c.req.param('id')}/`));
362
+ app.get('/report-files/:id/*', (c) => {
363
+ const id = c.req.param('id');
364
+ if (!REPORT_ID.test(id))
365
+ return c.notFound();
366
+ const prefix = `/report-files/${id}/`;
367
+ const relative = decodeURIComponent(c.req.path.slice(prefix.length)) || 'index.html';
368
+ const htmlDir = path.join(REPORTS_DIR, id, 'html');
369
+ if (!fs.existsSync(htmlDir))
370
+ return c.notFound();
371
+ // Joined videos live next to the HTML report (it owns its folder), but are served under the same prefix.
372
+ const videosDir = path.join(REPORTS_DIR, id, 'videos');
373
+ if (relative.startsWith('videos/') && fs.existsSync(videosDir)) {
374
+ const video = resolveInside(videosDir, relative.slice('videos/'.length));
375
+ return video ? sendFile(c, video) : c.notFound();
376
+ }
377
+ let file = resolveInside(htmlDir, relative);
378
+ if (file && fs.statSync(file).isDirectory())
379
+ file = resolveInside(htmlDir, path.join(relative, 'index.html'));
380
+ return file ? sendFile(c, file) : c.notFound();
381
+ });
382
+ // The static Inkstone theme guide, linked from the top bar of the UI.
383
+ app.get('/docs/theme-guide.html', (c) => sendFile(c, path.join(FRAMEWORK_ROOT, 'docs', 'theme-guide.html')));
384
+ // Built web app (production mode). In dev, Vite serves it and proxies /api here.
385
+ if (fs.existsSync(WEB_DIST)) {
386
+ app.get('*', (c) => {
387
+ const relative = decodeURIComponent(c.req.path).replace(/^\/+/, '') || 'index.html';
388
+ const file = resolveInside(WEB_DIST, relative);
389
+ if (file && fs.statSync(file).isFile())
390
+ return sendFile(c, file);
391
+ return sendFile(c, path.join(WEB_DIST, 'index.html'));
392
+ });
393
+ }
394
+ return app;
395
+ }
@@ -0,0 +1,23 @@
1
+ import { isValidCaseId } from '../../lib/case-id.js';
2
+ import { CaseError } from '../../lib/case-ops.js';
3
+ import { listCaseFileIds } from '../../lib/cases.js';
4
+ import type { CaseDetail, CaseImpact, CaseSummary, SpecDetail } from '../shared/types.js';
5
+ export { CaseError, isValidCaseId };
6
+ export declare const listCaseIds: typeof listCaseFileIds;
7
+ export declare function listCases(): CaseSummary[];
8
+ export declare function readCase(id: string): CaseDetail;
9
+ export declare function readSpec(id: string): SpecDetail;
10
+ /** Writes only if the file is unchanged since the editor loaded it (an agent or an external editor may change it too). */
11
+ export declare function writeSpec(id: string, content: string, expectedMtimeMs: number): SpecDetail;
12
+ /**
13
+ * Opens the spec of a case in the default application of the system. Resolves once the opener has handed the file over
14
+ * (or is still running after a moment, as `xdg-open` can be); rejects when there is no opener or it fails.
15
+ */
16
+ export declare function openSpec(id: string): Promise<void>;
17
+ /** Writes only if the file is unchanged since the editor loaded it (an agent may be editing it too). */
18
+ export declare function writeCase(id: string, content: string, expectedMtimeMs: number): CaseDetail;
19
+ /** Reuses `pnpm case:new` so the template stays in one place. */
20
+ export declare function createCase(id: string): Promise<CaseDetail>;
21
+ export declare const caseImpact: (id: string) => CaseImpact;
22
+ export declare const renameCase: (id: string, to: string) => CaseDetail;
23
+ export declare const deleteCase: (id: string) => CaseImpact;
@@ -0,0 +1,170 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { parse } from 'yaml';
6
+ import { isValidCaseId } from '../../lib/case-id.js';
7
+ import { assertValidNewId, CaseError, caseFilePath, deleteCase as deleteCaseFiles, getCaseImpact, renameCase as renameCaseFiles, specFilePath, } from '../../lib/case-ops.js';
8
+ import { listCaseFileIds, parseCaseMeta } from '../../lib/cases.js';
9
+ import { scriptArgs } from '../../lib/node-cli.js';
10
+ import { ROOT } from '../../lib/paths.js';
11
+ const MAX_BYTES = 1_000_000;
12
+ export { CaseError, isValidCaseId };
13
+ function fileFor(id) {
14
+ if (!isValidCaseId(id))
15
+ throw new CaseError(`Invalid case id "${id}".`, 400);
16
+ return caseFilePath(id);
17
+ }
18
+ function frontMatter(text) {
19
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
20
+ if (!match)
21
+ return {};
22
+ try {
23
+ const value = parse(match[1]);
24
+ return value && typeof value === 'object' ? value : {};
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ }
30
+ const asString = (value) => (typeof value === 'string' ? value : null);
31
+ export const listCaseIds = listCaseFileIds;
32
+ export function listCases() {
33
+ return listCaseIds().map((id) => {
34
+ const file = fileFor(id);
35
+ const text = fs.readFileSync(file, 'utf8');
36
+ const meta = frontMatter(text);
37
+ let parsed = null;
38
+ let problem = null;
39
+ try {
40
+ parsed = parseCaseMeta(id, text);
41
+ }
42
+ catch (error) {
43
+ problem = error.message;
44
+ }
45
+ return {
46
+ id,
47
+ title: asString(meta.title),
48
+ login: asString(meta.login),
49
+ suites: parsed?.suites ?? [],
50
+ writes: parsed?.writes ?? [],
51
+ writesNote: parsed?.writesNote ?? null,
52
+ envs: parsed?.envs ?? null,
53
+ problem,
54
+ group: id.includes('/') ? id.slice(0, id.indexOf('/')) : null,
55
+ hasSpec: fs.existsSync(specFilePath(id)),
56
+ updatedAt: fs.statSync(file).mtime.toISOString(),
57
+ };
58
+ });
59
+ }
60
+ export function readCase(id) {
61
+ const file = fileFor(id);
62
+ if (!fs.existsSync(file))
63
+ throw new CaseError(`Case "${id}" does not exist.`, 404);
64
+ return { id, content: fs.readFileSync(file, 'utf8'), mtimeMs: fs.statSync(file).mtimeMs };
65
+ }
66
+ /** The path comes from the case id only, never from the browser. */
67
+ function existingSpecFile(id) {
68
+ if (!isValidCaseId(id))
69
+ throw new CaseError(`Invalid case id "${id}".`, 400);
70
+ const file = specFilePath(id);
71
+ if (!fs.existsSync(file))
72
+ throw new CaseError(`Case "${id}" has no spec yet.`, 404);
73
+ return file;
74
+ }
75
+ export function readSpec(id) {
76
+ const file = existingSpecFile(id);
77
+ const stat = fs.statSync(file);
78
+ if (stat.size > MAX_BYTES)
79
+ throw new CaseError('Spec file is too large to show.', 400);
80
+ return {
81
+ id,
82
+ file: path.relative(ROOT, file),
83
+ path: file,
84
+ content: fs.readFileSync(file, 'utf8'),
85
+ mtimeMs: stat.mtimeMs,
86
+ };
87
+ }
88
+ /** Writes only if the file is unchanged since the editor loaded it (an agent or an external editor may change it too). */
89
+ export function writeSpec(id, content, expectedMtimeMs) {
90
+ const file = existingSpecFile(id);
91
+ if (Buffer.byteLength(content) > MAX_BYTES)
92
+ throw new CaseError('Spec file is too large.', 400);
93
+ if (fs.statSync(file).mtimeMs !== expectedMtimeMs) {
94
+ throw new CaseError('The file changed on disk since you opened it. Reload it (copy your edits first) to continue.', 409);
95
+ }
96
+ const temp = `${file}.${process.pid}.tmp`;
97
+ fs.writeFileSync(temp, content);
98
+ fs.renameSync(temp, file);
99
+ return readSpec(id);
100
+ }
101
+ /** The command that opens a file with the application the operating system has registered for it. */
102
+ function openCommand(file) {
103
+ if (process.platform === 'darwin')
104
+ return { command: 'open', args: [file] };
105
+ if (process.platform === 'win32')
106
+ return { command: 'cmd', args: ['/c', 'start', '""', file] };
107
+ return { command: 'xdg-open', args: [file] };
108
+ }
109
+ /**
110
+ * Opens the spec of a case in the default application of the system. Resolves once the opener has handed the file over
111
+ * (or is still running after a moment, as `xdg-open` can be); rejects when there is no opener or it fails.
112
+ */
113
+ export function openSpec(id) {
114
+ const { command, args } = openCommand(existingSpecFile(id));
115
+ return new Promise((resolve, reject) => {
116
+ const fail = (reason) => reject(new CaseError(`Could not open the spec: ${reason}`, 400));
117
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
118
+ const timer = setTimeout(() => {
119
+ child.unref();
120
+ resolve();
121
+ }, 3000);
122
+ child.on('error', (error) => {
123
+ clearTimeout(timer);
124
+ fail(error.message);
125
+ });
126
+ child.on('exit', (code) => {
127
+ clearTimeout(timer);
128
+ if (code === 0)
129
+ resolve();
130
+ else
131
+ fail(`"${command}" exited with code ${code}.`);
132
+ });
133
+ });
134
+ }
135
+ /** Writes only if the file is unchanged since the editor loaded it (an agent may be editing it too). */
136
+ export function writeCase(id, content, expectedMtimeMs) {
137
+ const file = fileFor(id);
138
+ if (!fs.existsSync(file))
139
+ throw new CaseError(`Case "${id}" does not exist.`, 404);
140
+ if (Buffer.byteLength(content) > MAX_BYTES)
141
+ throw new CaseError('Case file is too large.', 400);
142
+ if (fs.statSync(file).mtimeMs !== expectedMtimeMs) {
143
+ throw new CaseError('The file changed on disk since you opened it. Reload it (copy your edits first) to continue.', 409);
144
+ }
145
+ const temp = `${file}.${process.pid}.tmp`;
146
+ fs.writeFileSync(temp, content);
147
+ fs.renameSync(temp, file);
148
+ return readCase(id);
149
+ }
150
+ /** Reuses `pnpm case:new` so the template stays in one place. */
151
+ export async function createCase(id) {
152
+ assertValidNewId(id);
153
+ if (fs.existsSync(fileFor(id)))
154
+ throw new CaseError(`Case "${id}" already exists.`, 409);
155
+ await promisify(execFile)(process.execPath, [...scriptArgs('new-case.ts'), id], {
156
+ cwd: ROOT,
157
+ });
158
+ return readCase(id);
159
+ }
160
+ export const caseImpact = (id) => {
161
+ fileFor(id);
162
+ if (!fs.existsSync(caseFilePath(id)))
163
+ throw new CaseError(`Case "${id}" does not exist.`, 404);
164
+ return getCaseImpact(id);
165
+ };
166
+ export const renameCase = (id, to) => {
167
+ renameCaseFiles(id, to);
168
+ return readCase(to);
169
+ };
170
+ export const deleteCase = (id) => deleteCaseFiles(id);
@@ -0,0 +1,5 @@
1
+ import type { Context } from 'hono';
2
+ /** Resolves `relative` under `base`; returns null if the result escapes `base` (also via symlinks). */
3
+ export declare function resolveInside(base: string, relative: string): string | null;
4
+ /** Serves a file with HTTP Range support, which videos need for seeking. */
5
+ export declare function sendFile(c: Context, file: string): Response | Promise<Response>;
@@ -0,0 +1,67 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Readable } from 'node:stream';
4
+ const MIME = {
5
+ '.html': 'text/html; charset=utf-8',
6
+ '.js': 'text/javascript; charset=utf-8',
7
+ '.mjs': 'text/javascript; charset=utf-8',
8
+ '.css': 'text/css; charset=utf-8',
9
+ '.json': 'application/json; charset=utf-8',
10
+ '.webmanifest': 'application/manifest+json',
11
+ '.svg': 'image/svg+xml',
12
+ '.png': 'image/png',
13
+ '.jpg': 'image/jpeg',
14
+ '.jpeg': 'image/jpeg',
15
+ '.webm': 'video/webm',
16
+ '.mp4': 'video/mp4',
17
+ '.zip': 'application/zip',
18
+ '.ttf': 'font/ttf',
19
+ '.woff2': 'font/woff2',
20
+ '.txt': 'text/plain; charset=utf-8',
21
+ '.md': 'text/markdown; charset=utf-8',
22
+ };
23
+ /** Resolves `relative` under `base`; returns null if the result escapes `base` (also via symlinks). */
24
+ export function resolveInside(base, relative) {
25
+ const realBase = fs.realpathSync(base);
26
+ const target = path.resolve(realBase, '.' + path.sep + relative);
27
+ let real;
28
+ try {
29
+ real = fs.realpathSync(target);
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ return real === realBase || real.startsWith(realBase + path.sep) ? real : null;
35
+ }
36
+ /** Serves a file with HTTP Range support, which videos need for seeking. */
37
+ export function sendFile(c, file) {
38
+ const stat = fs.statSync(file, { throwIfNoEntry: false });
39
+ if (!stat?.isFile())
40
+ return c.notFound();
41
+ const headers = {
42
+ 'Content-Type': MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
43
+ 'Accept-Ranges': 'bytes',
44
+ 'Cache-Control': 'no-cache',
45
+ };
46
+ const range = /^bytes=(\d*)-(\d*)$/.exec(c.req.header('range') ?? '');
47
+ if (range && (range[1] || range[2])) {
48
+ const size = stat.size;
49
+ const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2]));
50
+ const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1;
51
+ if (start > end || start >= size) {
52
+ return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${size}` } });
53
+ }
54
+ return new Response(Readable.toWeb(fs.createReadStream(file, { start, end })), {
55
+ status: 206,
56
+ headers: {
57
+ ...headers,
58
+ 'Content-Range': `bytes ${start}-${end}/${size}`,
59
+ 'Content-Length': String(end - start + 1),
60
+ },
61
+ });
62
+ }
63
+ return new Response(Readable.toWeb(fs.createReadStream(file)), {
64
+ status: 200,
65
+ headers: { ...headers, 'Content-Length': String(stat.size) },
66
+ });
67
+ }
@@ -0,0 +1 @@
1
+ export {};