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,167 @@
1
+ import { log } from "#app/utils/log";
2
+
3
+ type TodoItem = {
4
+ id: string;
5
+ content: string;
6
+ status: "pending" | "in_progress" | "completed" | "cancelled";
7
+ };
8
+
9
+ function isValidTodoStatus(value: string): value is TodoItem["status"] {
10
+ return (
11
+ value === "pending" || value === "in_progress" || value === "completed" || value === "cancelled"
12
+ );
13
+ }
14
+
15
+ function parseTodowriteInput(input: unknown): { todos: unknown[]; merge: boolean } | undefined {
16
+ if (!input || typeof input !== "object" || !("todos" in input)) return undefined;
17
+ if (!Array.isArray(input.todos)) return undefined;
18
+ const merge = "merge" in input && input.merge === true;
19
+ return { todos: input.todos, merge };
20
+ }
21
+
22
+ function parseTodoItem(entry: unknown, index: number): TodoItem | undefined {
23
+ if (!entry || typeof entry !== "object") return undefined;
24
+ if (!("content" in entry) || typeof entry.content !== "string") return undefined;
25
+ const id = "id" in entry && typeof entry.id === "string" ? entry.id : String(index);
26
+ const status =
27
+ "status" in entry && typeof entry.status === "string" && isValidTodoStatus(entry.status)
28
+ ? entry.status
29
+ : "pending";
30
+ return { id, content: entry.content, status };
31
+ }
32
+
33
+ function renderTodoMarkdown(todos: TodoItem[]): string {
34
+ return todos
35
+ .map((todo) => {
36
+ switch (todo.status) {
37
+ case "completed":
38
+ return `- [x] ${todo.content}`;
39
+ case "cancelled":
40
+ return `- ~~${todo.content}~~`;
41
+ case "in_progress":
42
+ return `- [ ] <img src="https://uploads.terramend.com/Progress%20Indicator.gif" width="11" style="visibility: visible; max-width: 100%;" /> ${todo.content}`;
43
+ case "pending":
44
+ return `- [ ] ${todo.content}`;
45
+ default:
46
+ todo.status satisfies never;
47
+ return `- [ ] ${todo.content}`;
48
+ }
49
+ })
50
+ .join("\n");
51
+ }
52
+
53
+ export type TodoTracker = {
54
+ update: (input: unknown) => void;
55
+ flush: () => Promise<void>;
56
+ cancel: () => void;
57
+ /** resolves when any in-flight onUpdate call completes */
58
+ settled: () => Promise<void>;
59
+ /** mark in-progress items as completed (for final snapshot before review/progress post) */
60
+ completeInProgress: () => void;
61
+ renderCollapsible: (options?: { completeInProgress?: boolean }) => string;
62
+ readonly enabled: boolean;
63
+ /** true after the tracker has successfully called onUpdate at least once */
64
+ readonly hasPublished: boolean;
65
+ };
66
+
67
+ const DEBOUNCE_MS = 2000;
68
+
69
+ export function createTodoTracker(onUpdate: (body: string) => Promise<void>): TodoTracker {
70
+ const state = new Map<string, TodoItem>();
71
+ let enabled = true;
72
+ let hasPublished = false;
73
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
74
+ let inflightPromise: Promise<void> = Promise.resolve();
75
+
76
+ function scheduleUpdate() {
77
+ if (!enabled) return;
78
+ if (debounceTimer) clearTimeout(debounceTimer);
79
+ debounceTimer = setTimeout(() => {
80
+ debounceTimer = null;
81
+ if (!enabled || state.size === 0) return;
82
+ const markdown = renderTodoMarkdown(Array.from(state.values()));
83
+ inflightPromise = inflightPromise
84
+ .then(async () => {
85
+ if (!enabled) return;
86
+ await onUpdate(markdown);
87
+ hasPublished = true;
88
+ })
89
+ .catch((err) => {
90
+ log.debug(`todo progress update failed: ${err}`);
91
+ });
92
+ }, DEBOUNCE_MS);
93
+ }
94
+
95
+ return {
96
+ update(input: unknown) {
97
+ if (!enabled) return;
98
+ const parsed = parseTodowriteInput(input);
99
+ if (!parsed) return;
100
+ if (!parsed.merge) state.clear();
101
+ for (const [index, entry] of parsed.todos.entries()) {
102
+ const item = parseTodoItem(entry, index);
103
+ if (item) state.set(item.id, item);
104
+ }
105
+ log.debug(`» todowrite: ${state.size} items tracked`);
106
+ scheduleUpdate();
107
+ },
108
+
109
+ async flush() {
110
+ if (debounceTimer) {
111
+ clearTimeout(debounceTimer);
112
+ debounceTimer = null;
113
+ }
114
+ if (!enabled || state.size === 0) return;
115
+ const markdown = renderTodoMarkdown(Array.from(state.values()));
116
+ inflightPromise = inflightPromise
117
+ .then(async () => {
118
+ if (!enabled) return;
119
+ await onUpdate(markdown);
120
+ hasPublished = true;
121
+ })
122
+ .catch((err) => {
123
+ log.debug(`todo progress flush failed: ${err}`);
124
+ });
125
+ await inflightPromise;
126
+ },
127
+
128
+ cancel() {
129
+ enabled = false;
130
+ if (debounceTimer) {
131
+ clearTimeout(debounceTimer);
132
+ debounceTimer = null;
133
+ }
134
+ },
135
+
136
+ async settled() {
137
+ await inflightPromise;
138
+ },
139
+
140
+ completeInProgress() {
141
+ for (const item of state.values()) {
142
+ if (item.status === "in_progress") item.status = "completed";
143
+ }
144
+ },
145
+
146
+ renderCollapsible(options?: { completeInProgress?: boolean }): string {
147
+ if (state.size === 0) return "";
148
+ const shouldCompleteInProgress = options?.completeInProgress === true;
149
+ const todos = Array.from(state.values()).map((item) =>
150
+ shouldCompleteInProgress && item.status === "in_progress"
151
+ ? { ...item, status: "completed" as const }
152
+ : item,
153
+ );
154
+ const completed = todos.filter((t) => t.status === "completed").length;
155
+ const markdown = renderTodoMarkdown(todos);
156
+ return `<details>\n<summary>Task list (${completed}/${todos.length} completed)</summary>\n\n${markdown}\n\n</details>`;
157
+ },
158
+
159
+ get enabled() {
160
+ return enabled;
161
+ },
162
+
163
+ get hasPublished() {
164
+ return hasPublished;
165
+ },
166
+ };
167
+ }
@@ -0,0 +1,239 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const getInputMock = vi.fn((_name: string): string => "");
4
+ const setSecretMock = vi.fn();
5
+ const acquireNewTokenMock = vi.fn();
6
+ const onExitSignalMock = vi.fn((_handler: unknown) => removeSignalHandlerMock);
7
+ const removeSignalHandlerMock = vi.fn();
8
+
9
+ vi.mock("@actions/core", async (importOriginal) => ({
10
+ ...(await importOriginal<typeof import("@actions/core")>()),
11
+ getInput: (name: string) => getInputMock(name),
12
+ setSecret: (value: string) => setSecretMock(value),
13
+ }));
14
+
15
+ vi.mock("#app/utils/github", () => ({
16
+ acquireNewToken: (opts: unknown) => acquireNewTokenMock(opts),
17
+ }));
18
+
19
+ const globalsState = { isGitHubActions: true };
20
+
21
+ vi.mock("#app/utils/globals", () => ({
22
+ get isGitHubActions() {
23
+ return globalsState.isGitHubActions;
24
+ },
25
+ }));
26
+
27
+ vi.mock("#app/utils/exitHandler", () => ({
28
+ onExitSignal: (handler: unknown) => onExitSignalMock(handler),
29
+ }));
30
+
31
+ // resolveTokens guards module-level token state with an assert, so each test
32
+ // imports a fresh module instance.
33
+ async function loadToken() {
34
+ vi.resetModules();
35
+ return await import("#app/utils/token");
36
+ }
37
+
38
+ let fetchMock: ReturnType<typeof vi.fn>;
39
+
40
+ beforeEach(() => {
41
+ fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
42
+ vi.stubGlobal("fetch", fetchMock);
43
+ vi.stubEnv("GH_TOKEN", "");
44
+ vi.stubEnv("GITHUB_TOKEN", "");
45
+ vi.stubEnv("GITHUB_API_URL", "");
46
+ });
47
+
48
+ afterEach(() => {
49
+ vi.unstubAllGlobals();
50
+ vi.unstubAllEnvs();
51
+ vi.clearAllMocks();
52
+ globalsState.isGitHubActions = true;
53
+ });
54
+
55
+ describe("getJobToken", () => {
56
+ it("prefers the workflow token input", async () => {
57
+ const { getJobToken } = await loadToken();
58
+ getInputMock.mockReturnValueOnce("input-token");
59
+ vi.stubEnv("GH_TOKEN", "gh-token");
60
+ expect(getJobToken()).toBe("input-token");
61
+ });
62
+
63
+ it("falls back to GH_TOKEN when the input is empty", async () => {
64
+ const { getJobToken } = await loadToken();
65
+ vi.stubEnv("GH_TOKEN", "gh-token");
66
+ vi.stubEnv("GITHUB_TOKEN", "actions-token");
67
+ expect(getJobToken()).toBe("gh-token");
68
+ });
69
+
70
+ it("falls back to GITHUB_TOKEN when GH_TOKEN is unset", async () => {
71
+ const { getJobToken } = await loadToken();
72
+ vi.stubEnv("GITHUB_TOKEN", "actions-token");
73
+ expect(getJobToken()).toBe("actions-token");
74
+ });
75
+
76
+ it("throws when no token source is available", async () => {
77
+ const { getJobToken } = await loadToken();
78
+ expect(() => getJobToken()).toThrow("token input is required");
79
+ });
80
+ });
81
+
82
+ describe("resolveTokens with external GH_TOKEN", () => {
83
+ it("uses the external token for both git and MCP and masks it", async () => {
84
+ const token = await loadToken();
85
+ vi.stubEnv("GH_TOKEN", "external-token");
86
+
87
+ const ref = await token.resolveTokens({ push: "enabled" });
88
+ expect(ref.gitToken).toBe("external-token");
89
+ expect(ref.mcpToken).toBe("external-token");
90
+ expect(setSecretMock).toHaveBeenCalledWith("external-token");
91
+ expect(acquireNewTokenMock).not.toHaveBeenCalled();
92
+ expect(token.getGitHubInstallationToken()).toBe("external-token");
93
+
94
+ await ref[Symbol.asyncDispose]();
95
+ // dispose clears the stored value but does NOT revoke the user's own token
96
+ expect(fetchMock).not.toHaveBeenCalled();
97
+ expect(() => token.getGitHubInstallationToken()).toThrow(/tokens not set/);
98
+ });
99
+ });
100
+
101
+ describe("resolveTokens with acquired installation tokens", () => {
102
+ it("acquires a read-only git token when push is disabled", async () => {
103
+ const token = await loadToken();
104
+ acquireNewTokenMock.mockResolvedValueOnce("git-token").mockResolvedValueOnce("mcp-token");
105
+
106
+ const ref = await token.resolveTokens({ push: "disabled" });
107
+ expect(acquireNewTokenMock).toHaveBeenNthCalledWith(1, {
108
+ permissions: { contents: "read" },
109
+ });
110
+ expect(ref.gitToken).toBe("git-token");
111
+ expect(ref.mcpToken).toBe("mcp-token");
112
+ await ref[Symbol.asyncDispose]();
113
+ });
114
+
115
+ it("acquires a write git token (with workflows) when push is enabled", async () => {
116
+ const token = await loadToken();
117
+ acquireNewTokenMock.mockResolvedValueOnce("git-token").mockResolvedValueOnce("mcp-token");
118
+
119
+ const ref = await token.resolveTokens({ push: "enabled" });
120
+ expect(acquireNewTokenMock).toHaveBeenNthCalledWith(1, {
121
+ permissions: { contents: "write", workflows: "write" },
122
+ });
123
+ expect(acquireNewTokenMock).toHaveBeenNthCalledWith(2, {
124
+ permissions: {
125
+ contents: "write",
126
+ pull_requests: "write",
127
+ issues: "write",
128
+ checks: "read",
129
+ actions: "read",
130
+ },
131
+ });
132
+ expect(setSecretMock).toHaveBeenCalledWith("git-token");
133
+ expect(setSecretMock).toHaveBeenCalledWith("mcp-token");
134
+ expect(token.getGitHubInstallationToken()).toBe("mcp-token");
135
+ await ref[Symbol.asyncDispose]();
136
+ });
137
+
138
+ it("skips secret masking outside GitHub Actions", async () => {
139
+ const token = await loadToken();
140
+ globalsState.isGitHubActions = false;
141
+ acquireNewTokenMock.mockResolvedValueOnce("git-token").mockResolvedValueOnce("mcp-token");
142
+
143
+ const ref = await token.resolveTokens({ push: "enabled" });
144
+ expect(setSecretMock).not.toHaveBeenCalled();
145
+ await ref[Symbol.asyncDispose]();
146
+ });
147
+
148
+ it("skips masking the external token outside GitHub Actions", async () => {
149
+ const token = await loadToken();
150
+ globalsState.isGitHubActions = false;
151
+ vi.stubEnv("GH_TOKEN", "external-token");
152
+
153
+ const ref = await token.resolveTokens({ push: "enabled" });
154
+ expect(setSecretMock).not.toHaveBeenCalled();
155
+ await ref[Symbol.asyncDispose]();
156
+ });
157
+
158
+ it("rejects a second resolveTokens call while tokens are live", async () => {
159
+ const token = await loadToken();
160
+ acquireNewTokenMock.mockResolvedValue("some-token");
161
+
162
+ const ref = await token.resolveTokens({ push: "enabled" });
163
+ await expect(token.resolveTokens({ push: "enabled" })).rejects.toThrow(
164
+ /tokens are already resolved/,
165
+ );
166
+ await ref[Symbol.asyncDispose]();
167
+ });
168
+
169
+ it("dispose revokes both tokens and removes the exit-signal handler", async () => {
170
+ const token = await loadToken();
171
+ acquireNewTokenMock.mockResolvedValueOnce("git-token").mockResolvedValueOnce("mcp-token");
172
+
173
+ const ref = await token.resolveTokens({ push: "restricted" });
174
+ expect(onExitSignalMock).toHaveBeenCalledTimes(1);
175
+
176
+ await ref[Symbol.asyncDispose]();
177
+
178
+ expect(fetchMock).toHaveBeenCalledTimes(2);
179
+ const tokensRevoked = fetchMock.mock.calls.map(
180
+ (call) => (call[1] as RequestInit).headers as Record<string, string>,
181
+ );
182
+ expect(tokensRevoked.map((h) => h.Authorization).sort()).toEqual([
183
+ "Bearer git-token",
184
+ "Bearer mcp-token",
185
+ ]);
186
+ expect(removeSignalHandlerMock).toHaveBeenCalledTimes(1);
187
+ expect(() => token.getGitHubInstallationToken()).toThrow(/tokens not set/);
188
+ });
189
+
190
+ it("concurrent dispose calls share the in-flight revocation", async () => {
191
+ const token = await loadToken();
192
+ acquireNewTokenMock.mockResolvedValueOnce("git-token").mockResolvedValueOnce("mcp-token");
193
+
194
+ const ref = await token.resolveTokens({ push: "enabled" });
195
+ await Promise.all([ref[Symbol.asyncDispose](), ref[Symbol.asyncDispose]()]);
196
+
197
+ // 2 revocations total, not 4 — the second dispose awaited the first
198
+ expect(fetchMock).toHaveBeenCalledTimes(2);
199
+ });
200
+ });
201
+
202
+ describe("revokeGitHubInstallationToken", () => {
203
+ it("DELETEs the installation token with auth headers", async () => {
204
+ const { revokeGitHubInstallationToken } = await loadToken();
205
+ await revokeGitHubInstallationToken("revoke-me");
206
+
207
+ expect(fetchMock).toHaveBeenCalledWith("https://api.github.com/installation/token", {
208
+ method: "DELETE",
209
+ headers: {
210
+ Accept: "application/vnd.github+json",
211
+ Authorization: "Bearer revoke-me",
212
+ "X-GitHub-Api-Version": "2022-11-28",
213
+ },
214
+ });
215
+ });
216
+
217
+ it("honors GITHUB_API_URL for GitHub Enterprise Server", async () => {
218
+ const { revokeGitHubInstallationToken } = await loadToken();
219
+ vi.stubEnv("GITHUB_API_URL", "https://ghe.example.com/api/v3");
220
+ await revokeGitHubInstallationToken("revoke-me");
221
+
222
+ expect(fetchMock).toHaveBeenCalledWith(
223
+ "https://ghe.example.com/api/v3/installation/token",
224
+ expect.objectContaining({ method: "DELETE" }),
225
+ );
226
+ });
227
+
228
+ it("swallows revocation failures instead of throwing", async () => {
229
+ const { revokeGitHubInstallationToken } = await loadToken();
230
+ fetchMock.mockRejectedValueOnce(new Error("network down"));
231
+ await expect(revokeGitHubInstallationToken("revoke-me")).resolves.toBeUndefined();
232
+ });
233
+
234
+ it("swallows non-Error revocation rejections too", async () => {
235
+ const { revokeGitHubInstallationToken } = await loadToken();
236
+ fetchMock.mockRejectedValueOnce("string failure");
237
+ await expect(revokeGitHubInstallationToken("revoke-me")).resolves.toBeUndefined();
238
+ });
239
+ });
@@ -0,0 +1,186 @@
1
+ import assert from "node:assert/strict";
2
+ import * as core from "@actions/core";
3
+ import type { PushPermission } from "#app/external";
4
+ import { log } from "#app/utils/cli";
5
+ import { onExitSignal } from "#app/utils/exitHandler";
6
+ import { acquireNewToken } from "#app/utils/github";
7
+ import { isGitHubActions } from "#app/utils/globals";
8
+
9
+ // re-export for `terramend gha token` subcommand
10
+ export {
11
+ acquireNewToken as acquireInstallationToken,
12
+ revokeGitHubInstallationToken as revokeInstallationToken,
13
+ };
14
+
15
+ // store MCP token in memory for getGitHubInstallationToken()
16
+ let mcpTokenValue: string | undefined;
17
+
18
+ /**
19
+ * get the job-scoped token from action input.
20
+ * this token has permissions defined by the workflow's permissions block.
21
+ *
22
+ * fallback order:
23
+ * 1. INPUT_TOKEN (from workflow `with: token:`)
24
+ * 2. GH_TOKEN (external token override)
25
+ * 3. GITHUB_TOKEN (pre-acquired in tests or from GHA env)
26
+ */
27
+ export function getJobToken(): string {
28
+ const inputToken = core.getInput("token");
29
+ if (inputToken) {
30
+ return inputToken;
31
+ }
32
+
33
+ // fallback for test environment and local dev
34
+ const fallbackToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
35
+ if (fallbackToken) {
36
+ return fallbackToken;
37
+ }
38
+
39
+ throw new Error("token input is required");
40
+ }
41
+
42
+ export type TokenRef = {
43
+ gitToken: string;
44
+ mcpToken: string;
45
+ [Symbol.asyncDispose]: () => Promise<void>;
46
+ };
47
+
48
+ type ResolveTokensParams = {
49
+ push: PushPermission;
50
+ };
51
+
52
+ /**
53
+ * resolve tokens for the action run.
54
+ *
55
+ * creates two separate tokens:
56
+ * - gitToken: contents permission based on `push` setting (assumed exfiltratable)
57
+ * - push: enabled → contents:write (can push)
58
+ * - push: disabled → contents:read (read-only)
59
+ * - mcpToken: full installation token - used for GitHub API calls in MCP tools (not exfiltratable)
60
+ *
61
+ * security-conscious users can pass their own token via GH_TOKEN env var or inputs.token.
62
+ */
63
+ export async function resolveTokens(params: ResolveTokensParams): Promise<TokenRef> {
64
+ assert(!mcpTokenValue, "tokens are already resolved");
65
+
66
+ const externalToken = process.env.GH_TOKEN;
67
+
68
+ // external token takes precedence - use for both git and MCP
69
+ if (externalToken) {
70
+ mcpTokenValue = externalToken;
71
+
72
+ if (isGitHubActions) {
73
+ core.setSecret(externalToken);
74
+ }
75
+
76
+ log.info("» using external GH_TOKEN for both git and MCP");
77
+
78
+ return {
79
+ gitToken: externalToken,
80
+ mcpToken: externalToken,
81
+ async [Symbol.asyncDispose]() {
82
+ mcpTokenValue = undefined;
83
+ // GH_TOKEN isn't acquired here, so it's not revoked here either
84
+ },
85
+ };
86
+ }
87
+
88
+ // create git token based on push permission (assumed exfiltratable)
89
+ // disabled = read-only, restricted/enabled = write (MCP tools enforce branch restrictions)
90
+ // workflows permission is write-only in the API, so only requested when pushing is allowed
91
+ const gitPermissions =
92
+ params.push === "disabled"
93
+ ? { contents: "read" as const }
94
+ : { contents: "write" as const, workflows: "write" as const };
95
+ const gitToken = await acquireNewToken({ permissions: gitPermissions });
96
+ if (isGitHubActions) {
97
+ core.setSecret(gitToken);
98
+ }
99
+ log.info(
100
+ `» acquired git token (${Object.entries(gitPermissions)
101
+ .map((e) => e.join(":"))
102
+ .join(", ")})`,
103
+ );
104
+
105
+ // MCP token scoped to only what MCP tools actually need.
106
+ // not exfiltratable (only accessible via MCP tools), but scoped as defense-in-depth
107
+ // so even a compromised tool context can't touch secrets, admin, etc.
108
+ const mcpPermissions = {
109
+ contents: "write",
110
+ pull_requests: "write",
111
+ issues: "write",
112
+ checks: "read",
113
+ actions: "read",
114
+ } as const;
115
+ const mcpToken = await acquireNewToken({ permissions: mcpPermissions });
116
+ if (isGitHubActions) {
117
+ core.setSecret(mcpToken);
118
+ }
119
+ log.info(
120
+ `» acquired scoped MCP token (${Object.entries(mcpPermissions)
121
+ .map((e) => e.join(":"))
122
+ .join(", ")})`,
123
+ );
124
+
125
+ mcpTokenValue = mcpToken;
126
+
127
+ let disposingRef: PromiseWithResolvers<void> | undefined;
128
+
129
+ const dispose = async () => {
130
+ if (disposingRef) {
131
+ // this can happen if the signal arrives when disposing tokens
132
+ // we make sure to wait for the current dispose to complete
133
+ return disposingRef.promise;
134
+ }
135
+ disposingRef = Promise.withResolvers();
136
+ try {
137
+ mcpTokenValue = undefined;
138
+ // revoke both tokens
139
+ await Promise.all([
140
+ revokeGitHubInstallationToken(gitToken),
141
+ revokeGitHubInstallationToken(mcpToken),
142
+ ]);
143
+ } finally {
144
+ removeSignalHandler();
145
+ disposingRef.resolve();
146
+ disposingRef = undefined;
147
+ }
148
+ };
149
+
150
+ const removeSignalHandler = onExitSignal(dispose);
151
+
152
+ return {
153
+ gitToken,
154
+ mcpToken,
155
+ [Symbol.asyncDispose]: dispose,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * get the MCP token from memory.
161
+ * this is the token used for GitHub API calls in MCP tools.
162
+ */
163
+ export function getGitHubInstallationToken(): string {
164
+ assert(mcpTokenValue, "tokens not set. call resolveTokens first.");
165
+ return mcpTokenValue;
166
+ }
167
+
168
+ export async function revokeGitHubInstallationToken(token: string): Promise<void> {
169
+ const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
170
+
171
+ try {
172
+ await fetch(`${apiUrl}/installation/token`, {
173
+ method: "DELETE",
174
+ headers: {
175
+ Accept: "application/vnd.github+json",
176
+ Authorization: `Bearer ${token}`,
177
+ "X-GitHub-Api-Version": "2022-11-28",
178
+ },
179
+ });
180
+ log.debug("» installation token revoked");
181
+ } catch (error) {
182
+ log.info(
183
+ `Failed to revoke installation token: ${error instanceof Error ? error.message : String(error)}`,
184
+ );
185
+ }
186
+ }
@@ -0,0 +1,10 @@
1
+ import semver from "semver";
2
+ import packageJson from "#package.json" with { type: "json" };
3
+
4
+ export function getDevDependencyVersion(name: keyof typeof packageJson.devDependencies): string {
5
+ const version = packageJson.devDependencies[name];
6
+ if (!semver.valid(version)) {
7
+ throw new Error(`dev dependency "${name}" must be a pinned version, got "${version}"`);
8
+ }
9
+ return version;
10
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { validateCompatibility } from "#app/utils/versioning";
3
+
4
+ describe("validateCompatibility", () => {
5
+ it("should throw if payload version is invalid", () => {
6
+ expect(() => validateCompatibility("invalid", "1.0.0")).toThrow(/not a valid semantic version/);
7
+ });
8
+
9
+ it.each([
10
+ ["1.0.0", "1.0.0"], // same
11
+ ["1.0.0-alpha.1", "1.0.0"], // action is newer than pre-release
12
+ ["0.1.0", "0.1.1"], // action is newer during active development
13
+ ["0.0.158", "0.0.158"], // bug #129
14
+ ["0.0.159", "0.0.158"], // bug #129
15
+ ["0.0.158", "0.0.159"], // bug #129
16
+ ["1.0.0", "1.0.1"], // action patched
17
+ ["1.0.0", "1.1.0"], // action has a new feature (backward compatible)
18
+ ["1.0.1", "1.0.0"], // payload is newer (patch)
19
+ ["1.1.0", "1.0.0"], // payload is newer (feature is backward compatible)
20
+ ])("should accept compatible payload %#", (payloadVersion, actionVersion) => {
21
+ expect(() => validateCompatibility(payloadVersion, actionVersion)).not.toThrow();
22
+ });
23
+
24
+ it.each([
25
+ ["0.1.0", "0.2.0"], // action had breaking changes during active development
26
+ ["0.2.0", "0.1.0"], // payload had breaking changes during active development
27
+ ["2.0.0", "1.0.0"], // payload is majorly newer
28
+ ["1.0.0", "2.0.0"], // action had breaking changes
29
+ ])("should reject incompatible payload %#", (payloadVersion, actionVersion) => {
30
+ expect(() => validateCompatibility(payloadVersion, actionVersion)).toThrow(
31
+ /is incompatible with action version/,
32
+ );
33
+ });
34
+ });
@@ -0,0 +1,44 @@
1
+ import semver from "semver";
2
+
3
+ type CompatibilityPolicy =
4
+ /**
5
+ * Strict policy: the action must support the same features as the payload version declares
6
+ * @example Payload version 1.2.3 => ^1.2.0 range of action versions supported
7
+ * @example Payload version 0.1.55 => ^0.1.55 range of action versions supported
8
+ */
9
+ | "same-features"
10
+ /**
11
+ * Loose policy: the action must have no breaking changes compared to the payload version
12
+ * @example Payload version 1.2.3 => ^1.0.0 range of action versions supported
13
+ * @example Payload version 0.1.55 => ^0.1.0 range of action versions supported
14
+ */
15
+ | "non-breaking";
16
+
17
+ const COMPATIBILITY_POLICY: CompatibilityPolicy = "non-breaking";
18
+
19
+ /**
20
+ * @throws Error if the action can't process payload
21
+ * The compatibility is determined according to the COMPATIBILITY_POLICY above.
22
+ * @param payloadVersion the version of the payload
23
+ * @param actionVersion the version of the action (recipient)
24
+ */
25
+ export function validateCompatibility(payloadVersion: string, actionVersion: string): void {
26
+ const payloadSemVer = semver.parse(payloadVersion);
27
+ if (!payloadSemVer)
28
+ throw new Error(`Payload version ${payloadVersion} is not a valid semantic version.`);
29
+ const major = payloadSemVer.major;
30
+ const minor = payloadSemVer.minor;
31
+ const patch = payloadSemVer.patch;
32
+
33
+ const compatibilityRange =
34
+ COMPATIBILITY_POLICY === "same-features"
35
+ ? `^${major}.${minor}.${major === 0 ? patch : 0}`
36
+ : `^${major}.${major === 0 ? minor : 0}.${major === 0 ? "x" : 0}`; // non-breaking
37
+
38
+ if (!semver.satisfies(actionVersion, compatibilityRange)) {
39
+ throw new Error(
40
+ `Payload version ${payloadVersion} is incompatible with action version ${actionVersion}. ` +
41
+ `Please update your workflow to use at least ${semver.minVersion(compatibilityRange)} version of the action.`,
42
+ );
43
+ }
44
+ }