terramend 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (406) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +145 -0
  3. package/dist/agents/claude.d.ts +73 -0
  4. package/dist/agents/claudePretoolGate.d.ts +99 -0
  5. package/dist/agents/gateServer.d.ts +7 -0
  6. package/dist/agents/index.d.ts +6 -0
  7. package/dist/agents/nativeFsDenies.d.ts +28 -0
  8. package/dist/agents/opencode.d.ts +231 -0
  9. package/dist/agents/opencodePlugin.d.ts +85 -0
  10. package/dist/agents/opencodeShared.d.ts +40 -0
  11. package/dist/agents/postRun.d.ts +132 -0
  12. package/dist/agents/reviewer.d.ts +38 -0
  13. package/dist/agents/sessionLabeler.d.ts +97 -0
  14. package/dist/agents/shared.d.ts +189 -0
  15. package/dist/agents/subagentModels.d.ts +19 -0
  16. package/dist/agents/subagentToolGates.d.ts +55 -0
  17. package/dist/cli.mjs +197426 -0
  18. package/dist/external.d.ts +227 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +196783 -0
  21. package/dist/internal/index.d.ts +18 -0
  22. package/dist/internal.js +1714 -0
  23. package/dist/lifecycle.d.ts +2 -0
  24. package/dist/main.d.ts +8 -0
  25. package/dist/mcp/arkConfig.d.ts +1 -0
  26. package/dist/mcp/checkSuite.d.ts +25 -0
  27. package/dist/mcp/checkout.d.ts +77 -0
  28. package/dist/mcp/comment.d.ts +119 -0
  29. package/dist/mcp/commitInfo.d.ts +9 -0
  30. package/dist/mcp/crosswalk.d.ts +105 -0
  31. package/dist/mcp/dependencies.d.ts +8 -0
  32. package/dist/mcp/geminiSanitizer.d.ts +28 -0
  33. package/dist/mcp/git.d.ts +46 -0
  34. package/dist/mcp/guardrails.d.ts +104 -0
  35. package/dist/mcp/issue.d.ts +18 -0
  36. package/dist/mcp/issueComments.d.ts +9 -0
  37. package/dist/mcp/issueEvents.d.ts +9 -0
  38. package/dist/mcp/issueInfo.d.ts +9 -0
  39. package/dist/mcp/labels.d.ts +12 -0
  40. package/dist/mcp/localContext.d.ts +19 -0
  41. package/dist/mcp/moduleExtraction.d.ts +71 -0
  42. package/dist/mcp/moduleTests.d.ts +104 -0
  43. package/dist/mcp/modules.d.ts +179 -0
  44. package/dist/mcp/output.d.ts +12 -0
  45. package/dist/mcp/pathSafety.d.ts +14 -0
  46. package/dist/mcp/policy.d.ts +48 -0
  47. package/dist/mcp/pr.d.ts +49 -0
  48. package/dist/mcp/prInfo.d.ts +9 -0
  49. package/dist/mcp/providerSchema.d.ts +50 -0
  50. package/dist/mcp/review.d.ts +199 -0
  51. package/dist/mcp/reviewComments.d.ts +178 -0
  52. package/dist/mcp/roots.d.ts +58 -0
  53. package/dist/mcp/scope.d.ts +15 -0
  54. package/dist/mcp/selectMode.d.ts +18 -0
  55. package/dist/mcp/server.d.ts +48 -0
  56. package/dist/mcp/shared.d.ts +47 -0
  57. package/dist/mcp/shell.d.ts +37 -0
  58. package/dist/mcp/staleFix.d.ts +51 -0
  59. package/dist/mcp/terraform/cost.d.ts +55 -0
  60. package/dist/mcp/terraform/currency.d.ts +94 -0
  61. package/dist/mcp/terraform/decisions.d.ts +178 -0
  62. package/dist/mcp/terraform/findings.d.ts +75 -0
  63. package/dist/mcp/terraform/plan.d.ts +157 -0
  64. package/dist/mcp/terraform/scanners.d.ts +131 -0
  65. package/dist/mcp/terraform/tools.d.ts +63 -0
  66. package/dist/mcp/terraform/types.d.ts +172 -0
  67. package/dist/mcp/terraform.d.ts +22 -0
  68. package/dist/mcp/terratest.d.ts +83 -0
  69. package/dist/mcp/upload.d.ts +6 -0
  70. package/dist/models.d.ts +171 -0
  71. package/dist/modes.d.ts +26 -0
  72. package/dist/prep/index.d.ts +7 -0
  73. package/dist/prep/installNodeDependencies.d.ts +2 -0
  74. package/dist/prep/installPythonDependencies.d.ts +2 -0
  75. package/dist/prep/types.d.ts +31 -0
  76. package/dist/reviewQuality.d.ts +64 -0
  77. package/dist/skills/terraform-best-practices/SKILL.md +369 -0
  78. package/dist/toolState.d.ts +135 -0
  79. package/dist/utils/activity.d.ts +40 -0
  80. package/dist/utils/agent.d.ts +20 -0
  81. package/dist/utils/agentHangReport.d.ts +38 -0
  82. package/dist/utils/apiFetch.d.ts +19 -0
  83. package/dist/utils/apiKeys.d.ts +41 -0
  84. package/dist/utils/apiUrl.d.ts +20 -0
  85. package/dist/utils/assets.d.ts +8 -0
  86. package/dist/utils/billingErrors.d.ts +85 -0
  87. package/dist/utils/body.d.ts +34 -0
  88. package/dist/utils/buildTerramendFooter.d.ts +25 -0
  89. package/dist/utils/byokFallback.d.ts +85 -0
  90. package/dist/utils/claudeSubscription.d.ts +30 -0
  91. package/dist/utils/cli.d.ts +10 -0
  92. package/dist/utils/codexHome.d.ts +29 -0
  93. package/dist/utils/codexOAuth.d.ts +60 -0
  94. package/dist/utils/diffCoverage.d.ts +63 -0
  95. package/dist/utils/errorReport.d.ts +17 -0
  96. package/dist/utils/exitHandler.d.ts +8 -0
  97. package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
  98. package/dist/utils/gitAuth.d.ts +84 -0
  99. package/dist/utils/gitAuthServer.d.ts +24 -0
  100. package/dist/utils/github.d.ts +78 -0
  101. package/dist/utils/globals.d.ts +3 -0
  102. package/dist/utils/install.d.ts +60 -0
  103. package/dist/utils/instructions.d.ts +48 -0
  104. package/dist/utils/leapingComment.d.ts +11 -0
  105. package/dist/utils/learnings.d.ts +62 -0
  106. package/dist/utils/learningsTruncate.d.ts +25 -0
  107. package/dist/utils/lifecycle.d.ts +57 -0
  108. package/dist/utils/log.d.ts +111 -0
  109. package/dist/utils/normalizeEnv.d.ts +30 -0
  110. package/dist/utils/openCodeModels.d.ts +11 -0
  111. package/dist/utils/overrides.d.ts +40 -0
  112. package/dist/utils/packageManager.d.ts +49 -0
  113. package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
  114. package/dist/utils/payload.d.ts +105 -0
  115. package/dist/utils/prSummary.d.ts +61 -0
  116. package/dist/utils/progressComment.d.ts +146 -0
  117. package/dist/utils/providerErrors.d.ts +31 -0
  118. package/dist/utils/rangeDiff.d.ts +51 -0
  119. package/dist/utils/remediationCommand.d.ts +55 -0
  120. package/dist/utils/retry.d.ts +13 -0
  121. package/dist/utils/reviewCleanup.d.ts +14 -0
  122. package/dist/utils/run.d.ts +9 -0
  123. package/dist/utils/runContext.d.ts +60 -0
  124. package/dist/utils/runContextData.d.ts +23 -0
  125. package/dist/utils/runErrorRenderer.d.ts +64 -0
  126. package/dist/utils/runLifecycle.d.ts +86 -0
  127. package/dist/utils/runStartupLog.d.ts +15 -0
  128. package/dist/utils/secrets.d.ts +22 -0
  129. package/dist/utils/setup.d.ts +90 -0
  130. package/dist/utils/shell.d.ts +32 -0
  131. package/dist/utils/skills.d.ts +10 -0
  132. package/dist/utils/subprocess.d.ts +80 -0
  133. package/dist/utils/terraformMcp.d.ts +42 -0
  134. package/dist/utils/time.d.ts +15 -0
  135. package/dist/utils/timer.d.ts +23 -0
  136. package/dist/utils/todoTracking.d.ts +16 -0
  137. package/dist/utils/token.d.ts +39 -0
  138. package/dist/utils/version.d.ts +2 -0
  139. package/dist/utils/versioning.d.ts +7 -0
  140. package/dist/utils/vertex.d.ts +16 -0
  141. package/dist/utils/workflow.d.ts +13 -0
  142. package/package.json +119 -0
  143. package/src/agents/claude.test.ts +1016 -0
  144. package/src/agents/claude.ts +1246 -0
  145. package/src/agents/claudePretoolGate.test.ts +28 -0
  146. package/src/agents/claudePretoolGate.ts +173 -0
  147. package/src/agents/gateServer.test.ts +204 -0
  148. package/src/agents/gateServer.ts +124 -0
  149. package/src/agents/index.ts +10 -0
  150. package/src/agents/nativeFsDenies.ts +82 -0
  151. package/src/agents/opencode.test.ts +1440 -0
  152. package/src/agents/opencode.ts +1312 -0
  153. package/src/agents/opencodePlugin.ts +222 -0
  154. package/src/agents/opencodeShared.test.ts +34 -0
  155. package/src/agents/opencodeShared.ts +121 -0
  156. package/src/agents/postRun.test.ts +549 -0
  157. package/src/agents/postRun.ts +535 -0
  158. package/src/agents/reviewer.ts +104 -0
  159. package/src/agents/sessionLabeler.test.ts +247 -0
  160. package/src/agents/sessionLabeler.ts +178 -0
  161. package/src/agents/shared.test.ts +76 -0
  162. package/src/agents/shared.ts +292 -0
  163. package/src/agents/subagentModels.test.ts +113 -0
  164. package/src/agents/subagentModels.ts +40 -0
  165. package/src/agents/subagentRegistration.test.ts +41 -0
  166. package/src/agents/subagentToolGates.ts +114 -0
  167. package/src/cli.test.ts +129 -0
  168. package/src/cli.ts +105 -0
  169. package/src/commands/gha.test.ts +192 -0
  170. package/src/commands/gha.ts +188 -0
  171. package/src/commands/mcp.ts +122 -0
  172. package/src/config.ts +1 -0
  173. package/src/entry.ts +7 -0
  174. package/src/entryPost.stdlibOnly.test.ts +109 -0
  175. package/src/entryPost.ts +99 -0
  176. package/src/external.test.ts +16 -0
  177. package/src/external.ts +302 -0
  178. package/src/index.ts +11 -0
  179. package/src/internal/index.ts +71 -0
  180. package/src/lifecycle.ts +2 -0
  181. package/src/main.test.ts +873 -0
  182. package/src/main.ts +712 -0
  183. package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
  184. package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
  185. package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
  186. package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
  187. package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
  188. package/src/mcp/arkConfig.ts +7 -0
  189. package/src/mcp/checkSuite.test.ts +245 -0
  190. package/src/mcp/checkSuite.ts +255 -0
  191. package/src/mcp/checkout.test.ts +752 -0
  192. package/src/mcp/checkout.ts +886 -0
  193. package/src/mcp/comment.test.ts +772 -0
  194. package/src/mcp/comment.ts +582 -0
  195. package/src/mcp/commitInfo.test.ts +127 -0
  196. package/src/mcp/commitInfo.ts +61 -0
  197. package/src/mcp/crosswalk.test.ts +106 -0
  198. package/src/mcp/crosswalk.ts +339 -0
  199. package/src/mcp/dependencies.test.ts +309 -0
  200. package/src/mcp/dependencies.ts +189 -0
  201. package/src/mcp/geminiSanitizer.test.ts +287 -0
  202. package/src/mcp/geminiSanitizer.ts +207 -0
  203. package/src/mcp/git.test.ts +1083 -0
  204. package/src/mcp/git.ts +890 -0
  205. package/src/mcp/guardrails.test.ts +705 -0
  206. package/src/mcp/guardrails.ts +465 -0
  207. package/src/mcp/issue.test.ts +113 -0
  208. package/src/mcp/issue.ts +73 -0
  209. package/src/mcp/issueComments.test.ts +69 -0
  210. package/src/mcp/issueComments.ts +48 -0
  211. package/src/mcp/issueEvents.test.ts +134 -0
  212. package/src/mcp/issueEvents.ts +100 -0
  213. package/src/mcp/issueInfo.test.ts +104 -0
  214. package/src/mcp/issueInfo.ts +72 -0
  215. package/src/mcp/labels.test.ts +52 -0
  216. package/src/mcp/labels.ts +34 -0
  217. package/src/mcp/localContext.ts +28 -0
  218. package/src/mcp/localServer.test.ts +75 -0
  219. package/src/mcp/localServer.ts +131 -0
  220. package/src/mcp/moduleExtraction.test.ts +261 -0
  221. package/src/mcp/moduleExtraction.ts +313 -0
  222. package/src/mcp/moduleTests.test.ts +269 -0
  223. package/src/mcp/moduleTests.ts +421 -0
  224. package/src/mcp/modules.test.ts +640 -0
  225. package/src/mcp/modules.ts +696 -0
  226. package/src/mcp/output.test.ts +96 -0
  227. package/src/mcp/output.ts +70 -0
  228. package/src/mcp/pathSafety.test.ts +44 -0
  229. package/src/mcp/pathSafety.ts +28 -0
  230. package/src/mcp/policy.test.ts +282 -0
  231. package/src/mcp/policy.ts +199 -0
  232. package/src/mcp/pr.test.ts +387 -0
  233. package/src/mcp/pr.ts +194 -0
  234. package/src/mcp/prInfo.test.ts +96 -0
  235. package/src/mcp/prInfo.ts +91 -0
  236. package/src/mcp/providerSchema.test.ts +85 -0
  237. package/src/mcp/providerSchema.ts +175 -0
  238. package/src/mcp/review.test.ts +936 -0
  239. package/src/mcp/review.ts +923 -0
  240. package/src/mcp/reviewComments.test.ts +549 -0
  241. package/src/mcp/reviewComments.ts +896 -0
  242. package/src/mcp/roots.test.ts +175 -0
  243. package/src/mcp/roots.ts +217 -0
  244. package/src/mcp/scope.test.ts +59 -0
  245. package/src/mcp/scope.ts +65 -0
  246. package/src/mcp/security.test.ts +720 -0
  247. package/src/mcp/selectMode.test.ts +210 -0
  248. package/src/mcp/selectMode.ts +181 -0
  249. package/src/mcp/server.test.ts +292 -0
  250. package/src/mcp/server.ts +403 -0
  251. package/src/mcp/shared.ts +100 -0
  252. package/src/mcp/shell.test.ts +520 -0
  253. package/src/mcp/shell.ts +505 -0
  254. package/src/mcp/staleFix.test.ts +237 -0
  255. package/src/mcp/staleFix.ts +277 -0
  256. package/src/mcp/terraform/cost.ts +163 -0
  257. package/src/mcp/terraform/currency.test.ts +338 -0
  258. package/src/mcp/terraform/currency.ts +336 -0
  259. package/src/mcp/terraform/decisions.ts +527 -0
  260. package/src/mcp/terraform/findings.ts +333 -0
  261. package/src/mcp/terraform/plan.ts +348 -0
  262. package/src/mcp/terraform/scanners.ts +809 -0
  263. package/src/mcp/terraform/tools.test.ts +1071 -0
  264. package/src/mcp/terraform/tools.ts +908 -0
  265. package/src/mcp/terraform/types.ts +305 -0
  266. package/src/mcp/terraform.test.ts +1957 -0
  267. package/src/mcp/terraform.ts +23 -0
  268. package/src/mcp/terratest.test.ts +105 -0
  269. package/src/mcp/terratest.ts +196 -0
  270. package/src/mcp/toolFiltering.test.ts +85 -0
  271. package/src/mcp/upload.test.ts +180 -0
  272. package/src/mcp/upload.ts +112 -0
  273. package/src/models.test.ts +300 -0
  274. package/src/models.ts +708 -0
  275. package/src/modes.test.ts +107 -0
  276. package/src/modes.ts +880 -0
  277. package/src/prep/index.ts +43 -0
  278. package/src/prep/installNodeDependencies.test.ts +298 -0
  279. package/src/prep/installNodeDependencies.ts +196 -0
  280. package/src/prep/installPythonDependencies.test.ts +268 -0
  281. package/src/prep/installPythonDependencies.ts +199 -0
  282. package/src/prep/types.ts +38 -0
  283. package/src/reviewQuality.test.ts +63 -0
  284. package/src/reviewQuality.ts +134 -0
  285. package/src/runCli.test.ts +214 -0
  286. package/src/runCli.ts +282 -0
  287. package/src/skills/terraform-best-practices/SKILL.md +369 -0
  288. package/src/toolState.test.ts +45 -0
  289. package/src/toolState.ts +252 -0
  290. package/src/utils/activity.test.ts +188 -0
  291. package/src/utils/activity.ts +210 -0
  292. package/src/utils/agent.test.ts +251 -0
  293. package/src/utils/agent.ts +139 -0
  294. package/src/utils/agentHangReport.test.ts +203 -0
  295. package/src/utils/agentHangReport.ts +170 -0
  296. package/src/utils/apiFetch.test.ts +115 -0
  297. package/src/utils/apiFetch.ts +62 -0
  298. package/src/utils/apiKeys.test.ts +344 -0
  299. package/src/utils/apiKeys.ts +206 -0
  300. package/src/utils/apiUrl.test.ts +30 -0
  301. package/src/utils/apiUrl.ts +59 -0
  302. package/src/utils/assets.test.ts +153 -0
  303. package/src/utils/assets.ts +107 -0
  304. package/src/utils/billingErrors.test.ts +121 -0
  305. package/src/utils/billingErrors.ts +189 -0
  306. package/src/utils/body.test.ts +217 -0
  307. package/src/utils/body.ts +168 -0
  308. package/src/utils/buildTerramendFooter.test.ts +38 -0
  309. package/src/utils/buildTerramendFooter.ts +82 -0
  310. package/src/utils/byokFallback.test.ts +205 -0
  311. package/src/utils/byokFallback.ts +128 -0
  312. package/src/utils/claudeSubscription.test.ts +179 -0
  313. package/src/utils/claudeSubscription.ts +93 -0
  314. package/src/utils/cli.ts +31 -0
  315. package/src/utils/codexHome.test.ts +190 -0
  316. package/src/utils/codexHome.ts +191 -0
  317. package/src/utils/codexOAuth.ts +147 -0
  318. package/src/utils/codexRefreshDetect.test.ts +85 -0
  319. package/src/utils/codexRefreshDetect.ts +35 -0
  320. package/src/utils/diffCoverage.test.ts +468 -0
  321. package/src/utils/diffCoverage.ts +404 -0
  322. package/src/utils/errorReport.test.ts +135 -0
  323. package/src/utils/errorReport.ts +83 -0
  324. package/src/utils/exitHandler.ts +35 -0
  325. package/src/utils/fixDoubleEscapedString.ts +9 -0
  326. package/src/utils/ghaCore.ts +13 -0
  327. package/src/utils/gitAuth.test.ts +322 -0
  328. package/src/utils/gitAuth.ts +263 -0
  329. package/src/utils/gitAuthServer.test.ts +260 -0
  330. package/src/utils/gitAuthServer.ts +182 -0
  331. package/src/utils/github.test.ts +615 -0
  332. package/src/utils/github.ts +538 -0
  333. package/src/utils/globals.ts +9 -0
  334. package/src/utils/humanEditCapture.test.ts +100 -0
  335. package/src/utils/humanEditCapture.ts +193 -0
  336. package/src/utils/install.test.ts +768 -0
  337. package/src/utils/install.ts +492 -0
  338. package/src/utils/instructions.test.ts +240 -0
  339. package/src/utils/instructions.ts +543 -0
  340. package/src/utils/leapingComment.test.ts +51 -0
  341. package/src/utils/leapingComment.ts +18 -0
  342. package/src/utils/learnings.test.ts +87 -0
  343. package/src/utils/learnings.ts +138 -0
  344. package/src/utils/learningsTocRender.test.ts +116 -0
  345. package/src/utils/learningsTruncate.test.ts +39 -0
  346. package/src/utils/learningsTruncate.ts +42 -0
  347. package/src/utils/lifecycle.test.ts +195 -0
  348. package/src/utils/lifecycle.ts +198 -0
  349. package/src/utils/log.test.ts +402 -0
  350. package/src/utils/log.ts +432 -0
  351. package/src/utils/normalizeEnv.test.ts +91 -0
  352. package/src/utils/normalizeEnv.ts +106 -0
  353. package/src/utils/openCodeModels.ts +82 -0
  354. package/src/utils/overrides.test.ts +89 -0
  355. package/src/utils/overrides.ts +98 -0
  356. package/src/utils/packageManager.test.ts +321 -0
  357. package/src/utils/packageManager.ts +257 -0
  358. package/src/utils/patchWorkflowRunFields.test.ts +92 -0
  359. package/src/utils/patchWorkflowRunFields.ts +150 -0
  360. package/src/utils/payload.test.ts +497 -0
  361. package/src/utils/payload.ts +371 -0
  362. package/src/utils/postApiFetch.ts +51 -0
  363. package/src/utils/prSummary.test.ts +224 -0
  364. package/src/utils/prSummary.ts +147 -0
  365. package/src/utils/progressComment.ts +261 -0
  366. package/src/utils/providerErrors.test.ts +315 -0
  367. package/src/utils/providerErrors.ts +172 -0
  368. package/src/utils/rangeDiff.test.ts +236 -0
  369. package/src/utils/rangeDiff.ts +182 -0
  370. package/src/utils/remediationCommand.test.ts +163 -0
  371. package/src/utils/remediationCommand.ts +119 -0
  372. package/src/utils/retry.test.ts +153 -0
  373. package/src/utils/retry.ts +58 -0
  374. package/src/utils/reviewCleanup.ts +106 -0
  375. package/src/utils/run.ts +99 -0
  376. package/src/utils/runContext.ts +145 -0
  377. package/src/utils/runContextData.ts +58 -0
  378. package/src/utils/runErrorRenderer.test.ts +95 -0
  379. package/src/utils/runErrorRenderer.ts +259 -0
  380. package/src/utils/runFixture.ts +76 -0
  381. package/src/utils/runLifecycle.ts +237 -0
  382. package/src/utils/runStartupLog.ts +60 -0
  383. package/src/utils/secrets.test.ts +103 -0
  384. package/src/utils/secrets.ts +177 -0
  385. package/src/utils/setup.test.ts +509 -0
  386. package/src/utils/setup.ts +352 -0
  387. package/src/utils/shell.ts +103 -0
  388. package/src/utils/skills.test.ts +46 -0
  389. package/src/utils/skills.ts +67 -0
  390. package/src/utils/subprocess.test.ts +170 -0
  391. package/src/utils/subprocess.ts +438 -0
  392. package/src/utils/terraformMcp.test.ts +63 -0
  393. package/src/utils/terraformMcp.ts +83 -0
  394. package/src/utils/time.test.ts +105 -0
  395. package/src/utils/time.ts +59 -0
  396. package/src/utils/timer.test.ts +91 -0
  397. package/src/utils/timer.ts +72 -0
  398. package/src/utils/todoTracking.test.ts +223 -0
  399. package/src/utils/todoTracking.ts +167 -0
  400. package/src/utils/token.test.ts +239 -0
  401. package/src/utils/token.ts +186 -0
  402. package/src/utils/version.ts +10 -0
  403. package/src/utils/versioning.test.ts +34 -0
  404. package/src/utils/versioning.ts +44 -0
  405. package/src/utils/vertex.ts +85 -0
  406. package/src/utils/workflow.ts +25 -0
