@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
@@ -0,0 +1,953 @@
1
+ /**
2
+ * Deterministic forge identity selection.
3
+ *
4
+ * The active `gh` account is not authoritative. A host can hold several
5
+ * authenticated accounts, and `GH_TOKEN`/`GITHUB_TOKEN` (or the GitLab
6
+ * equivalents) shadows all of them with a token that may be expired, belong to
7
+ * another user, or lack access to the repository being worked on. Trusting
8
+ * either one is how an agent ends up with a 401/403/404 on a tracker write.
9
+ *
10
+ * This module tests every reachable credential against the exact repository,
11
+ * ranks the usable ones, and returns the strongest. Tokens are only ever
12
+ * placed in a child process environment: they are never printed, never passed
13
+ * as arguments, and never included in an error message.
14
+ */
15
+
16
+ export interface ExecResult {
17
+ stdout: string;
18
+ stderr: string;
19
+ code: number;
20
+ killed?: boolean;
21
+ }
22
+
23
+ export interface ExecOptions {
24
+ cwd: string;
25
+ env?: Record<string, string>;
26
+ timeout?: number;
27
+ /** Aborting kills the child; the probe then counts as failed. */
28
+ signal?: AbortSignal;
29
+ }
30
+
31
+ /** Runs one command with an explicit environment; never throws for exit status. */
32
+ export type Exec = (
33
+ command: string,
34
+ args: string[],
35
+ options: ExecOptions,
36
+ ) => Promise<ExecResult>;
37
+
38
+ import {
39
+ configuredGiteaUrl,
40
+ GITEA_TOKEN_KEYS,
41
+ type Forge as ForgeName,
42
+ } from "./forge";
43
+ import {
44
+ giteaRepository,
45
+ giteaUser,
46
+ type FetchLike,
47
+ } from "./gitea";
48
+
49
+ export type Forge = ForgeName;
50
+ export type RequiredAccess = "read" | "write";
51
+ export type Permission = "admin" | "maintain" | "push" | "triage" | "pull" | "none";
52
+
53
+ export interface ResolvedAuth {
54
+ forge: Forge;
55
+ host: string;
56
+ project: string;
57
+ login: string;
58
+ /**
59
+ * Token to inject into the CLI child. Undefined means the CLI credential
60
+ * store itself is authenticated and must be used without an override.
61
+ */
62
+ token?: string;
63
+ /** Where the credential came from, for diagnostics. Never a token value. */
64
+ source: string;
65
+ /** Gitea HTTP origin, when the forge is Gitea. */
66
+ baseUrl?: string;
67
+ active: boolean;
68
+ permission: Permission;
69
+ canWrite: boolean;
70
+ }
71
+
72
+ export interface AuthFailure {
73
+ error: string;
74
+ }
75
+
76
+ export type AuthOutcome = ResolvedAuth | AuthFailure;
77
+
78
+ export interface ResolveOptions {
79
+ forge: Forge;
80
+ host: string;
81
+ project: string;
82
+ cwd: string;
83
+ required?: RequiredAccess;
84
+ env?: Record<string, string | undefined>;
85
+ /**
86
+ * Absolute epoch deadline for the whole resolution. Every probe is bounded
87
+ * by what remains, so one stalled credential helper cannot outlive it.
88
+ */
89
+ deadline?: number;
90
+ /** Aborting kills the in-flight probe. */
91
+ signal?: AbortSignal;
92
+ /** Gitea HTTP origin; defaults to the environment or the origin remote. */
93
+ baseUrl?: string;
94
+ /** Injectable HTTP transport for the Gitea API. */
95
+ giteaFetch?: FetchLike;
96
+ }
97
+
98
+ const GITHUB_TOKEN_KEYS = [
99
+ "GH_TOKEN",
100
+ "GITHUB_TOKEN",
101
+ "GH_ENTERPRISE_TOKEN",
102
+ "GITHUB_ENTERPRISE_TOKEN",
103
+ ] as const;
104
+
105
+ const GITLAB_TOKEN_KEYS = [
106
+ "GITLAB_TOKEN",
107
+ "GITLAB_ACCESS_TOKEN",
108
+ "OAUTH_TOKEN",
109
+ ] as const;
110
+
111
+ const TOKEN_KEYS = [
112
+ ...GITHUB_TOKEN_KEYS,
113
+ ...GITLAB_TOKEN_KEYS,
114
+ ...GITEA_TOKEN_KEYS,
115
+ ] as const;
116
+
117
+ const CLI_ENV = {
118
+ GH_PROMPT_DISABLED: "1",
119
+ GH_NO_UPDATE_NOTIFIER: "1",
120
+ GIT_TERMINAL_PROMPT: "0",
121
+ NO_COLOR: "1",
122
+ } as const;
123
+
124
+ interface Candidate {
125
+ token: string;
126
+ login?: string;
127
+ source: string;
128
+ active: boolean;
129
+ fromEnvironment: boolean;
130
+ order: number;
131
+ }
132
+
133
+ interface Inspected {
134
+ login: string;
135
+ token: string;
136
+ source: string;
137
+ active: boolean;
138
+ fromEnvironment: boolean;
139
+ order: number;
140
+ permission: Permission;
141
+ canWrite: boolean;
142
+ }
143
+
144
+ interface Diagnostic {
145
+ label: string;
146
+ detail: string;
147
+ }
148
+
149
+ function isObject(value: unknown): value is Record<string, unknown> {
150
+ return typeof value === "object" && value !== null && !Array.isArray(value);
151
+ }
152
+
153
+ function stringEnvironment(
154
+ source: Record<string, string | undefined> | undefined,
155
+ ): Record<string, string> {
156
+ const environment: Record<string, string> = {};
157
+ for (const [key, value] of Object.entries(source ?? process.env)) {
158
+ if (typeof value === "string") environment[key] = value;
159
+ }
160
+ return environment;
161
+ }
162
+
163
+ function withoutTokens(base: Record<string, string>, forge: Forge): Record<string, string> {
164
+ const environment = { ...base };
165
+ const keys =
166
+ forge === "github"
167
+ ? GITHUB_TOKEN_KEYS
168
+ : forge === "gitlab"
169
+ ? GITLAB_TOKEN_KEYS
170
+ : GITEA_TOKEN_KEYS;
171
+ for (const key of keys) delete environment[key];
172
+ return environment;
173
+ }
174
+
175
+ function firstLine(value: string): string {
176
+ return (
177
+ value
178
+ .split("\n")
179
+ .map((line) => line.trim())
180
+ .find((line) => line.length > 0) ?? "no diagnostic"
181
+ );
182
+ }
183
+
184
+ function probeOptions(
185
+ options: ResolveOptions,
186
+ env: Record<string, string>,
187
+ ): ExecOptions {
188
+ return {
189
+ cwd: options.cwd,
190
+ env,
191
+ ...(options.deadline === undefined
192
+ ? {}
193
+ : { timeout: Math.max(1, options.deadline - Date.now()) }),
194
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
195
+ };
196
+ }
197
+
198
+ async function attempt(
199
+ exec: Exec,
200
+ command: string,
201
+ args: string[],
202
+ options: ExecOptions,
203
+ ): Promise<ExecResult> {
204
+ try {
205
+ return await exec(command, args, options);
206
+ } catch (error) {
207
+ return {
208
+ stdout: "",
209
+ stderr: error instanceof Error ? error.message : String(error),
210
+ code: -1,
211
+ killed: false,
212
+ };
213
+ }
214
+ }
215
+
216
+ function parseObject(stdout: string): Record<string, unknown> | undefined {
217
+ try {
218
+ const value: unknown = JSON.parse(stdout);
219
+ return isObject(value) ? value : undefined;
220
+ } catch {
221
+ return undefined;
222
+ }
223
+ }
224
+
225
+ function githubPermission(value: unknown): Permission {
226
+ if (!isObject(value)) return "none";
227
+ if (value.admin === true) return "admin";
228
+ if (value.maintain === true) return "maintain";
229
+ if (value.push === true) return "push";
230
+ if (value.triage === true) return "triage";
231
+ if (value.pull === true) return "pull";
232
+ return "none";
233
+ }
234
+
235
+ function rank(permission: Permission): number {
236
+ switch (permission) {
237
+ case "admin":
238
+ return 5;
239
+ case "maintain":
240
+ return 4;
241
+ case "push":
242
+ return 3;
243
+ case "triage":
244
+ return 2;
245
+ case "pull":
246
+ return 1;
247
+ case "none":
248
+ return 0;
249
+ }
250
+ }
251
+
252
+ function selectBest(
253
+ inspected: Inspected[],
254
+ required: RequiredAccess,
255
+ host: string,
256
+ project: string,
257
+ forge: Forge,
258
+ diagnostics: Diagnostic[],
259
+ ): AuthOutcome {
260
+ for (const entry of inspected) {
261
+ if (!entry.canWrite && required === "write") {
262
+ diagnostics.push({
263
+ label: `${entry.login} (${entry.source})`,
264
+ detail: `${entry.permission} access only`,
265
+ });
266
+ }
267
+ }
268
+ const usable = inspected.filter((entry) =>
269
+ required === "read" ? entry.permission !== "none" : entry.canWrite,
270
+ );
271
+ if (usable.length === 0) {
272
+ const forgeName = forge === "github" ? "GitHub" : "GitLab";
273
+ const lines = [
274
+ `no authenticated ${forgeName} account has ${required} access to ${project} on ${host}`,
275
+ ...diagnostics.map((entry) => `- ${entry.label}: ${entry.detail}`),
276
+ ];
277
+ if (forge === "github") {
278
+ lines.push(
279
+ `Run 'gh auth login --hostname ${host}' for an account with ${required} access to ${project}, ` +
280
+ "or set GH_TOKEN to its token.",
281
+ );
282
+ } else {
283
+ lines.push(
284
+ `Run 'glab auth login --hostname ${host}' for an account with ${required} access to ${project}, ` +
285
+ "or set GITLAB_TOKEN to its token.",
286
+ );
287
+ }
288
+ return { error: lines.join("\n") };
289
+ }
290
+
291
+ usable.sort((left, right) => {
292
+ const byPermission = rank(right.permission) - rank(left.permission);
293
+ if (byPermission !== 0) return byPermission;
294
+ const owner = project.split("/")[0]?.toLowerCase() ?? "";
295
+ const byOwner =
296
+ Number(right.login.toLowerCase() === owner) -
297
+ Number(left.login.toLowerCase() === owner);
298
+ if (byOwner !== 0) return byOwner;
299
+ const byActive = Number(right.active) - Number(left.active);
300
+ if (byActive !== 0) return byActive;
301
+ const byEnvironment =
302
+ Number(right.fromEnvironment) - Number(left.fromEnvironment);
303
+ if (byEnvironment !== 0) return byEnvironment;
304
+ return left.order - right.order;
305
+ });
306
+
307
+ const best = usable[0];
308
+ if (best === undefined) {
309
+ return { error: `no usable authenticated account for ${project} on ${host}` };
310
+ }
311
+ return {
312
+ forge,
313
+ host,
314
+ project,
315
+ login: best.login,
316
+ token: best.token,
317
+ source: best.source,
318
+ active: best.active,
319
+ permission: best.permission,
320
+ canWrite: best.canWrite,
321
+ };
322
+ }
323
+
324
+ function githubTokenEnvironment(
325
+ base: Record<string, string>,
326
+ host: string,
327
+ token: string,
328
+ ): Record<string, string> {
329
+ const environment = withoutTokens(base, "github");
330
+ if (host === "github.com") {
331
+ environment.GH_TOKEN = token;
332
+ } else {
333
+ // Enterprise hosts read the enterprise variables; set GH_TOKEN too so an
334
+ // ambient github.com token can never win the precedence check.
335
+ environment.GH_ENTERPRISE_TOKEN = token;
336
+ environment.GITHUB_ENTERPRISE_TOKEN = token;
337
+ environment.GH_TOKEN = token;
338
+ }
339
+ environment.GH_HOST = host;
340
+ return { ...environment, ...CLI_ENV };
341
+ }
342
+
343
+ interface GithubAccount {
344
+ login: string;
345
+ active: boolean;
346
+ state: string;
347
+ error: string;
348
+ }
349
+
350
+ function parseGithubAccounts(stdout: string, host: string): GithubAccount[] | undefined {
351
+ const document = parseObject(stdout);
352
+ if (document === undefined) return undefined;
353
+ const hosts = document.hosts;
354
+ if (!isObject(hosts)) return undefined;
355
+ const entries = hosts[host] ?? hosts[host.toLowerCase()];
356
+ if (entries === undefined) return [];
357
+ if (!Array.isArray(entries)) return undefined;
358
+ const accounts: GithubAccount[] = [];
359
+ for (const entry of entries) {
360
+ if (!isObject(entry)) continue;
361
+ const login = typeof entry.login === "string" ? entry.login : "";
362
+ const active = entry.active === true;
363
+ const state = typeof entry.state === "string" ? entry.state : "";
364
+ const error = typeof entry.error === "string" ? entry.error : "";
365
+ accounts.push({ login, active, state, error });
366
+ }
367
+ return accounts;
368
+ }
369
+
370
+ function environmentCandidates(
371
+ base: Record<string, string>,
372
+ host: string,
373
+ forge: "github" | "gitlab",
374
+ ): Candidate[] {
375
+ const names =
376
+ forge === "github"
377
+ ? host === "github.com"
378
+ ? ["GH_TOKEN", "GITHUB_TOKEN"]
379
+ : ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]
380
+ : ["GITLAB_TOKEN", "GITLAB_ACCESS_TOKEN", "OAUTH_TOKEN"];
381
+ const candidates: Candidate[] = [];
382
+ const seen = new Set<string>();
383
+ let order = 0;
384
+ for (const name of names) {
385
+ const token = base[name]?.trim();
386
+ if (token === undefined || token.length === 0 || seen.has(token)) continue;
387
+ seen.add(token);
388
+ candidates.push({
389
+ token,
390
+ source: name,
391
+ active: false,
392
+ fromEnvironment: true,
393
+ order: order++,
394
+ });
395
+ }
396
+ return candidates;
397
+ }
398
+
399
+ async function inspectGithub(
400
+ exec: Exec,
401
+ options: ResolveOptions,
402
+ base: Record<string, string>,
403
+ candidate: Candidate,
404
+ ): Promise<Inspected | Diagnostic> {
405
+ const environment = githubTokenEnvironment(base, options.host, candidate.token);
406
+ let login = candidate.login;
407
+ if (login === undefined || login.length === 0) {
408
+ const user = await attempt(
409
+ exec,
410
+ "gh",
411
+ ["api", "--hostname", options.host, "user", "--jq", ".login"],
412
+ probeOptions(options, environment),
413
+ );
414
+ if (user.code !== 0) {
415
+ return {
416
+ label: candidate.source,
417
+ detail: `token rejected (${firstLine(user.stderr)})`,
418
+ };
419
+ }
420
+ login = user.stdout.trim();
421
+ if (login.length === 0) {
422
+ return { label: candidate.source, detail: "token rejected (no user returned)" };
423
+ }
424
+ }
425
+
426
+ const repository = await attempt(
427
+ exec,
428
+ "gh",
429
+ [
430
+ "api",
431
+ "--hostname",
432
+ options.host,
433
+ `repos/${options.project}`,
434
+ "--jq",
435
+ ".permissions",
436
+ ],
437
+ probeOptions(options, environment),
438
+ );
439
+ if (repository.code !== 0) {
440
+ const detail = firstLine(repository.stderr);
441
+ const denied = /HTTP (403|404)/.test(detail) ? "no access" : "request failed";
442
+ return { label: `${login} (${candidate.source})`, detail: `${denied} (${detail})` };
443
+ }
444
+ const parsed = parseObject(repository.stdout);
445
+ if (parsed === undefined) {
446
+ return {
447
+ label: `${login} (${candidate.source})`,
448
+ detail: "repository permission response was not JSON",
449
+ };
450
+ }
451
+ const permission = githubPermission(parsed);
452
+ if (permission === "none") {
453
+ return { label: `${login} (${candidate.source})`, detail: "no repository access" };
454
+ }
455
+ return {
456
+ login,
457
+ token: candidate.token,
458
+ source: candidate.source,
459
+ active: candidate.active,
460
+ fromEnvironment: candidate.fromEnvironment,
461
+ order: candidate.order,
462
+ permission,
463
+ canWrite: permission !== "pull",
464
+ };
465
+ }
466
+
467
+ async function resolveGithub(
468
+ exec: Exec,
469
+ options: ResolveOptions,
470
+ base: Record<string, string>,
471
+ ): Promise<AuthOutcome> {
472
+ const required = options.required ?? "read";
473
+ const diagnostics: Diagnostic[] = [];
474
+ const candidates = environmentCandidates(base, options.host, "github");
475
+ let order = candidates.length;
476
+
477
+ const status = await attempt(
478
+ exec,
479
+ "gh",
480
+ ["auth", "status", "--hostname", options.host, "--json", "hosts"],
481
+ probeOptions(options, withoutTokens(base, "github")),
482
+ );
483
+ if (status.code !== 0 && status.stdout.trim().length === 0) {
484
+ return { error: `gh is unavailable or failed: ${firstLine(status.stderr)}` };
485
+ }
486
+ const accounts = parseGithubAccounts(status.stdout, options.host);
487
+ if (accounts === undefined) {
488
+ return { error: "gh auth status returned an unexpected JSON shape" };
489
+ }
490
+
491
+ for (const account of accounts) {
492
+ if (account.state !== "success" || account.login.length === 0) {
493
+ const label = account.active ? "active account" : "gh account";
494
+ const detail = account.error.length > 0 ? firstLine(account.error) : "authentication failed";
495
+ diagnostics.push({ label, detail });
496
+ continue;
497
+ }
498
+ const token = await attempt(
499
+ exec,
500
+ "gh",
501
+ ["auth", "token", "--hostname", options.host, "--user", account.login],
502
+ probeOptions(options, withoutTokens(base, "github")),
503
+ );
504
+ if (token.code !== 0 || token.stdout.trim().length === 0) {
505
+ diagnostics.push({
506
+ label: account.login,
507
+ detail: `token unavailable (${firstLine(token.stderr) || "no token"})`,
508
+ });
509
+ continue;
510
+ }
511
+ candidates.push({
512
+ token: token.stdout.trim(),
513
+ login: account.login,
514
+ source: "gh account",
515
+ active: account.active,
516
+ fromEnvironment: false,
517
+ order: order++,
518
+ });
519
+ }
520
+
521
+ if (candidates.length === 0) {
522
+ const rationale =
523
+ diagnostics.length > 0
524
+ ? diagnostics.map((entry) => `- ${entry.label}: ${entry.detail}`)
525
+ : ["- no authenticated account for the host"];
526
+ return {
527
+ error: [
528
+ `no authenticated GitHub account is available for ${options.host}`,
529
+ ...rationale,
530
+ `Run 'gh auth login --hostname ${options.host}' for an account with access to ${options.project}.`,
531
+ ].join("\n"),
532
+ };
533
+ }
534
+
535
+ const inspected: Inspected[] = [];
536
+ for (const candidate of candidates) {
537
+ const result = await inspectGithub(exec, options, base, candidate);
538
+ if ("permission" in result) {
539
+ inspected.push(result);
540
+ } else {
541
+ diagnostics.push(result);
542
+ }
543
+ }
544
+
545
+ return selectBest(
546
+ inspected,
547
+ required,
548
+ options.host,
549
+ options.project,
550
+ "github",
551
+ diagnostics,
552
+ );
553
+ }
554
+
555
+ interface GitlabStatus {
556
+ accounts: Candidate[];
557
+ diagnostics: Diagnostic[];
558
+ }
559
+
560
+ function parseGitlabTokens(stdout: string): string[] {
561
+ const tokens: string[] = [];
562
+ for (const line of stdout.split("\n")) {
563
+ const match = /(?:^|\s)Token:\s*(\S+)\s*$/.exec(line);
564
+ const token = match?.[1]?.trim();
565
+ if (token !== undefined && token.length > 0) tokens.push(token);
566
+ }
567
+ return [...new Set(tokens)];
568
+ }
569
+
570
+ async function gitlabStatus(
571
+ exec: Exec,
572
+ options: ResolveOptions,
573
+ base: Record<string, string>,
574
+ ): Promise<GitlabStatus> {
575
+ const environment = withoutTokens(base, "gitlab");
576
+ const status = await attempt(
577
+ exec,
578
+ "glab",
579
+ ["auth", "status", "--hostname", options.host, "--show-token"],
580
+ probeOptions(options, environment),
581
+ );
582
+ if (status.code !== 0 && status.stdout.trim().length === 0) {
583
+ return {
584
+ accounts: [],
585
+ diagnostics: [
586
+ { label: "glab", detail: firstLine(status.stderr) || "not authenticated" },
587
+ ],
588
+ };
589
+ }
590
+ const tokens = parseGitlabTokens(status.stdout);
591
+ const accounts: Candidate[] = tokens.map((token, index) => ({
592
+ token,
593
+ source: "glab token",
594
+ active: true,
595
+ fromEnvironment: false,
596
+ order: index,
597
+ }));
598
+ if (accounts.length === 0 && status.code === 0) {
599
+ // The credential store is authenticated, but it did not expose a token.
600
+ // Commands can still use it when no token override is present.
601
+ accounts.push({
602
+ token: "",
603
+ source: "glab credential store",
604
+ active: true,
605
+ fromEnvironment: false,
606
+ order: 0,
607
+ });
608
+ }
609
+ return {
610
+ accounts,
611
+ diagnostics:
612
+ status.code === 0
613
+ ? []
614
+ : [{ label: "glab", detail: firstLine(status.stderr) || "authentication failed" }],
615
+ };
616
+ }
617
+
618
+ function gitlabPermission(value: unknown): Permission {
619
+ if (!isObject(value)) return "none";
620
+ const projectAccess = isObject(value.project_access)
621
+ ? value.project_access.access_level
622
+ : undefined;
623
+ const groupAccess = isObject(value.group_access)
624
+ ? value.group_access.access_level
625
+ : undefined;
626
+ const levels = [projectAccess, groupAccess].filter(
627
+ (level): level is number => typeof level === "number",
628
+ );
629
+ const access = levels.length > 0 ? Math.max(...levels) : 0;
630
+ if (access >= 50) return "admin";
631
+ if (access >= 40) return "maintain";
632
+ if (access >= 30) return "push";
633
+ if (access >= 20) return "triage";
634
+ if (access >= 10) return "pull";
635
+ return "none";
636
+ }
637
+
638
+ function gitlabTokenEnvironment(
639
+ base: Record<string, string>,
640
+ host: string,
641
+ token: string,
642
+ ): Record<string, string> {
643
+ const environment = withoutTokens(base, "gitlab");
644
+ if (token.length > 0) environment.GITLAB_TOKEN = token;
645
+ environment.GITLAB_HOST = host;
646
+ return { ...environment, ...CLI_ENV };
647
+ }
648
+
649
+ async function inspectGitlab(
650
+ exec: Exec,
651
+ options: ResolveOptions,
652
+ base: Record<string, string>,
653
+ candidate: Candidate,
654
+ ): Promise<Inspected | Diagnostic> {
655
+ const environment = gitlabTokenEnvironment(base, options.host, candidate.token);
656
+ const user = await attempt(
657
+ exec,
658
+ "glab",
659
+ ["api", "--hostname", options.host, "user"],
660
+ probeOptions(options, environment),
661
+ );
662
+ if (user.code !== 0) {
663
+ return {
664
+ label: candidate.source,
665
+ detail: `token rejected (${firstLine(user.stderr)})`,
666
+ };
667
+ }
668
+ const userDocument = parseObject(user.stdout);
669
+ const login =
670
+ userDocument !== undefined && typeof userDocument.username === "string"
671
+ ? userDocument.username
672
+ : "";
673
+ if (login.length === 0) {
674
+ return { label: candidate.source, detail: "token returned no username" };
675
+ }
676
+
677
+ const endpoint = `projects/${encodeURIComponent(options.project)}`;
678
+ const project = await attempt(
679
+ exec,
680
+ "glab",
681
+ ["api", "--hostname", options.host, endpoint],
682
+ probeOptions(options, environment),
683
+ );
684
+ if (project.code !== 0) {
685
+ const detail = firstLine(project.stderr);
686
+ const denied = /(401|403|404)/.test(detail) ? "no access" : "request failed";
687
+ return { label: `${login} (${candidate.source})`, detail: `${denied} (${detail})` };
688
+ }
689
+ const document = parseObject(project.stdout);
690
+ if (document === undefined) {
691
+ return {
692
+ label: `${login} (${candidate.source})`,
693
+ detail: "repository permission response was not JSON",
694
+ };
695
+ }
696
+ let permission = gitlabPermission(document.permissions);
697
+ if (permission === "none" && document.visibility === "public") {
698
+ permission = "pull";
699
+ }
700
+ if (permission === "none") {
701
+ return { label: `${login} (${candidate.source})`, detail: "no repository access" };
702
+ }
703
+ return {
704
+ login,
705
+ token: candidate.token,
706
+ source: candidate.source,
707
+ active: candidate.active,
708
+ fromEnvironment: candidate.fromEnvironment,
709
+ order: candidate.order,
710
+ permission,
711
+ canWrite: permission !== "pull",
712
+ };
713
+ }
714
+
715
+ async function resolveGitlab(
716
+ exec: Exec,
717
+ options: ResolveOptions,
718
+ base: Record<string, string>,
719
+ ): Promise<AuthOutcome> {
720
+ const required = options.required ?? "read";
721
+ const diagnostics: Diagnostic[] = [];
722
+ const candidates = environmentCandidates(base, options.host, "gitlab");
723
+ const status = await gitlabStatus(exec, options, base);
724
+ diagnostics.push(...status.diagnostics);
725
+ let order = candidates.length;
726
+ for (const account of status.accounts) {
727
+ candidates.push({ ...account, order: order++ });
728
+ }
729
+ if (candidates.length === 0) {
730
+ const rationale =
731
+ diagnostics.length > 0
732
+ ? diagnostics.map((entry) => `- ${entry.label}: ${entry.detail}`)
733
+ : ["- no authenticated account for the host"];
734
+ return {
735
+ error: [
736
+ `no authenticated GitLab account is available for ${options.host}`,
737
+ ...rationale,
738
+ `Run 'glab auth login --hostname ${options.host}' for an account with access to ${options.project}.`,
739
+ ].join("\n"),
740
+ };
741
+ }
742
+
743
+ const inspected: Inspected[] = [];
744
+ for (const candidate of candidates) {
745
+ const result = await inspectGitlab(exec, options, base, candidate);
746
+ if ("permission" in result) {
747
+ inspected.push(result);
748
+ } else {
749
+ diagnostics.push(result);
750
+ }
751
+ }
752
+
753
+ return selectBest(
754
+ inspected,
755
+ required,
756
+ options.host,
757
+ options.project,
758
+ "gitlab",
759
+ diagnostics,
760
+ );
761
+ }
762
+
763
+ function giteaPermission(value: unknown): Permission {
764
+ if (!isObject(value)) return "none";
765
+ if (value.admin === true) return "admin";
766
+ if (value.push === true) return "push";
767
+ if (value.pull === true) return "pull";
768
+ return "none";
769
+ }
770
+
771
+ function giteaFailure(message: string): AuthFailure {
772
+ return { error: message };
773
+ }
774
+
775
+ /**
776
+ * Gitea is resolved through its public API: an anonymous read proves a public
777
+ * repository, and a token proves the account, its repository access, and its
778
+ * write permission. No CLI is required.
779
+ */
780
+ async function resolveGitea(
781
+ options: ResolveOptions,
782
+ base: Record<string, string>,
783
+ ): Promise<AuthOutcome> {
784
+ const required = options.required ?? "read";
785
+ const baseUrl = (
786
+ options.baseUrl ??
787
+ configuredGiteaUrl(base) ??
788
+ ""
789
+ ).replace(/\/+$/, "");
790
+ if (baseUrl.length === 0) {
791
+ return giteaFailure(
792
+ `no Gitea address is configured for ${options.host}; set DEVBOARD_GITEA_URL`,
793
+ );
794
+ }
795
+ const token =
796
+ (options.env?.GITEA_TOKEN ?? options.env?.DEVBOARD_GITEA_TOKEN ?? "").trim();
797
+ const target = {
798
+ baseUrl,
799
+ project: options.project,
800
+ ...(token.length === 0 ? {} : { token }),
801
+ };
802
+ const fetchImpl = options.giteaFetch ?? fetch;
803
+
804
+ try {
805
+ const repository = await giteaRepository(target, fetchImpl);
806
+ const permission = giteaPermission(repository.permissions);
807
+ if (token.length === 0) {
808
+ if (permission === "none") {
809
+ return giteaFailure(
810
+ `no Gitea credential is configured for ${options.project} on ${baseUrl}, ` +
811
+ "and the repository is not public",
812
+ );
813
+ }
814
+ if (required === "write") {
815
+ return giteaFailure(
816
+ `no Gitea credential is configured for ${options.project} on ${baseUrl}; ` +
817
+ "writing needs GITEA_TOKEN",
818
+ );
819
+ }
820
+ return {
821
+ forge: "gitea",
822
+ host: options.host,
823
+ project: options.project,
824
+ login: "anonymous",
825
+ source: "public repository",
826
+ active: true,
827
+ permission,
828
+ canWrite: false,
829
+ baseUrl,
830
+ };
831
+ }
832
+ const login = await giteaUser(target, fetchImpl);
833
+ if (login.length === 0) {
834
+ return giteaFailure(`the Gitea token returned no account for ${baseUrl}`);
835
+ }
836
+ if (permission === "none") {
837
+ return giteaFailure(
838
+ `the Gitea account ${login} has no access to ${options.project} on ${baseUrl}`,
839
+ );
840
+ }
841
+ if (required === "write" && permission === "pull") {
842
+ return giteaFailure(
843
+ `the Gitea account ${login} cannot write to ${options.project} on ${baseUrl}`,
844
+ );
845
+ }
846
+ return {
847
+ forge: "gitea",
848
+ host: options.host,
849
+ project: options.project,
850
+ login,
851
+ token,
852
+ source: "GITEA_TOKEN",
853
+ active: true,
854
+ permission,
855
+ canWrite: permission !== "pull",
856
+ baseUrl,
857
+ };
858
+ } catch (error) {
859
+ return giteaFailure(
860
+ `could not resolve Gitea access for ${options.project} on ${baseUrl}: ` +
861
+ (error instanceof Error ? error.message : String(error)),
862
+ );
863
+ }
864
+ }
865
+
866
+ /**
867
+ * Resolve the one credential the lifecycle should use for a project.
868
+ *
869
+ * Accounts are tested against the project itself, not against the host: a
870
+ * token can be valid for the host and still have no access to this repository.
871
+ * Candidates are ranked by capability first, then repository ownership, then
872
+ * the `gh` active account, then explicit environment tokens.
873
+ */
874
+ export async function resolveForgeAuth(
875
+ exec: Exec,
876
+ options: ResolveOptions,
877
+ ): Promise<AuthOutcome> {
878
+ const base = stringEnvironment(options.env);
879
+ if (options.forge === "github") return resolveGithub(exec, options, base);
880
+ if (options.forge === "gitlab") return resolveGitlab(exec, options, base);
881
+ return resolveGitea(options, base);
882
+ }
883
+
884
+ export function isAuthFailure(value: AuthOutcome): value is AuthFailure {
885
+ return "error" in value;
886
+ }
887
+
888
+ /**
889
+ * Environment for one forge command. The selected credential wins over every
890
+ * ambient token, and the host is pinned so `GH_HOST` cannot redirect it.
891
+ */
892
+ export function authEnvironment(
893
+ auth: ResolvedAuth,
894
+ base: Record<string, string | undefined> = process.env,
895
+ ): Record<string, string> {
896
+ const environment = stringEnvironment(base);
897
+ if (auth.forge === "github") {
898
+ for (const key of GITHUB_TOKEN_KEYS) delete environment[key];
899
+ if (auth.token === undefined) {
900
+ throw new Error("GitHub auth resolution returned no token");
901
+ }
902
+ if (auth.host === "github.com") {
903
+ environment.GH_TOKEN = auth.token;
904
+ } else {
905
+ environment.GH_ENTERPRISE_TOKEN = auth.token;
906
+ environment.GITHUB_ENTERPRISE_TOKEN = auth.token;
907
+ environment.GH_TOKEN = auth.token;
908
+ }
909
+ environment.GH_HOST = auth.host;
910
+ } else if (auth.forge === "gitlab") {
911
+ for (const key of GITLAB_TOKEN_KEYS) delete environment[key];
912
+ if (auth.token !== undefined && auth.token.length > 0) {
913
+ environment.GITLAB_TOKEN = auth.token;
914
+ }
915
+ environment.GITLAB_HOST = auth.host;
916
+ } else {
917
+ for (const key of GITEA_TOKEN_KEYS) delete environment[key];
918
+ if (auth.token !== undefined && auth.token.length > 0) {
919
+ environment.GITEA_TOKEN = auth.token;
920
+ }
921
+ if (auth.baseUrl !== undefined && auth.baseUrl.length > 0) {
922
+ environment.DEVBOARD_GITEA_URL = auth.baseUrl;
923
+ }
924
+ }
925
+ return { ...environment, ...CLI_ENV };
926
+ }
927
+
928
+ /**
929
+ * Apply the selection to this process so every child (extension calls and the
930
+ * agent's shell) sees the same identity. Pinning `GH_HOST` prevents an ambient
931
+ * host override from sending the token to a different instance.
932
+ */
933
+ export function applyAuthToProcess(auth: ResolvedAuth): void {
934
+ const environment = authEnvironment(auth, process.env);
935
+ for (const key of [
936
+ ...TOKEN_KEYS,
937
+ "GH_HOST",
938
+ "GITLAB_HOST",
939
+ "DEVBOARD_GITEA_URL",
940
+ ] as const) {
941
+ delete process.env[key];
942
+ }
943
+ for (const key of [
944
+ ...TOKEN_KEYS,
945
+ "GH_HOST",
946
+ "GITLAB_HOST",
947
+ "DEVBOARD_GITEA_URL",
948
+ ...Object.keys(CLI_ENV),
949
+ ] as const) {
950
+ const value = environment[key];
951
+ if (value !== undefined) process.env[key] = value;
952
+ }
953
+ }