From 1f7620ea59d34b1b337c6b71b9739b36cab45301 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Tue, 3 Nov 2026 13:34:42 -0500 Subject: [PATCH] feat(cli): Authenticate headless installs with setup codes --- packages/cli/src/commands/cli/setup.ts | 12 +- packages/cli/test/commands/cli/setup.test.ts | 136 ++++++++++++++++++- 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/cli/setup.ts b/packages/cli/src/commands/cli/setup.ts index 48481b1c3..32088a044 100644 --- a/packages/cli/src/commands/cli/setup.ts +++ b/packages/cli/src/commands/cli/setup.ts @@ -34,7 +34,7 @@ import { warmCachesAfterLogin, } from "../../lib/completion-warmup.js"; import { CLI_VERSION } from "../../lib/constants.js"; -import { getAuthConfig } from "../../lib/db/auth.js"; +import { getAuthConfig, hasStoredAuthCredentials } from "../../lib/db/auth.js"; import { getOnOffPreference, setOnOffPreference, @@ -55,6 +55,7 @@ import { CommandOutput } from "../../lib/formatters/output.js"; import { runInteractiveLogin } from "../../lib/interactive-login.js"; import { interactivePromptsAllowed } from "../../lib/interactive-prompts.js"; import { logger } from "../../lib/logger.js"; +import { bootstrapAuthentication } from "../../lib/setup-code.js"; import { addToFpath, addToGitHubPath, @@ -789,6 +790,14 @@ export const setupCommand = buildCommand({ warn, }); + if (getEnv().SENTRY_SETUP_CODE && !hasStoredAuthCredentials()) { + await bootstrapAuthentication(); + emit("Authenticated with Sentry using the setup code."); + if (!flags["no-completions"]) { + await warmCachesAfterLogin(); + } + } + if (flags["ensure-auth-scopes"]) { await bestEffort( "Authorization", @@ -830,6 +839,7 @@ export const setupCommand = buildCommand({ flags.quiet || flags["ensure-auth-scopes"] || getEnv().SENTRY_INIT === "1" || + Boolean(getEnv().SENTRY_SETUP_CODE) || !(interactivePromptsAllowed() && isatty(0) && isatty(1) && isatty(2)) ) { return; diff --git a/packages/cli/test/commands/cli/setup.test.ts b/packages/cli/test/commands/cli/setup.test.ts index d5815d50c..fa3208c1e 100644 --- a/packages/cli/test/commands/cli/setup.test.ts +++ b/packages/cli/test/commands/cli/setup.test.ts @@ -62,6 +62,8 @@ vi.mock("../../../src/lib/windows-path.js", async (importOriginal) => ({ import { app } from "../../../src/app.js"; import { warmCachesAfterLogin } from "../../../src/lib/completion-warmup.js"; import type { SentryContext } from "../../../src/context.js"; +import { getAccountRow } from "../../../src/lib/db/accounts.js"; +import { EXIT } from "../../../src/lib/errors.js"; import { getAuthConfig, setAuthToken } from "../../../src/lib/db/auth.js"; import { getOnOffPreference, @@ -139,7 +141,10 @@ function createMockContext( }), }, stderr: { - write: vi.fn((_s: string) => true), + write: vi.fn((s: string) => { + stderrChunks.push(String(s)); + return true; + }), }, stdin: process.stdin, env, @@ -161,7 +166,10 @@ function createMockContext( }), }, stderr: { - write: vi.fn((_s: string) => true), + write: vi.fn((s: string) => { + stderrChunks.push(String(s)); + return true; + }), }, stdin: process.stdin, setFlags: () => { @@ -1022,6 +1030,130 @@ describe("sentry cli setup", () => { }; } + test.each([true, false])( + "redeems a setup code with completions=%s without opening a browser", + async (completions) => { + vi.mocked(warmCachesAfterLogin).mockClear(); + + const claim = Buffer.from( + JSON.stringify({ url: "https://sentry.io", iat: 1 }), + ).toString("base64"); + const code = "sntryc_" + claim + "_" + "a".repeat(43); + const fixture = createInstall({ + completions, + env: { SENTRY_SETUP_CODE: code }, + }); + const fetch = vi.fn().mockResolvedValue( + Response.json({ + access_token: "setup-token", + refresh_token: "refresh-token", + expires_in: 3600, + token_type: "bearer", + }), + ); + vi.stubGlobal("fetch", fetch); + try { + await fixture.run(); + expect(readFileSync(fixture.binaryPath, "utf8")).toBe( + "fixture-binary", + ); + expect(fetch).toHaveBeenCalledTimes(1); + expect(getAuthConfig()?.token).toBe("setup-token"); + expect(warmCachesAfterLogin).toHaveBeenCalledTimes( + completions ? 1 : 0, + ); + expect(fixture.getOutput()).toContain( + "Authenticated with Sentry using the setup code", + ); + expect(fixture.getOutput()).not.toContain(code); + expect(interactiveLogin.runInteractiveLogin).not.toHaveBeenCalled(); + expect(fixture.context.process.exitCode).toBe(0); + } finally { + vi.unstubAllGlobals(); + } + }, + ); + + test("finishes installation configuration when setup-code redemption fails", async () => { + const claim = Buffer.from( + JSON.stringify({ url: "https://sentry.io" }), + ).toString("base64"); + const code = "sntryc_" + claim + "_" + "a".repeat(43); + const fixture = createInstall({ env: { SENTRY_SETUP_CODE: code } }); + const fetch = vi + .fn() + .mockResolvedValue( + Response.json({ error: "invalid_grant" }, { status: 400 }), + ); + vi.stubGlobal("fetch", fetch); + + try { + await fixture.run(); + + expect(fetch).toHaveBeenCalledOnce(); + expect(getInstallInfo()?.path).toBe(fixture.binaryPath); + expect(getOnOffPreference("agent-skills")).toBe(false); + expect(readFileSync(fixture.binaryPath, "utf8")).toBe("fixture-binary"); + expect(fixture.context.process.exitCode).toBeGreaterThan(0); + expect(fixture.getOutput()).not.toContain(code); + expect(interactiveLogin.runInteractiveLogin).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + + test.each([true, false])( + "keeps stored authentication with a retained code when install=%s", + async (install) => { + setAuthToken("existing-token"); + const fixture = createInstall({ + install, + env: { SENTRY_SETUP_CODE: "consumed-code" }, + }); + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + try { + await fixture.run(); + expect(getAuthConfig()?.token).toBe("existing-token"); + expect(fetch).not.toHaveBeenCalled(); + expect(fixture.context.process.exitCode).toBe(0); + } finally { + vi.unstubAllGlobals(); + } + }, + ); + + test.each([true, false])( + "shows a bootstrap recovery command for an expired login when install=%s", + async (install) => { + const account = setAuthToken("expired-token", -1, undefined, { + host: "https://sentry.io", + }); + const claim = Buffer.from( + JSON.stringify({ url: "https://sentry.io" }), + ).toString("base64"); + const code = "sntryc_" + claim + "_" + "a".repeat(43); + const fixture = createInstall({ + install, + env: { SENTRY_SETUP_CODE: code }, + }); + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + try { + await fixture.run(); + expect(fixture.context.process.exitCode).toBe(EXIT.VALIDATION); + expect(fixture.getOutput()).toContain( + "sentry auth bootstrap --force", + ); + expect(fixture.getOutput()).not.toContain(code); + expect(fetch).not.toHaveBeenCalled(); + expect(getAccountRow(account)?.token).toBe("expired-token"); + } finally { + vi.unstubAllGlobals(); + } + }, + ); + test("starts OAuth only after installing the binary and displaying success", async () => { const fixture = createInstall(); vi.mocked(interactiveLogin.runInteractiveLogin).mockImplementation(