@@ -0,0 +1,139 @@
1
+ import type { Agent } from "#app/agents/index";
2
+ import { agents } from "#app/agents/index";
3
+ import {
4
+ BEDROCK_MODEL_ID_ENV,
5
+ getModelProvider,
6
+ isBedrockAnthropicId,
7
+ isVertexAnthropicId,
8
+ resolveCliModel,
9
+ resolveDisplayAlias,
10
+ VERTEX_MODEL_ID_ENV,
11
+ } from "#app/models";
12
+ import { log } from "#app/utils/cli";
13
+ import { VERTEX_SERVICE_ACCOUNT_JSON_ENV } from "#app/utils/vertex";
14
+
15
+ function hasEnvVar(name: string): boolean {
16
+ const val = process.env[name];
17
+ return typeof val === "string" && val.length > 0;
18
+ }
19
+
20
+ function hasClaudeCodeAuth(): boolean {
21
+ return hasEnvVar("CLAUDE_CODE_OAUTH_TOKEN") || hasEnvVar("ANTHROPIC_API_KEY");
22
+ }
23
+
24
+ function hasBedrockAuth(): boolean {
25
+ return (
26
+ hasEnvVar("AWS_BEARER_TOKEN_BEDROCK") ||
27
+ (hasEnvVar("AWS_ACCESS_KEY_ID") && hasEnvVar("AWS_SECRET_ACCESS_KEY"))
28
+ );
29
+ }
30
+
31
+ function hasVertexAuth(): boolean {
32
+ return hasEnvVar(VERTEX_SERVICE_ACCOUNT_JSON_ENV);
33
+ }
34
+
35
+ /**
36
+ * resolve a single slug to its CLI-ready model string. routing aliases
37
+ * (e.g. `bedrock/byok`) defer to their backing env var instead of the
38
+ * sentinel stored in `resolve`. shared between TERRAMEND_MODEL override
39
+ * and repo-config slug resolution so both paths get the same routing
40
+ * semantics — without this helper, `TERRAMEND_MODEL=bedrock/byok` would
41
+ * leak the literal sentinel string `"bedrock"` downstream.
42
+ */
43
+ function resolveSlug(slug: string): string | undefined {
44
+ const alias = resolveDisplayAlias(slug);
45
+ if (alias?.routing === "bedrock") {
46
+ const bedrockId = process.env[BEDROCK_MODEL_ID_ENV]?.trim();
47
+ if (!bedrockId) {
48
+ throw new Error(
49
+ `${BEDROCK_MODEL_ID_ENV} env var is required when the model is set to "${slug}". ` +
50
+ `set it to an AWS Bedrock model ID from the Bedrock console. ` +
51
+ `see https://docs.terramend.com/bedrock for setup.`,
52
+ );
53
+ }
54
+ return bedrockId;
55
+ }
56
+ if (alias?.routing === "vertex") {
57
+ const vertexId = process.env[VERTEX_MODEL_ID_ENV]?.trim();
58
+ if (!vertexId) {
59
+ throw new Error(
60
+ `${VERTEX_MODEL_ID_ENV} env var is required when the model is set to "${slug}". ` +
61
+ `set it to a Google Vertex AI model ID from Model Garden. ` +
62
+ `see https://docs.terramend.com/vertex for setup.`,
63
+ );
64
+ }
65
+ return vertexId;
66
+ }
67
+ return resolveCliModel(slug);
68
+ }
69
+
70
+ /**
71
+ * resolve the effective model for this run.
72
+ *
73
+ * priority:
74
+ * 1. TERRAMEND_MODEL env var — resolved through the alias registry first,
75
+ * so values like "anthropic/claude-opus" become "anthropic/claude-opus-4-7".
76
+ * raw specifiers (e.g. "anthropic/claude-opus-4-6") pass through unchanged.
77
+ * always wins — bypasses Bedrock routing entirely. to test a different
78
+ * Bedrock model, change `BEDROCK_MODEL_ID`, not `TERRAMEND_MODEL`.
79
+ * 2. slug from repo config / payload → alias registry. routing slugs
80
+ * (e.g. `bedrock/byok`) defer to a separate env var (`BEDROCK_MODEL_ID`).
81
+ * 3. undefined — agent will auto-select.
82
+ */
83
+ export function resolveModel(ctx: { slug?: string | undefined }): string | undefined {
84
+ const envModel = process.env.TERRAMEND_MODEL?.trim();
85
+ if (envModel) {
86
+ return resolveSlug(envModel) ?? envModel;
87
+ }
88
+
89
+ if (ctx.slug) {
90
+ const resolved = resolveSlug(ctx.slug);
91
+ if (resolved) {
92
+ return resolved;
93
+ }
94
+ log.warning(`» unknown model slug "${ctx.slug}" — agent will auto-select`);
95
+ }
96
+
97
+ return undefined;
98
+ }
99
+
100
+ export function resolveAgent(ctx: { model?: string | undefined }): Agent {
101
+ // 1. explicit env var override (escape hatch)
102
+ const envAgent = process.env.TERRAMEND_AGENT?.trim();
103
+ if (envAgent) {
104
+ if (envAgent in agents) {
105
+ return agents[envAgent as keyof typeof agents];
106
+ }
107
+ log.warning(`» unknown TERRAMEND_AGENT="${envAgent}" — falling through to auto-select`);
108
+ }
109
+
110
+ // 2. Bedrock routing: when BEDROCK_MODEL_ID is the resolved model, route
111
+ // Anthropic IDs through claude-code (which supports Bedrock natively
112
+ // once CLAUDE_CODE_USE_BEDROCK=1) and everything else through opencode's
113
+ // `amazon-bedrock` provider.
114
+ if (ctx.model && hasBedrockAuth() && process.env[BEDROCK_MODEL_ID_ENV]?.trim() === ctx.model) {
115
+ return isBedrockAnthropicId(ctx.model) ? agents.claude : agents.opencode;
116
+ }
117
+
118
+ // 3. Vertex routing: same shape as Bedrock, but Anthropic Vertex IDs are
119
+ // anchored `claude-*` IDs and non-Anthropic models use opencode's
120
+ // `google-vertex` provider.
121
+ if (ctx.model && hasVertexAuth() && process.env[VERTEX_MODEL_ID_ENV]?.trim() === ctx.model) {
122
+ return isVertexAnthropicId(ctx.model) ? agents.claude : agents.opencode;
123
+ }
124
+
125
+ // 4. if model is Anthropic and Claude Code credentials are available, use Claude Code
126
+ if (ctx.model) {
127
+ try {
128
+ const provider = getModelProvider(ctx.model);
129
+ if (provider === "anthropic" && hasClaudeCodeAuth()) {
130
+ return agents.claude;
131
+ }
132
+ } catch {
133
+ // invalid model format — fall through
134
+ }
135
+ }
136
+
137
+ // 5. default: OpenCode (universal, supports all providers)
138
+ return agents.opencode;
139
+ }
@@ -0,0 +1,203 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { type AgentDiagnostic, formatAgentHangBody } from "#app/utils/agentHangReport";
3
+
4
+ function makeDiagnostic(overrides: Partial<AgentDiagnostic> = {}): AgentDiagnostic {
5
+ return {
6
+ label: "Terramend",
7
+ recentStderr: [],
8
+ lastProviderError: undefined,
9
+ eventCount: 0,
10
+ ...overrides,
11
+ };
12
+ }
13
+
14
+ describe("formatAgentHangBody", () => {
15
+ it("returns null when no diagnostic is available", () => {
16
+ const body = formatAgentHangBody({
17
+ diagnostic: undefined,
18
+ isHang: true,
19
+ errorMessage: "activity timeout: no output for 301s",
20
+ });
21
+ expect(body).toBeNull();
22
+ });
23
+
24
+ it("renders a hang headline with parsed idle seconds", () => {
25
+ const body = formatAgentHangBody({
26
+ diagnostic: makeDiagnostic({ eventCount: 12 }),
27
+ isHang: true,
28
+ errorMessage: "activity timeout: no output for 301s",
29
+ });
30
+ expect(body).toContain("**Terramend stalled**");
31
+ expect(body).toContain("stopped emitting events for 301s");
32
+ expect(body).toContain("12 events were processed before the failure.");
33
+ });
34
+
35
+ it("falls back to a generic hang explanation when idle seconds cannot be parsed", () => {
36
+ const body = formatAgentHangBody({
37
+ diagnostic: makeDiagnostic({ eventCount: 3 }),
38
+ isHang: true,
39
+ errorMessage: "watchdog fired",
40
+ });
41
+ expect(body).toContain(
42
+ "The agent stopped emitting events and was killed by the activity-timeout watchdog.",
43
+ );
44
+ });
45
+
46
+ it("renders a failure headline with the raw error for non-hang exits", () => {
47
+ const body = formatAgentHangBody({
48
+ diagnostic: makeDiagnostic({ eventCount: 1 }),
49
+ isHang: false,
50
+ errorMessage: "spawn exited with code 7",
51
+ });
52
+ expect(body).toContain("**Terramend failed**");
53
+ expect(body).toContain("The agent exited unexpectedly: spawn exited with code 7");
54
+ });
55
+
56
+ it("includes the provider-error label as a likely cause in the headline", () => {
57
+ const body = formatAgentHangBody({
58
+ diagnostic: makeDiagnostic({ lastProviderError: "provider auth error" }),
59
+ isHang: true,
60
+ errorMessage: "no output for 60s",
61
+ });
62
+ expect(body).toContain("— likely cause: `provider auth error`");
63
+ // labeled cause suppresses the reachability nudge for zero-event runs
64
+ expect(body).toContain("No events were emitted before the failure.");
65
+ expect(body).not.toContain("check whether the model provider is reachable");
66
+ });
67
+
68
+ it("nudges about provider reachability when zero events and no provider label", () => {
69
+ const body = formatAgentHangBody({
70
+ diagnostic: makeDiagnostic(),
71
+ isHang: true,
72
+ errorMessage: "no output for 60s",
73
+ });
74
+ expect(body).toContain(
75
+ "No events were emitted — check whether the model provider is reachable.",
76
+ );
77
+ });
78
+
79
+ it("omits the stderr details block when no stderr was captured", () => {
80
+ const body = formatAgentHangBody({
81
+ diagnostic: makeDiagnostic(),
82
+ isHang: true,
83
+ errorMessage: "no output for 60s",
84
+ });
85
+ expect(body).not.toContain("<details>");
86
+ });
87
+
88
+ it("renders captured stderr inside a fenced details block", () => {
89
+ const body = formatAgentHangBody({
90
+ diagnostic: makeDiagnostic({ recentStderr: ["line one", "line two"] }),
91
+ isHang: true,
92
+ errorMessage: "no output for 60s",
93
+ });
94
+ expect(body).toContain("<details><summary>Recent agent stderr</summary>");
95
+ expect(body).toContain("line one\nline two");
96
+ expect(body).toContain("```");
97
+ });
98
+
99
+ it("escalates the fence beyond the longest backtick run in stderr", () => {
100
+ const body = formatAgentHangBody({
101
+ diagnostic: makeDiagnostic({ recentStderr: ["payload with ````` five backticks"] }),
102
+ isHang: true,
103
+ errorMessage: "no output for 60s",
104
+ });
105
+ expect(body).toContain("``````\npayload with ````` five backticks\n``````");
106
+ });
107
+
108
+ it("truncates stderr tails beyond the byte cap and marks the truncation", () => {
109
+ const longLine = "x".repeat(1000);
110
+ const body = formatAgentHangBody({
111
+ diagnostic: makeDiagnostic({ recentStderr: [longLine, longLine, longLine, longLine] }),
112
+ isHang: true,
113
+ errorMessage: "no output for 60s",
114
+ });
115
+ expect(body).toContain("... (older lines truncated)");
116
+ // the rendered tail is bounded: cap + truncation banner + surrounding markdown
117
+ expect(body).toBeTypeOf("string");
118
+ if (body) expect(body.length).toBeLessThan(3600);
119
+ });
120
+ });
121
+
122
+ describe("formatAgentHangBody billing-exhausted branch", () => {
123
+ it("replaces the generic headline with a billing CTA when the label matches", () => {
124
+ const body = formatAgentHangBody({
125
+ diagnostic: makeDiagnostic({ lastProviderError: "provider billing exhausted" }),
126
+ isHang: true,
127
+ errorMessage: "no output for 300s",
128
+ });
129
+ expect(body).toContain("**Terramend stopped**");
130
+ expect(body).toContain("billing-exhausted response");
131
+ expect(body).toContain("Top up your model-provider balance");
132
+ expect(body).not.toContain("stalled");
133
+ });
134
+
135
+ it("links the provider billing url when stderr embeds a known billing host", () => {
136
+ const url = "https://opencode.ai/settings/billing?from=zen";
137
+ const body = formatAgentHangBody({
138
+ diagnostic: makeDiagnostic({
139
+ lastProviderError: "provider billing exhausted",
140
+ recentStderr: ["some noise", `error: out of credits, visit ${url} to top up`],
141
+ }),
142
+ isHang: true,
143
+ errorMessage: "no output for 300s",
144
+ });
145
+ expect(body).toContain(`[${url}](${url})`);
146
+ });
147
+
148
+ it("prefers the most recent billing url in the stderr buffer", () => {
149
+ const older = "https://console.anthropic.com/settings/old";
150
+ const newer = "https://console.anthropic.com/settings/billing";
151
+ const body = formatAgentHangBody({
152
+ diagnostic: makeDiagnostic({
153
+ lastProviderError: "provider billing exhausted",
154
+ recentStderr: [`see ${older}`, `see ${newer}`],
155
+ }),
156
+ isHang: true,
157
+ errorMessage: "no output for 300s",
158
+ });
159
+ expect(body).toContain(`[${newer}](${newer})`);
160
+ // the older url still shows in the stderr tail, but must not be the CTA link
161
+ expect(body).not.toContain(`[${older}]`);
162
+ });
163
+
164
+ it("ignores urls on non-billing hosts so a stray link cannot pose as the remedy", () => {
165
+ const body = formatAgentHangBody({
166
+ diagnostic: makeDiagnostic({
167
+ lastProviderError: "provider billing exhausted",
168
+ recentStderr: ["see https://evil.example.com/billing for details"],
169
+ }),
170
+ isHang: true,
171
+ errorMessage: "no output for 300s",
172
+ });
173
+ expect(body).toContain("Top up your model-provider balance");
174
+ // the stray url still shows in the stderr tail, but must not become a CTA link
175
+ expect(body).not.toContain("[https://evil.example.com");
176
+ });
177
+
178
+ it("matches a google cloud billing console url", () => {
179
+ const url = "https://console.cloud.google.com/billing/12345";
180
+ const body = formatAgentHangBody({
181
+ diagnostic: makeDiagnostic({
182
+ lastProviderError: "provider billing exhausted",
183
+ recentStderr: [`visit ${url}`],
184
+ }),
185
+ isHang: true,
186
+ errorMessage: "no output for 300s",
187
+ });
188
+ expect(body).toContain(`[${url}](${url})`);
189
+ });
190
+
191
+ it("includes the stderr details block in the billing body too", () => {
192
+ const body = formatAgentHangBody({
193
+ diagnostic: makeDiagnostic({
194
+ lastProviderError: "provider billing exhausted",
195
+ recentStderr: ["insufficient balance"],
196
+ }),
197
+ isHang: true,
198
+ errorMessage: "no output for 300s",
199
+ });
200
+ expect(body).toContain("<details><summary>Recent agent stderr</summary>");
201
+ expect(body).toContain("insufficient balance");
202
+ });
203
+ });
@@ -0,0 +1,170 @@
1
+ const MAX_STDERR_BYTES = 3000;
2
+
3
+ /**
4
+ * mutable per-run handle the agent harness writes to as a run progresses.
5
+ * the action's outer try/catch in `main.ts` reads this off `toolState` when
6
+ * the activity-timeout watchdog wins the race against the harness's own
7
+ * catch — the bare timer reject reason ("activity timeout: no output for
8
+ * 302s") tells the user nothing actionable, but `recentStderr` +
9
+ * `lastProviderError` together usually point straight at the upstream cause.
10
+ *
11
+ * `recentStderr` is shared by reference with the harness's bounded ring
12
+ * buffer, so the diagnostic always reflects the latest captured tail.
13
+ */
14
+ export type AgentDiagnostic = {
15
+ /** display label for the agent, e.g. "Terramend". used in the headline. */
16
+ label: string;
17
+ /** shared reference to the harness's bounded stderr ring buffer. */
18
+ recentStderr: string[];
19
+ /** most-recent provider-error label from `detectProviderError`, if any. */
20
+ lastProviderError: string | undefined;
21
+ /** count of stdout events successfully parsed before the failure. */
22
+ eventCount: number;
23
+ };
24
+
25
+ /**
26
+ * Build a user-facing markdown body for an agent hang or failure.
27
+ *
28
+ * Rendered into both the PR progress comment and the GitHub Actions job
29
+ * summary. Returns `null` when no diagnostic is available, which signals to
30
+ * the caller to fall back to its bare-error rendering.
31
+ *
32
+ * `errorMessage` is the underlying timer / spawn reject string (e.g.
33
+ * `activity timeout: no output for 301s`). The idle seconds are parsed out
34
+ * of it for the hang explanation — total runtime would overstate the stall
35
+ * for runs that streamed for a long time before going quiet.
36
+ */
37
+ export function formatAgentHangBody(input: {
38
+ diagnostic: AgentDiagnostic | undefined;
39
+ isHang: boolean;
40
+ errorMessage: string;
41
+ }): string | null {
42
+ if (!input.diagnostic) return null;
43
+
44
+ // billing exhaustion (CreditsError / FreeUsageLimitError / spending cap /
45
+ // Insufficient balance) is mis-classified as transient by upstream harnesses
46
+ // and the run only ends when the activity-timeout watchdog fires (see #778).
47
+ // when we recognise the billing label, replace the generic "stalled — auth
48
+ // error" headline with a billing-specific CTA that names the actual remedy.
49
+ if (input.diagnostic.lastProviderError === "provider billing exhausted") {
50
+ return formatBillingExhaustedBody(input.diagnostic);
51
+ }
52
+
53
+ const verb = input.isHang ? "stalled" : "failed";
54
+ const cause = input.diagnostic.lastProviderError
55
+ ? ` — likely cause: \`${input.diagnostic.lastProviderError}\``
56
+ : "";
57
+ const headline = `**${input.diagnostic.label} ${verb}**${cause}`;
58
+
59
+ const explanation = formatExplanation({
60
+ isHang: input.isHang,
61
+ errorMessage: input.errorMessage,
62
+ });
63
+ const parts = [headline, "", `${explanation} ${formatEventsPart(input.diagnostic)}`];
64
+
65
+ const tail = renderStderrTail(input.diagnostic.recentStderr);
66
+ if (tail) {
67
+ // pick a fence longer than any backtick run in the body so a stderr line
68
+ // containing ``` (provider error JSON occasionally embeds it) can't
69
+ // terminate the fence early and corrupt the rest of the markdown.
70
+ const fence = pickFence(tail);
71
+ parts.push(
72
+ "",
73
+ "<details><summary>Recent agent stderr</summary>",
74
+ "",
75
+ fence,
76
+ tail,
77
+ fence,
78
+ "",
79
+ "</details>",
80
+ );
81
+ }
82
+
83
+ return parts.join("\n");
84
+ }
85
+
86
+ function formatExplanation(input: { isHang: boolean; errorMessage: string }): string {
87
+ if (!input.isHang) return `The agent exited unexpectedly: ${input.errorMessage}`;
88
+ const idleSec = parseIdleSec(input.errorMessage);
89
+ if (idleSec === undefined) {
90
+ return "The agent stopped emitting events and was killed by the activity-timeout watchdog.";
91
+ }
92
+ return `The agent stopped emitting events for ${idleSec}s and was killed by the activity-timeout watchdog.`;
93
+ }
94
+
95
+ function parseIdleSec(message: string): number | undefined {
96
+ const match = /no output for (\d+)s/.exec(message);
97
+ return match ? Number(match[1]) : undefined;
98
+ }
99
+
100
+ function formatEventsPart(diagnostic: AgentDiagnostic): string {
101
+ if (diagnostic.eventCount > 0) {
102
+ return `${diagnostic.eventCount} events were processed before the failure.`;
103
+ }
104
+ // when the provider-error label already names the cause in the headline,
105
+ // the reachability nudge below contradicts it (e.g. an immediate 401 also
106
+ // produces zero events but isn't a reachability problem). suppress it.
107
+ if (diagnostic.lastProviderError) return "No events were emitted before the failure.";
108
+ return "No events were emitted — check whether the model provider is reachable.";
109
+ }
110
+
111
+ function renderStderrTail(lines: readonly string[]): string {
112
+ if (lines.length === 0) return "";
113
+ const joined = lines.join("\n");
114
+ if (joined.length <= MAX_STDERR_BYTES) return joined;
115
+ return `... (older lines truncated)\n${joined.slice(-MAX_STDERR_BYTES)}`;
116
+ }
117
+
118
+ function pickFence(content: string): string {
119
+ let max = 0;
120
+ for (const match of content.matchAll(/`+/g)) {
121
+ if (match[0].length > max) max = match[0].length;
122
+ }
123
+ return "`".repeat(Math.max(3, max + 1));
124
+ }
125
+
126
+ /**
127
+ * Pull a billing URL out of the captured stderr if the provider helpfully
128
+ * embedded one (OpenCode Zen does — Anthropic and Gemini do not). Restricted
129
+ * to known billing/console hosts so a stray URL elsewhere in the buffer
130
+ * can't masquerade as the remedy link.
131
+ */
132
+ function extractBillingUrl(lines: readonly string[]): string | undefined {
133
+ const urlPattern =
134
+ /https:\/\/(?:opencode\.ai\/[^\s"]*billing[^\s"]*|console\.anthropic\.com[^\s"]*|console\.cloud\.google\.com[^\s"]*billing[^\s"]*)/i;
135
+ for (let i = lines.length - 1; i >= 0; i--) {
136
+ const m = urlPattern.exec(lines[i] ?? "");
137
+ if (m) return m[0];
138
+ }
139
+ return undefined;
140
+ }
141
+
142
+ function formatBillingExhaustedBody(diagnostic: AgentDiagnostic): string {
143
+ const headline = `**${diagnostic.label} stopped** — your model provider returned a billing-exhausted response.`;
144
+
145
+ const billingUrl = extractBillingUrl(diagnostic.recentStderr);
146
+ const cta = billingUrl
147
+ ? `Top up your provider balance, then re-run: [${billingUrl}](${billingUrl})`
148
+ : "Top up your model-provider balance (or rotate to a key with remaining credits) and re-run.";
149
+ const explanation =
150
+ "The agent kept retrying the request because the provider marked the failure as transient. Terramend's activity-timeout watchdog ended the run after no further events were emitted.";
151
+
152
+ const parts = [headline, "", explanation, "", cta];
153
+
154
+ const tail = renderStderrTail(diagnostic.recentStderr);
155
+ if (tail) {
156
+ const fence = pickFence(tail);
157
+ parts.push(
158
+ "",
159
+ "<details><summary>Recent agent stderr</summary>",
160
+ "",
161
+ fence,
162
+ tail,
163
+ fence,
164
+ "",
165
+ "</details>",
166
+ );
167
+ }
168
+
169
+ return parts.join("\n");
170
+ }
@@ -0,0 +1,115 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { apiFetch } from "#app/utils/apiFetch";
3
+
4
+ vi.mock("#app/utils/cli", () => ({
5
+ log: {
6
+ info: vi.fn(),
7
+ debug: vi.fn(),
8
+ warning: vi.fn(),
9
+ error: vi.fn(),
10
+ success: vi.fn(),
11
+ },
12
+ }));
13
+
14
+ type FetchArgs = [string, RequestInit];
15
+
16
+ function stubFetch(): ReturnType<typeof vi.fn> {
17
+ const fetchMock = vi.fn(async () => new Response("{}", { status: 200 }));
18
+ vi.stubGlobal("fetch", fetchMock);
19
+ return fetchMock;
20
+ }
21
+
22
+ function lastFetchCall(fetchMock: ReturnType<typeof vi.fn>): FetchArgs {
23
+ const call = fetchMock.mock.calls.at(-1);
24
+ if (!call) throw new Error("expected fetch to have been called");
25
+ return call as FetchArgs;
26
+ }
27
+
28
+ afterEach(() => {
29
+ vi.unstubAllEnvs();
30
+ vi.unstubAllGlobals();
31
+ });
32
+
33
+ describe("apiFetch", () => {
34
+ it("defaults to GET against the configured API_URL without bypass artifacts", async () => {
35
+ vi.stubEnv("API_URL", "http://localhost:3000");
36
+ vi.stubEnv("VERCEL_AUTOMATION_BYPASS_SECRET", "");
37
+ const fetchMock = stubFetch();
38
+
39
+ await apiFetch({ path: "/api/foo" });
40
+
41
+ const [url, init] = lastFetchCall(fetchMock);
42
+ expect(url).toBe("http://localhost:3000/api/foo");
43
+ expect(init.method).toBe("GET");
44
+ expect(init.headers).toEqual({});
45
+ expect(init.body).toBeUndefined();
46
+ expect(init.signal).toBeUndefined();
47
+ });
48
+
49
+ it("falls back to https://terramend.dev when API_URL is unset", async () => {
50
+ vi.stubEnv("API_URL", "");
51
+ vi.stubEnv("VERCEL_AUTOMATION_BYPASS_SECRET", "");
52
+ const fetchMock = stubFetch();
53
+
54
+ await apiFetch({ path: "/api/foo" });
55
+
56
+ const [url] = lastFetchCall(fetchMock);
57
+ expect(url).toBe("https://terramend.dev/api/foo");
58
+ });
59
+
60
+ it("adds the Vercel bypass secret as both a query param and a header", async () => {
61
+ vi.stubEnv("API_URL", "http://localhost:3000");
62
+ vi.stubEnv("VERCEL_AUTOMATION_BYPASS_SECRET", "shh");
63
+ const fetchMock = stubFetch();
64
+
65
+ await apiFetch({ path: "/api/foo", method: "POST", body: "{}" });
66
+
67
+ const [url, init] = lastFetchCall(fetchMock);
68
+ expect(new URL(url).searchParams.get("x-vercel-protection-bypass")).toBe("shh");
69
+ expect(init.headers).toMatchObject({ "x-vercel-protection-bypass": "shh" });
70
+ });
71
+
72
+ it("strips Content-Type headers (any casing) from body-less requests", async () => {
73
+ vi.stubEnv("API_URL", "http://localhost:3000");
74
+ vi.stubEnv("VERCEL_AUTOMATION_BYPASS_SECRET", "");
75
+ const fetchMock = stubFetch();
76
+
77
+ await apiFetch({
78
+ path: "/api/foo",
79
+ method: "POST",
80
+ headers: { "Content-Type": "application/json", Authorization: "Bearer tok" },
81
+ });
82
+
83
+ const [, init] = lastFetchCall(fetchMock);
84
+ expect(init.headers).toEqual({ Authorization: "Bearer tok" });
85
+ });
86
+
87
+ it("keeps Content-Type and forwards body, method, and signal when a body is present", async () => {
88
+ vi.stubEnv("API_URL", "http://localhost:3000");
89
+ vi.stubEnv("VERCEL_AUTOMATION_BYPASS_SECRET", "");
90
+ const fetchMock = stubFetch();
91
+ const controller = new AbortController();
92
+
93
+ await apiFetch({
94
+ path: "/api/foo",
95
+ method: "PATCH",
96
+ headers: { "Content-Type": "application/json" },
97
+ body: '{"a":1}',
98
+ signal: controller.signal,
99
+ });
100
+
101
+ const [, init] = lastFetchCall(fetchMock);
102
+ expect(init.method).toBe("PATCH");
103
+ expect(init.headers).toEqual({ "Content-Type": "application/json" });
104
+ expect(init.body).toBe('{"a":1}');
105
+ expect(init.signal).toBe(controller.signal);
106
+ });
107
+
108
+ it("propagates getApiUrl validation errors without fetching", async () => {
109
+ vi.stubEnv("API_URL", "https://evil.example.com");
110
+ const fetchMock = stubFetch();
111
+
112
+ await expect(apiFetch({ path: "/api/foo" })).rejects.toThrow("is not allowed");
113
+ expect(fetchMock).not.toHaveBeenCalled();
114
+ });
115
+ });
@@ -0,0 +1,62 @@
1
+ import { getApiUrl } from "#app/utils/apiUrl";
2
+ import { log } from "#app/utils/cli";
3
+
4
+ type ApiFetchOptions = {
5
+ path: string;
6
+ method?: string | undefined;
7
+ headers?: Record<string, string> | undefined;
8
+ body?: string | undefined;
9
+ signal?: AbortSignal | undefined;
10
+ };
11
+
12
+ /**
13
+ * fetch wrapper for hitting the Terramend API with Vercel deployment protection bypass.
14
+ *
15
+ * adds the bypass secret as BOTH a query parameter and a header for maximum reliability.
16
+ * the server-side forwarding code uses query params, and the Vercel docs say both work,
17
+ * so we do both as belt-and-suspenders.
18
+ *
19
+ * the query param approach is the primary bypass mechanism (matches server-side forwarding).
20
+ * the header is added as a fallback.
21
+ */
22
+ export async function apiFetch(options: ApiFetchOptions): Promise<Response> {
23
+ const apiUrl = getApiUrl();
24
+ const url = new URL(options.path, apiUrl);
25
+
26
+ const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
27
+ if (bypassSecret) {
28
+ url.searchParams.set("x-vercel-protection-bypass", bypassSecret);
29
+ }
30
+
31
+ const headers: Record<string, string> = {
32
+ ...options.headers,
33
+ };
34
+
35
+ // also add as header for belt-and-suspenders
36
+ if (bypassSecret) {
37
+ headers["x-vercel-protection-bypass"] = bypassSecret;
38
+ }
39
+
40
+ // never send Content-Type on body-less requests. empirically, Vercel's
41
+ // Next.js lambda adapter (Next 16.1.x) throws `SyntaxError: Unexpected
42
+ // end of data` before delegating to the route handler — returning a 500 —
43
+ // when Content-Type is set but no body is present. exact mechanism is
44
+ // unverified (minified runtime frame), but Content-Type on a body-less
45
+ // request has no defined semantics per RFC 9110 §8.3 anyway. see #692.
46
+ if (!options.body) {
47
+ for (const key of Object.keys(headers)) {
48
+ if (key.toLowerCase() === "content-type") delete headers[key];
49
+ }
50
+ }
51
+
52
+ log.debug(`api fetch: ${options.method ?? "GET"} ${url.pathname}`);
53
+
54
+ const init: RequestInit = {
55
+ method: options.method ?? "GET",
56
+ headers,
57
+ };
58
+ if (options.body) init.body = options.body;
59
+ if (options.signal) init.signal = options.signal;
60
+
61
+ return fetch(url.toString(), init);
62
+ }