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,170 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { describe, expect, it } from "vitest";
3
+ import { spawn, TailBuffer } from "#app/utils/subprocess";
4
+
5
+ describe("spawn error path", () => {
6
+ it("surfaces ENOENT-style spawn failures in stderr so callers can diagnose", async () => {
7
+ // before this regression-test's fix, spawn resolved with exitCode=1 and
8
+ // an empty stderr buffer when the command itself couldn't start —
9
+ // lifecycle hook warnings then said "output: (empty)" and users had no
10
+ // way to tell a broken script from a flaky one.
11
+ const result = await spawn({
12
+ cmd: "/nonexistent-command-for-spawn-test-xyz",
13
+ args: [],
14
+ env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
15
+ activityTimeout: 0,
16
+ });
17
+
18
+ expect(result.exitCode).toBe(1);
19
+ expect(result.stderr).toContain("/nonexistent-command-for-spawn-test-xyz");
20
+ expect(result.stderr).toMatch(/ENOENT|not found/i);
21
+ });
22
+
23
+ it("clears the SIGKILL escalator when a timed-out child exits cleanly from SIGTERM", async () => {
24
+ // regression: the overall-timeout path did
25
+ // setTimeout(() => { if (!child.killed) child.kill("SIGKILL") }, 5000)
26
+ // without capturing the timer id. if the child responded to SIGTERM and
27
+ // `close` fired promptly, the SIGKILL escalator stayed in the event loop
28
+ // for up to 5 seconds — delaying any clean shutdown by that long.
29
+ const beforeHandles = process.getActiveResourcesInfo().filter((r) => r === "Timeout").length;
30
+
31
+ // sleep does not install a TERM trap, so the default action (terminate)
32
+ // fires immediately — `close` lands within ms of the SIGTERM, giving us
33
+ // the orphaned-escalator window that the bug would have triggered.
34
+ const result = await spawn({
35
+ cmd: "sleep",
36
+ args: ["30"],
37
+ env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
38
+ activityTimeout: 0,
39
+ timeout: 200,
40
+ }).catch((err) => err);
41
+
42
+ // timed out, so we get the SpawnTimeoutError
43
+ expect(result).toBeInstanceOf(Error);
44
+
45
+ // the SIGKILL escalator (and any other timer spawn() owned) must be
46
+ // cleared by the time the promise settles — active timer count should
47
+ // not have grown past the pre-spawn baseline.
48
+ const afterHandles = process.getActiveResourcesInfo().filter((r) => r === "Timeout").length;
49
+ expect(afterHandles).toBeLessThanOrEqual(beforeHandles);
50
+ });
51
+
52
+ it("killGroup: true propagates SIGKILL to grandchildren so close fires promptly", async () => {
53
+ // regression: node_modules/opencode-ai/bin/opencode is a Node shim that
54
+ // spawnSyncs the native binary with stdio:"inherit". without killGroup,
55
+ // child.kill("SIGKILL") hit only the shim — the native binary was
56
+ // reparented to PID 1, kept holding our stdout pipe via the inherited
57
+ // fds, and `child.on("close")` never fired (because pipes stayed open).
58
+ // a 5-min outer safety-net timer eventually rejected the agent promise,
59
+ // but the grandchild kept running until the GitHub Actions job-level
60
+ // timeout. this test replicates the shape with bash + a backgrounded
61
+ // sleep grandchild: with killGroup, close fires promptly after SIGKILL;
62
+ // without it, the parent would wait for sleep to exit (30s).
63
+ //
64
+ // the activity-check interval is fixed at 5s so the earliest the kill
65
+ // can fire is ~5s after start. budget 15s end-to-end.
66
+ const before = performance.now();
67
+ const result = await spawn({
68
+ cmd: "bash",
69
+ args: ["-c", "sleep 30 & wait"],
70
+ env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
71
+ activityTimeout: 1000,
72
+ killGroup: true,
73
+ }).catch((err) => err);
74
+ const elapsed = performance.now() - before;
75
+
76
+ expect(result).toBeInstanceOf(Error);
77
+ // 10s ceiling: 5s activity-check tick + signal delivery. a regression
78
+ // here (no killGroup) would hang for the full 30s sleep.
79
+ expect(elapsed).toBeLessThan(10_000);
80
+ }, 20_000);
81
+
82
+ it('retain:"tail" caps stderr at maxRetainedBytes and prepends a truncation sentinel', async () => {
83
+ // regression for issue #680: unbounded `stderrBuffer += chunk` previously
84
+ // crashed the wrapper with `RangeError: Invalid string length` once V8's
85
+ // ~1 GiB kMaxLength was breached on long-lived agent runs. the fix caps
86
+ // retention with a TailBuffer; this test exercises the cap end-to-end by
87
+ // emitting ~2 MiB of stderr against a 256 KiB ceiling and asserts the
88
+ // wrapper does not crash, the result is bounded, and the sentinel is
89
+ // present so downstream consumers can detect the truncation.
90
+ const result = await spawn({
91
+ cmd: "bash",
92
+ // print ~2 MiB to stderr in 64 KiB chunks. `yes` + head gives us a
93
+ // reliable byte budget that's well above the 256 KiB cap below.
94
+ args: ["-c", "yes ABCDEFGH | head -c 2097152 1>&2"],
95
+ env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
96
+ activityTimeout: 0,
97
+ maxRetainedBytes: 256 * 1024,
98
+ });
99
+
100
+ expect(result.exitCode).toBe(0);
101
+ expect(result.stderr).toMatch(/truncated by retain:tail cap/);
102
+ expect(result.stderr.length).toBeLessThan(256 * 1024 + 200);
103
+ }, 15_000);
104
+
105
+ it('retain:"none" returns empty stdout/stderr regardless of child output', async () => {
106
+ // long-lived agent callers (opencode, claude) drain via onStdout/onStderr
107
+ // and never read result.stdout/result.stderr — they pass retain:"none"
108
+ // to skip the per-chunk concatenation entirely. assert that contract:
109
+ // empty strings out, but onStdout still fires.
110
+ const chunks: string[] = [];
111
+ const result = await spawn({
112
+ cmd: "bash",
113
+ args: ["-c", "echo hello; echo world 1>&2"],
114
+ env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
115
+ activityTimeout: 0,
116
+ retain: "none",
117
+ onStdout: (chunk) => chunks.push(chunk),
118
+ });
119
+
120
+ expect(result.exitCode).toBe(0);
121
+ expect(result.stdout).toBe("");
122
+ expect(result.stderr).toBe("");
123
+ expect(chunks.join("")).toContain("hello");
124
+ });
125
+
126
+ it('retain defaults to "tail" so short-lived callers keep failure-surfacing snapshots', async () => {
127
+ // lock the default explicitly. gitAuth, package installs, and lifecycle
128
+ // hooks all rely on `result.stderr` being non-empty on failure — flipping
129
+ // the default to "none" would silently break their error messages while
130
+ // all other tests in this file kept passing.
131
+ const result = await spawn({
132
+ cmd: "bash",
133
+ args: ["-c", "echo -n diagnostic-output 1>&2; exit 7"],
134
+ env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
135
+ activityTimeout: 0,
136
+ });
137
+
138
+ expect(result.exitCode).toBe(7);
139
+ expect(result.stderr).toBe("diagnostic-output");
140
+ });
141
+
142
+ it("TailBuffer drops oldest bytes once the cap is exceeded", () => {
143
+ const buf = new TailBuffer(10);
144
+ buf.append("0123456789");
145
+ expect(buf.toString()).toBe("0123456789");
146
+ buf.append("abcde");
147
+ // 0-9 plus abcde = 15 chars; cap is 10, so we keep the last 10 = "56789abcde"
148
+ expect(buf.toString()).toMatch(/truncated by retain:tail cap/);
149
+ expect(buf.toString()).toContain("56789abcde");
150
+ });
151
+
152
+ it("reports signal-killed subprocesses as failures, not success", async () => {
153
+ // regression: before the fix, `child.on("close", (exitCode) => ...)`
154
+ // discarded the signal parameter and `exitCode || 0` coerced the
155
+ // node-delivered null to 0. lifecycle hooks killed by OOM, segfault,
156
+ // or external SIGTERM were silently reported as exit code 0, and
157
+ // lifecycle.ts's `if (result.exitCode !== 0)` skipped the warning —
158
+ // so callers proceeded as if setup/post-checkout/prepush had succeeded.
159
+ const result = await spawn({
160
+ cmd: "bash",
161
+ args: ["-c", "kill -KILL $$"],
162
+ env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
163
+ activityTimeout: 0,
164
+ });
165
+
166
+ expect(result.exitCode).not.toBe(0);
167
+ expect(result.stderr).toMatch(/killed by signal/i);
168
+ expect(result.stderr).toMatch(/SIGKILL/);
169
+ });
170
+ });
@@ -0,0 +1,438 @@
1
+ import { type ChildProcess, spawn as nodeSpawn } from "node:child_process";
2
+ import { performance } from "node:perf_hooks";
3
+ import {
4
+ DEFAULT_ACTIVITY_CHECK_INTERVAL_MS,
5
+ DEFAULT_ACTIVITY_TIMEOUT_MS,
6
+ } from "#app/utils/activity";
7
+ import { log } from "#app/utils/cli";
8
+ import { onExitSignal } from "#app/utils/exitHandler";
9
+
10
+ export type TrackChildOptions = {
11
+ child: ChildProcess;
12
+ // if true, kill the entire process group (requires detached spawn)
13
+ killGroup?: boolean;
14
+ };
15
+
16
+ // sentinel codes for timeout rejections — callers (e.g. lifecycle.ts) use
17
+ // these to distinguish timeouts from other errors without string-matching
18
+ // on the error message, which is fragile to rewording.
19
+ export const SPAWN_TIMEOUT_CODE = "E_SPAWN_TIMEOUT";
20
+ export const SPAWN_ACTIVITY_TIMEOUT_CODE = "E_SPAWN_ACTIVITY_TIMEOUT";
21
+
22
+ export class SpawnTimeoutError extends Error {
23
+ readonly code: typeof SPAWN_TIMEOUT_CODE | typeof SPAWN_ACTIVITY_TIMEOUT_CODE;
24
+ constructor(
25
+ message: string,
26
+ code: typeof SPAWN_TIMEOUT_CODE | typeof SPAWN_ACTIVITY_TIMEOUT_CODE,
27
+ ) {
28
+ super(message);
29
+ this.name = "SpawnTimeoutError";
30
+ this.code = code;
31
+ }
32
+ }
33
+
34
+ // track all spawned child processes for cleanup on Ctrl+C
35
+ const activeChildren = new Map<ChildProcess, boolean>();
36
+
37
+ // signal handler override (used by test runner for graceful shutdown)
38
+ export type SignalHandler = (signal: NodeJS.Signals) => void;
39
+ let externalSignalHandler: SignalHandler | null = null;
40
+
41
+ // track a child process for cleanup on Ctrl+C
42
+ export function trackChild(options: TrackChildOptions): void {
43
+ // the signal handler cleans up all tracked children
44
+ // so we only have to install it once some child gets tracked
45
+ installSignalHandler();
46
+ activeChildren.set(options.child, options.killGroup ?? false);
47
+ }
48
+
49
+ // untrack a child process
50
+ export function untrackChild(child: ChildProcess): void {
51
+ activeChildren.delete(child);
52
+ }
53
+
54
+ // allow callers to override default signal handling
55
+ export function setSignalHandler(handler: SignalHandler | null): void {
56
+ externalSignalHandler = handler;
57
+ }
58
+
59
+ // kill all tracked children without exiting
60
+ export function killTrackedChildren() {
61
+ for (const entry of activeChildren) {
62
+ const child = entry[0];
63
+ const killGroup = entry[1];
64
+ if (killGroup && child.pid) {
65
+ try {
66
+ process.kill(-child.pid, "SIGKILL");
67
+ continue;
68
+ } catch {
69
+ // fall through to direct kill
70
+ }
71
+ }
72
+ child.kill("SIGKILL");
73
+ }
74
+ }
75
+
76
+ // install signal handlers once (call early in process lifecycle)
77
+ let handlersInstalled = false;
78
+ function installSignalHandler(): void {
79
+ if (handlersInstalled) return;
80
+ handlersInstalled = true;
81
+ onExitSignal((signal) => {
82
+ if (externalSignalHandler) {
83
+ externalSignalHandler(signal);
84
+ return;
85
+ }
86
+ const count = activeChildren.size;
87
+ if (count > 0) {
88
+ log.info(`» received ${signal}, killing ${count} subprocess(es)...`);
89
+ }
90
+ killTrackedChildren();
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Controls what the wrapper retains in memory across the child's lifetime
96
+ * for the post-hoc `SpawnResult.stdout` / `SpawnResult.stderr` snapshots.
97
+ *
98
+ * Streaming callbacks (`onStdout` / `onStderr`) fire regardless — `retain`
99
+ * only governs the buffered snapshot returned in `SpawnResult`.
100
+ *
101
+ * - `"tail"` (default): keep the last `maxRetainedBytes` UTF-16 code units
102
+ * of each stream. Once the cap is exceeded, oldest bytes are sliced off
103
+ * and the result is prefixed with a `... [N MiB truncated] ...` sentinel.
104
+ * Right default for short-lived commands whose failure mode is in their
105
+ * final output (git errors, install failures, hook scripts).
106
+ * - `"none"`: skip the buffer entirely. `SpawnResult.stdout` / `.stderr`
107
+ * are empty strings. Use this for long-lived streaming agents that already
108
+ * drain via `onStdout` / `onStderr` and never read the buffered snapshot.
109
+ *
110
+ * Default cap is 8 MiB — well below V8's ~1 GiB `kMaxLength` so `+= chunk`
111
+ * can never throw `RangeError: Invalid string length`.
112
+ */
113
+ export type RetainMode = "tail" | "none";
114
+
115
+ export const DEFAULT_MAX_RETAINED_BYTES = 8 * 1024 * 1024;
116
+
117
+ export interface SpawnOptions {
118
+ cmd: string;
119
+ args: string[];
120
+ env?: NodeJS.ProcessEnv;
121
+ input?: string;
122
+ timeout?: number;
123
+ // activity timeout: kill process if no stdout for this many ms (default:
124
+ // DEFAULT_ACTIVITY_TIMEOUT_MS, 0 to disable). only stdout resets the timer —
125
+ // stderr (e.g. provider error retries) does not count as progress.
126
+ activityTimeout?: number;
127
+ // fired synchronously when the activity timeout kills the process. used by
128
+ // callers (main.ts) to tear down shared resources like the MCP HTTP server
129
+ // so that lingering SSE reconnects don't keep the outer activity timer
130
+ // alive after the subprocess is already dead.
131
+ onActivityTimeout?: (() => void) | undefined;
132
+ cwd?: string;
133
+ stdio?: ("pipe" | "ignore" | "inherit")[];
134
+ onStdout?: (chunk: string) => void;
135
+ onStderr?: (chunk: string) => void;
136
+ // when true, spawn the child detached (its own process group) and route all
137
+ // kill paths (timeout, activity timeout, ctrl-c) through `process.kill(-pid, ...)`
138
+ // so signals reach grandchildren too. critical for binaries that fork through
139
+ // a shim (e.g. node_modules/opencode-ai/bin/opencode is a Node shim that
140
+ // spawnSync's the native binary; without killGroup, SIGKILL only hits the
141
+ // shim and the native binary is reparented to PID 1, holds our stdout pipe
142
+ // open, keeps emitting NDJSON, and `child.on("close")` never fires —
143
+ // producing zombie runs that hang until the GitHub Actions job timeout).
144
+ killGroup?: boolean;
145
+ retain?: RetainMode;
146
+ maxRetainedBytes?: number;
147
+ }
148
+
149
+ /**
150
+ * Bounded string accumulator that keeps the tail of appended chunks.
151
+ * Once the cap is exceeded, oldest bytes are sliced off and `toString()`
152
+ * prefixes the survivors with a sentinel describing the elided byte count.
153
+ *
154
+ * Exported because long-lived agent runtimes (opencode, claude) also
155
+ * accumulate per-run narration strings independently of the spawn wrapper
156
+ * and need the same protection against V8's `kMaxLength`.
157
+ */
158
+ export class TailBuffer {
159
+ // explicit field declarations rather than constructor parameter properties:
160
+ // node's strip-only TS loader (used by action/test/run.ts in CI) rejects
161
+ // `constructor(private readonly cap: number)` with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX.
162
+ private readonly cap: number;
163
+ private buffer = "";
164
+ private truncatedBytes = 0;
165
+
166
+ constructor(cap: number) {
167
+ this.cap = cap;
168
+ }
169
+
170
+ append(chunk: string): void {
171
+ if (this.cap <= 0) return;
172
+ this.buffer += chunk;
173
+ if (this.buffer.length > this.cap) {
174
+ const drop = this.buffer.length - this.cap;
175
+ this.truncatedBytes += drop;
176
+ this.buffer = this.buffer.slice(drop);
177
+ }
178
+ }
179
+
180
+ toString(): string {
181
+ if (this.truncatedBytes === 0) return this.buffer;
182
+ const mib = (this.truncatedBytes / 1024 / 1024).toFixed(1);
183
+ return `... [${mib} MiB truncated by retain:tail cap] ...\n${this.buffer}`;
184
+ }
185
+ }
186
+
187
+ export interface SpawnResult {
188
+ stdout: string;
189
+ stderr: string;
190
+ exitCode: number;
191
+ durationMs: number;
192
+ }
193
+
194
+ /**
195
+ * Spawn a subprocess with streaming callbacks and buffered results
196
+ */
197
+ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
198
+ const activityTimeoutMs = options.activityTimeout ?? DEFAULT_ACTIVITY_TIMEOUT_MS;
199
+
200
+ installSignalHandler();
201
+
202
+ const startTime = performance.now();
203
+ // capped accumulators — unbounded `+= chunk` previously crashed the wrapper
204
+ // with `RangeError: Invalid string length` once V8's ~1 GiB kMaxLength was
205
+ // breached on long-lived agent subprocesses (e.g. multi-lens opencode
206
+ // Reviews on large monorepos). retain:"none" skips the buffer entirely
207
+ // for callers that already drain via onStdout/onStderr.
208
+ const retain: RetainMode = options.retain ?? "tail";
209
+ const cap = options.maxRetainedBytes ?? DEFAULT_MAX_RETAINED_BYTES;
210
+ const stdoutBuffer = retain === "none" ? null : new TailBuffer(cap);
211
+ const stderrBuffer = retain === "none" ? null : new TailBuffer(cap);
212
+
213
+ const killGroup = options.killGroup ?? false;
214
+
215
+ return new Promise((resolve, reject) => {
216
+ // security: caller must provide complete env object, not merged with process.env
217
+ const child = nodeSpawn(options.cmd, options.args, {
218
+ env: options.env || {
219
+ PATH: process.env.PATH || "",
220
+ HOME: process.env.HOME || "",
221
+ },
222
+ stdio: options.stdio || ["pipe", "pipe", "pipe"],
223
+ cwd: options.cwd || process.cwd(),
224
+ detached: killGroup,
225
+ });
226
+
227
+ // sends `signal` to the entire process group when killGroup is set, so
228
+ // grandchildren (e.g. the native opencode binary spawned by the
229
+ // opencode-ai Node shim) die with the parent. falls back to a direct
230
+ // child kill if the process-group send fails (common when the child
231
+ // already exited or was never made a process group leader).
232
+ const killSelf = (signal: NodeJS.Signals): void => {
233
+ if (killGroup && child.pid) {
234
+ try {
235
+ process.kill(-child.pid, signal);
236
+ return;
237
+ } catch {
238
+ // fall through to direct kill
239
+ }
240
+ }
241
+ child.kill(signal);
242
+ };
243
+
244
+ // track child for cleanup on Ctrl+C
245
+ trackChild({ child, killGroup });
246
+
247
+ let timeoutId: NodeJS.Timeout | undefined;
248
+ let sigkillEscalatorId: NodeJS.Timeout | undefined;
249
+ let activityCheckIntervalId: NodeJS.Timeout | undefined;
250
+ let isTimedOut = false;
251
+ let isActivityTimedOut = false;
252
+ let lastActivityTime = performance.now();
253
+ // idle-ms snapshot taken at the moment the activity timer decides to kill.
254
+ // we reuse it when composing the SpawnTimeoutError so a final stdout chunk
255
+ // that races with `close` (and resets lastActivityTime via updateActivity)
256
+ // can't make the error message contradict the "no output for Ns" log line.
257
+ let killedAtIdleMs: number | undefined;
258
+
259
+ // overall timeout
260
+ if (options.timeout) {
261
+ timeoutId = setTimeout(() => {
262
+ isTimedOut = true;
263
+ killSelf("SIGTERM");
264
+
265
+ // track the escalator so a graceful SIGTERM response (close fires
266
+ // before the 5s elapses) can clear it. without capture, this timer
267
+ // was orphaned in the event loop and kept node alive for up to 5s
268
+ // past a timed-out subprocess's clean exit.
269
+ sigkillEscalatorId = setTimeout(() => {
270
+ if (!child.killed) {
271
+ killSelf("SIGKILL");
272
+ }
273
+ }, 5000);
274
+ }, options.timeout);
275
+ }
276
+
277
+ // activity timeout: kill if no output for too long
278
+ if (activityTimeoutMs > 0) {
279
+ log.debug(
280
+ `spawn activity timer: pid=${child.pid} cmd=${options.cmd} timeout=${activityTimeoutMs}ms`,
281
+ );
282
+ activityCheckIntervalId = setInterval(() => {
283
+ const idleMs = performance.now() - lastActivityTime;
284
+ log.debug(
285
+ `spawn activity check: pid=${child.pid} idle=${Math.round(idleMs)}ms / ${activityTimeoutMs}ms`,
286
+ );
287
+ if (idleMs > activityTimeoutMs) {
288
+ isActivityTimedOut = true;
289
+ killedAtIdleMs = idleMs;
290
+ const idleSec = Math.round(idleMs / 1000);
291
+ log.info(
292
+ `no output for ${idleSec}s from pid=${child.pid} (${options.cmd}), killing process${killGroup ? " group" : ""}`,
293
+ );
294
+ killSelf("SIGKILL");
295
+ clearInterval(activityCheckIntervalId);
296
+ try {
297
+ options.onActivityTimeout?.();
298
+ } catch (err) {
299
+ log.debug(
300
+ `spawn onActivityTimeout handler threw: ${err instanceof Error ? err.message : String(err)}`,
301
+ );
302
+ }
303
+ }
304
+ }, DEFAULT_ACTIVITY_CHECK_INTERVAL_MS);
305
+ }
306
+
307
+ function updateActivity(): void {
308
+ lastActivityTime = performance.now();
309
+ }
310
+
311
+ // wrap handlers in try/catch as defense in depth for synchronous throws
312
+ // inside the listener body. the historical `+= chunk` RangeError was such
313
+ // a throw — synchronous and fatal under node's default uncaught-exception
314
+ // policy. with the TailBuffer cap in place the wrapper-side `append` can
315
+ // no longer throw, but the catch keeps protecting against any future
316
+ // synchronous regression in this path.
317
+ //
318
+ // note: this does NOT catch rejections from async user callbacks —
319
+ // `options.onStdout?.(chunk)` returns a Promise in the agent callers
320
+ // (claude.ts, opencode.ts) and a throw inside an async callback surfaces
321
+ // as an unhandled Promise rejection, not a synchronous exception. agent
322
+ // callers handle their own NDJSON-parse failures internally; the
323
+ // synchronous protection here is what matters for the RangeError class
324
+ // of bugs (issue #680).
325
+ if (child.stdout) {
326
+ child.stdout.on("data", (data: Buffer) => {
327
+ try {
328
+ updateActivity();
329
+ const chunk = data.toString();
330
+ stdoutBuffer?.append(chunk);
331
+ options.onStdout?.(chunk);
332
+ } catch (err) {
333
+ log.debug(
334
+ `spawn stdout handler threw: ${err instanceof Error ? err.message : String(err)}`,
335
+ );
336
+ }
337
+ });
338
+ }
339
+
340
+ if (child.stderr) {
341
+ child.stderr.on("data", (data: Buffer) => {
342
+ try {
343
+ const chunk = data.toString();
344
+ stderrBuffer?.append(chunk);
345
+ options.onStderr?.(chunk);
346
+ } catch (err) {
347
+ log.debug(
348
+ `spawn stderr handler threw: ${err instanceof Error ? err.message : String(err)}`,
349
+ );
350
+ }
351
+ });
352
+ }
353
+
354
+ child.on("close", (exitCode, signal) => {
355
+ const durationMs = performance.now() - startTime;
356
+
357
+ untrackChild(child);
358
+ if (timeoutId) clearTimeout(timeoutId);
359
+ if (sigkillEscalatorId) clearTimeout(sigkillEscalatorId);
360
+ if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
361
+
362
+ if (isTimedOut) {
363
+ reject(
364
+ new SpawnTimeoutError(`process timed out after ${options.timeout}ms`, SPAWN_TIMEOUT_CODE),
365
+ );
366
+ return;
367
+ }
368
+
369
+ if (isActivityTimedOut) {
370
+ // prefer the idle-ms captured when the kill fired (killedAtIdleMs).
371
+ // recomputing from lastActivityTime here would be wrong if the child
372
+ // emitted one final stdout chunk between SIGKILL and close — the
373
+ // chunk's updateActivity() would reset lastActivityTime and the error
374
+ // would report near-zero idle, contradicting the kill-site log line.
375
+ const idleMs = killedAtIdleMs ?? performance.now() - lastActivityTime;
376
+ const idleSec = Math.round(idleMs / 1000);
377
+ reject(
378
+ new SpawnTimeoutError(
379
+ `activity timeout: no output for ${idleSec}s`,
380
+ SPAWN_ACTIVITY_TIMEOUT_CODE,
381
+ ),
382
+ );
383
+ return;
384
+ }
385
+
386
+ // when a child is killed by signal (OOM, segfault, external SIGTERM),
387
+ // node delivers (code=null, signal=<name>). without this branch,
388
+ // `exitCode || 0` coerced null to 0 and lifecycle hooks silently
389
+ // appeared to succeed when they'd actually been killed — caller
390
+ // checked `result.exitCode !== 0` and moved on.
391
+ let resolvedExitCode = exitCode ?? 0;
392
+ let resolvedStderr = stderrBuffer?.toString() ?? "";
393
+ if (exitCode === null && signal) {
394
+ const killMsg = `[spawn] ${options.cmd}: killed by signal ${signal}`;
395
+ resolvedStderr = resolvedStderr ? `${resolvedStderr}\n${killMsg}` : killMsg;
396
+ resolvedExitCode = 1;
397
+ }
398
+
399
+ resolve({
400
+ stdout: stdoutBuffer?.toString() ?? "",
401
+ stderr: resolvedStderr,
402
+ exitCode: resolvedExitCode,
403
+ durationMs,
404
+ });
405
+ });
406
+
407
+ child.on("error", (error) => {
408
+ const durationMs = performance.now() - startTime;
409
+
410
+ untrackChild(child);
411
+ if (timeoutId) clearTimeout(timeoutId);
412
+ if (sigkillEscalatorId) clearTimeout(sigkillEscalatorId);
413
+ if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
414
+
415
+ // surface the spawn error in stderr so callers (e.g. lifecycle hook
416
+ // warnings) don't just see "exit code 1, output: (empty)" when the
417
+ // command was misspelled, missing, or unexecutable. without this a
418
+ // user with a bad postCheckout script got an opaque failure, retried
419
+ // per the guidance, and hit the same wall every run.
420
+ const errMsg = `[spawn] ${options.cmd}: ${error.message}`;
421
+ console.error(errMsg);
422
+ const existingStderr = stderrBuffer?.toString() ?? "";
423
+ const finalStderr = existingStderr ? `${existingStderr}\n${errMsg}` : errMsg;
424
+
425
+ resolve({
426
+ stdout: stdoutBuffer?.toString() ?? "",
427
+ stderr: finalStderr,
428
+ exitCode: 1,
429
+ durationMs,
430
+ });
431
+ });
432
+
433
+ if (options.input && child.stdin && options.stdio?.[0] !== "ignore") {
434
+ child.stdin.write(options.input);
435
+ child.stdin.end();
436
+ }
437
+ });
438
+ }
@@ -0,0 +1,63 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const spawnSyncMock = vi.hoisted(() => vi.fn());
4
+
5
+ vi.mock("node:child_process", async (importOriginal) => {
6
+ const actual = await importOriginal<typeof import("node:child_process")>();
7
+ return { ...actual, spawnSync: spawnSyncMock };
8
+ });
9
+
10
+ import {
11
+ _clearDockerProbeCache,
12
+ resolveTerraformMcp,
13
+ TERRAFORM_MCP_IMAGE,
14
+ } from "#app/utils/terraformMcp";
15
+
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ _clearDockerProbeCache();
19
+ });
20
+
21
+ function dockerProbe(result: { status?: number | null; error?: Error }) {
22
+ spawnSyncMock.mockReturnValue({
23
+ status: result.status ?? 0,
24
+ error: result.error,
25
+ stdout: "",
26
+ stderr: "",
27
+ } as unknown as ReturnType<typeof import("node:child_process").spawnSync>);
28
+ }
29
+
30
+ describe("resolveTerraformMcp", () => {
31
+ it("is disabled when the input is off — and never probes docker", () => {
32
+ expect(resolveTerraformMcp({ terraformMcp: false })).toEqual({ kind: "disabled" });
33
+ expect(spawnSyncMock).not.toHaveBeenCalled();
34
+ });
35
+
36
+ it("resolves the pinned image with the registry-only toolset when docker is present", () => {
37
+ dockerProbe({ status: 0 });
38
+ const resolution = resolveTerraformMcp({ terraformMcp: true });
39
+ expect(resolution).toEqual({
40
+ kind: "available",
41
+ command: "docker",
42
+ args: ["run", "-i", "--rm", TERRAFORM_MCP_IMAGE, "--toolsets=registry"],
43
+ });
44
+ // version-pinned, never :latest (P4 will move this to a digest pin).
45
+ expect(TERRAFORM_MCP_IMAGE).toMatch(/^hashicorp\/terraform-mcp-server:\d+\.\d+\.\d+$/);
46
+ });
47
+
48
+ it("degrades green with a note when docker is missing (ENOENT or non-zero)", () => {
49
+ dockerProbe({ status: null, error: new Error("spawn docker ENOENT") });
50
+ const resolution = resolveTerraformMcp({ terraformMcp: true });
51
+ expect(resolution.kind).toBe("docker_missing");
52
+ if (resolution.kind === "docker_missing") {
53
+ expect(resolution.note).toContain("docker is not available");
54
+ }
55
+ });
56
+
57
+ it("caches the docker probe across calls", () => {
58
+ dockerProbe({ status: 0 });
59
+ resolveTerraformMcp({ terraformMcp: true });
60
+ resolveTerraformMcp({ terraformMcp: true });
61
+ expect(spawnSyncMock).toHaveBeenCalledTimes(1);
62
+ });
63
+ });