@dsdenes/devboard 0.1.560 → 0.1.563

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 (323) hide show
  1. package/AGENTS.md +59 -0
  2. package/README.md +2497 -26
  3. package/bin/devboard.mjs +352 -118
  4. package/client/App.svelte +523 -0
  5. package/client/FeedbackOverlay.svelte +1125 -0
  6. package/client/FlowMode.svelte +1015 -0
  7. package/client/app.css +3922 -0
  8. package/client/env.d.ts +23 -0
  9. package/client/lib/commands.ts +836 -0
  10. package/client/lib/components/ActivityBar.svelte +295 -0
  11. package/client/lib/components/AdrTable.svelte +241 -0
  12. package/client/lib/components/CommandPalette.svelte +198 -0
  13. package/client/lib/components/ConceptKind.svelte +66 -0
  14. package/client/lib/components/ConfirmDialog.svelte +62 -0
  15. package/client/lib/components/DataTable.svelte +243 -0
  16. package/client/lib/components/DetailSplit.svelte +68 -0
  17. package/client/lib/components/DiscoverOverview.svelte +898 -0
  18. package/client/lib/components/DiscoverWorkspace.svelte +437 -0
  19. package/client/lib/components/DraftsPage.svelte +186 -0
  20. package/client/lib/components/GoalWorkspace.svelte +1042 -0
  21. package/client/lib/components/IssuesAgentFocus.svelte +117 -0
  22. package/client/lib/components/IssuesAgentLog.svelte +50 -0
  23. package/client/lib/components/IssuesQuestion.svelte +95 -0
  24. package/client/lib/components/IssuesWorkspace.svelte +577 -0
  25. package/client/lib/components/MermaidDiagram.svelte +89 -0
  26. package/client/lib/components/MonacoEditor.svelte +190 -0
  27. package/client/lib/components/NotificationRegion.svelte +195 -0
  28. package/client/lib/components/PaneHandle.svelte +25 -0
  29. package/client/lib/components/ScoutIssues.svelte +215 -0
  30. package/client/lib/components/ScoutWorkspace.svelte +180 -0
  31. package/client/lib/components/Sidebar.svelte +622 -0
  32. package/client/lib/components/StatusBar.svelte +118 -0
  33. package/client/lib/components/Terminal.svelte +193 -0
  34. package/client/lib/components/WatchdogWorkspace.svelte +537 -0
  35. package/client/lib/components/ui/button.svelte +56 -0
  36. package/client/lib/components/ui/icon-button.svelte +56 -0
  37. package/client/lib/components/ui/input.svelte +19 -0
  38. package/client/lib/components/ui/page-header.svelte +120 -0
  39. package/client/lib/components/ui/stage-track.svelte +141 -0
  40. package/client/lib/components/watchdog-indicator.svelte +189 -0
  41. package/client/lib/connection.svelte.ts +210 -0
  42. package/client/lib/dependency-diagram.test.ts +90 -0
  43. package/client/lib/dependency-diagram.ts +75 -0
  44. package/client/lib/discover-format.test.ts +265 -0
  45. package/client/lib/discover-format.ts +394 -0
  46. package/client/lib/discover.svelte.ts +412 -0
  47. package/client/lib/draft-format.test.ts +48 -0
  48. package/client/lib/draft-format.ts +13 -0
  49. package/client/lib/flow-save.test.ts +148 -0
  50. package/client/lib/flow.svelte.ts +445 -0
  51. package/client/lib/goal-reconnect.test.ts +282 -0
  52. package/client/lib/goal.svelte.ts +652 -0
  53. package/client/lib/issues.svelte.ts +439 -0
  54. package/client/lib/layout.svelte.ts +234 -0
  55. package/client/lib/notification-issue.test.ts +53 -0
  56. package/client/lib/notification-issue.ts +27 -0
  57. package/client/lib/notifications.svelte.ts +62 -0
  58. package/client/lib/poll-status.test.ts +141 -0
  59. package/client/lib/poll-status.ts +88 -0
  60. package/client/lib/record-status.test.ts +19 -0
  61. package/client/lib/record-status.ts +25 -0
  62. package/client/lib/scout.svelte.ts +164 -0
  63. package/client/lib/skill-document.test.ts +77 -0
  64. package/client/lib/skill-document.ts +57 -0
  65. package/client/lib/status-tone.test.ts +40 -0
  66. package/client/lib/status-tone.ts +33 -0
  67. package/client/lib/types.ts +578 -0
  68. package/client/lib/utils.ts +6 -0
  69. package/client/lib/viewport.svelte.ts +20 -0
  70. package/client/lib/watchdog.svelte.ts +69 -0
  71. package/client/main.ts +7 -0
  72. package/dist/apple-touch-icon.png +0 -0
  73. package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
  74. package/dist/assets/arc-BZgCSX2H.js +1 -0
  75. package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
  76. package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
  77. package/dist/assets/array-BifhSqXX.js +1 -0
  78. package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
  79. package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
  80. package/dist/assets/channel-CgM4lcUW.js +1 -0
  81. package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  82. package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
  83. package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  84. package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
  85. package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
  86. package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
  87. package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
  88. package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
  89. package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
  90. package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
  91. package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  92. package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
  93. package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
  94. package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
  95. package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
  96. package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
  97. package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
  98. package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
  99. package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
  100. package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
  101. package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  102. package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
  103. package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
  104. package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
  105. package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
  106. package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
  107. package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  108. package/dist/assets/dagre-D7JM2Yhv.js +1 -0
  109. package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
  110. package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  111. package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
  112. package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
  113. package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
  114. package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
  115. package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
  116. package/dist/assets/dist-Dce-2YbA.js +1 -0
  117. package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
  118. package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
  119. package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
  120. package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
  121. package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
  122. package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
  123. package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
  124. package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
  125. package/dist/assets/graphlib-DS17s2tU.js +1 -0
  126. package/dist/assets/index-ByQ-XX38.css +1 -0
  127. package/dist/assets/index-CIf5TpfY.js +214 -0
  128. package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
  129. package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
  130. package/dist/assets/init-C-OQMol4.js +1 -0
  131. package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
  132. package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
  133. package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
  134. package/dist/assets/katex-DJhrJSbw.js +257 -0
  135. package/dist/assets/line-N9XiCREw.js +1 -0
  136. package/dist/assets/linear-C9hnqmJv.js +1 -0
  137. package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
  138. package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
  139. package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
  140. package/dist/assets/monaco-DjpsE8to.css +1 -0
  141. package/dist/assets/monaco-DqzJaNIe.js +908 -0
  142. package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  143. package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
  144. package/dist/assets/path-fybaL0A-.js +1 -0
  145. package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
  146. package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
  147. package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
  148. package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
  149. package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
  150. package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
  151. package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
  152. package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
  153. package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
  154. package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
  155. package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
  156. package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  157. package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  158. package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
  159. package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
  160. package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  161. package/dist/assets/src-CGp3bfio.js +1 -0
  162. package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
  163. package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
  164. package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
  165. package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
  166. package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
  167. package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
  168. package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
  169. package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
  170. package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
  171. package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
  172. package/dist/assets/xterm-BrP-ENHg.css +1 -0
  173. package/dist/assets/xterm-C8zLTNGk.js +16 -0
  174. package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
  175. package/dist/favicon.ico +0 -0
  176. package/dist/favicon.svg +6 -0
  177. package/dist/index.html +34 -0
  178. package/extensions/implement-goal-issue.ts +368 -0
  179. package/extensions/implement-next-epic.ts +835 -0
  180. package/index.html +28 -0
  181. package/install.sh +111 -0
  182. package/issues/control.ts +91 -0
  183. package/issues/guards.ts +12 -0
  184. package/issues/ledger.ts +122 -0
  185. package/issues/legacy.ts +68 -0
  186. package/issues/lock.ts +305 -0
  187. package/issues/paths.ts +76 -0
  188. package/issues/preflight.ts +189 -0
  189. package/issues/repository.ts +203 -0
  190. package/issues/scout/actions.ts +255 -0
  191. package/issues/scout/control.ts +80 -0
  192. package/issues/scout/github.ts +358 -0
  193. package/issues/scout/paths.ts +129 -0
  194. package/issues/scout/preflight.ts +277 -0
  195. package/issues/scout/recommendation.ts +400 -0
  196. package/issues/scout/scan.ts +143 -0
  197. package/issues/scout/state.ts +448 -0
  198. package/issues/scout/worker.ts +1282 -0
  199. package/issues/state.ts +584 -0
  200. package/issues/tracker.ts +271 -0
  201. package/issues/worker.ts +1065 -0
  202. package/lib/dependencies.ts +61 -0
  203. package/lib/forge-auth.ts +953 -0
  204. package/lib/forge.ts +141 -0
  205. package/lib/gitea.ts +729 -0
  206. package/lib/github-release.ts +318 -0
  207. package/lib/inventory.test.ts +79 -0
  208. package/lib/inventory.ts +53 -0
  209. package/lib/release.test.ts +269 -0
  210. package/lib/release.ts +801 -0
  211. package/omp/commands/adr-resolve.md +8 -0
  212. package/omp/commands/discover.md +6 -0
  213. package/omp/commands/epic-next.md +8 -0
  214. package/omp/commands/flow-change.md +8 -0
  215. package/omp/commands/flow-goal.md +8 -0
  216. package/omp/commands/flow-implement.md +8 -0
  217. package/omp/commands/flow-widget.md +8 -0
  218. package/omp/commands/goal-draft.md +8 -0
  219. package/omp/commands/goal-outcome.md +8 -0
  220. package/omp/commands/implement-issue.md +8 -0
  221. package/omp/commands/prepare-skills.md +6 -0
  222. package/omp/commands/scout-recommendations.md +8 -0
  223. package/omp/commands/setup-devboard.md +24 -0
  224. package/omp/config.yml +12 -0
  225. package/omp/extensions/implement-goal-issue.ts +2 -0
  226. package/omp/extensions/implement-next-epic.ts +2 -0
  227. package/omp/mcp.json +9 -0
  228. package/package.json +86 -7
  229. package/prompts/adr-resolve.md +130 -0
  230. package/prompts/discover.md +541 -0
  231. package/prompts/epic-next.md +533 -0
  232. package/prompts/flow-change.md +76 -0
  233. package/prompts/flow-goal.md +126 -0
  234. package/prompts/flow-implement.md +103 -0
  235. package/prompts/flow-widget.md +54 -0
  236. package/prompts/goal-draft.md +147 -0
  237. package/prompts/goal-outcome.md +87 -0
  238. package/prompts/implement-issue.md +66 -0
  239. package/prompts/prepare-skills.md +142 -0
  240. package/prompts/scout-recommendations.md +143 -0
  241. package/public/apple-touch-icon.png +0 -0
  242. package/public/favicon.ico +0 -0
  243. package/public/favicon.svg +6 -0
  244. package/server/acp.ts +540 -0
  245. package/server/activity.test.ts +96 -0
  246. package/server/activity.ts +48 -0
  247. package/server/app.test.ts +882 -0
  248. package/server/app.ts +6179 -0
  249. package/server/ask.test.ts +543 -0
  250. package/server/ask.ts +589 -0
  251. package/server/dependencies.test.ts +118 -0
  252. package/server/discover-remote.test.ts +1602 -0
  253. package/server/discover-sheet.test.ts +176 -0
  254. package/server/discover.test.ts +1510 -0
  255. package/server/discover.ts +3198 -0
  256. package/server/feedback.test.ts +458 -0
  257. package/server/feedback.ts +369 -0
  258. package/server/fixtures/adr-source.ts +71 -0
  259. package/server/fixtures/hang-download.ts +14 -0
  260. package/server/fixtures/stub-agent.ts +1870 -0
  261. package/server/fixtures/stub-forge.ts +141 -0
  262. package/server/fixtures/stub-gh.ts +57 -0
  263. package/server/flow-pr.test.ts +976 -0
  264. package/server/flow.test.ts +1632 -0
  265. package/server/flow.ts +2655 -0
  266. package/server/forge-pr.ts +387 -0
  267. package/server/goal-creation.test.ts +957 -0
  268. package/server/goal-draft.test.ts +348 -0
  269. package/server/goal-drafts.ts +449 -0
  270. package/server/goal-proposal.test.ts +479 -0
  271. package/server/goal-proposal.ts +232 -0
  272. package/server/goal-revision.test.ts +613 -0
  273. package/server/goal-slug.test.ts +56 -0
  274. package/server/goal-slug.ts +96 -0
  275. package/server/goal.ts +20 -0
  276. package/server/index.ts +147 -0
  277. package/server/issue-close.test.ts +100 -0
  278. package/server/issue-close.ts +178 -0
  279. package/server/issues.test.ts +167 -0
  280. package/server/issues.ts +479 -0
  281. package/server/journal.test.ts +350 -0
  282. package/server/journal.ts +329 -0
  283. package/server/lifecycle-pause.test.ts +1795 -0
  284. package/server/lifecycle.test.ts +1879 -0
  285. package/server/parallel-lifecycle.test.ts +1029 -0
  286. package/server/persistence-restart.test.ts +723 -0
  287. package/server/persistence.test.ts +1935 -0
  288. package/server/records.test.ts +180 -0
  289. package/server/records.ts +99 -0
  290. package/server/reset-implementation.test.ts +997 -0
  291. package/server/run.test.ts +611 -0
  292. package/server/scout.test.ts +877 -0
  293. package/server/scout.ts +459 -0
  294. package/server/skills-preparation.test.ts +1073 -0
  295. package/server/state.ts +249 -0
  296. package/server/tracker.ts +369 -0
  297. package/server/updates.test.ts +444 -0
  298. package/server/updates.ts +198 -0
  299. package/server/version.test.ts +140 -0
  300. package/server/version.ts +151 -0
  301. package/server/watchdog.ts +389 -0
  302. package/svelte.config.js +5 -0
  303. package/templates/host-AGENTS.md +235 -0
  304. package/tsconfig.json +21 -0
  305. package/vite.config.ts +21 -0
  306. package/watchdog/cleanup-main.ts +98 -0
  307. package/watchdog/cleanup.ts +301 -0
  308. package/watchdog/collect.ts +311 -0
  309. package/watchdog/coordinator.ts +397 -0
  310. package/watchdog/delivery.ts +107 -0
  311. package/watchdog/discovery.ts +209 -0
  312. package/watchdog/findings.ts +211 -0
  313. package/watchdog/github.ts +189 -0
  314. package/watchdog/history.ts +138 -0
  315. package/watchdog/install.ts +103 -0
  316. package/watchdog/ledger.ts +188 -0
  317. package/watchdog/lock.ts +63 -0
  318. package/watchdog/main.ts +106 -0
  319. package/watchdog/status.ts +71 -0
  320. package/watchdog/systemd.ts +96 -0
  321. package/watchdog/uninstall.ts +30 -0
  322. package/watchdog/units/devboard-watchdog.service +9 -0
  323. package/watchdog/units/devboard-watchdog.timer +10 -0
