From 43f6f74c10a52177048b8a4b68a9f55194bf4bb8 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 22 Jun 2026 16:32:14 +0200 Subject: [PATCH] feat(auth): launch browser login regardless of TTY autoAuthMiddleware ran the OAuth device flow (which opens the browser) only in an interactive terminal, so an unauthenticated command in a non-TTY (piped output, redirected stdin, CI) failed immediately. Remove the isatty(0) gate so the same existing login flow runs for every command regardless of TTY; on success the command retries as before. On failure, behavior is unchanged from today: a non-TTY re-throws the original auth error (exit 10, "Not authenticated"), an interactive terminal exits 1. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cli.ts | 54 ++++++++++++++++++++++++------------------- test/e2e/auth.test.ts | 16 +++++++++++++ 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 9a74aadbc..ae9dc0912 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -447,42 +447,48 @@ export async function runCli(cliArgs: string[]): Promise { /** * Auto-authentication middleware. * - * Catches auth errors (not_authenticated, expired) in interactive TTYs - * and runs the login flow. On success, retries through the full middleware - * chain so inner middlewares (e.g., trial prompt) also apply to the retry. + * Catches auth errors (not_authenticated, expired) and runs the login flow. + * On success, retries through the full middleware chain so inner middlewares + * (e.g., trial prompt) also apply to the retry. + * + * Runs regardless of TTY: the device flow opens the browser when possible and + * otherwise prints the verification URL + QR code, so it also works when + * stdin is piped/redirected. */ const autoAuthMiddleware: ErrorMiddleware = async (next, argv) => { try { await next(argv); } catch (err) { - // Use isatty(0) for reliable stdin TTY detection (process.stdin.isTTY can be undefined in Bun) - // Errors can opt-out via skipAutoAuth (e.g., auth status command) + // Only recover auth errors that haven't opted out (e.g. auth status sets + // skipAutoAuth); rethrow everything else unchanged. if ( - err instanceof AuthError && - (err.reason === "not_authenticated" || err.reason === "expired") && - !err.skipAutoAuth && - isatty(0) + !(err instanceof AuthError) || + err.skipAutoAuth || + !["not_authenticated", "expired"].includes(err.reason) ) { - process.stderr.write( - err.reason === "expired" - ? "Authentication expired. Starting login flow...\n\n" - : "Authentication required. Starting login flow...\n\n" - ); - - const loginSuccess = await runInteractiveLogin(); + throw err; + } - if (loginSuccess) { - process.stderr.write("\nRetrying command...\n\n"); - await next(argv); - return; - } + process.stderr.write( + err.reason === "expired" + ? "Authentication expired. Starting login flow...\n\n" + : "Authentication required. Starting login flow...\n\n" + ); - // Login failed or was cancelled - process.exitCode = 1; + const loginSuccess = await runInteractiveLogin(); + if (loginSuccess) { + process.stderr.write("\nRetrying command...\n\n"); + await next(argv); return; } - throw err; + // Login failed or was cancelled. In a non-TTY, re-throw so the original + // auth error's standard message and exit code surface ("Not + // authenticated", exit 10); in a TTY the flow already reported it. + if (!isatty(0)) { + throw err; + } + process.exitCode = 1; } }; diff --git a/test/e2e/auth.test.ts b/test/e2e/auth.test.ts index 14ccefec0..22cd923ec 100644 --- a/test/e2e/auth.test.ts +++ b/test/e2e/auth.test.ts @@ -76,6 +76,22 @@ describe("sentry auth status", () => { }); }); +describe("non-TTY auto-auth", () => { + // The auto-auth middleware now attempts the OAuth device flow regardless of + // TTY (the test subprocess has no TTY on stdin). The device-code request + // fails fast against the mock (no /oauth/device/code/ route → 404), so the + // command still exits 10 with the standard not-authenticated message — but + // only after attempting to start the login flow. + test("attempts login flow then exits not-authenticated", async () => { + const result = await ctx.run(["api", "organizations/"]); + + const output = result.stdout + result.stderr; + expect(output).toMatch(/starting login flow/i); + expect(output).toMatch(/not authenticated|login/i); + expect(result.exitCode).toBe(EXIT.AUTH_NOT_AUTHENTICATED); + }); +}); + describe("sentry auth login --token", () => { test("stores valid API token", { timeout: 10_000 }, async () => { const result = await ctx.run([