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,768 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { chmodSync, createWriteStream, existsSync, mkdirSync } from "node:fs";
3
+ import { mkdtemp } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { setTimeout as sleep } from "node:timers/promises";
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import {
9
+ installFromCurl,
10
+ installFromDirectTarball,
11
+ installFromGithub,
12
+ installFromGithubTarball,
13
+ installFromNpmTarball,
14
+ } from "#app/utils/install";
15
+
16
+ vi.mock("node:child_process", () => ({
17
+ spawnSync: vi.fn(),
18
+ }));
19
+
20
+ vi.mock("node:fs", () => ({
21
+ chmodSync: vi.fn(),
22
+ createWriteStream: vi.fn(() => ({})),
23
+ existsSync: vi.fn(() => false),
24
+ mkdirSync: vi.fn(),
25
+ }));
26
+
27
+ vi.mock("node:fs/promises", () => ({
28
+ mkdtemp: vi.fn(),
29
+ }));
30
+
31
+ vi.mock("node:stream/promises", () => ({
32
+ pipeline: vi.fn(async () => undefined),
33
+ }));
34
+
35
+ vi.mock("node:timers/promises", () => ({
36
+ setTimeout: vi.fn(async () => undefined),
37
+ }));
38
+
39
+ vi.mock("#app/utils/cli", () => ({
40
+ log: {
41
+ info: vi.fn(),
42
+ debug: vi.fn(),
43
+ warning: vi.fn(),
44
+ error: vi.fn(),
45
+ success: vi.fn(),
46
+ },
47
+ }));
48
+
49
+ const TEMP = join("tmp", "terramend-install-test");
50
+
51
+ const existsSyncMock = vi.mocked(existsSync);
52
+ const spawnSyncMock = vi.mocked(spawnSync);
53
+ const sleepMock = vi.mocked(sleep);
54
+
55
+ type FakeResponseInit = {
56
+ ok?: boolean;
57
+ status?: number;
58
+ statusText?: string;
59
+ json?: unknown;
60
+ body?: unknown;
61
+ headers?: Record<string, string>;
62
+ headersGet?: (name: string) => string | null;
63
+ };
64
+
65
+ function fakeResponse(init: FakeResponseInit = {}): Response {
66
+ const headers = init.headersGet ? { get: init.headersGet } : new Headers(init.headers ?? {});
67
+ return {
68
+ ok: init.ok ?? true,
69
+ status: init.status ?? 200,
70
+ statusText: init.statusText ?? "OK",
71
+ headers,
72
+ json: async () => init.json,
73
+ body: "body" in init ? init.body : {},
74
+ } as unknown as Response;
75
+ }
76
+
77
+ function spawnResult(
78
+ init: { status?: number | null; stdout?: string; stderr?: string } = {},
79
+ ): ReturnType<typeof spawnSync> {
80
+ return {
81
+ status: init.status ?? 0,
82
+ stdout: init.stdout ?? "",
83
+ stderr: init.stderr ?? "",
84
+ } as unknown as ReturnType<typeof spawnSync>;
85
+ }
86
+
87
+ function stubFetch(...responses: Response[]): ReturnType<typeof vi.fn> {
88
+ const fetchMock = vi.fn();
89
+ for (const response of responses) {
90
+ fetchMock.mockResolvedValueOnce(response);
91
+ }
92
+ vi.stubGlobal("fetch", fetchMock);
93
+ return fetchMock;
94
+ }
95
+
96
+ function fetchUrl(fetchMock: ReturnType<typeof vi.fn>, index: number): string {
97
+ const call = fetchMock.mock.calls.at(index);
98
+ if (!call) throw new Error(`expected fetch call at index ${index}`);
99
+ return String(call[0]);
100
+ }
101
+
102
+ beforeEach(() => {
103
+ vi.stubEnv("TERRAMEND_TEMP_DIR", TEMP);
104
+ vi.stubEnv("NPM_REGISTRY", "");
105
+ spawnSyncMock.mockReturnValue(spawnResult());
106
+ });
107
+
108
+ afterEach(() => {
109
+ vi.resetAllMocks();
110
+ vi.unstubAllEnvs();
111
+ vi.unstubAllGlobals();
112
+ });
113
+
114
+ describe("installFromNpmTarball", () => {
115
+ const params = { packageName: "mytool", version: "1.2.3", executablePath: "bin/cli.js" };
116
+ const cliPath = join(TEMP, "package", "bin/cli.js");
117
+
118
+ it("throws when TERRAMEND_TEMP_DIR is not set", async () => {
119
+ vi.stubEnv("TERRAMEND_TEMP_DIR", undefined);
120
+ await expect(installFromNpmTarball(params)).rejects.toThrow("TERRAMEND_TEMP_DIR is not set");
121
+ });
122
+
123
+ it("returns the cached binary without fetching", async () => {
124
+ existsSyncMock.mockReturnValue(true);
125
+ const fetchMock = stubFetch();
126
+
127
+ await expect(installFromNpmTarball(params)).resolves.toBe(cliPath);
128
+ expect(fetchMock).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it("downloads, extracts, and chmods an exact version from the default registry", async () => {
132
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
133
+ const fetchMock = stubFetch(fakeResponse());
134
+
135
+ await expect(installFromNpmTarball(params)).resolves.toBe(cliPath);
136
+
137
+ expect(fetchUrl(fetchMock, 0)).toBe("https://registry.npmjs.org/mytool/-/mytool-1.2.3.tgz");
138
+ expect(spawnSyncMock).toHaveBeenCalledWith(
139
+ "tar",
140
+ ["-xzf", join(TEMP, "package.tgz"), "-C", TEMP],
141
+ expect.objectContaining({ stdio: "pipe", encoding: "utf-8" }),
142
+ );
143
+ expect(chmodSync).toHaveBeenCalledWith(cliPath, 0o755);
144
+ expect(createWriteStream).toHaveBeenCalledWith(join(TEMP, "package.tgz"));
145
+ });
146
+
147
+ it("resolves 'latest' through the registry metadata first", async () => {
148
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
149
+ const fetchMock = stubFetch(
150
+ fakeResponse({ json: { "dist-tags": { latest: "9.9.9" }, versions: {} } }),
151
+ fakeResponse(),
152
+ );
153
+
154
+ await installFromNpmTarball({ ...params, version: "latest" });
155
+
156
+ expect(fetchUrl(fetchMock, 0)).toBe("https://registry.npmjs.org/mytool");
157
+ expect(fetchUrl(fetchMock, 1)).toBe("https://registry.npmjs.org/mytool/-/mytool-9.9.9.tgz");
158
+ });
159
+
160
+ it("resolves caret ranges and honors a custom NPM_REGISTRY", async () => {
161
+ vi.stubEnv("NPM_REGISTRY", "https://npm.corp.example");
162
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
163
+ const fetchMock = stubFetch(
164
+ fakeResponse({ json: { "dist-tags": { latest: "1.4.0" }, versions: {} } }),
165
+ fakeResponse(),
166
+ );
167
+
168
+ await installFromNpmTarball({ ...params, version: "^1.0.0" });
169
+
170
+ expect(fetchUrl(fetchMock, 0)).toBe("https://npm.corp.example/mytool");
171
+ expect(fetchUrl(fetchMock, 1)).toBe("https://npm.corp.example/mytool/-/mytool-1.4.0.tgz");
172
+ });
173
+
174
+ it("throws and warns when version resolution fails", async () => {
175
+ stubFetch(fakeResponse({ ok: false, status: 500 }));
176
+
177
+ await expect(installFromNpmTarball({ ...params, version: "~2.0.0" })).rejects.toThrow(
178
+ "Failed to query registry: 500",
179
+ );
180
+ });
181
+
182
+ it("stringifies non-Error registry failures in the warning", async () => {
183
+ const fetchMock = vi.fn().mockRejectedValueOnce("registry offline");
184
+ vi.stubGlobal("fetch", fetchMock);
185
+
186
+ await expect(installFromNpmTarball({ ...params, version: "latest" })).rejects.toBe(
187
+ "registry offline",
188
+ );
189
+ });
190
+
191
+ it("URL-encodes scoped package names in the tarball URL", async () => {
192
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
193
+ const fetchMock = stubFetch(fakeResponse());
194
+
195
+ await installFromNpmTarball({ ...params, packageName: "@scope/pkg", version: "2.0.0" });
196
+
197
+ expect(fetchUrl(fetchMock, 0)).toBe("https://registry.npmjs.org/@scope%2Fpkg/-/pkg-2.0.0.tgz");
198
+ });
199
+
200
+ it("throws when the tarball download fails", async () => {
201
+ stubFetch(fakeResponse({ ok: false, status: 404, statusText: "Not Found" }));
202
+
203
+ await expect(installFromNpmTarball(params)).rejects.toThrow(
204
+ "Failed to download tarball: 404 Not Found",
205
+ );
206
+ });
207
+
208
+ it("throws when the tarball response has no body", async () => {
209
+ stubFetch(fakeResponse({ body: null }));
210
+
211
+ await expect(installFromNpmTarball(params)).rejects.toThrow("Response body is null");
212
+ });
213
+
214
+ it("surfaces tar stderr when extraction fails", async () => {
215
+ stubFetch(fakeResponse());
216
+ spawnSyncMock.mockReturnValue(spawnResult({ status: 1, stderr: "tar: boom" }));
217
+
218
+ await expect(installFromNpmTarball(params)).rejects.toThrow(
219
+ "Failed to extract tarball: tar: boom",
220
+ );
221
+ });
222
+
223
+ it("falls back to 'Unknown error' when tar produces no output", async () => {
224
+ stubFetch(fakeResponse());
225
+ spawnSyncMock.mockReturnValue(spawnResult({ status: 1 }));
226
+
227
+ await expect(installFromNpmTarball(params)).rejects.toThrow(
228
+ "Failed to extract tarball: Unknown error",
229
+ );
230
+ });
231
+
232
+ it("throws when the executable is missing from the extracted package", async () => {
233
+ existsSyncMock.mockReturnValue(false);
234
+ stubFetch(fakeResponse());
235
+
236
+ await expect(installFromNpmTarball(params)).rejects.toThrow(
237
+ `Executable not found in extracted package at ${cliPath}`,
238
+ );
239
+ });
240
+
241
+ it("runs npm install with the AUTHORIZED guard flag when installDependencies is set", async () => {
242
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
243
+ stubFetch(fakeResponse());
244
+
245
+ await installFromNpmTarball({ ...params, installDependencies: true });
246
+
247
+ expect(spawnSyncMock).toHaveBeenCalledTimes(2);
248
+ expect(spawnSyncMock).toHaveBeenLastCalledWith(
249
+ "npm",
250
+ ["install", "--production"],
251
+ expect.objectContaining({
252
+ cwd: join(TEMP, "package"),
253
+ env: expect.objectContaining({ AUTHORIZED: "1" }),
254
+ }),
255
+ );
256
+ });
257
+
258
+ it("throws when dependency installation fails", async () => {
259
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
260
+ stubFetch(fakeResponse());
261
+ spawnSyncMock
262
+ .mockReturnValueOnce(spawnResult())
263
+ .mockReturnValueOnce(spawnResult({ status: 1, stderr: "EACCES" }));
264
+
265
+ await expect(installFromNpmTarball({ ...params, installDependencies: true })).rejects.toThrow(
266
+ "Failed to install dependencies: EACCES",
267
+ );
268
+ });
269
+
270
+ it("falls back to npm stdout, then 'Unknown error', when npm fails silently", async () => {
271
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
272
+ stubFetch(fakeResponse());
273
+ spawnSyncMock
274
+ .mockReturnValueOnce(spawnResult())
275
+ .mockReturnValueOnce(spawnResult({ status: 1, stdout: "npm ERR! 403" }));
276
+
277
+ await expect(installFromNpmTarball({ ...params, installDependencies: true })).rejects.toThrow(
278
+ "Failed to install dependencies: npm ERR! 403",
279
+ );
280
+
281
+ existsSyncMock.mockReset();
282
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
283
+ stubFetch(fakeResponse());
284
+ spawnSyncMock
285
+ .mockReturnValueOnce(spawnResult())
286
+ .mockReturnValueOnce(spawnResult({ status: 1 }));
287
+
288
+ await expect(installFromNpmTarball({ ...params, installDependencies: true })).rejects.toThrow(
289
+ "Failed to install dependencies: Unknown error",
290
+ );
291
+ });
292
+ });
293
+
294
+ describe("installFromGithub", () => {
295
+ const params = { owner: "o", repo: "r", assetName: "tool.bin" };
296
+ const installDir = join(TEMP, "github-o-r");
297
+ const release = {
298
+ tag_name: "v1",
299
+ assets: [{ name: "tool.bin", browser_download_url: "https://gh.example/dl/tool.bin" }],
300
+ };
301
+
302
+ it("returns the cached binary without fetching", async () => {
303
+ existsSyncMock.mockReturnValue(true);
304
+ const fetchMock = stubFetch();
305
+
306
+ await expect(installFromGithub({ ...params, executablePath: "bin/tool" })).resolves.toBe(
307
+ join(installDir, "bin/tool"),
308
+ );
309
+ expect(fetchMock).not.toHaveBeenCalled();
310
+ });
311
+
312
+ it("downloads the latest release asset into the deterministic temp dir", async () => {
313
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
314
+ const fetchMock = stubFetch(fakeResponse({ json: release }), fakeResponse());
315
+
316
+ await expect(installFromGithub(params)).resolves.toBe(join(installDir, "tool.bin"));
317
+
318
+ expect(fetchUrl(fetchMock, 0)).toBe("https://api.github.com/repos/o/r/releases/latest");
319
+ expect(fetchUrl(fetchMock, 1)).toBe("https://gh.example/dl/tool.bin");
320
+ expect(mkdirSync).toHaveBeenCalledWith(installDir, { recursive: true });
321
+ expect(chmodSync).toHaveBeenCalledWith(join(installDir, "tool.bin"), 0o755);
322
+ });
323
+
324
+ it("falls back to mkdtemp when TERRAMEND_TEMP_DIR is unset", async () => {
325
+ vi.stubEnv("TERRAMEND_TEMP_DIR", undefined);
326
+ const mkdtempDir = join("mk", "o-r-github-abc");
327
+ vi.mocked(mkdtemp).mockResolvedValue(mkdtempDir);
328
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
329
+ stubFetch(fakeResponse({ json: release }), fakeResponse());
330
+
331
+ await expect(installFromGithub(params)).resolves.toBe(join(mkdtempDir, "tool.bin"));
332
+ expect(mkdtemp).toHaveBeenCalledWith(join(tmpdir(), "o-r-github-"));
333
+ });
334
+
335
+ it("pins the release by tag and sends the installation token", async () => {
336
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
337
+ const fetchMock = stubFetch(fakeResponse({ json: release }), fakeResponse());
338
+
339
+ await installFromGithub({ ...params, tag: "v2", githubInstallationToken: "ghs_tok" });
340
+
341
+ expect(fetchUrl(fetchMock, 0)).toBe("https://api.github.com/repos/o/r/releases/tags/v2");
342
+ expect(fetchMock).toHaveBeenCalledWith(expect.any(String), {
343
+ headers: { Authorization: "Bearer ghs_tok" },
344
+ });
345
+ });
346
+
347
+ it("returns the explicit executablePath instead of the download path", async () => {
348
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
349
+ stubFetch(fakeResponse({ json: release }), fakeResponse());
350
+
351
+ await expect(installFromGithub({ ...params, executablePath: "bin/tool" })).resolves.toBe(
352
+ join(installDir, "bin/tool"),
353
+ );
354
+ });
355
+
356
+ it("falls back to the literal 'asset' cache name when no names are given", async () => {
357
+ existsSyncMock.mockReturnValue(true);
358
+
359
+ await expect(installFromGithub({ owner: "o", repo: "r" })).resolves.toBe(
360
+ join(installDir, "asset"),
361
+ );
362
+ });
363
+
364
+ it("falls back to the 'asset' file name when the download URL has no basename", async () => {
365
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
366
+ const slashRelease = {
367
+ tag_name: "v1",
368
+ assets: [{ name: "tool.bin", browser_download_url: "https://gh.example/dl/" }],
369
+ };
370
+ stubFetch(fakeResponse({ json: slashRelease }), fakeResponse());
371
+
372
+ await expect(installFromGithub(params)).resolves.toBe(join(installDir, "asset"));
373
+ });
374
+
375
+ it("throws when the requested asset is not in the release", async () => {
376
+ stubFetch(fakeResponse({ json: { tag_name: "v1", assets: [] } }));
377
+
378
+ await expect(installFromGithub(params)).rejects.toThrow(
379
+ "Asset 'tool.bin' not found in release v1",
380
+ );
381
+ });
382
+
383
+ it("throws when the downloaded executable does not exist", async () => {
384
+ existsSyncMock.mockReturnValue(false);
385
+ stubFetch(fakeResponse({ json: release }), fakeResponse());
386
+
387
+ await expect(installFromGithub(params)).rejects.toThrow(
388
+ `Executable not found at ${join(installDir, "tool.bin")}`,
389
+ );
390
+ });
391
+
392
+ it("throws when the asset response has no body", async () => {
393
+ stubFetch(fakeResponse({ json: release }), fakeResponse({ body: null }));
394
+
395
+ await expect(installFromGithub(params)).rejects.toThrow("Response body is null");
396
+ });
397
+ });
398
+
399
+ describe("fetchWithRetry (via installFromGithub release fetch)", () => {
400
+ const params = { owner: "o", repo: "r", assetName: "tool.bin" };
401
+ const release = {
402
+ tag_name: "v1",
403
+ assets: [{ name: "tool.bin", browser_download_url: "https://gh.example/dl/tool.bin" }],
404
+ };
405
+
406
+ it("throws immediately on a failure without a Retry-After header", async () => {
407
+ stubFetch(fakeResponse({ ok: false, status: 500, statusText: "Internal Server Error" }));
408
+
409
+ await expect(installFromGithub(params)).rejects.toThrow(
410
+ "Failed to fetch release: 500 Internal Server Error",
411
+ );
412
+ expect(sleepMock).not.toHaveBeenCalled();
413
+ });
414
+
415
+ it("waits the advertised Retry-After and retries once", async () => {
416
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
417
+ stubFetch(
418
+ fakeResponse({ ok: false, status: 429, headers: { "Retry-After": "2" } }),
419
+ fakeResponse({ json: release }),
420
+ fakeResponse(),
421
+ );
422
+
423
+ await expect(installFromGithub(params)).resolves.toBe(join(TEMP, "github-o-r", "tool.bin"));
424
+ expect(sleepMock).toHaveBeenCalledWith(2000);
425
+ });
426
+
427
+ it("reports a failed retry distinctly", async () => {
428
+ stubFetch(
429
+ fakeResponse({ ok: false, status: 429, headers: { "Retry-After": "1" } }),
430
+ fakeResponse({ ok: false, status: 403, statusText: "Forbidden" }),
431
+ );
432
+
433
+ await expect(installFromGithub(params)).rejects.toThrow(
434
+ "Failed to fetch release: 403 Forbidden (retry failed)",
435
+ );
436
+ });
437
+
438
+ it("does not retry when Retry-After is zero", async () => {
439
+ stubFetch(
440
+ fakeResponse({
441
+ ok: false,
442
+ status: 429,
443
+ statusText: "Too Many Requests",
444
+ headers: { "Retry-After": "0" },
445
+ }),
446
+ );
447
+
448
+ await expect(installFromGithub(params)).rejects.toThrow(
449
+ "Failed to fetch release: 429 Too Many Requests",
450
+ );
451
+ expect(sleepMock).not.toHaveBeenCalled();
452
+ });
453
+
454
+ it("does not retry when Retry-After is not a number", async () => {
455
+ stubFetch(
456
+ fakeResponse({
457
+ ok: false,
458
+ status: 429,
459
+ statusText: "Too Many Requests",
460
+ headers: { "Retry-After": "soon" },
461
+ }),
462
+ );
463
+
464
+ await expect(installFromGithub(params)).rejects.toThrow(
465
+ "Failed to fetch release: 429 Too Many Requests",
466
+ );
467
+ expect(sleepMock).not.toHaveBeenCalled();
468
+ });
469
+
470
+ it("falls back to the lowercase retry-after header", async () => {
471
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
472
+ stubFetch(
473
+ fakeResponse({
474
+ ok: false,
475
+ status: 429,
476
+ // case-sensitive lookup: only the exact lowercase name matches
477
+ headersGet: (name) => (name === "retry-after" ? "1" : null),
478
+ }),
479
+ fakeResponse({ json: release }),
480
+ fakeResponse(),
481
+ );
482
+
483
+ await expect(installFromGithub(params)).resolves.toBe(join(TEMP, "github-o-r", "tool.bin"));
484
+ expect(sleepMock).toHaveBeenCalledWith(1000);
485
+ });
486
+ });
487
+
488
+ describe("installFromGithubTarball", () => {
489
+ const os = process.platform === "darwin" ? "darwin" : "linux";
490
+ const arch = process.arch === "arm64" ? "arm64" : "x64";
491
+ const assetName = `tool-${os}-${arch}.tar.gz`;
492
+ const params = {
493
+ owner: "o",
494
+ repo: "r",
495
+ assetNamePattern: "tool-{os}-{arch}.tar.gz",
496
+ executablePath: "bin/tool",
497
+ };
498
+ const cliPath = join(TEMP, "bin/tool");
499
+ const release = {
500
+ tag_name: "v3",
501
+ assets: [{ name: assetName, browser_download_url: `https://gh.example/dl/${assetName}` }],
502
+ };
503
+
504
+ it("throws when TERRAMEND_TEMP_DIR is not set", async () => {
505
+ vi.stubEnv("TERRAMEND_TEMP_DIR", undefined);
506
+ await expect(installFromGithubTarball(params)).rejects.toThrow("TERRAMEND_TEMP_DIR is not set");
507
+ });
508
+
509
+ it("returns the cached binary without fetching", async () => {
510
+ existsSyncMock.mockReturnValue(true);
511
+ const fetchMock = stubFetch();
512
+
513
+ await expect(installFromGithubTarball(params)).resolves.toBe(cliPath);
514
+ expect(fetchMock).not.toHaveBeenCalled();
515
+ });
516
+
517
+ it("resolves the platform asset, extracts it, and returns the executable", async () => {
518
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
519
+ const fetchMock = stubFetch(fakeResponse({ json: release }), fakeResponse());
520
+
521
+ await expect(installFromGithubTarball({ ...params, tag: "v3" })).resolves.toBe(cliPath);
522
+
523
+ expect(fetchUrl(fetchMock, 0)).toBe("https://api.github.com/repos/o/r/releases/tags/v3");
524
+ expect(fetchUrl(fetchMock, 1)).toBe(`https://gh.example/dl/${assetName}`);
525
+ expect(spawnSyncMock).toHaveBeenCalledWith(
526
+ "tar",
527
+ ["-xzf", join(TEMP, assetName), "-C", TEMP],
528
+ expect.objectContaining({ stdio: "pipe" }),
529
+ );
530
+ expect(chmodSync).toHaveBeenCalledWith(cliPath, 0o755);
531
+ });
532
+
533
+ it("sends the installation token when provided", async () => {
534
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
535
+ const fetchMock = stubFetch(fakeResponse({ json: release }), fakeResponse());
536
+
537
+ await installFromGithubTarball({ ...params, githubInstallationToken: "ghs_tok" });
538
+
539
+ expect(fetchMock).toHaveBeenCalledWith(expect.any(String), {
540
+ headers: { Authorization: "Bearer ghs_tok" },
541
+ });
542
+ });
543
+
544
+ it("throws when the platform asset is missing from the release", async () => {
545
+ stubFetch(fakeResponse({ json: { tag_name: "v3", assets: [] } }));
546
+
547
+ await expect(installFromGithubTarball(params)).rejects.toThrow(
548
+ `Asset '${assetName}' not found in release v3`,
549
+ );
550
+ });
551
+
552
+ it("throws when the asset response has no body", async () => {
553
+ stubFetch(fakeResponse({ json: release }), fakeResponse({ body: null }));
554
+
555
+ await expect(installFromGithubTarball(params)).rejects.toThrow("Response body is null");
556
+ });
557
+
558
+ it("surfaces extraction failures", async () => {
559
+ stubFetch(fakeResponse({ json: release }), fakeResponse());
560
+ spawnSyncMock.mockReturnValue(spawnResult({ status: 2, stdout: "bad archive" }));
561
+
562
+ await expect(installFromGithubTarball(params)).rejects.toThrow(
563
+ "Failed to extract tarball: bad archive",
564
+ );
565
+ });
566
+
567
+ it("reports 'Unknown error' when tar fails without output", async () => {
568
+ stubFetch(fakeResponse({ json: release }), fakeResponse());
569
+ spawnSyncMock.mockReturnValue(spawnResult({ status: 2 }));
570
+
571
+ await expect(installFromGithubTarball(params)).rejects.toThrow(
572
+ "Failed to extract tarball: Unknown error",
573
+ );
574
+ });
575
+
576
+ it("throws when the executable is missing after extraction", async () => {
577
+ existsSyncMock.mockReturnValue(false);
578
+ stubFetch(fakeResponse({ json: release }), fakeResponse());
579
+
580
+ await expect(installFromGithubTarball(params)).rejects.toThrow(
581
+ `Executable not found in extracted tarball at ${cliPath}`,
582
+ );
583
+ });
584
+ });
585
+
586
+ describe("installFromDirectTarball", () => {
587
+ const params = { url: "https://example.com/pkg.tgz", executablePath: "bin/tool" };
588
+ const extractDir = join(TEMP, "direct-package");
589
+ const cliPath = join(extractDir, "bin/tool");
590
+
591
+ it("throws when TERRAMEND_TEMP_DIR is not set", async () => {
592
+ vi.stubEnv("TERRAMEND_TEMP_DIR", undefined);
593
+ await expect(installFromDirectTarball(params)).rejects.toThrow("TERRAMEND_TEMP_DIR is not set");
594
+ });
595
+
596
+ it("returns the cached binary without fetching", async () => {
597
+ existsSyncMock.mockReturnValue(true);
598
+ const fetchMock = stubFetch();
599
+
600
+ await expect(installFromDirectTarball(params)).resolves.toBe(cliPath);
601
+ expect(fetchMock).not.toHaveBeenCalled();
602
+ });
603
+
604
+ it("downloads and extracts without strip-components by default", async () => {
605
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
606
+ const fetchMock = stubFetch(fakeResponse());
607
+
608
+ await expect(installFromDirectTarball(params)).resolves.toBe(cliPath);
609
+
610
+ expect(fetchUrl(fetchMock, 0)).toBe("https://example.com/pkg.tgz");
611
+ expect(mkdirSync).toHaveBeenCalledWith(extractDir, { recursive: true });
612
+ expect(spawnSyncMock).toHaveBeenCalledWith(
613
+ "tar",
614
+ ["-xzf", join(TEMP, "direct-package.tgz"), "-C", extractDir],
615
+ expect.objectContaining({ stdio: "pipe" }),
616
+ );
617
+ expect(chmodSync).toHaveBeenCalledWith(cliPath, 0o755);
618
+ });
619
+
620
+ it("floors and forwards stripComponents to tar", async () => {
621
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
622
+ stubFetch(fakeResponse());
623
+
624
+ await installFromDirectTarball({ ...params, stripComponents: 2.7 });
625
+
626
+ expect(spawnSyncMock).toHaveBeenCalledWith(
627
+ "tar",
628
+ ["-xzf", join(TEMP, "direct-package.tgz"), "-C", extractDir, "--strip-components=2"],
629
+ expect.anything(),
630
+ );
631
+ });
632
+
633
+ it("omits strip-components when it is zero", async () => {
634
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
635
+ stubFetch(fakeResponse());
636
+
637
+ await installFromDirectTarball({ ...params, stripComponents: 0 });
638
+
639
+ expect(spawnSyncMock).toHaveBeenCalledWith(
640
+ "tar",
641
+ ["-xzf", join(TEMP, "direct-package.tgz"), "-C", extractDir],
642
+ expect.anything(),
643
+ );
644
+ });
645
+
646
+ it("throws when the download fails", async () => {
647
+ stubFetch(fakeResponse({ ok: false, status: 500, statusText: "Internal Server Error" }));
648
+
649
+ await expect(installFromDirectTarball(params)).rejects.toThrow(
650
+ "failed to download tarball: 500 Internal Server Error",
651
+ );
652
+ });
653
+
654
+ it("throws when the response has no body", async () => {
655
+ stubFetch(fakeResponse({ body: null }));
656
+
657
+ await expect(installFromDirectTarball(params)).rejects.toThrow("response body is null");
658
+ });
659
+
660
+ it("surfaces extraction failures", async () => {
661
+ stubFetch(fakeResponse());
662
+ spawnSyncMock.mockReturnValue(spawnResult({ status: 1, stderr: "gzip: corrupt" }));
663
+
664
+ await expect(installFromDirectTarball(params)).rejects.toThrow(
665
+ "failed to extract tarball: gzip: corrupt",
666
+ );
667
+ });
668
+
669
+ it("reports 'unknown error' when tar fails without output", async () => {
670
+ stubFetch(fakeResponse());
671
+ spawnSyncMock.mockReturnValue(spawnResult({ status: 1 }));
672
+
673
+ await expect(installFromDirectTarball(params)).rejects.toThrow(
674
+ "failed to extract tarball: unknown error",
675
+ );
676
+ });
677
+
678
+ it("throws when the executable is missing after extraction", async () => {
679
+ existsSyncMock.mockReturnValue(false);
680
+ stubFetch(fakeResponse());
681
+
682
+ await expect(installFromDirectTarball(params)).rejects.toThrow(
683
+ `executable not found in extracted tarball at ${cliPath}`,
684
+ );
685
+ });
686
+ });
687
+
688
+ describe("installFromCurl", () => {
689
+ const params = { installUrl: "https://example.com/install.sh", executableName: "mytool" };
690
+ const cliPath = join(TEMP, ".local", "bin", "mytool");
691
+ const scriptPath = join(TEMP, "install.sh");
692
+
693
+ it("throws when TERRAMEND_TEMP_DIR is not set", async () => {
694
+ vi.stubEnv("TERRAMEND_TEMP_DIR", undefined);
695
+ await expect(installFromCurl(params)).rejects.toThrow("TERRAMEND_TEMP_DIR is not set");
696
+ });
697
+
698
+ it("returns the cached binary without fetching", async () => {
699
+ existsSyncMock.mockReturnValue(true);
700
+ const fetchMock = stubFetch();
701
+
702
+ await expect(installFromCurl(params)).resolves.toBe(cliPath);
703
+ expect(fetchMock).not.toHaveBeenCalled();
704
+ });
705
+
706
+ it("downloads the script and runs it with HOME pointed at the temp dir", async () => {
707
+ existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true);
708
+ const fetchMock = stubFetch(fakeResponse());
709
+
710
+ await expect(installFromCurl(params)).resolves.toBe(cliPath);
711
+
712
+ expect(fetchUrl(fetchMock, 0)).toBe("https://example.com/install.sh");
713
+ expect(spawnSyncMock).toHaveBeenCalledWith(
714
+ "bash",
715
+ [scriptPath],
716
+ expect.objectContaining({
717
+ cwd: TEMP,
718
+ env: expect.objectContaining({
719
+ HOME: TEMP,
720
+ XDG_CONFIG_HOME: join(TEMP, ".config"),
721
+ }),
722
+ }),
723
+ );
724
+ // the script and the resulting binary are both made executable
725
+ expect(chmodSync).toHaveBeenCalledWith(scriptPath, 0o755);
726
+ expect(chmodSync).toHaveBeenCalledWith(cliPath, 0o755);
727
+ });
728
+
729
+ it("throws when the install script download fails", async () => {
730
+ stubFetch(fakeResponse({ ok: false, status: 404 }));
731
+
732
+ await expect(installFromCurl(params)).rejects.toThrow("Failed to download install script: 404");
733
+ });
734
+
735
+ it("throws when the install script response has no body", async () => {
736
+ stubFetch(fakeResponse({ body: null }));
737
+
738
+ await expect(installFromCurl(params)).rejects.toThrow("Response body is null");
739
+ });
740
+
741
+ it("reports the exit code and output when the install script fails", async () => {
742
+ stubFetch(fakeResponse());
743
+ spawnSyncMock.mockReturnValue(spawnResult({ status: 2, stderr: "no curl" }));
744
+
745
+ await expect(installFromCurl(params)).rejects.toThrow(
746
+ "Failed to install mytool. Install script exited with code 2. Output: no curl",
747
+ );
748
+ });
749
+
750
+ it("falls back to stdout, then 'No output', when the script fails silently", async () => {
751
+ stubFetch(fakeResponse());
752
+ spawnSyncMock.mockReturnValue(spawnResult({ status: 3, stdout: "partial log" }));
753
+
754
+ await expect(installFromCurl(params)).rejects.toThrow("Output: partial log");
755
+
756
+ stubFetch(fakeResponse());
757
+ spawnSyncMock.mockReturnValue(spawnResult({ status: 3 }));
758
+
759
+ await expect(installFromCurl(params)).rejects.toThrow("Output: No output");
760
+ });
761
+
762
+ it("throws when the executable is missing after the script ran", async () => {
763
+ existsSyncMock.mockReturnValue(false);
764
+ stubFetch(fakeResponse());
765
+
766
+ await expect(installFromCurl(params)).rejects.toThrow(`Executable not found at ${cliPath}`);
767
+ });
768
+ });