package/lib/release.ts ADDED
@@ -0,0 +1,801 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import {
5
+ copyFile,
6
+ mkdir,
7
+ readFile,
8
+ readdir,
9
+ rename,
10
+ rm,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { basename, join, resolve } from "node:path";
14
+ import { configuredGiteaToken, configuredGiteaUrl } from "./forge";
15
+ import {
16
+ giteaDownloadAsset,
17
+ giteaReleaseByTag,
18
+ repoPath,
19
+ type GiteaAsset,
20
+ type GiteaReleaseDocument,
21
+ type GiteaTarget,
22
+ } from "./gitea";
23
+
24
+ /**
25
+ * Release transport shared by the dashboard server and its verification
26
+ * scripts. npm is the authority for the version and artifact. A configured
27
+ * Gitea release is only a fallback for hosts where npm cannot resolve or
28
+ * serve the package; it is never required for an update check.
29
+ */
30
+
31
+ export const RELEASE_REPO = "dsdenes/devboard";
32
+ export const PACKAGE_NAME = "@dsdenes/devboard";
33
+ export const RELEASE_PREFIX = "devboard-";
34
+ export const CHECKSUM_SUFFIX = ".sha256";
35
+
36
+ export interface GiteaSource {
37
+ kind: "gitea";
38
+ baseUrl: string;
39
+ repo: string;
40
+ token?: string;
41
+ }
42
+
43
+ export type ReleaseSource =
44
+ | { kind: "npm"; env: NodeJS.ProcessEnv; fallback?: GiteaSource }
45
+ | { kind: "dir"; directory: string }
46
+ | GiteaSource;
47
+
48
+ export interface ReleaseAsset {
49
+ name: string;
50
+ id?: number;
51
+ size?: number;
52
+ }
53
+
54
+ export interface ReleaseInfo {
55
+ version: string;
56
+ tag: string;
57
+ assets: ReleaseAsset[];
58
+ }
59
+
60
+ export interface CommandResult {
61
+ code: number;
62
+ stdout: string;
63
+ stderr: string;
64
+ killed: boolean;
65
+ }
66
+
67
+ export interface ExecOptions {
68
+ cwd?: string;
69
+ env?: NodeJS.ProcessEnv;
70
+ timeoutMs?: number;
71
+ maxBytes?: number;
72
+ }
73
+
74
+ export class ReleaseError extends Error {}
75
+
76
+ /**
77
+ * The feed answered, but not with a release description the harness can read:
78
+ * a proxy or captive-portal page, a truncated body, or a payload that is not a
79
+ * release object. The cause is outside the harness, so a check that hits it is
80
+ * retried as feed context instead of being reported as a defect.
81
+ */
82
+ export class ReleaseFeedError extends ReleaseError {}
83
+
84
+ function trimmed(value: string | undefined): string | undefined {
85
+ if (value === undefined) return undefined;
86
+ const text = value.trim();
87
+ return text.length > 0 ? text.replace(/\/+$/, "") : undefined;
88
+ }
89
+
90
+ function giteaFallback(env: NodeJS.ProcessEnv): GiteaSource | undefined {
91
+ const baseUrl = trimmed(configuredGiteaUrl(env)) ?? trimmed(env.DEVBOARD_RELEASE_URL);
92
+ if (baseUrl === undefined) return undefined;
93
+ const token = configuredGiteaToken(env);
94
+ return {
95
+ kind: "gitea",
96
+ baseUrl,
97
+ repo: env.DEVBOARD_RELEASE_REPO ?? RELEASE_REPO,
98
+ ...(token === undefined ? {} : { token }),
99
+ };
100
+ }
101
+
102
+ /**
103
+ * The release backend the environment asks for. A directory overrides every
104
+ * network source for CI and fixtures. Otherwise npm is the authority, with a
105
+ * configured Gitea release available as a fallback.
106
+ */
107
+ export function releaseSource(
108
+ env: NodeJS.ProcessEnv = process.env,
109
+ stateDir?: string,
110
+ ): ReleaseSource {
111
+ const directory = env.DEVBOARD_RELEASE_DIR;
112
+ if (directory !== undefined && directory.length > 0) {
113
+ return { kind: "dir", directory: resolve(directory) };
114
+ }
115
+ const cache = env.npm_config_cache ??
116
+ (stateDir === undefined ? undefined : join(resolve(stateDir), "npm-cache"));
117
+ const fallback = giteaFallback(env);
118
+ return {
119
+ kind: "npm",
120
+ env: {
121
+ ...env,
122
+ ...(cache === undefined ? {} : { npm_config_cache: cache }),
123
+ },
124
+ ...(fallback === undefined ? {} : { fallback }),
125
+ };
126
+ }
127
+
128
+ /** Runs one command without a shell; stdout is bounded so a hung tool cannot exhaust memory. */
129
+ export function exec(
130
+ command: string,
131
+ args: string[],
132
+ options: ExecOptions = {},
133
+ ): Promise<CommandResult> {
134
+ const maxBytes = options.maxBytes ?? 16 * 1024 * 1024;
135
+ return new Promise((settle, reject) => {
136
+ let child;
137
+ try {
138
+ child = spawn(command, args, {
139
+ cwd: options.cwd,
140
+ env: options.env ?? process.env,
141
+ stdio: ["ignore", "pipe", "pipe"],
142
+ });
143
+ } catch (error) {
144
+ reject(error);
145
+ return;
146
+ }
147
+ const out: Buffer[] = [];
148
+ const err: Buffer[] = [];
149
+ let outBytes = 0;
150
+ let errBytes = 0;
151
+ let killed = false;
152
+ const timer =
153
+ options.timeoutMs === undefined
154
+ ? undefined
155
+ : setTimeout(() => {
156
+ killed = true;
157
+ child.kill("SIGKILL");
158
+ }, options.timeoutMs);
159
+ timer?.unref?.();
160
+ child.stdout.on("data", (chunk: Buffer) => {
161
+ if (outBytes >= maxBytes) return;
162
+ outBytes += chunk.length;
163
+ out.push(chunk);
164
+ });
165
+ child.stderr.on("data", (chunk: Buffer) => {
166
+ if (errBytes >= maxBytes) return;
167
+ errBytes += chunk.length;
168
+ err.push(chunk);
169
+ });
170
+ child.on("error", (error) => {
171
+ if (timer !== undefined) clearTimeout(timer);
172
+ reject(error);
173
+ });
174
+ child.on("close", (code) => {
175
+ if (timer !== undefined) clearTimeout(timer);
176
+ settle({
177
+ code: code ?? 1,
178
+ stdout: Buffer.concat(out).toString("utf8"),
179
+ stderr: Buffer.concat(err).toString("utf8"),
180
+ killed,
181
+ });
182
+ });
183
+ });
184
+ }
185
+
186
+ function failure(tool: string, result: CommandResult): ReleaseError {
187
+ const detail = (result.stderr || result.stdout || "no output").trim().split("\n")[0] ?? "";
188
+ return new ReleaseError(
189
+ `${tool} failed${result.killed ? " (timed out)" : ""}: ${detail.length > 0 ? detail : "no output"}`,
190
+ );
191
+ }
192
+
193
+ /**
194
+ * Text the tools print when the host cannot reach the registry or feed at all:
195
+ * no route, DNS or proxy failure, refused or reset connection, or a timeout.
196
+ * The cause is the network, not the harness, so it is retried without being
197
+ * reported as a defect.
198
+ */
199
+ const UNREACHABLE_PATTERNS = [
200
+ "network is unreachable",
201
+ "no route to host",
202
+ "error connecting to",
203
+ "connection refused",
204
+ "connection reset",
205
+ "connection timed out",
206
+ "i/o timeout",
207
+ "no such host",
208
+ "server misbehaving",
209
+ "temporary failure in name resolution",
210
+ "tls handshake timeout",
211
+ "context deadline exceeded",
212
+ "enotfound",
213
+ "eai_again",
214
+ "(timed out)",
215
+ ];
216
+
217
+ /** True when a release check failed because the network was unreachable. */
218
+ export function isReleaseUnreachable(error: unknown): boolean {
219
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
220
+ if (UNREACHABLE_PATTERNS.some((pattern) => message.includes(pattern))) return true;
221
+ // A host without the release client cannot reach the registry at all. Node
222
+ // and Bun report the failed spawn as ENOENT before the tool prints anything.
223
+ const failure = error as (NodeJS.ErrnoException & { syscall?: unknown }) | undefined;
224
+ const syscall = typeof failure?.syscall === "string" ? failure.syscall : "";
225
+ return failure?.code === "ENOENT" && (message.includes("spawn") || syscall.startsWith("spawn"));
226
+ }
227
+
228
+ export function tarballName(version: string): string {
229
+ return `${RELEASE_PREFIX}${version}.tgz`;
230
+ }
231
+
232
+ export function checksumName(version: string): string {
233
+ return `${tarballName(version)}${CHECKSUM_SUFFIX}`;
234
+ }
235
+
236
+ /** True when `candidate` is a later release than `installed`. */
237
+ export function isNewerVersion(candidate: string, installed: string): boolean {
238
+ const left = candidate.split("-")[0]?.split(".").map(Number) ?? [];
239
+ const right = installed.split("-")[0]?.split(".").map(Number) ?? [];
240
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
241
+ const a = left[index] ?? 0;
242
+ const b = right[index] ?? 0;
243
+ if (Number.isNaN(a) || Number.isNaN(b)) break;
244
+ if (a !== b) return a > b;
245
+ }
246
+ return false;
247
+ }
248
+
249
+ function versionFromTag(tag: string): string {
250
+ return tag.replace(/^v/, "");
251
+ }
252
+
253
+ function versionFromTarball(name: string): string | undefined {
254
+ const match = new RegExp(`^(?:dsdenes-)?${RELEASE_PREFIX}(.+)\\.tgz$`).exec(name);
255
+ return match?.[1];
256
+ }
257
+
258
+ /** The newest release a directory holds; used by fixtures and offline checks. */
259
+ async function directoryRelease(directory: string): Promise<ReleaseInfo> {
260
+ let names: string[];
261
+ try {
262
+ names = await readdir(directory);
263
+ } catch (error) {
264
+ throw new ReleaseError(
265
+ `release directory is unreadable: ${error instanceof Error ? error.message : String(error)}`,
266
+ );
267
+ }
268
+ const tarballs = names
269
+ .filter((name) => name.endsWith(".tgz") && versionFromTarball(name) !== undefined)
270
+ .map((name) => ({ name, version: versionFromTarball(name) as string }))
271
+ .sort((left, right) => (isNewerVersion(left.version, right.version) ? -1 : 1));
272
+ const newest = tarballs[0];
273
+ if (newest === undefined) throw new ReleaseError("no release tarball in DEVBOARD_RELEASE_DIR");
274
+ const checksum = `${newest.name}${CHECKSUM_SUFFIX}`;
275
+ if (!existsSync(join(directory, checksum))) {
276
+ throw new ReleaseError(`release checksum is missing: ${checksum}`);
277
+ }
278
+ return {
279
+ version: newest.version,
280
+ tag: `v${newest.version}`,
281
+ assets: [{ name: newest.name }, { name: checksum }],
282
+ };
283
+ }
284
+
285
+ function npmResultText(result: CommandResult): string {
286
+ const text = (result.stderr || result.stdout).trim().split("\n")[0]?.trim() ?? "";
287
+ return text.length > 0 ? text : "no output";
288
+ }
289
+
290
+ /** The newest version npm publishes for this package. */
291
+ export async function npmLatestVersion(env: NodeJS.ProcessEnv): Promise<string> {
292
+ const npm = env.DEVBOARD_NPM ?? "npm";
293
+ const result = await exec(
294
+ npm,
295
+ ["view", PACKAGE_NAME, "version", "--prefer-online"],
296
+ { env, timeoutMs: 120_000 },
297
+ );
298
+ if (result.code !== 0) {
299
+ throw new ReleaseError(`npm could not resolve ${PACKAGE_NAME}: ${npmResultText(result)}`);
300
+ }
301
+ const lines = result.stdout.trim().split("\n").filter((line) => line.trim().length > 0);
302
+ const version = lines.at(-1)?.trim() ?? "";
303
+ if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
304
+ throw new ReleaseError(`npm returned an unreadable version: ${npmResultText(result)}`);
305
+ }
306
+ return version;
307
+ }
308
+
309
+ /** The tarball npm advertises for one published version. */
310
+ export async function npmArtifactUrl(
311
+ env: NodeJS.ProcessEnv,
312
+ version: string,
313
+ ): Promise<string> {
314
+ const npm = env.DEVBOARD_NPM ?? "npm";
315
+ const result = await exec(
316
+ npm,
317
+ ["view", `${PACKAGE_NAME}@${version}`, "dist.tarball", "--prefer-online"],
318
+ { env, timeoutMs: 120_000 },
319
+ );
320
+ if (result.code !== 0) {
321
+ throw new ReleaseError(
322
+ `npm could not serve ${PACKAGE_NAME}@${version}: ${npmResultText(result)}`,
323
+ );
324
+ }
325
+ const value = result.stdout.trim().split("\n").filter((line) => line.length > 0).at(-1) ?? "";
326
+ if (!/^https?:\/\//.test(value)) {
327
+ throw new ReleaseError(`npm returned no tarball for ${PACKAGE_NAME}@${version}`);
328
+ }
329
+ return value;
330
+ }
331
+
332
+ /**
333
+ * The error for a body that is not one JSON release object: a proxy page, a
334
+ * truncated response, or a payload from a different tool. The first line is
335
+ * kept, bounded, so the journal names what arrived instead of just failing.
336
+ */
337
+ function unreadableDescription(stdout: string): ReleaseFeedError {
338
+ const first = stdout.trim().split("\n")[0]?.trim() ?? "";
339
+ const snippet =
340
+ first.length === 0 ? "no output" : first.length > 200 ? `${first.slice(0, 200)}…` : first;
341
+ return new ReleaseFeedError(`the release feed returned an unreadable description: ${snippet}`);
342
+ }
343
+
344
+ function releaseAssets(value: unknown): ReleaseAsset[] {
345
+ if (!Array.isArray(value)) return [];
346
+ const assets: ReleaseAsset[] = [];
347
+ for (const asset of value) {
348
+ if (typeof asset !== "object" || asset === null) continue;
349
+ const name = "name" in asset ? asset.name : undefined;
350
+ const id = "id" in asset ? asset.id : undefined;
351
+ const size = "size" in asset ? asset.size : undefined;
352
+ if (typeof name !== "string") continue;
353
+ assets.push({
354
+ name,
355
+ ...(typeof id === "number" ? { id } : {}),
356
+ ...(typeof size === "number" ? { size } : {}),
357
+ });
358
+ }
359
+ return assets;
360
+ }
361
+
362
+ /**
363
+ * Reads a release document the Gitea API prints. Anything that is not one JSON
364
+ * release object is a feed the harness cannot use, not a harness defect.
365
+ */
366
+ export function parseReleaseDescription(stdout: string): ReleaseInfo {
367
+ let parsed: unknown;
368
+ try {
369
+ parsed = JSON.parse(stdout) as unknown;
370
+ } catch {
371
+ throw unreadableDescription(stdout);
372
+ }
373
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
374
+ throw unreadableDescription(stdout);
375
+ }
376
+ if (!("tag_name" in parsed) || typeof parsed.tag_name !== "string") {
377
+ throw new ReleaseError("the latest release has no tag");
378
+ }
379
+ return {
380
+ version: versionFromTag(parsed.tag_name),
381
+ tag: parsed.tag_name,
382
+ assets: releaseAssets("assets" in parsed ? parsed.assets : undefined),
383
+ };
384
+ }
385
+
386
+ function releaseFromDocument(document: GiteaReleaseDocument): ReleaseInfo {
387
+ if (typeof document.tag_name !== "string") {
388
+ throw new ReleaseError("the latest release has no tag");
389
+ }
390
+ return {
391
+ version: versionFromTag(document.tag_name),
392
+ tag: document.tag_name,
393
+ assets: releaseAssets(document.assets),
394
+ };
395
+ }
396
+
397
+ function giteaTarget(source: GiteaSource): GiteaTarget {
398
+ return {
399
+ baseUrl: source.baseUrl,
400
+ project: source.repo,
401
+ ...(source.token === undefined ? {} : { token: source.token }),
402
+ };
403
+ }
404
+
405
+ async function giteaRelease(source: GiteaSource): Promise<ReleaseInfo> {
406
+ if (source.baseUrl.length === 0) {
407
+ throw new ReleaseError(
408
+ "the Gitea release address is not configured; set DEVBOARD_GITEA_URL",
409
+ );
410
+ }
411
+ const target = giteaTarget(source);
412
+ const response = await fetch(
413
+ `${target.baseUrl}/api/v1/repos/${repoPath(target)}/releases/latest`,
414
+ {
415
+ headers: {
416
+ Accept: "application/json",
417
+ ...(source.token === undefined ? {} : { Authorization: `token ${source.token}` }),
418
+ },
419
+ },
420
+ );
421
+ if (!response.ok) {
422
+ throw new ReleaseError(
423
+ `the release feed answered HTTP ${response.status}: ${await response.text().catch(() => "")}`,
424
+ );
425
+ }
426
+ return parseReleaseDescription(await response.text());
427
+ }
428
+
429
+ async function giteaReleaseForVersion(
430
+ source: GiteaSource,
431
+ version: string,
432
+ ): Promise<ReleaseInfo> {
433
+ const document = await giteaReleaseByTag(giteaTarget(source), `v${version}`);
434
+ if (document === undefined) {
435
+ throw new ReleaseError(`the release feed has no ${PACKAGE_NAME}@${version}`);
436
+ }
437
+ return releaseFromDocument(document);
438
+ }
439
+
440
+ /** Metadata for the latest version npm publishes, or its configured fallback. */
441
+ export async function latestRelease(source: ReleaseSource): Promise<ReleaseInfo> {
442
+ if (source.kind === "dir") return directoryRelease(source.directory);
443
+ if (source.kind === "gitea") return giteaRelease(source);
444
+ try {
445
+ const version = await npmLatestVersion(source.env);
446
+ return { version, tag: `v${version}`, assets: [] };
447
+ } catch (error) {
448
+ if (source.fallback === undefined) throw error;
449
+ return giteaRelease(source.fallback);
450
+ }
451
+ }
452
+
453
+ /** How long one release asset download may run before it is killed. */
454
+ export const DEFAULT_DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
455
+
456
+ export interface DownloadReleaseOptions {
457
+ /** Deadline for each asset download; defaults to `DEFAULT_DOWNLOAD_TIMEOUT_MS`. */
458
+ timeoutMs?: number;
459
+ }
460
+
461
+ /** Copies the release tarball and checksum into `destination`. */
462
+ export async function downloadRelease(
463
+ source: ReleaseSource,
464
+ release: ReleaseInfo,
465
+ destination: string,
466
+ options: DownloadReleaseOptions = {},
467
+ ): Promise<{ tarball: string; checksum: string }> {
468
+ if (source.kind === "npm") {
469
+ throw new ReleaseError("npm installs the registry artifact directly; no tarball download");
470
+ }
471
+ const tarball = join(destination, tarballName(release.version));
472
+ const checksum = join(destination, checksumName(release.version));
473
+ const names = new Set(release.assets.map((asset) => asset.name));
474
+ if (!names.has(basename(tarball)) || !names.has(basename(checksum))) {
475
+ throw new ReleaseError(
476
+ `release ${release.tag} lacks ${basename(tarball)} or ${basename(checksum)}`,
477
+ );
478
+ }
479
+ if (source.kind === "dir") {
480
+ await mkdir(destination, { recursive: true });
481
+ await copyFile(join(source.directory, basename(tarball)), tarball);
482
+ await copyFile(join(source.directory, basename(checksum)), checksum);
483
+ return { tarball, checksum };
484
+ }
485
+ if (source.baseUrl.length === 0) {
486
+ throw new ReleaseError(
487
+ "the Gitea release address is not configured; set DEVBOARD_GITEA_URL",
488
+ );
489
+ }
490
+ const target = giteaTarget(source);
491
+ const timeoutMs = options.timeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
492
+ const byName = new Map(release.assets.map((asset) => [asset.name, asset]));
493
+ for (const [name, path] of [
494
+ [basename(tarball), tarball],
495
+ [basename(checksum), checksum],
496
+ ] as const) {
497
+ const asset = byName.get(name);
498
+ if (asset === undefined) throw new ReleaseError(`release ${release.tag} lacks ${name}`);
499
+ const giteaAsset: GiteaAsset = {
500
+ name: asset.name,
501
+ ...(asset.id === undefined ? {} : { id: asset.id }),
502
+ ...(asset.size === undefined ? {} : { size: asset.size }),
503
+ };
504
+ try {
505
+ await giteaDownloadAsset(target, giteaAsset, path, { timeoutMs });
506
+ } catch (error) {
507
+ throw error instanceof ReleaseError
508
+ ? error
509
+ : new ReleaseError(error instanceof Error ? error.message : String(error));
510
+ }
511
+ }
512
+ return { tarball, checksum };
513
+ }
514
+
515
+ /** Parses the digest from a `sha256sum` file. */
516
+ export function parseChecksum(text: string): string | undefined {
517
+ return /^([0-9a-fA-F]{64})\b/.exec(text.trim())?.[1]?.toLowerCase();
518
+ }
519
+
520
+ export async function fileSha256(path: string): Promise<string> {
521
+ const bytes = await readFile(path);
522
+ return createHash("sha256").update(bytes).digest("hex");
523
+ }
524
+
525
+ /** Verifies a downloaded tarball; a mismatch is fatal and never installs anything. */
526
+ export async function verifyChecksum(tarball: string, checksumFile: string): Promise<string> {
527
+ const expected = parseChecksum(await readFile(checksumFile, "utf8"));
528
+ if (expected === undefined) {
529
+ throw new ReleaseError(`unreadable checksum file: ${basename(checksumFile)}`);
530
+ }
531
+ const actual = await fileSha256(tarball);
532
+ if (actual !== expected) {
533
+ throw new ReleaseError(
534
+ `checksum mismatch for ${basename(tarball)}: expected ${expected}, got ${actual}`,
535
+ );
536
+ }
537
+ return actual;
538
+ }
539
+
540
+ export async function readPackageVersion(directory: string): Promise<string | null> {
541
+ try {
542
+ const parsed: unknown = JSON.parse(await readFile(join(directory, "package.json"), "utf8"));
543
+ if (typeof parsed !== "object" || parsed === null) return null;
544
+ const version = (parsed as { version?: unknown }).version;
545
+ return typeof version === "string" && version.length > 0 ? version : null;
546
+ } catch {
547
+ return null;
548
+ }
549
+ }
550
+
551
+ export function packageDir(prefix: string): string {
552
+ return join(prefix, "node_modules", "@dsdenes", "devboard");
553
+ }
554
+
555
+ /** The package directory under a host's npm prefix, kept for older callers. */
556
+ export const harnessPackageDir = packageDir;
557
+
558
+ export async function installedHarnessVersion(prefix: string): Promise<string | null> {
559
+ return readPackageVersion(packageDir(prefix));
560
+ }
561
+
562
+ /** Records the devboard dependency the host prefix holds. */
563
+ export async function writePrefixPackage(prefix: string, version: string): Promise<void> {
564
+ const path = join(prefix, "package.json");
565
+ let existing: Record<string, unknown> = {};
566
+ try {
567
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
568
+ if (typeof parsed === "object" && parsed !== null) existing = parsed as Record<string, unknown>;
569
+ } catch {
570
+ existing = {};
571
+ }
572
+ const current = existing.dependencies;
573
+ const dependencies =
574
+ typeof current === "object" && current !== null
575
+ ? { ...(current as Record<string, unknown>) }
576
+ : {};
577
+ delete dependencies[`@dsdenes/${"devboard-harness"}`];
578
+ dependencies[PACKAGE_NAME] = version;
579
+ await writeFile(
580
+ path,
581
+ `${JSON.stringify(
582
+ {
583
+ ...existing,
584
+ name: typeof existing.name === "string" ? existing.name : "devboard-host",
585
+ private: true,
586
+ dependencies,
587
+ },
588
+ null,
589
+ 2,
590
+ )}\n`,
591
+ );
592
+ }
593
+
594
+ export interface InstallTarballOptions {
595
+ /** npm prefix that will hold `node_modules/@dsdenes/devboard`. */
596
+ prefix: string;
597
+ tarball: string;
598
+ version: string;
599
+ env?: NodeJS.ProcessEnv;
600
+ timeoutMs?: number;
601
+ }
602
+
603
+ /**
604
+ * Replaces the prefix's whole dependency tree with the staged one, so the
605
+ * package and its runtime dependencies move together.
606
+ */
607
+ async function swapNodeModules(prefix: string, staged: string): Promise<string> {
608
+ const updates = join(prefix, ".tmp", "updates");
609
+ await mkdir(updates, { recursive: true });
610
+ const target = join(prefix, "node_modules");
611
+ const backup = join(updates, `previous-${Date.now()}-${randomUUID()}`);
612
+ let moved = false;
613
+ if (existsSync(target)) {
614
+ await rename(target, backup);
615
+ moved = true;
616
+ }
617
+ try {
618
+ await rename(staged, target);
619
+ } catch (error) {
620
+ if (moved) await rename(backup, target).catch(() => undefined);
621
+ throw error;
622
+ }
623
+ await rm(backup, { recursive: true, force: true });
624
+ return packageDir(prefix);
625
+ }
626
+
627
+ async function stagedInstall(
628
+ prefix: string,
629
+ spec: string,
630
+ version: string,
631
+ env: NodeJS.ProcessEnv,
632
+ timeoutMs: number,
633
+ ): Promise<string> {
634
+ const updates = join(prefix, ".tmp", "updates");
635
+ await mkdir(updates, { recursive: true });
636
+ const staging = join(updates, `stage-${version}-${randomUUID()}`);
637
+ await mkdir(staging, { recursive: true });
638
+ const npm = env.DEVBOARD_NPM ?? "npm";
639
+ try {
640
+ const result = await exec(
641
+ npm,
642
+ [
643
+ "install",
644
+ "--prefix",
645
+ staging,
646
+ "--ignore-scripts",
647
+ "--no-audit",
648
+ "--no-fund",
649
+ "--no-save",
650
+ spec,
651
+ ],
652
+ {
653
+ // The server's working directory can be the package directory that an
654
+ // earlier update replaced and deleted. npm inherits that process cwd
655
+ // and cannot start in a missing directory, so run it in the staging
656
+ // directory this call just created.
657
+ cwd: staging,
658
+ env,
659
+ timeoutMs,
660
+ },
661
+ );
662
+ if (result.code !== 0) throw failure("npm install", result);
663
+ const staged = packageDir(staging);
664
+ const stagedVersion = await readPackageVersion(staged);
665
+ if (stagedVersion !== version) {
666
+ throw new ReleaseError(
667
+ `installed package reports ${stagedVersion ?? "no version"}, expected ${version}`,
668
+ );
669
+ }
670
+ return await swapNodeModules(prefix, join(staging, "node_modules"));
671
+ } finally {
672
+ await rm(staging, { recursive: true, force: true });
673
+ }
674
+ }
675
+
676
+ /**
677
+ * Installs one verified tarball through npm into a staging prefix and only then
678
+ * moves the dependency tree into place. A failed install never damages the
679
+ * package the running server still has open.
680
+ */
681
+ export async function installHarnessTarball(options: InstallTarballOptions): Promise<string> {
682
+ return await stagedInstall(
683
+ options.prefix,
684
+ options.tarball,
685
+ options.version,
686
+ options.env ?? process.env,
687
+ options.timeoutMs ?? 10 * 60_000,
688
+ );
689
+ }
690
+
691
+ async function installDirectoryRelease(
692
+ source: Extract<ReleaseSource, { kind: "dir" }>,
693
+ release: ReleaseInfo,
694
+ prefix: string,
695
+ stateDir: string,
696
+ env: NodeJS.ProcessEnv,
697
+ downloadTimeoutMs?: number,
698
+ ): Promise<string> {
699
+ const downloadDir = join(stateDir, "updates", `download-${randomUUID()}`);
700
+ await mkdir(downloadDir, { recursive: true });
701
+ try {
702
+ const files = await downloadRelease(source, release, downloadDir, {
703
+ ...(downloadTimeoutMs === undefined ? {} : { timeoutMs: downloadTimeoutMs }),
704
+ });
705
+ await verifyChecksum(files.tarball, files.checksum);
706
+ return await installHarnessTarball({
707
+ prefix,
708
+ tarball: files.tarball,
709
+ version: release.version,
710
+ env,
711
+ });
712
+ } finally {
713
+ await rm(downloadDir, { recursive: true, force: true });
714
+ }
715
+ }
716
+
717
+ async function installGiteaRelease(
718
+ source: GiteaSource,
719
+ release: ReleaseInfo,
720
+ prefix: string,
721
+ stateDir: string,
722
+ env: NodeJS.ProcessEnv,
723
+ downloadTimeoutMs?: number,
724
+ ): Promise<string> {
725
+ const downloadDir = join(stateDir, "updates", `download-${randomUUID()}`);
726
+ await mkdir(downloadDir, { recursive: true });
727
+ try {
728
+ const files = await downloadRelease(source, release, downloadDir, {
729
+ ...(downloadTimeoutMs === undefined ? {} : { timeoutMs: downloadTimeoutMs }),
730
+ });
731
+ await verifyChecksum(files.tarball, files.checksum);
732
+ return await installHarnessTarball({
733
+ prefix,
734
+ tarball: files.tarball,
735
+ version: release.version,
736
+ env,
737
+ });
738
+ } finally {
739
+ await rm(downloadDir, { recursive: true, force: true });
740
+ }
741
+ }
742
+
743
+ /** Downloads, verifies, and installs one release; returns the installed version. */
744
+ export async function installRelease(options: {
745
+ source: ReleaseSource;
746
+ release: ReleaseInfo;
747
+ prefix: string;
748
+ stateDir: string;
749
+ env?: NodeJS.ProcessEnv;
750
+ /** Deadline for each asset download; defaults to `DEFAULT_DOWNLOAD_TIMEOUT_MS`. */
751
+ downloadTimeoutMs?: number;
752
+ }): Promise<string> {
753
+ const env = options.env ?? process.env;
754
+ let installed: string;
755
+ if (options.source.kind === "npm") {
756
+ try {
757
+ await stagedInstall(
758
+ options.prefix,
759
+ `${PACKAGE_NAME}@${options.release.version}`,
760
+ options.release.version,
761
+ options.source.env,
762
+ 10 * 60_000,
763
+ );
764
+ installed = options.release.version;
765
+ } catch (error) {
766
+ if (options.source.fallback === undefined) throw error;
767
+ const release = await giteaReleaseForVersion(
768
+ options.source.fallback,
769
+ options.release.version,
770
+ );
771
+ installed = await installGiteaRelease(
772
+ options.source.fallback,
773
+ release,
774
+ options.prefix,
775
+ options.stateDir,
776
+ options.source.env,
777
+ options.downloadTimeoutMs,
778
+ );
779
+ }
780
+ } else if (options.source.kind === "dir") {
781
+ installed = await installDirectoryRelease(
782
+ options.source,
783
+ options.release,
784
+ options.prefix,
785
+ options.stateDir,
786
+ env,
787
+ options.downloadTimeoutMs,
788
+ );
789
+ } else {
790
+ installed = await installGiteaRelease(
791
+ options.source,
792
+ options.release,
793
+ options.prefix,
794
+ options.stateDir,
795
+ env,
796
+ options.downloadTimeoutMs,
797
+ );
798
+ }
799
+ await writePrefixPackage(options.prefix, installed);
800
+ return installed;
801
+ }