Skip to content
Draft
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
54 changes: 30 additions & 24 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,42 +447,48 @@ export async function runCli(cliArgs: string[]): Promise<void> {
/**
* 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;
}
};

Expand Down
16 changes: 16 additions & 0 deletions test/e2e/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Loading