diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 58acc1cfa..808d5e93d 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -295,7 +295,7 @@ export async function handleLocalOp( case "run-commands": return await runCommands(payload, options.dryRun); case "apply-patchset": - return await applyPatchset(payload, options.dryRun); + return await applyPatchset(payload, options.dryRun, options.authToken); case "create-sentry-project": return await createSentryProject(payload, options); case "detect-sentry": @@ -582,15 +582,41 @@ function applyPatchsetDryRun(payload: ApplyPatchsetPayload): LocalOpResult { return { ok: true, data: { applied } }; } +/** Pattern matching empty or placeholder SENTRY_AUTH_TOKEN values in env files. + * Uses [ \t] (horizontal whitespace) instead of \s to avoid consuming newlines. */ +const EMPTY_AUTH_TOKEN_RE = + /^(SENTRY_AUTH_TOKEN[ \t]*=[ \t]*)(?:['"]?[ \t]*['"]?)?[ \t]*$/m; + /** * Resolve the final file content for a full-content patch (create only), - * pretty-printing JSON files to preserve readable formatting. + * pretty-printing JSON files to preserve readable formatting, and injecting + * the auth token into env files when the server left it empty. */ -function resolvePatchContent(patch: { path: string; patch: string }): string { - if (!patch.path.endsWith(".json")) { - return patch.patch; +function resolvePatchContent( + patch: { path: string; patch: string }, + authToken?: string +): string { + let content = patch.path.endsWith(".json") + ? prettyPrintJson(patch.patch, DEFAULT_JSON_INDENT) + : patch.patch; + + // Inject the auth token into env files when the AI left the value empty. + // The server never has access to the user's token, so it generates + // SENTRY_AUTH_TOKEN= (empty). We fill it in client-side. + if (authToken && isEnvFile(patch.path) && EMPTY_AUTH_TOKEN_RE.test(content)) { + content = content.replace( + EMPTY_AUTH_TOKEN_RE, + (_, prefix) => `${prefix}${authToken}` + ); } - return prettyPrintJson(patch.patch, DEFAULT_JSON_INDENT); + + return content; +} + +/** Returns true if the file path looks like a .env file. */ +function isEnvFile(filePath: string): boolean { + const name = filePath.split("/").pop() ?? ""; + return name === ".env" || name.startsWith(".env."); } const VALID_PATCH_ACTIONS = new Set(["create", "modify", "delete"]); @@ -623,13 +649,15 @@ async function applyEdits( async function applySinglePatch( absPath: string, - patch: ApplyPatchsetPatch + patch: ApplyPatchsetPatch, + authToken?: string ): Promise { switch (patch.action) { case "create": { await fs.promises.mkdir(path.dirname(absPath), { recursive: true }); const content = resolvePatchContent( - patch as ApplyPatchsetPatch & { patch: string } + patch as ApplyPatchsetPatch & { patch: string }, + authToken ); await fs.promises.writeFile(absPath, content, "utf-8"); break; @@ -656,7 +684,8 @@ async function applySinglePatch( async function applyPatchset( payload: ApplyPatchsetPayload, - dryRun?: boolean + dryRun?: boolean, + authToken?: string ): Promise { if (dryRun) { return applyPatchsetDryRun(payload); @@ -693,7 +722,7 @@ async function applyPatchset( } } - await applySinglePatch(absPath, patch); + await applySinglePatch(absPath, patch, authToken); applied.push({ path: patch.path, action: patch.action }); } diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index 255488d26..5a4bc5da6 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -15,6 +15,8 @@ export type WizardOptions = { org?: string; /** Explicit project name from CLI arg (e.g., "my-app" from "acme/my-app"). Overrides wizard-detected name. */ project?: string; + /** Auth token for injecting into generated env files (e.g., .env.sentry-build-plugin). Never sent to the server. */ + authToken?: string; }; // Local-op suspend payloads diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index ed08a977b..42707b684 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -587,6 +587,12 @@ export async function runWizard(initialOptions: WizardOptions): Promise { }; const token = getAuthToken(); + + // Make the auth token available to local-ops for injecting into generated + // env files (e.g. .env.sentry-build-plugin). The token is never sent to + // the remote server — it stays client-side only. + options.authToken = token; + const client = new MastraClient({ baseUrl: MASTRA_API_URL, headers: token ? { Authorization: `Bearer ${token}` } : {}, diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index 577f20929..e56096a28 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -1072,6 +1072,149 @@ describe("handleLocalOp", () => { const content = readFileSync(join(testDir, "existing.ts"), "utf-8"); expect(content).toContain('const updated = "new-value"'); }); + + test("injects auth token into .env.sentry-build-plugin with empty SENTRY_AUTH_TOKEN", async () => { + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { + path: ".env.sentry-build-plugin", + action: "create", + patch: "SENTRY_AUTH_TOKEN=\n", + }, + ], + }, + }; + + const opts = makeOptions({ + directory: testDir, + authToken: "sntrys_test_token_123", + }); + const result = await handleLocalOp(payload, opts); + expect(result.ok).toBe(true); + + const content = readFileSync( + join(testDir, ".env.sentry-build-plugin"), + "utf-8" + ); + expect(content).toBe("SENTRY_AUTH_TOKEN=sntrys_test_token_123\n"); + }); + + test("does not inject auth token into non-env files", async () => { + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { + path: "config.ts", + action: "create", + patch: "SENTRY_AUTH_TOKEN=\n", + }, + ], + }, + }; + + const opts = makeOptions({ + directory: testDir, + authToken: "sntrys_test_token_123", + }); + const result = await handleLocalOp(payload, opts); + expect(result.ok).toBe(true); + + const content = readFileSync(join(testDir, "config.ts"), "utf-8"); + expect(content).toBe("SENTRY_AUTH_TOKEN=\n"); + }); + + test("does not overwrite existing non-empty SENTRY_AUTH_TOKEN", async () => { + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { + path: ".env.sentry-build-plugin", + action: "create", + patch: "SENTRY_AUTH_TOKEN=existing_value\n", + }, + ], + }, + }; + + const opts = makeOptions({ + directory: testDir, + authToken: "sntrys_different_token", + }); + const result = await handleLocalOp(payload, opts); + expect(result.ok).toBe(true); + + const content = readFileSync( + join(testDir, ".env.sentry-build-plugin"), + "utf-8" + ); + expect(content).toBe("SENTRY_AUTH_TOKEN=existing_value\n"); + }); + + test("handles .env file with empty quoted SENTRY_AUTH_TOKEN", async () => { + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { + path: ".env.sentry-build-plugin", + action: "create", + patch: 'SENTRY_AUTH_TOKEN=""\n', + }, + ], + }, + }; + + const opts = makeOptions({ + directory: testDir, + authToken: "sntrys_test_token_456", + }); + const result = await handleLocalOp(payload, opts); + expect(result.ok).toBe(true); + + const content = readFileSync( + join(testDir, ".env.sentry-build-plugin"), + "utf-8" + ); + expect(content).toBe("SENTRY_AUTH_TOKEN=sntrys_test_token_456\n"); + }); + + test("does not inject when no auth token is available", async () => { + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { + path: ".env.sentry-build-plugin", + action: "create", + patch: "SENTRY_AUTH_TOKEN=\n", + }, + ], + }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + + const content = readFileSync( + join(testDir, ".env.sentry-build-plugin"), + "utf-8" + ); + expect(content).toBe("SENTRY_AUTH_TOKEN=\n"); + }); }); });