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.
- package/LICENSE +661 -0
- package/README.md +145 -0
- package/dist/agents/claude.d.ts +73 -0
- package/dist/agents/claudePretoolGate.d.ts +99 -0
- package/dist/agents/gateServer.d.ts +7 -0
- package/dist/agents/index.d.ts +6 -0
- package/dist/agents/nativeFsDenies.d.ts +28 -0
- package/dist/agents/opencode.d.ts +231 -0
- package/dist/agents/opencodePlugin.d.ts +85 -0
- package/dist/agents/opencodeShared.d.ts +40 -0
- package/dist/agents/postRun.d.ts +132 -0
- package/dist/agents/reviewer.d.ts +38 -0
- package/dist/agents/sessionLabeler.d.ts +97 -0
- package/dist/agents/shared.d.ts +189 -0
- package/dist/agents/subagentModels.d.ts +19 -0
- package/dist/agents/subagentToolGates.d.ts +55 -0
- package/dist/cli.mjs +197426 -0
- package/dist/external.d.ts +227 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +196783 -0
- package/dist/internal/index.d.ts +18 -0
- package/dist/internal.js +1714 -0
- package/dist/lifecycle.d.ts +2 -0
- package/dist/main.d.ts +8 -0
- package/dist/mcp/arkConfig.d.ts +1 -0
- package/dist/mcp/checkSuite.d.ts +25 -0
- package/dist/mcp/checkout.d.ts +77 -0
- package/dist/mcp/comment.d.ts +119 -0
- package/dist/mcp/commitInfo.d.ts +9 -0
- package/dist/mcp/crosswalk.d.ts +105 -0
- package/dist/mcp/dependencies.d.ts +8 -0
- package/dist/mcp/geminiSanitizer.d.ts +28 -0
- package/dist/mcp/git.d.ts +46 -0
- package/dist/mcp/guardrails.d.ts +104 -0
- package/dist/mcp/issue.d.ts +18 -0
- package/dist/mcp/issueComments.d.ts +9 -0
- package/dist/mcp/issueEvents.d.ts +9 -0
- package/dist/mcp/issueInfo.d.ts +9 -0
- package/dist/mcp/labels.d.ts +12 -0
- package/dist/mcp/localContext.d.ts +19 -0
- package/dist/mcp/moduleExtraction.d.ts +71 -0
- package/dist/mcp/moduleTests.d.ts +104 -0
- package/dist/mcp/modules.d.ts +179 -0
- package/dist/mcp/output.d.ts +12 -0
- package/dist/mcp/pathSafety.d.ts +14 -0
- package/dist/mcp/policy.d.ts +48 -0
- package/dist/mcp/pr.d.ts +49 -0
- package/dist/mcp/prInfo.d.ts +9 -0
- package/dist/mcp/providerSchema.d.ts +50 -0
- package/dist/mcp/review.d.ts +199 -0
- package/dist/mcp/reviewComments.d.ts +178 -0
- package/dist/mcp/roots.d.ts +58 -0
- package/dist/mcp/scope.d.ts +15 -0
- package/dist/mcp/selectMode.d.ts +18 -0
- package/dist/mcp/server.d.ts +48 -0
- package/dist/mcp/shared.d.ts +47 -0
- package/dist/mcp/shell.d.ts +37 -0
- package/dist/mcp/staleFix.d.ts +51 -0
- package/dist/mcp/terraform/cost.d.ts +55 -0
- package/dist/mcp/terraform/currency.d.ts +94 -0
- package/dist/mcp/terraform/decisions.d.ts +178 -0
- package/dist/mcp/terraform/findings.d.ts +75 -0
- package/dist/mcp/terraform/plan.d.ts +157 -0
- package/dist/mcp/terraform/scanners.d.ts +131 -0
- package/dist/mcp/terraform/tools.d.ts +63 -0
- package/dist/mcp/terraform/types.d.ts +172 -0
- package/dist/mcp/terraform.d.ts +22 -0
- package/dist/mcp/terratest.d.ts +83 -0
- package/dist/mcp/upload.d.ts +6 -0
- package/dist/models.d.ts +171 -0
- package/dist/modes.d.ts +26 -0
- package/dist/prep/index.d.ts +7 -0
- package/dist/prep/installNodeDependencies.d.ts +2 -0
- package/dist/prep/installPythonDependencies.d.ts +2 -0
- package/dist/prep/types.d.ts +31 -0
- package/dist/reviewQuality.d.ts +64 -0
- package/dist/skills/terraform-best-practices/SKILL.md +369 -0
- package/dist/toolState.d.ts +135 -0
- package/dist/utils/activity.d.ts +40 -0
- package/dist/utils/agent.d.ts +20 -0
- package/dist/utils/agentHangReport.d.ts +38 -0
- package/dist/utils/apiFetch.d.ts +19 -0
- package/dist/utils/apiKeys.d.ts +41 -0
- package/dist/utils/apiUrl.d.ts +20 -0
- package/dist/utils/assets.d.ts +8 -0
- package/dist/utils/billingErrors.d.ts +85 -0
- package/dist/utils/body.d.ts +34 -0
- package/dist/utils/buildTerramendFooter.d.ts +25 -0
- package/dist/utils/byokFallback.d.ts +85 -0
- package/dist/utils/claudeSubscription.d.ts +30 -0
- package/dist/utils/cli.d.ts +10 -0
- package/dist/utils/codexHome.d.ts +29 -0
- package/dist/utils/codexOAuth.d.ts +60 -0
- package/dist/utils/diffCoverage.d.ts +63 -0
- package/dist/utils/errorReport.d.ts +17 -0
- package/dist/utils/exitHandler.d.ts +8 -0
- package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
- package/dist/utils/gitAuth.d.ts +84 -0
- package/dist/utils/gitAuthServer.d.ts +24 -0
- package/dist/utils/github.d.ts +78 -0
- package/dist/utils/globals.d.ts +3 -0
- package/dist/utils/install.d.ts +60 -0
- package/dist/utils/instructions.d.ts +48 -0
- package/dist/utils/leapingComment.d.ts +11 -0
- package/dist/utils/learnings.d.ts +62 -0
- package/dist/utils/learningsTruncate.d.ts +25 -0
- package/dist/utils/lifecycle.d.ts +57 -0
- package/dist/utils/log.d.ts +111 -0
- package/dist/utils/normalizeEnv.d.ts +30 -0
- package/dist/utils/openCodeModels.d.ts +11 -0
- package/dist/utils/overrides.d.ts +40 -0
- package/dist/utils/packageManager.d.ts +49 -0
- package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
- package/dist/utils/payload.d.ts +105 -0
- package/dist/utils/prSummary.d.ts +61 -0
- package/dist/utils/progressComment.d.ts +146 -0
- package/dist/utils/providerErrors.d.ts +31 -0
- package/dist/utils/rangeDiff.d.ts +51 -0
- package/dist/utils/remediationCommand.d.ts +55 -0
- package/dist/utils/retry.d.ts +13 -0
- package/dist/utils/reviewCleanup.d.ts +14 -0
- package/dist/utils/run.d.ts +9 -0
- package/dist/utils/runContext.d.ts +60 -0
- package/dist/utils/runContextData.d.ts +23 -0
- package/dist/utils/runErrorRenderer.d.ts +64 -0
- package/dist/utils/runLifecycle.d.ts +86 -0
- package/dist/utils/runStartupLog.d.ts +15 -0
- package/dist/utils/secrets.d.ts +22 -0
- package/dist/utils/setup.d.ts +90 -0
- package/dist/utils/shell.d.ts +32 -0
- package/dist/utils/skills.d.ts +10 -0
- package/dist/utils/subprocess.d.ts +80 -0
- package/dist/utils/terraformMcp.d.ts +42 -0
- package/dist/utils/time.d.ts +15 -0
- package/dist/utils/timer.d.ts +23 -0
- package/dist/utils/todoTracking.d.ts +16 -0
- package/dist/utils/token.d.ts +39 -0
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/versioning.d.ts +7 -0
- package/dist/utils/vertex.d.ts +16 -0
- package/dist/utils/workflow.d.ts +13 -0
- package/package.json +119 -0
- package/src/agents/claude.test.ts +1016 -0
- package/src/agents/claude.ts +1246 -0
- package/src/agents/claudePretoolGate.test.ts +28 -0
- package/src/agents/claudePretoolGate.ts +173 -0
- package/src/agents/gateServer.test.ts +204 -0
- package/src/agents/gateServer.ts +124 -0
- package/src/agents/index.ts +10 -0
- package/src/agents/nativeFsDenies.ts +82 -0
- package/src/agents/opencode.test.ts +1440 -0
- package/src/agents/opencode.ts +1312 -0
- package/src/agents/opencodePlugin.ts +222 -0
- package/src/agents/opencodeShared.test.ts +34 -0
- package/src/agents/opencodeShared.ts +121 -0
- package/src/agents/postRun.test.ts +549 -0
- package/src/agents/postRun.ts +535 -0
- package/src/agents/reviewer.ts +104 -0
- package/src/agents/sessionLabeler.test.ts +247 -0
- package/src/agents/sessionLabeler.ts +178 -0
- package/src/agents/shared.test.ts +76 -0
- package/src/agents/shared.ts +292 -0
- package/src/agents/subagentModels.test.ts +113 -0
- package/src/agents/subagentModels.ts +40 -0
- package/src/agents/subagentRegistration.test.ts +41 -0
- package/src/agents/subagentToolGates.ts +114 -0
- package/src/cli.test.ts +129 -0
- package/src/cli.ts +105 -0
- package/src/commands/gha.test.ts +192 -0
- package/src/commands/gha.ts +188 -0
- package/src/commands/mcp.ts +122 -0
- package/src/config.ts +1 -0
- package/src/entry.ts +7 -0
- package/src/entryPost.stdlibOnly.test.ts +109 -0
- package/src/entryPost.ts +99 -0
- package/src/external.test.ts +16 -0
- package/src/external.ts +302 -0
- package/src/index.ts +11 -0
- package/src/internal/index.ts +71 -0
- package/src/lifecycle.ts +2 -0
- package/src/main.test.ts +873 -0
- package/src/main.ts +712 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
- package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
- package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
- package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
- package/src/mcp/arkConfig.ts +7 -0
- package/src/mcp/checkSuite.test.ts +245 -0
- package/src/mcp/checkSuite.ts +255 -0
- package/src/mcp/checkout.test.ts +752 -0
- package/src/mcp/checkout.ts +886 -0
- package/src/mcp/comment.test.ts +772 -0
- package/src/mcp/comment.ts +582 -0
- package/src/mcp/commitInfo.test.ts +127 -0
- package/src/mcp/commitInfo.ts +61 -0
- package/src/mcp/crosswalk.test.ts +106 -0
- package/src/mcp/crosswalk.ts +339 -0
- package/src/mcp/dependencies.test.ts +309 -0
- package/src/mcp/dependencies.ts +189 -0
- package/src/mcp/geminiSanitizer.test.ts +287 -0
- package/src/mcp/geminiSanitizer.ts +207 -0
- package/src/mcp/git.test.ts +1083 -0
- package/src/mcp/git.ts +890 -0
- package/src/mcp/guardrails.test.ts +705 -0
- package/src/mcp/guardrails.ts +465 -0
- package/src/mcp/issue.test.ts +113 -0
- package/src/mcp/issue.ts +73 -0
- package/src/mcp/issueComments.test.ts +69 -0
- package/src/mcp/issueComments.ts +48 -0
- package/src/mcp/issueEvents.test.ts +134 -0
- package/src/mcp/issueEvents.ts +100 -0
- package/src/mcp/issueInfo.test.ts +104 -0
- package/src/mcp/issueInfo.ts +72 -0
- package/src/mcp/labels.test.ts +52 -0
- package/src/mcp/labels.ts +34 -0
- package/src/mcp/localContext.ts +28 -0
- package/src/mcp/localServer.test.ts +75 -0
- package/src/mcp/localServer.ts +131 -0
- package/src/mcp/moduleExtraction.test.ts +261 -0
- package/src/mcp/moduleExtraction.ts +313 -0
- package/src/mcp/moduleTests.test.ts +269 -0
- package/src/mcp/moduleTests.ts +421 -0
- package/src/mcp/modules.test.ts +640 -0
- package/src/mcp/modules.ts +696 -0
- package/src/mcp/output.test.ts +96 -0
- package/src/mcp/output.ts +70 -0
- package/src/mcp/pathSafety.test.ts +44 -0
- package/src/mcp/pathSafety.ts +28 -0
- package/src/mcp/policy.test.ts +282 -0
- package/src/mcp/policy.ts +199 -0
- package/src/mcp/pr.test.ts +387 -0
- package/src/mcp/pr.ts +194 -0
- package/src/mcp/prInfo.test.ts +96 -0
- package/src/mcp/prInfo.ts +91 -0
- package/src/mcp/providerSchema.test.ts +85 -0
- package/src/mcp/providerSchema.ts +175 -0
- package/src/mcp/review.test.ts +936 -0
- package/src/mcp/review.ts +923 -0
- package/src/mcp/reviewComments.test.ts +549 -0
- package/src/mcp/reviewComments.ts +896 -0
- package/src/mcp/roots.test.ts +175 -0
- package/src/mcp/roots.ts +217 -0
- package/src/mcp/scope.test.ts +59 -0
- package/src/mcp/scope.ts +65 -0
- package/src/mcp/security.test.ts +720 -0
- package/src/mcp/selectMode.test.ts +210 -0
- package/src/mcp/selectMode.ts +181 -0
- package/src/mcp/server.test.ts +292 -0
- package/src/mcp/server.ts +403 -0
- package/src/mcp/shared.ts +100 -0
- package/src/mcp/shell.test.ts +520 -0
- package/src/mcp/shell.ts +505 -0
- package/src/mcp/staleFix.test.ts +237 -0
- package/src/mcp/staleFix.ts +277 -0
- package/src/mcp/terraform/cost.ts +163 -0
- package/src/mcp/terraform/currency.test.ts +338 -0
- package/src/mcp/terraform/currency.ts +336 -0
- package/src/mcp/terraform/decisions.ts +527 -0
- package/src/mcp/terraform/findings.ts +333 -0
- package/src/mcp/terraform/plan.ts +348 -0
- package/src/mcp/terraform/scanners.ts +809 -0
- package/src/mcp/terraform/tools.test.ts +1071 -0
- package/src/mcp/terraform/tools.ts +908 -0
- package/src/mcp/terraform/types.ts +305 -0
- package/src/mcp/terraform.test.ts +1957 -0
- package/src/mcp/terraform.ts +23 -0
- package/src/mcp/terratest.test.ts +105 -0
- package/src/mcp/terratest.ts +196 -0
- package/src/mcp/toolFiltering.test.ts +85 -0
- package/src/mcp/upload.test.ts +180 -0
- package/src/mcp/upload.ts +112 -0
- package/src/models.test.ts +300 -0
- package/src/models.ts +708 -0
- package/src/modes.test.ts +107 -0
- package/src/modes.ts +880 -0
- package/src/prep/index.ts +43 -0
- package/src/prep/installNodeDependencies.test.ts +298 -0
- package/src/prep/installNodeDependencies.ts +196 -0
- package/src/prep/installPythonDependencies.test.ts +268 -0
- package/src/prep/installPythonDependencies.ts +199 -0
- package/src/prep/types.ts +38 -0
- package/src/reviewQuality.test.ts +63 -0
- package/src/reviewQuality.ts +134 -0
- package/src/runCli.test.ts +214 -0
- package/src/runCli.ts +282 -0
- package/src/skills/terraform-best-practices/SKILL.md +369 -0
- package/src/toolState.test.ts +45 -0
- package/src/toolState.ts +252 -0
- package/src/utils/activity.test.ts +188 -0
- package/src/utils/activity.ts +210 -0
- package/src/utils/agent.test.ts +251 -0
- package/src/utils/agent.ts +139 -0
- package/src/utils/agentHangReport.test.ts +203 -0
- package/src/utils/agentHangReport.ts +170 -0
- package/src/utils/apiFetch.test.ts +115 -0
- package/src/utils/apiFetch.ts +62 -0
- package/src/utils/apiKeys.test.ts +344 -0
- package/src/utils/apiKeys.ts +206 -0
- package/src/utils/apiUrl.test.ts +30 -0
- package/src/utils/apiUrl.ts +59 -0
- package/src/utils/assets.test.ts +153 -0
- package/src/utils/assets.ts +107 -0
- package/src/utils/billingErrors.test.ts +121 -0
- package/src/utils/billingErrors.ts +189 -0
- package/src/utils/body.test.ts +217 -0
- package/src/utils/body.ts +168 -0
- package/src/utils/buildTerramendFooter.test.ts +38 -0
- package/src/utils/buildTerramendFooter.ts +82 -0
- package/src/utils/byokFallback.test.ts +205 -0
- package/src/utils/byokFallback.ts +128 -0
- package/src/utils/claudeSubscription.test.ts +179 -0
- package/src/utils/claudeSubscription.ts +93 -0
- package/src/utils/cli.ts +31 -0
- package/src/utils/codexHome.test.ts +190 -0
- package/src/utils/codexHome.ts +191 -0
- package/src/utils/codexOAuth.ts +147 -0
- package/src/utils/codexRefreshDetect.test.ts +85 -0
- package/src/utils/codexRefreshDetect.ts +35 -0
- package/src/utils/diffCoverage.test.ts +468 -0
- package/src/utils/diffCoverage.ts +404 -0
- package/src/utils/errorReport.test.ts +135 -0
- package/src/utils/errorReport.ts +83 -0
- package/src/utils/exitHandler.ts +35 -0
- package/src/utils/fixDoubleEscapedString.ts +9 -0
- package/src/utils/ghaCore.ts +13 -0
- package/src/utils/gitAuth.test.ts +322 -0
- package/src/utils/gitAuth.ts +263 -0
- package/src/utils/gitAuthServer.test.ts +260 -0
- package/src/utils/gitAuthServer.ts +182 -0
- package/src/utils/github.test.ts +615 -0
- package/src/utils/github.ts +538 -0
- package/src/utils/globals.ts +9 -0
- package/src/utils/humanEditCapture.test.ts +100 -0
- package/src/utils/humanEditCapture.ts +193 -0
- package/src/utils/install.test.ts +768 -0
- package/src/utils/install.ts +492 -0
- package/src/utils/instructions.test.ts +240 -0
- package/src/utils/instructions.ts +543 -0
- package/src/utils/leapingComment.test.ts +51 -0
- package/src/utils/leapingComment.ts +18 -0
- package/src/utils/learnings.test.ts +87 -0
- package/src/utils/learnings.ts +138 -0
- package/src/utils/learningsTocRender.test.ts +116 -0
- package/src/utils/learningsTruncate.test.ts +39 -0
- package/src/utils/learningsTruncate.ts +42 -0
- package/src/utils/lifecycle.test.ts +195 -0
- package/src/utils/lifecycle.ts +198 -0
- package/src/utils/log.test.ts +402 -0
- package/src/utils/log.ts +432 -0
- package/src/utils/normalizeEnv.test.ts +91 -0
- package/src/utils/normalizeEnv.ts +106 -0
- package/src/utils/openCodeModels.ts +82 -0
- package/src/utils/overrides.test.ts +89 -0
- package/src/utils/overrides.ts +98 -0
- package/src/utils/packageManager.test.ts +321 -0
- package/src/utils/packageManager.ts +257 -0
- package/src/utils/patchWorkflowRunFields.test.ts +92 -0
- package/src/utils/patchWorkflowRunFields.ts +150 -0
- package/src/utils/payload.test.ts +497 -0
- package/src/utils/payload.ts +371 -0
- package/src/utils/postApiFetch.ts +51 -0
- package/src/utils/prSummary.test.ts +224 -0
- package/src/utils/prSummary.ts +147 -0
- package/src/utils/progressComment.ts +261 -0
- package/src/utils/providerErrors.test.ts +315 -0
- package/src/utils/providerErrors.ts +172 -0
- package/src/utils/rangeDiff.test.ts +236 -0
- package/src/utils/rangeDiff.ts +182 -0
- package/src/utils/remediationCommand.test.ts +163 -0
- package/src/utils/remediationCommand.ts +119 -0
- package/src/utils/retry.test.ts +153 -0
- package/src/utils/retry.ts +58 -0
- package/src/utils/reviewCleanup.ts +106 -0
- package/src/utils/run.ts +99 -0
- package/src/utils/runContext.ts +145 -0
- package/src/utils/runContextData.ts +58 -0
- package/src/utils/runErrorRenderer.test.ts +95 -0
- package/src/utils/runErrorRenderer.ts +259 -0
- package/src/utils/runFixture.ts +76 -0
- package/src/utils/runLifecycle.ts +237 -0
- package/src/utils/runStartupLog.ts +60 -0
- package/src/utils/secrets.test.ts +103 -0
- package/src/utils/secrets.ts +177 -0
- package/src/utils/setup.test.ts +509 -0
- package/src/utils/setup.ts +352 -0
- package/src/utils/shell.ts +103 -0
- package/src/utils/skills.test.ts +46 -0
- package/src/utils/skills.ts +67 -0
- package/src/utils/subprocess.test.ts +170 -0
- package/src/utils/subprocess.ts +438 -0
- package/src/utils/terraformMcp.test.ts +63 -0
- package/src/utils/terraformMcp.ts +83 -0
- package/src/utils/time.test.ts +105 -0
- package/src/utils/time.ts +59 -0
- package/src/utils/timer.test.ts +91 -0
- package/src/utils/timer.ts +72 -0
- package/src/utils/todoTracking.test.ts +223 -0
- package/src/utils/todoTracking.ts +167 -0
- package/src/utils/token.test.ts +239 -0
- package/src/utils/token.ts +186 -0
- package/src/utils/version.ts +10 -0
- package/src/utils/versioning.test.ts +34 -0
- package/src/utils/versioning.ts +44 -0
- package/src/utils/vertex.ts +85 -0
- package/src/utils/workflow.ts +25 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { downloadAssetsInMarkdown } from "#app/utils/assets";
|
|
6
|
+
|
|
7
|
+
const TOKEN = "ghs_assets_test_token";
|
|
8
|
+
|
|
9
|
+
type FetchResponseSpec = {
|
|
10
|
+
ok?: boolean;
|
|
11
|
+
status?: number;
|
|
12
|
+
contentType?: string | null;
|
|
13
|
+
body?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function makeResponse(spec: FetchResponseSpec = {}): Response {
|
|
17
|
+
const body = spec.body ?? "binary-bytes";
|
|
18
|
+
const headers = new Headers();
|
|
19
|
+
if (spec.contentType) headers.set("content-type", spec.contentType);
|
|
20
|
+
return {
|
|
21
|
+
ok: spec.ok ?? true,
|
|
22
|
+
status: spec.status ?? 200,
|
|
23
|
+
headers,
|
|
24
|
+
arrayBuffer: async () => new TextEncoder().encode(body).buffer,
|
|
25
|
+
} as unknown as Response;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let dir: string;
|
|
29
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
dir = mkdtempSync(path.join(tmpdir(), "terramend-assets-test-"));
|
|
33
|
+
fetchMock = vi.fn(async () => makeResponse());
|
|
34
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
vi.unstubAllGlobals();
|
|
39
|
+
rmSync(dir, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("downloadAssetsInMarkdown", () => {
|
|
43
|
+
it("returns the markdown unchanged and skips fetch when no asset urls are present", async () => {
|
|
44
|
+
const markdown = "plain text  not a github asset";
|
|
45
|
+
const result = await downloadAssetsInMarkdown(markdown, dir, TOKEN);
|
|
46
|
+
expect(result).toBe(markdown);
|
|
47
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("downloads a github.com asset with the installation token and rewrites the url", async () => {
|
|
51
|
+
const url = "https://github.com/user-attachments/assets/abc-123";
|
|
52
|
+
fetchMock.mockResolvedValueOnce(makeResponse({ contentType: "image/png", body: "png-data" }));
|
|
53
|
+
|
|
54
|
+
const result = await downloadAssetsInMarkdown(`before  after`, dir, TOKEN);
|
|
55
|
+
|
|
56
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
57
|
+
const [calledUrl, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
58
|
+
expect(calledUrl).toBe(url);
|
|
59
|
+
expect(init.headers).toEqual({ Authorization: `Bearer ${TOKEN}` });
|
|
60
|
+
|
|
61
|
+
expect(result).not.toContain(url);
|
|
62
|
+
const localPathMatch = /!\[shot\]\((.+)\)/.exec(result);
|
|
63
|
+
expect(localPathMatch).not.toBeNull();
|
|
64
|
+
const localPath = localPathMatch?.[1] ?? "";
|
|
65
|
+
expect(localPath.startsWith(path.join(dir, "assets"))).toBe(true);
|
|
66
|
+
expect(localPath.endsWith(".png")).toBe(true);
|
|
67
|
+
expect(readFileSync(localPath, "utf-8")).toBe("png-data");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("fetches signed CDN urls WITHOUT an Authorization header", async () => {
|
|
71
|
+
const url = "https://private-user-images.githubusercontent.com/1/2.png?jwt=sig";
|
|
72
|
+
await downloadAssetsInMarkdown(``, dir, TOKEN);
|
|
73
|
+
|
|
74
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
75
|
+
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
76
|
+
expect(init.headers).toEqual({});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("extracts urls from html <img> tags and owner/repo asset paths", async () => {
|
|
80
|
+
const url = "https://github.com/octo/repo/assets/99/deadbeef.gif";
|
|
81
|
+
const markdown = `<p><img alt="x" src="${url}"></p>`;
|
|
82
|
+
const result = await downloadAssetsInMarkdown(markdown, dir, TOKEN);
|
|
83
|
+
|
|
84
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
85
|
+
expect(result).not.toContain(url);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("downloads each unique url once and rewrites every occurrence", async () => {
|
|
89
|
+
const url = "https://github.com/user-attachments/assets/dup-1.png";
|
|
90
|
+
const markdown = `\n\n<img src="${url}">`;
|
|
91
|
+
const result = await downloadAssetsInMarkdown(markdown, dir, TOKEN);
|
|
92
|
+
|
|
93
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
94
|
+
expect(result).not.toContain(url);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("leaves the url untouched when the download responds non-ok", async () => {
|
|
98
|
+
const url = "https://github.com/user-attachments/assets/missing.png";
|
|
99
|
+
fetchMock.mockResolvedValueOnce(makeResponse({ ok: false, status: 404 }));
|
|
100
|
+
|
|
101
|
+
const result = await downloadAssetsInMarkdown(``, dir, TOKEN);
|
|
102
|
+
expect(result).toContain(url);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("leaves the url untouched when fetch throws", async () => {
|
|
106
|
+
const url = "https://github.com/user-attachments/assets/error.png";
|
|
107
|
+
fetchMock.mockRejectedValueOnce(new Error("network down"));
|
|
108
|
+
|
|
109
|
+
const result = await downloadAssetsInMarkdown(``, dir, TOKEN);
|
|
110
|
+
expect(result).toContain(url);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("extension resolution", () => {
|
|
115
|
+
async function localPathFor(url: string, contentType: string | null): Promise<string> {
|
|
116
|
+
fetchMock.mockResolvedValueOnce(makeResponse({ contentType }));
|
|
117
|
+
const result = await downloadAssetsInMarkdown(``, dir, TOKEN);
|
|
118
|
+
const match = /!\[x\]\((.+)\)/.exec(result);
|
|
119
|
+
expect(match).not.toBeNull();
|
|
120
|
+
return match?.[1] ?? "";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const base = "https://github.com/user-attachments/assets";
|
|
124
|
+
|
|
125
|
+
it("keeps a whitelisted extension from the url path", async () => {
|
|
126
|
+
expect(await localPathFor(`${base}/clip.webm`, null)).toMatch(/\.webm$/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("normalizes .jpeg in the url path to .jpg", async () => {
|
|
130
|
+
expect(await localPathFor(`${base}/photo.jpeg`, null)).toMatch(/\.jpg$/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it.each([
|
|
134
|
+
["image/jpeg", ".jpg"],
|
|
135
|
+
["image/gif", ".gif"],
|
|
136
|
+
["image/webp", ".webp"],
|
|
137
|
+
["image/svg+xml", ".svg"],
|
|
138
|
+
["video/mp4", ".mp4"],
|
|
139
|
+
["video/quicktime", ".mov"],
|
|
140
|
+
["video/webm", ".webm"],
|
|
141
|
+
])("derives %s from the response content-type as %s", async (contentType, ext) => {
|
|
142
|
+
const localPath = await localPathFor(`${base}/raw-${ext.slice(1)}`, contentType);
|
|
143
|
+
expect(localPath.endsWith(ext)).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("defaults to .png when neither path nor content-type resolve", async () => {
|
|
147
|
+
expect(await localPathFor(`${base}/opaque-blob`, "application/octet-stream")).toMatch(/\.png$/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("defaults to .png when the response has no content-type header at all", async () => {
|
|
151
|
+
expect(await localPathFor(`${base}/headerless-blob`, null)).toMatch(/\.png$/);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { log } from "#app/utils/cli";
|
|
5
|
+
|
|
6
|
+
// github image hosts that appear in issue/PR/review/comment markdown.
|
|
7
|
+
// - github.com/user-attachments/assets/... and github.com/<owner>/<repo>/assets/...
|
|
8
|
+
// are the raw urls present in *unrendered* bodies (the MCP tools return these);
|
|
9
|
+
// fetching them needs the installation token.
|
|
10
|
+
// - {private-user-images,user-images,camo}.githubusercontent.com/... are the signed
|
|
11
|
+
// urls produced by body_html→turndown (see resolveBody); they self-authenticate via
|
|
12
|
+
// a jwt/signature in the query string and must be fetched WITHOUT an Authorization
|
|
13
|
+
// header (sending the token to the CDN would leak it).
|
|
14
|
+
const ASSET_HOST = String.raw`(?:github\.com\/(?:user-attachments\/assets|[^/\s]+\/[^/\s]+\/assets)\/|(?:private-user-images|user-images|camo)\.githubusercontent\.com\/)`;
|
|
15
|
+
const MARKDOWN_IMAGE = new RegExp(String.raw`!\[[^\]]*\]\((https:\/\/${ASSET_HOST}[^\s"')]+)`, "g");
|
|
16
|
+
const HTML_IMAGE = new RegExp(String.raw`<img[^>]+src=["'](https:\/\/${ASSET_HOST}[^"'\s]+)`, "g");
|
|
17
|
+
|
|
18
|
+
const ALLOWED_EXTENSIONS = new Set([
|
|
19
|
+
".png",
|
|
20
|
+
".jpg",
|
|
21
|
+
".gif",
|
|
22
|
+
".webp",
|
|
23
|
+
".svg",
|
|
24
|
+
".mp4",
|
|
25
|
+
".mov",
|
|
26
|
+
".webm",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* downloads any github-hosted image/video assets referenced in `markdown` to
|
|
31
|
+
* `<tmpdir>/assets` and rewrites the urls to the local file paths, so the agent can
|
|
32
|
+
* read screenshots directly instead of relying on remote (often short-lived, signed)
|
|
33
|
+
* urls. unique urls are downloaded once and every occurrence is rewritten. assets that
|
|
34
|
+
* fail to download are left untouched.
|
|
35
|
+
*/
|
|
36
|
+
export async function downloadAssetsInMarkdown(
|
|
37
|
+
markdown: string,
|
|
38
|
+
tmpdir: string,
|
|
39
|
+
githubToken: string,
|
|
40
|
+
): Promise<string> {
|
|
41
|
+
const urls = new Set<string>();
|
|
42
|
+
for (const match of markdown.matchAll(MARKDOWN_IMAGE)) urls.add(match[1]!);
|
|
43
|
+
for (const match of markdown.matchAll(HTML_IMAGE)) urls.add(match[1]!);
|
|
44
|
+
|
|
45
|
+
if (urls.size === 0) return markdown;
|
|
46
|
+
|
|
47
|
+
const assetsDir = path.join(tmpdir, "assets");
|
|
48
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
49
|
+
|
|
50
|
+
log.debug(`[assets] found ${urls.size} asset(s) to download`);
|
|
51
|
+
|
|
52
|
+
let result = markdown;
|
|
53
|
+
for (const url of urls) {
|
|
54
|
+
const localPath = await downloadAsset(url, assetsDir, githubToken);
|
|
55
|
+
if (localPath) result = result.replaceAll(url, localPath);
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function downloadAsset(
|
|
61
|
+
url: string,
|
|
62
|
+
assetsDir: string,
|
|
63
|
+
githubToken: string,
|
|
64
|
+
): Promise<string | null> {
|
|
65
|
+
// only github.com itself needs the installation token; the githubusercontent CDN
|
|
66
|
+
// urls carry their own signature. `redirect: "follow"` (undici default) strips the
|
|
67
|
+
// Authorization header on cross-origin hops, so the token never reaches S3/the CDN.
|
|
68
|
+
const needsAuth = new URL(url).hostname === "github.com";
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch(url, {
|
|
72
|
+
headers: needsAuth ? { Authorization: `Bearer ${githubToken}` } : {},
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
log.warning(`[assets] failed to download ${url}: ${res.status}`);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
80
|
+
const ext = resolveExtension(url, res.headers.get("content-type"));
|
|
81
|
+
const filename = `${crypto.createHash("sha256").update(url).digest("hex").slice(0, 16)}${ext}`;
|
|
82
|
+
const localPath = path.join(assetsDir, filename);
|
|
83
|
+
fs.writeFileSync(localPath, buffer);
|
|
84
|
+
log.debug(`[assets] downloaded ${url} to ${localPath}`);
|
|
85
|
+
return localPath;
|
|
86
|
+
} catch (e) {
|
|
87
|
+
log.warning(`[assets] error downloading ${url}: ${e}`);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** picks a safe, whitelisted file extension from the url path or response content-type. */
|
|
93
|
+
function resolveExtension(url: string, contentType: string | null): string {
|
|
94
|
+
const fromPath = path.extname(new URL(url).pathname).toLowerCase();
|
|
95
|
+
if (ALLOWED_EXTENSIONS.has(fromPath)) return fromPath;
|
|
96
|
+
if (fromPath === ".jpeg") return ".jpg";
|
|
97
|
+
|
|
98
|
+
const ct = contentType?.toLowerCase() ?? "";
|
|
99
|
+
if (ct.includes("jpeg") || ct.includes("jpg")) return ".jpg";
|
|
100
|
+
if (ct.includes("gif")) return ".gif";
|
|
101
|
+
if (ct.includes("webp")) return ".webp";
|
|
102
|
+
if (ct.includes("svg")) return ".svg";
|
|
103
|
+
if (ct.includes("mp4")) return ".mp4";
|
|
104
|
+
if (ct.includes("quicktime") || ct.includes("mov")) return ".mov";
|
|
105
|
+
if (ct.includes("webm")) return ".webm";
|
|
106
|
+
return ".png";
|
|
107
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
BillingError,
|
|
4
|
+
formatBillingErrorSummary,
|
|
5
|
+
formatTransientErrorSummary,
|
|
6
|
+
TransientError,
|
|
7
|
+
} from "#app/utils/billingErrors";
|
|
8
|
+
|
|
9
|
+
describe("BillingError", () => {
|
|
10
|
+
it("defaults the classification fields", () => {
|
|
11
|
+
const error = new BillingError("payment failed");
|
|
12
|
+
expect(error.name).toBe("BillingError");
|
|
13
|
+
expect(error.message).toBe("payment failed");
|
|
14
|
+
expect(error.code).toBeNull();
|
|
15
|
+
expect(error.declineCode).toBeNull();
|
|
16
|
+
expect(error.needsReauthentication).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("keeps the provided classification fields", () => {
|
|
20
|
+
const error = new BillingError("declined", {
|
|
21
|
+
code: "router_requires_card",
|
|
22
|
+
declineCode: "insufficient_funds",
|
|
23
|
+
needsReauthentication: true,
|
|
24
|
+
});
|
|
25
|
+
expect(error.code).toBe("router_requires_card");
|
|
26
|
+
expect(error.declineCode).toBe("insufficient_funds");
|
|
27
|
+
expect(error.needsReauthentication).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("formatBillingErrorSummary", () => {
|
|
32
|
+
it("renders the add-a-card CTA for router_requires_card", () => {
|
|
33
|
+
const msg = formatBillingErrorSummary(
|
|
34
|
+
new BillingError("x", { code: "router_requires_card" }),
|
|
35
|
+
"acme",
|
|
36
|
+
);
|
|
37
|
+
expect(msg).toContain("**Add a card to start using Terramend Router.**");
|
|
38
|
+
expect(msg).toContain("https://terramend.com/console/acme#model-access");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("renders the exhausted-balance CTA for router_balance_exhausted", () => {
|
|
42
|
+
const msg = formatBillingErrorSummary(
|
|
43
|
+
new BillingError("x", { code: "router_balance_exhausted" }),
|
|
44
|
+
"acme",
|
|
45
|
+
);
|
|
46
|
+
expect(msg).toContain("balance is exhausted");
|
|
47
|
+
expect(msg).toContain("https://terramend.com/console/acme#billing");
|
|
48
|
+
expect(msg).toContain("https://terramend.com/console/acme#model-access");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("renders the cut-short framing for router_keylimit_exhausted", () => {
|
|
52
|
+
const msg = formatBillingErrorSummary(
|
|
53
|
+
new BillingError("x", { code: "router_keylimit_exhausted" }),
|
|
54
|
+
"acme",
|
|
55
|
+
);
|
|
56
|
+
expect(msg).toContain("cut short");
|
|
57
|
+
expect(msg).toContain("#billing");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("renders the monthly-cap framing for router_monthly_limit", () => {
|
|
61
|
+
const msg = formatBillingErrorSummary(
|
|
62
|
+
new BillingError("x", { code: "router_monthly_limit" }),
|
|
63
|
+
"acme",
|
|
64
|
+
);
|
|
65
|
+
expect(msg).toContain("monthly spend limit");
|
|
66
|
+
expect(msg).toContain("#model-access");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("renders the 3DS branch with the specific decline code when present", () => {
|
|
70
|
+
const msg = formatBillingErrorSummary(
|
|
71
|
+
new BillingError("x", { needsReauthentication: true, declineCode: "authentication_needed" }),
|
|
72
|
+
"acme",
|
|
73
|
+
);
|
|
74
|
+
expect(msg).toContain("3D Secure");
|
|
75
|
+
expect(msg).toContain("`authentication_needed`");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("falls back to authentication_required when 3DS has no decline code", () => {
|
|
79
|
+
const msg = formatBillingErrorSummary(
|
|
80
|
+
new BillingError("x", { needsReauthentication: true }),
|
|
81
|
+
"acme",
|
|
82
|
+
);
|
|
83
|
+
expect(msg).toContain("`authentication_required`");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("renders the card-declined branch with the Stripe sub-code", () => {
|
|
87
|
+
const msg = formatBillingErrorSummary(
|
|
88
|
+
new BillingError("x", { declineCode: "lost_card" }),
|
|
89
|
+
"acme",
|
|
90
|
+
);
|
|
91
|
+
expect(msg).toContain("**Your card was declined** (`lost_card`).");
|
|
92
|
+
expect(msg).toContain("#billing");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("renders the empty-balance default branch", () => {
|
|
96
|
+
const msg = formatBillingErrorSummary(new BillingError("x"), "acme");
|
|
97
|
+
expect(msg).toContain("**Your Terramend balance is empty.**");
|
|
98
|
+
expect(msg).toContain("https://terramend.com/console/acme#billing");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("URL-encodes the owner in the console deep link", () => {
|
|
102
|
+
const msg = formatBillingErrorSummary(new BillingError("x"), "weird org");
|
|
103
|
+
expect(msg).toContain("https://terramend.com/console/weird%20org#billing");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("TransientError / formatTransientErrorSummary", () => {
|
|
108
|
+
it("names the error class", () => {
|
|
109
|
+
const error = new TransientError("sync in flight");
|
|
110
|
+
expect(error.name).toBe("TransientError");
|
|
111
|
+
expect(error.message).toBe("sync in flight");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("frames the failure as temporary and includes the message + console link", () => {
|
|
115
|
+
const msg = formatTransientErrorSummary(new TransientError("usage sync incomplete"), "acme");
|
|
116
|
+
expect(msg).toContain("temporarily unavailable");
|
|
117
|
+
expect(msg).toContain("usage sync incomplete");
|
|
118
|
+
expect(msg).toContain("status.terramend.com");
|
|
119
|
+
expect(msg).toContain("https://terramend.com/console/acme#billing");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing-error classification + user-facing copy for `/api/proxy-token`
|
|
3
|
+
* failures and OpenRouter mid-run exhaustion. Two error classes (Billing vs.
|
|
4
|
+
* Transient) keep the framing honest: a card decline is *not* the same UX as
|
|
5
|
+
* a 503 from the proxy service. Both originate in `utils/proxy.ts` (mint
|
|
6
|
+
* failures) and `utils/runErrorRenderer.ts` (mid-run keylimit reclassify).
|
|
7
|
+
*
|
|
8
|
+
* Renderers return markdown bodies that are written into both the GitHub
|
|
9
|
+
* Actions job summary and the PR progress comment.
|
|
10
|
+
*
|
|
11
|
+
* Lives outside `main.ts` so adding a new error `code` branch is a one-file
|
|
12
|
+
* edit that does not retrigger the full LLM CI matrix (`action/main.ts` is
|
|
13
|
+
* in `action/test/coverage.ts::ALWAYS_RUN_ALL`).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Billing-layer error surfaced from `/api/proxy-token` as a 402. User-actionable
|
|
18
|
+
* — distinct from TransientError (503 / transient sync issue) so the job
|
|
19
|
+
* summary + PR comment can use affirmative "you need to do X" copy rather than
|
|
20
|
+
* the ambiguous "billing error" label that makes transient outages look like
|
|
21
|
+
* the user's fault.
|
|
22
|
+
*
|
|
23
|
+
* `code` is a server-side discriminator: `router_requires_card` (no card + no
|
|
24
|
+
* wallet balance on Router), or null for unclassified. `declineCode` is
|
|
25
|
+
* Stripe's more specific sub-reason on `card_declined` (e.g.
|
|
26
|
+
* `insufficient_funds`, `lost_card`). `needsReauthentication` is the 3DS case
|
|
27
|
+
* broken out for convenience.
|
|
28
|
+
*/
|
|
29
|
+
export class BillingError extends Error {
|
|
30
|
+
code: string | null;
|
|
31
|
+
declineCode: string | null;
|
|
32
|
+
needsReauthentication: boolean;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
message: string,
|
|
36
|
+
opts: {
|
|
37
|
+
code?: string | null;
|
|
38
|
+
declineCode?: string | null;
|
|
39
|
+
needsReauthentication?: boolean;
|
|
40
|
+
} = {},
|
|
41
|
+
) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = "BillingError";
|
|
44
|
+
this.code = opts.code ?? null;
|
|
45
|
+
this.declineCode = opts.declineCode ?? null;
|
|
46
|
+
this.needsReauthentication = opts.needsReauthentication ?? false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Transient service failures from `/api/proxy-token` (503: partial OpenRouter
|
|
52
|
+
* usage sync, DB flake, in-flight payment intent). Not the user's fault — the
|
|
53
|
+
* summary uses "temporarily unavailable" framing, and the non-zero exit lets
|
|
54
|
+
* GH Actions apply whatever retry policy the workflow has configured.
|
|
55
|
+
*/
|
|
56
|
+
export class TransientError extends Error {
|
|
57
|
+
constructor(message: string) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = "TransientError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Deep link into the right console section for the failing account. Anchors
|
|
65
|
+
* are defined in `app/console/[owner]/page.tsx` (`#billing`, `#model-access`).
|
|
66
|
+
* `owner` is the GitHub login of the repo's account — i.e. the org or user
|
|
67
|
+
* that pays for this repo's runs, which is the right scope for billing.
|
|
68
|
+
*/
|
|
69
|
+
function billingConsoleUrl(owner: string, anchor: "billing" | "model-access"): string {
|
|
70
|
+
return `https://terramend.com/console/${encodeURIComponent(owner)}#${anchor}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Render a BillingError as user-facing markdown (shared between GH job summary
|
|
75
|
+
* and the PR progress comment). Goals:
|
|
76
|
+
*
|
|
77
|
+
* - quiet, not alarmist — bold first line instead of an `### ❌` H3, since
|
|
78
|
+
* the comment already has Terramend branding in the footer
|
|
79
|
+
* - actionable — every branch ends in a single CTA deep-linked to the
|
|
80
|
+
* correct section of the owner's console
|
|
81
|
+
* - honest — say what actually went wrong (card declined vs. balance
|
|
82
|
+
* empty vs. 3DS required), don't lump them under "billing error"
|
|
83
|
+
*
|
|
84
|
+
* Branches:
|
|
85
|
+
* - `router_requires_card`: user is on Router mode with no card AND no
|
|
86
|
+
* wallet balance (signup credit exhausted or not granted). Frame as
|
|
87
|
+
* "add a card to continue", link to `#model-access` where the Add
|
|
88
|
+
* Card flow lives.
|
|
89
|
+
* - `router_balance_exhausted`: user has a card on file but auto-reload is
|
|
90
|
+
* disabled and they've spent past their $5 overdraft buffer. Frame as
|
|
91
|
+
* "balance ran out" and surface both remediation paths (top up, or flip
|
|
92
|
+
* on auto-reload).
|
|
93
|
+
* - `router_keylimit_exhausted`: OpenRouter rejected mid-run because the
|
|
94
|
+
* per-run key budget was exhausted while the agent was working. The
|
|
95
|
+
* wallet is now negative; same remediation as `router_balance_exhausted`
|
|
96
|
+
* but framed for the after-the-fact case ("this run was cut short").
|
|
97
|
+
* - `needsReauthentication`: issuer requires 3DS on every off-session
|
|
98
|
+
* charge. Re-adding the card won't help — the only escape is a manual
|
|
99
|
+
* top-up where 3DS runs interactively in Stripe Checkout.
|
|
100
|
+
* - `declineCode` set: Stripe declined a real charge. Show the sub-code
|
|
101
|
+
* so support can act on it; tell the user we'll retry on next dispatch.
|
|
102
|
+
* - default: balance hit zero with no in-flight charge (auto-reload off
|
|
103
|
+
* or amount below threshold). Direct them to top up or enable auto-reload.
|
|
104
|
+
*/
|
|
105
|
+
export function formatBillingErrorSummary(error: BillingError, owner: string): string {
|
|
106
|
+
if (error.code === "router_requires_card") {
|
|
107
|
+
return [
|
|
108
|
+
"**Add a card to start using Terramend Router.**",
|
|
109
|
+
"",
|
|
110
|
+
"Router proxies OpenRouter at raw cost — no platform markup. Add a card and we'll auto-reload your wallet so runs keep flowing.",
|
|
111
|
+
"",
|
|
112
|
+
`[Add a card →](${billingConsoleUrl(owner, "model-access")})`,
|
|
113
|
+
].join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (error.code === "router_balance_exhausted") {
|
|
117
|
+
return [
|
|
118
|
+
"**Your Terramend Router balance is exhausted.**",
|
|
119
|
+
"",
|
|
120
|
+
"You have a card on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.",
|
|
121
|
+
"",
|
|
122
|
+
`[Top up balance →](${billingConsoleUrl(owner, "billing")}) · [Enable auto-reload →](${billingConsoleUrl(owner, "model-access")})`,
|
|
123
|
+
].join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (error.code === "router_keylimit_exhausted") {
|
|
127
|
+
return [
|
|
128
|
+
"**This run was cut short — your Terramend Router balance ran out mid-run.**",
|
|
129
|
+
"",
|
|
130
|
+
"OpenRouter stopped the agent because the per-run budget was exhausted. Your wallet is now negative; top up or enable auto-reload to keep runs flowing.",
|
|
131
|
+
"",
|
|
132
|
+
`[Top up balance →](${billingConsoleUrl(owner, "billing")}) · [Enable auto-reload →](${billingConsoleUrl(owner, "model-access")})`,
|
|
133
|
+
].join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (error.code === "router_monthly_limit") {
|
|
137
|
+
return [
|
|
138
|
+
"**Terramend Router hit its monthly spend limit.**",
|
|
139
|
+
"",
|
|
140
|
+
"Auto-reloads are paused for the rest of this UTC month. Ask your admin to raise the cap, or wait for it to reset at 00:00 UTC on the 1st.",
|
|
141
|
+
"",
|
|
142
|
+
`[Adjust limit →](${billingConsoleUrl(owner, "model-access")})`,
|
|
143
|
+
].join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (error.needsReauthentication) {
|
|
147
|
+
const code = error.declineCode ?? "authentication_required";
|
|
148
|
+
return [
|
|
149
|
+
`**Your card issuer requires 3D Secure on every charge** (\`${code}\`).`,
|
|
150
|
+
"",
|
|
151
|
+
"Terramend can't complete a 3DS challenge from inside a workflow. Top up your Router balance once in Stripe Checkout — subsequent runs draw from the prepaid balance without re-triggering 3DS.",
|
|
152
|
+
"",
|
|
153
|
+
`[Top up balance →](${billingConsoleUrl(owner, "billing")})`,
|
|
154
|
+
].join("\n");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (error.declineCode) {
|
|
158
|
+
return [
|
|
159
|
+
`**Your card was declined** (\`${error.declineCode}\`).`,
|
|
160
|
+
"",
|
|
161
|
+
"Update your payment method and Terramend will retry on the next run.",
|
|
162
|
+
"",
|
|
163
|
+
`[Update payment method →](${billingConsoleUrl(owner, "billing")})`,
|
|
164
|
+
].join("\n");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return [
|
|
168
|
+
"**Your Terramend balance is empty.**",
|
|
169
|
+
"",
|
|
170
|
+
"Top up your balance or enable auto-reload to keep runs flowing.",
|
|
171
|
+
"",
|
|
172
|
+
`[Manage billing →](${billingConsoleUrl(owner, "billing")})`,
|
|
173
|
+
].join("\n");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Render a TransientError as user-facing markdown. Distinct framing from
|
|
178
|
+
* BillingError so the user doesn't read an alarm and assume their card
|
|
179
|
+
* failed — this branch is "our fault, retry shortly", not theirs.
|
|
180
|
+
*/
|
|
181
|
+
export function formatTransientErrorSummary(error: TransientError, owner: string): string {
|
|
182
|
+
return [
|
|
183
|
+
"**Terramend billing is temporarily unavailable.**",
|
|
184
|
+
"",
|
|
185
|
+
error.message,
|
|
186
|
+
"",
|
|
187
|
+
`Usually transient — the next dispatch should succeed. If it persists, check [status.terramend.com](https://status.terramend.com) or [your console](${billingConsoleUrl(owner, "billing")}).`,
|
|
188
|
+
].join("\n");
|
|
189
|
+
}
|