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
package/src/utils/log.ts
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logging utilities that work well in both local and GitHub Actions environments
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
6
|
+
import * as core from "@actions/core";
|
|
7
|
+
import { table } from "table";
|
|
8
|
+
import { type AgentUsage, formatCostUsd } from "#app/agents/shared";
|
|
9
|
+
import { isGitHubActions, isInsideDocker } from "#app/utils/globals";
|
|
10
|
+
|
|
11
|
+
// --- log prefix via AsyncLocalStorage ---
|
|
12
|
+
|
|
13
|
+
type LogContext = { prefix: string };
|
|
14
|
+
|
|
15
|
+
const logContext = new AsyncLocalStorage<LogContext>();
|
|
16
|
+
|
|
17
|
+
const MAGENTA = "\x1b[35m";
|
|
18
|
+
const RESET = "\x1b[0m";
|
|
19
|
+
|
|
20
|
+
/** run `fn` with every log line prefixed by `prefix` (e.g. "[task-label]") in magenta */
|
|
21
|
+
export function withLogPrefix<T>(prefix: string, fn: () => Promise<T>): Promise<T> {
|
|
22
|
+
return logContext.run({ prefix }, fn);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function prefixLines(message: string): string {
|
|
26
|
+
const ctx = logContext.getStore();
|
|
27
|
+
if (!ctx) return message;
|
|
28
|
+
const colored = `${MAGENTA}${ctx.prefix}${RESET} `;
|
|
29
|
+
return message
|
|
30
|
+
.split("\n")
|
|
31
|
+
.map((line) => `${colored}${line}`)
|
|
32
|
+
.join("\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** plain-text prefix (no ANSI) for GitHub Actions group names */
|
|
36
|
+
function prefixPlain(name: string): string {
|
|
37
|
+
const ctx = logContext.getStore();
|
|
38
|
+
if (!ctx) return name;
|
|
39
|
+
return `${ctx.prefix} ${name}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- log sink ----------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
type LogSink = "actions" | "stderr";
|
|
45
|
+
|
|
46
|
+
let sink: LogSink = "actions";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Route ALL log output to stderr instead of `@actions/core` (which writes to
|
|
50
|
+
* stdout). Required before starting a stdio MCP server: stdout is the JSON-RPC
|
|
51
|
+
* channel there, and a single stray diagnostic line corrupts the framing.
|
|
52
|
+
* The GitHub Action path never calls this — its sink stays `@actions/core`.
|
|
53
|
+
*/
|
|
54
|
+
export function setLogSink(next: LogSink): void {
|
|
55
|
+
sink = next;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** info-level line via the active sink. */
|
|
59
|
+
function emitInfo(line: string): void {
|
|
60
|
+
if (sink === "stderr") {
|
|
61
|
+
process.stderr.write(`${line}\n`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
core.info(line);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const isRunnerDebugEnabled = () => core.isDebug();
|
|
68
|
+
|
|
69
|
+
const isLocalDebugEnabled = () =>
|
|
70
|
+
process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true";
|
|
71
|
+
|
|
72
|
+
const isDebugEnabled = () => isLocalDebugEnabled() || isRunnerDebugEnabled();
|
|
73
|
+
|
|
74
|
+
/** timestamp prefix for debug mode — empty string when debug is off */
|
|
75
|
+
function ts(): string {
|
|
76
|
+
return isDebugEnabled() ? `[${new Date().toISOString()}] ` : "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Format arguments into a single string for logging
|
|
81
|
+
*/
|
|
82
|
+
function formatArgs(args: unknown[]): string {
|
|
83
|
+
return args
|
|
84
|
+
.map((arg) => {
|
|
85
|
+
if (typeof arg === "string") return arg;
|
|
86
|
+
if (arg instanceof Error) return `${arg.message}\n${arg.stack}`;
|
|
87
|
+
return JSON.stringify(arg);
|
|
88
|
+
})
|
|
89
|
+
.join(" ");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Start a collapsed group (GitHub Actions) or regular group (local)
|
|
94
|
+
*/
|
|
95
|
+
function startGroup(name: string): void {
|
|
96
|
+
const prefixed = prefixPlain(name);
|
|
97
|
+
if (sink === "stderr") {
|
|
98
|
+
emitInfo(`▼ ${prefixed}`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (isGitHubActions) {
|
|
102
|
+
core.startGroup(prefixed);
|
|
103
|
+
} else {
|
|
104
|
+
console.group(prefixed);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* End a collapsed group
|
|
110
|
+
*/
|
|
111
|
+
function endGroup(): void {
|
|
112
|
+
if (sink === "stderr") return;
|
|
113
|
+
if (isGitHubActions) {
|
|
114
|
+
core.endGroup();
|
|
115
|
+
} else {
|
|
116
|
+
console.groupEnd();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Run a callback within a collapsed group
|
|
122
|
+
*/
|
|
123
|
+
function group(name: string, fn: () => void): void {
|
|
124
|
+
startGroup(name);
|
|
125
|
+
fn();
|
|
126
|
+
endGroup();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Print a formatted box with text (for console output)
|
|
131
|
+
*/
|
|
132
|
+
function boxString(
|
|
133
|
+
text: string,
|
|
134
|
+
options?: {
|
|
135
|
+
title?: string;
|
|
136
|
+
maxWidth?: number;
|
|
137
|
+
indent?: string;
|
|
138
|
+
padding?: number;
|
|
139
|
+
},
|
|
140
|
+
): string {
|
|
141
|
+
const { title, maxWidth = 80, indent = "", padding = 1 } = options || {};
|
|
142
|
+
|
|
143
|
+
const lines = text.trim().split("\n");
|
|
144
|
+
const wrappedLines: string[] = [];
|
|
145
|
+
|
|
146
|
+
for (const line of lines) {
|
|
147
|
+
if (line.length <= maxWidth - padding * 2) {
|
|
148
|
+
wrappedLines.push(line);
|
|
149
|
+
} else {
|
|
150
|
+
const words = line.split(" ");
|
|
151
|
+
let currentLine = "";
|
|
152
|
+
|
|
153
|
+
for (const word of words) {
|
|
154
|
+
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
|
155
|
+
if (testLine.length <= maxWidth - padding * 2) {
|
|
156
|
+
currentLine = testLine;
|
|
157
|
+
} else {
|
|
158
|
+
if (currentLine) {
|
|
159
|
+
wrappedLines.push(currentLine);
|
|
160
|
+
currentLine = "";
|
|
161
|
+
}
|
|
162
|
+
// wrap long words by breaking them into chunks
|
|
163
|
+
const maxLineLength = maxWidth - padding * 2;
|
|
164
|
+
let remainingWord = word;
|
|
165
|
+
while (remainingWord.length > maxLineLength) {
|
|
166
|
+
wrappedLines.push(remainingWord.substring(0, maxLineLength));
|
|
167
|
+
remainingWord = remainingWord.substring(maxLineLength);
|
|
168
|
+
}
|
|
169
|
+
currentLine = remainingWord;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (currentLine) {
|
|
174
|
+
wrappedLines.push(currentLine);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const maxLineLength = Math.max(...wrappedLines.map((line) => line.length));
|
|
180
|
+
const contentBoxWidth = maxLineLength + padding * 2;
|
|
181
|
+
|
|
182
|
+
// ensure box width is at least as wide as the title line when title exists
|
|
183
|
+
const titleLineLength = title ? ` ${title} `.length : 0;
|
|
184
|
+
const boxWidth = Math.max(contentBoxWidth, titleLineLength);
|
|
185
|
+
|
|
186
|
+
let result = "";
|
|
187
|
+
|
|
188
|
+
if (title) {
|
|
189
|
+
const titleLine = ` ${title} `;
|
|
190
|
+
const titlePadding = Math.max(0, boxWidth - titleLine.length);
|
|
191
|
+
result += `${indent}┌${titleLine}${"─".repeat(titlePadding)}┐\n`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!title) {
|
|
195
|
+
result += `${indent}┌${"─".repeat(boxWidth)}┐\n`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const line of wrappedLines) {
|
|
199
|
+
const paddedLine = line.padEnd(maxLineLength);
|
|
200
|
+
result += `${indent}│${" ".repeat(padding)}${paddedLine}${" ".repeat(padding)}│\n`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
result += `${indent}└${"─".repeat(boxWidth)}┘`;
|
|
204
|
+
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Print a formatted box with text
|
|
210
|
+
*/
|
|
211
|
+
function box(
|
|
212
|
+
text: string,
|
|
213
|
+
options?: {
|
|
214
|
+
title?: string;
|
|
215
|
+
maxWidth?: number;
|
|
216
|
+
},
|
|
217
|
+
): void {
|
|
218
|
+
const boxContent = boxString(text, options);
|
|
219
|
+
emitInfo(prefixLines(boxContent));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Overwrite the job summary with the given text.
|
|
224
|
+
* Skips if:
|
|
225
|
+
* - Not in GitHub Actions
|
|
226
|
+
* - Running inside Docker (CI tests inherit host env vars but can't access host paths)
|
|
227
|
+
* - GITHUB_STEP_SUMMARY not set
|
|
228
|
+
*/
|
|
229
|
+
export async function writeSummary(text: string): Promise<void> {
|
|
230
|
+
if (!isGitHubActions) return;
|
|
231
|
+
|
|
232
|
+
// CI tests run in Docker with GITHUB_ACTIONS=true inherited from host,
|
|
233
|
+
// but the GITHUB_STEP_SUMMARY path points to a host filesystem location
|
|
234
|
+
// that doesn't exist inside the container
|
|
235
|
+
if (isInsideDocker) return;
|
|
236
|
+
|
|
237
|
+
if (!process.env.GITHUB_STEP_SUMMARY) return;
|
|
238
|
+
|
|
239
|
+
await core.summary.addRaw(text).write({ overwrite: true });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Print a formatted table using the table package
|
|
244
|
+
*/
|
|
245
|
+
function printTable(
|
|
246
|
+
rows: Array<Array<{ data: string; header?: boolean } | string>>,
|
|
247
|
+
options?: {
|
|
248
|
+
title?: string;
|
|
249
|
+
},
|
|
250
|
+
): void {
|
|
251
|
+
const { title } = options || {};
|
|
252
|
+
|
|
253
|
+
// Convert rows to string arrays for the table package
|
|
254
|
+
const tableData = rows.map((row) =>
|
|
255
|
+
row.map((cell) => {
|
|
256
|
+
if (typeof cell === "string") {
|
|
257
|
+
return cell;
|
|
258
|
+
}
|
|
259
|
+
return cell.data;
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const formatted = table(tableData);
|
|
264
|
+
|
|
265
|
+
if (title) {
|
|
266
|
+
emitInfo(prefixLines(`\n${title}`));
|
|
267
|
+
}
|
|
268
|
+
emitInfo(prefixLines(`\n${formatted}\n`));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Print a separator line
|
|
273
|
+
*/
|
|
274
|
+
function separator(length: number = 50): void {
|
|
275
|
+
const separatorText = "─".repeat(length);
|
|
276
|
+
emitInfo(prefixLines(separatorText));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Main logging utility object - import this once and access all utilities
|
|
281
|
+
*/
|
|
282
|
+
export const log = {
|
|
283
|
+
/** Print info message */
|
|
284
|
+
info: (...args: unknown[]): void => {
|
|
285
|
+
emitInfo(prefixLines(`${ts()}${formatArgs(args)}`));
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
/** Print a warning message. Use only for warnings that should be displayed in the job summary. */
|
|
289
|
+
warning: (...args: unknown[]): void => {
|
|
290
|
+
if (sink === "stderr") {
|
|
291
|
+
emitInfo(prefixLines(`${ts()}warning: ${formatArgs(args)}`));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
core.warning(prefixLines(`${ts()}${formatArgs(args)}`));
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
/** Print an error message. Use only for errors that should be displayed in the job summary. */
|
|
298
|
+
error: (...args: unknown[]): void => {
|
|
299
|
+
if (sink === "stderr") {
|
|
300
|
+
emitInfo(prefixLines(`${ts()}error: ${formatArgs(args)}`));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
core.error(prefixLines(`${ts()}${formatArgs(args)}`));
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
/** Print success message */
|
|
307
|
+
success: (...args: unknown[]): void => {
|
|
308
|
+
emitInfo(prefixLines(`${ts()}» ${formatArgs(args)}`));
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
/** Print debug message (only when debug mode is enabled) */
|
|
312
|
+
debug: (...args: unknown[]): void => {
|
|
313
|
+
if (sink === "actions" && isRunnerDebugEnabled()) {
|
|
314
|
+
core.debug(prefixLines(formatArgs(args)));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (isLocalDebugEnabled()) {
|
|
318
|
+
emitInfo(prefixLines(`${ts()}[DEBUG] ${formatArgs(args)}`));
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
/** Print a formatted box with text */
|
|
323
|
+
box,
|
|
324
|
+
|
|
325
|
+
/** Print a formatted table using the table package */
|
|
326
|
+
table: printTable,
|
|
327
|
+
|
|
328
|
+
/** Print a separator line */
|
|
329
|
+
separator,
|
|
330
|
+
|
|
331
|
+
/** Start a collapsed group (GitHub Actions) or regular group (local) */
|
|
332
|
+
startGroup,
|
|
333
|
+
|
|
334
|
+
/** End a collapsed group */
|
|
335
|
+
endGroup,
|
|
336
|
+
|
|
337
|
+
/** Run a callback within a collapsed group */
|
|
338
|
+
group,
|
|
339
|
+
|
|
340
|
+
/** Log tool call information to console with formatted output */
|
|
341
|
+
toolCall: ({ toolName, input }: { toolName: string; input: unknown }): void => {
|
|
342
|
+
const inputFormatted = formatJsonValue(input);
|
|
343
|
+
const output = inputFormatted !== "{}" ? `» ${toolName}(${inputFormatted})` : `» ${toolName}()`;
|
|
344
|
+
|
|
345
|
+
log.info(output.trimEnd());
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Format a value as JSON, using compact format for simple values and pretty-printed for complex ones
|
|
351
|
+
*/
|
|
352
|
+
export function formatJsonValue(value: unknown): string {
|
|
353
|
+
const compact = JSON.stringify(value);
|
|
354
|
+
return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value, null, 2) : compact;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Format a multi-line string with proper indentation for tool call output
|
|
359
|
+
* First line has the label, subsequent lines are indented 4 spaces
|
|
360
|
+
*/
|
|
361
|
+
export function formatIndentedField(label: string, content: string): string {
|
|
362
|
+
if (!content.includes("\n")) {
|
|
363
|
+
return ` ${label}: ${content}\n`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const lines = content.split("\n");
|
|
367
|
+
let formatted = ` ${label}: ${lines[0]}\n`;
|
|
368
|
+
for (let i = 1; i < lines.length; i++) {
|
|
369
|
+
formatted += ` ${lines[i]}\n`;
|
|
370
|
+
}
|
|
371
|
+
return formatted;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* format aggregated usage data as a markdown table for the GitHub step summary.
|
|
376
|
+
*
|
|
377
|
+
* columns mirror the per-run stdout token table emitted by `logTokenTable`
|
|
378
|
+
* (Input / Cache Read / Cache Write / Output / Total / Cost ($)) so the job
|
|
379
|
+
* summary and the in-run logs can be compared row-for-row.
|
|
380
|
+
*
|
|
381
|
+
* notes:
|
|
382
|
+
* - `AgentUsage.inputTokens` is the sum of non-cached input + cache read
|
|
383
|
+
* + cache write (set that way by both agent harnesses' `buildUsage`),
|
|
384
|
+
* so the non-cached Input column is recovered by subtracting cache fields.
|
|
385
|
+
* - `costUsd` is sourced from models.dev (OpenCode) or `total_cost_usd`
|
|
386
|
+
* (Claude CLI). absent rows show `—` so per-agent coverage is obvious.
|
|
387
|
+
*/
|
|
388
|
+
export function formatUsageSummary(entries: AgentUsage[]): string {
|
|
389
|
+
if (entries.length === 0) return "";
|
|
390
|
+
|
|
391
|
+
const header = "| Agent | Input | Cache Read | Cache Write | Output | Total | Cost ($) |";
|
|
392
|
+
const separatorRow = "| --- | ---: | ---: | ---: | ---: | ---: | ---: |";
|
|
393
|
+
const fmt = (n: number) => n.toLocaleString("en-US");
|
|
394
|
+
|
|
395
|
+
const nonCachedInput = (e: AgentUsage): number =>
|
|
396
|
+
Math.max(0, e.inputTokens - (e.cacheReadTokens ?? 0) - (e.cacheWriteTokens ?? 0));
|
|
397
|
+
const totalFor = (e: AgentUsage): number =>
|
|
398
|
+
nonCachedInput(e) + (e.cacheReadTokens ?? 0) + (e.cacheWriteTokens ?? 0) + e.outputTokens;
|
|
399
|
+
const costCell = (e: AgentUsage): string =>
|
|
400
|
+
typeof e.costUsd === "number" && e.costUsd > 0 ? formatCostUsd(e.costUsd) : "—";
|
|
401
|
+
|
|
402
|
+
const rows = entries.map(
|
|
403
|
+
(e) =>
|
|
404
|
+
`| ${e.agent} | ${fmt(nonCachedInput(e))} | ${fmt(e.cacheReadTokens ?? 0)} | ${fmt(e.cacheWriteTokens ?? 0)} | ${fmt(e.outputTokens)} | ${fmt(totalFor(e))} | ${costCell(e)} |`,
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const totalsRows: string[] = [];
|
|
408
|
+
if (entries.length > 1) {
|
|
409
|
+
const totalInput = entries.reduce((sum, e) => sum + nonCachedInput(e), 0);
|
|
410
|
+
const totalOutput = entries.reduce((sum, e) => sum + e.outputTokens, 0);
|
|
411
|
+
const totalCacheRead = entries.reduce((sum, e) => sum + (e.cacheReadTokens ?? 0), 0);
|
|
412
|
+
const totalCacheWrite = entries.reduce((sum, e) => sum + (e.cacheWriteTokens ?? 0), 0);
|
|
413
|
+
const grandTotal = totalInput + totalCacheRead + totalCacheWrite + totalOutput;
|
|
414
|
+
const totalCostUsd = entries.reduce((sum, e) => sum + (e.costUsd ?? 0), 0);
|
|
415
|
+
const totalCostCell = totalCostUsd > 0 ? `**${formatCostUsd(totalCostUsd)}**` : "—";
|
|
416
|
+
totalsRows.push(
|
|
417
|
+
`| **Total** | **${fmt(totalInput)}** | **${fmt(totalCacheRead)}** | **${fmt(totalCacheWrite)}** | **${fmt(totalOutput)}** | **${fmt(grandTotal)}** | ${totalCostCell} |`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return [
|
|
422
|
+
"<details>",
|
|
423
|
+
"<summary>Usage</summary>",
|
|
424
|
+
"",
|
|
425
|
+
header,
|
|
426
|
+
separatorRow,
|
|
427
|
+
...rows,
|
|
428
|
+
...totalsRows,
|
|
429
|
+
"",
|
|
430
|
+
"</details>",
|
|
431
|
+
].join("\n");
|
|
432
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { normalizeEnv, sanitizeSecret } from "#app/utils/normalizeEnv";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* These tests pin the load-bearing invariants of secret sanitisation:
|
|
6
|
+
* - sensitive values are trimmed before downstream code reads them
|
|
7
|
+
* - whitespace-only values are NOT silently zeroed (leave env unchanged)
|
|
8
|
+
* - case normalisation still happens
|
|
9
|
+
*
|
|
10
|
+
* Masking (`core.setSecret`) is delegated to `@actions/core` and trusted to
|
|
11
|
+
* work as documented — we don't spy on stdout to re-test the toolkit.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
describe("normalizeEnv: process.env state contract", () => {
|
|
15
|
+
let originalEnv: NodeJS.ProcessEnv;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
// normalizeEnv() iterates the entire process.env, so the test must
|
|
19
|
+
// control it. snapshot + full wipe + restore is the cleanest isolation.
|
|
20
|
+
originalEnv = { ...process.env };
|
|
21
|
+
for (const k of Object.keys(process.env)) delete process.env[k];
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
for (const k of Object.keys(process.env)) delete process.env[k];
|
|
26
|
+
Object.assign(process.env, originalEnv);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("trims trailing newline from sensitive env vars", () => {
|
|
30
|
+
process.env.ANTHROPIC_API_KEY = "sk-ant-secret-value\n";
|
|
31
|
+
normalizeEnv();
|
|
32
|
+
expect(process.env.ANTHROPIC_API_KEY).toBe("sk-ant-secret-value");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("trims surrounding whitespace including \\r\\n and spaces", () => {
|
|
36
|
+
process.env.OPENAI_API_KEY = " sk-openai-value\r\n ";
|
|
37
|
+
normalizeEnv();
|
|
38
|
+
expect(process.env.OPENAI_API_KEY).toBe("sk-openai-value");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("leaves clean sensitive values untouched", () => {
|
|
42
|
+
process.env.ANTHROPIC_API_KEY = "sk-ant-clean";
|
|
43
|
+
normalizeEnv();
|
|
44
|
+
expect(process.env.ANTHROPIC_API_KEY).toBe("sk-ant-clean");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("ignores non-sensitive env vars", () => {
|
|
48
|
+
process.env.NODE_ENV = "production\n";
|
|
49
|
+
normalizeEnv();
|
|
50
|
+
expect(process.env.NODE_ENV).toBe("production\n");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("canonicalises case and trims the value", () => {
|
|
54
|
+
process.env.anthropic_api_key = "sk-ant-lowercase\n";
|
|
55
|
+
normalizeEnv();
|
|
56
|
+
expect(process.env.ANTHROPIC_API_KEY).toBe("sk-ant-lowercase");
|
|
57
|
+
expect(process.env.anthropic_api_key).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("preserves whitespace-only values rather than silently zeroing them", () => {
|
|
61
|
+
// contract: don't mutate when value is whitespace-only. caller sees the
|
|
62
|
+
// misconfigured value verbatim and either fails clearly downstream or
|
|
63
|
+
// logs a missing-key error.
|
|
64
|
+
process.env.ANTHROPIC_API_KEY = " \n ";
|
|
65
|
+
normalizeEnv();
|
|
66
|
+
expect(process.env.ANTHROPIC_API_KEY).toBe(" \n ");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("preserves embedded newlines (toolkit masks each line)", () => {
|
|
70
|
+
// multi-line PEMs aren't used in practice, but if one slipped in via a
|
|
71
|
+
// DB secret we don't want to silently mutate it. trim() only touches
|
|
72
|
+
// the ends; @actions/core handles per-line masking via the runner.
|
|
73
|
+
process.env.ANTHROPIC_API_KEY = "line1\nline2";
|
|
74
|
+
normalizeEnv();
|
|
75
|
+
expect(process.env.ANTHROPIC_API_KEY).toBe("line1\nline2");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("sanitizeSecret return value", () => {
|
|
80
|
+
it("returns the trimmed value for a sensitive secret with trailing newline", () => {
|
|
81
|
+
expect(sanitizeSecret("ANTHROPIC_API_KEY", "sk-ant-secret\n")).toBe("sk-ant-secret");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("returns the value unchanged when no trimming is needed", () => {
|
|
85
|
+
expect(sanitizeSecret("ANTHROPIC_API_KEY", "sk-ant-clean")).toBe("sk-ant-clean");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns null for whitespace-only input so caller can skip injection", () => {
|
|
89
|
+
expect(sanitizeSecret("ANTHROPIC_API_KEY", " \n")).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import * as core from "@actions/core";
|
|
2
|
+
import { log } from "#app/utils/cli";
|
|
3
|
+
import { isSensitiveEnvName } from "#app/utils/secrets";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Trim surrounding whitespace from a sensitive value and register it as a
|
|
7
|
+
* GitHub Actions log mask. Trailing newlines from terminal-copy paste are a
|
|
8
|
+
* common footgun: the value travels through GH Actions logs and any tool
|
|
9
|
+
* that re-emits parts of it leaks the unmasked tail. Trimming canonicalises
|
|
10
|
+
* the value so the mask matches exactly what downstream tools will print.
|
|
11
|
+
*
|
|
12
|
+
* Masking is delegated to `core.setSecret` (not raw `console.log`) so the
|
|
13
|
+
* toolkit percent-encodes `\r`/`\n`; the runner V2 parser decodes them and
|
|
14
|
+
* registers the full value plus every non-empty line as separate masks. That
|
|
15
|
+
* keeps us safe for embedded-newline values (PEMs, kubeconfigs, JSON blobs)
|
|
16
|
+
* even though they aren't currently used.
|
|
17
|
+
*
|
|
18
|
+
* Returns the trimmed value, or `null` when the input was whitespace-only —
|
|
19
|
+
* callers must leave `process.env` untouched in that case so a misconfigured
|
|
20
|
+
* value surfaces as a clear "missing key" downstream rather than silently
|
|
21
|
+
* mutating to the empty string.
|
|
22
|
+
*/
|
|
23
|
+
export function sanitizeSecret(key: string, value: string): string | null {
|
|
24
|
+
const trimmed = value.trim();
|
|
25
|
+
if (trimmed.length === 0) {
|
|
26
|
+
log.warning(
|
|
27
|
+
`» ${key} is whitespace-only — leaving env var unchanged. check your secret value.`,
|
|
28
|
+
);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (trimmed !== value) {
|
|
32
|
+
log.warning(
|
|
33
|
+
`» stripped whitespace from ${key} (whitespace in secret values breaks GitHub Actions log masking)`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
core.setSecret(trimmed);
|
|
37
|
+
return trimmed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Normalize environment variables to uppercase.
|
|
42
|
+
* This handles case-insensitive env var names (e.g., `anthropic_api_key` -> `ANTHROPIC_API_KEY`).
|
|
43
|
+
*
|
|
44
|
+
* If there are conflicts (same key with different capitalizations but different values),
|
|
45
|
+
* logs a warning and keeps the uppercase version.
|
|
46
|
+
*
|
|
47
|
+
* Also trims and masks sensitive values so accidental trailing whitespace
|
|
48
|
+
* doesn't defeat GitHub Actions log masking.
|
|
49
|
+
*/
|
|
50
|
+
export function normalizeEnv(): void {
|
|
51
|
+
const upperKeys = new Map<string, string[]>();
|
|
52
|
+
|
|
53
|
+
// group keys by their uppercase form
|
|
54
|
+
for (const key of Object.keys(process.env)) {
|
|
55
|
+
const upper = key.toUpperCase();
|
|
56
|
+
const existing = upperKeys.get(upper) || [];
|
|
57
|
+
existing.push(key);
|
|
58
|
+
upperKeys.set(upper, existing);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// process each group
|
|
62
|
+
for (const [upperKey, keys] of upperKeys) {
|
|
63
|
+
if (keys.length === 1) {
|
|
64
|
+
const key = keys[0]!;
|
|
65
|
+
if (key !== upperKey) {
|
|
66
|
+
// single key, just needs uppercasing
|
|
67
|
+
process.env[upperKey] = process.env[key];
|
|
68
|
+
delete process.env[key];
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// multiple keys with different capitalizations
|
|
74
|
+
const values = keys.map((k) => process.env[k]);
|
|
75
|
+
const uniqueValues = new Set(values);
|
|
76
|
+
|
|
77
|
+
if (uniqueValues.size > 1) {
|
|
78
|
+
// conflict: different values for different capitalizations
|
|
79
|
+
log.warning(
|
|
80
|
+
`env var conflict: ${keys.join(", ")} have different values. using uppercase ${upperKey}.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// prefer the uppercase version if it exists, otherwise use the first one
|
|
85
|
+
const preferredKey = keys.find((k) => k === upperKey) || keys[0]!;
|
|
86
|
+
const preferredValue = process.env[preferredKey];
|
|
87
|
+
|
|
88
|
+
// delete all variants
|
|
89
|
+
for (const key of keys) {
|
|
90
|
+
delete process.env[key];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// set the uppercase version
|
|
94
|
+
process.env[upperKey] = preferredValue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// trim + mask sensitive values after case normalisation so each key is
|
|
98
|
+
// visited exactly once with its final, canonical value
|
|
99
|
+
for (const key of Object.keys(process.env)) {
|
|
100
|
+
if (!isSensitiveEnvName(key)) continue;
|
|
101
|
+
const value = process.env[key];
|
|
102
|
+
if (typeof value !== "string" || value.length === 0) continue;
|
|
103
|
+
const sanitized = sanitizeSecret(key, value);
|
|
104
|
+
if (sanitized !== null) process.env[key] = sanitized;
|
|
105
|
+
}
|
|
106
|
+
}
|