Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 39 additions & 10 deletions src/lib/init/local-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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"]);
Expand Down Expand Up @@ -623,13 +649,15 @@ async function applyEdits(

async function applySinglePatch(
absPath: string,
patch: ApplyPatchsetPatch
patch: ApplyPatchsetPatch,
authToken?: string
): Promise<void> {
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;
Expand All @@ -656,7 +684,8 @@ async function applySinglePatch(

async function applyPatchset(
payload: ApplyPatchsetPayload,
dryRun?: boolean
dryRun?: boolean,
authToken?: string
): Promise<LocalOpResult> {
if (dryRun) {
return applyPatchsetDryRun(payload);
Expand Down Expand Up @@ -693,7 +722,7 @@ async function applyPatchset(
}
}

await applySinglePatch(absPath, patch);
await applySinglePatch(absPath, patch, authToken);
applied.push({ path: patch.path, action: patch.action });
}

Expand Down
2 changes: 2 additions & 0 deletions src/lib/init/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/lib/init/wizard-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,12 @@ export async function runWizard(initialOptions: WizardOptions): Promise<void> {
};

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}` } : {},
Expand Down
143 changes: 143 additions & 0 deletions test/lib/init/local-ops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});

Expand Down
Loading