Skip to content

Commit 6d48fda

Browse files
authored
feat(auth): auto-trigger login flow when authentication required (#170)
## Summary When a command requires authentication but the user isn't logged in, automatically start the OAuth device flow instead of showing an error. After successful login, the original command retries automatically. Only triggers in interactive TTY environments - non-TTY sessions (CI, scripts, piped input) continue showing the error message as before. ## Changes - Extract device flow UI logic into `src/lib/interactive-login.ts` for reuse - Refactor `auth login` command to use the shared helper - Add `executeWithAutoAuth()` wrapper in `bin.ts` that catches `AuthError("not_authenticated")` and triggers login ## Test Plan 1. Log out: `sentry auth logout` 2. Run a command that requires auth: `sentry issue list` 3. Should see "Authentication required. Starting login flow..." and the OAuth device flow starts 4. Complete login in browser 5. Original command should automatically retry and succeed **Non-TTY test:** ```bash echo "" | sentry issue list # Should show: "Error: Not authenticated. Run 'sentry auth login' first." ```
1 parent eb05488 commit 6d48fda

7 files changed

Lines changed: 257 additions & 108 deletions

File tree

src/app.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { logRoute } from "./commands/log/index.js";
1616
import { orgRoute } from "./commands/org/index.js";
1717
import { projectRoute } from "./commands/project/index.js";
1818
import { CLI_VERSION } from "./lib/constants.js";
19-
import { CliError, getExitCode } from "./lib/errors.js";
19+
import { AuthError, CliError, getExitCode } from "./lib/errors.js";
2020
import { error as errorColor } from "./lib/formatters/colors.js";
2121

2222
/** Top-level route map containing all CLI commands */
@@ -44,13 +44,20 @@ export const routes = buildRouteMap({
4444
/**
4545
* Custom error formatting for CLI errors.
4646
*
47-
* - CliError subclasses: Show clean user-friendly message without stack trace
47+
* - AuthError (not_authenticated): Re-thrown to allow auto-login flow in bin.ts
48+
* - Other CliError subclasses: Show clean user-friendly message without stack trace
4849
* - Other errors: Show stack trace for debugging unexpected issues
4950
*/
5051
const customText: ApplicationText = {
5152
...text_en,
5253
exceptionWhileRunningCommand: (exc: unknown, ansiColor: boolean): string => {
53-
// Report all command errors to Sentry. Stricli catches exceptions and doesn't
54+
// Re-throw AuthError("not_authenticated") for auto-login flow in bin.ts
55+
// Don't capture to Sentry - it's an expected state (user not logged in), not an error
56+
if (exc instanceof AuthError && exc.reason === "not_authenticated") {
57+
throw exc;
58+
}
59+
60+
// Report command errors to Sentry. Stricli catches exceptions and doesn't
5461
// re-throw, so we must capture here to get visibility into command failures.
5562
Sentry.captureException(exc);
5663

src/bin.ts

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { isatty } from "node:tty";
12
import { run } from "@stricli/core";
23
import { app } from "./app.js";
34
import { buildContext } from "./context.js";
4-
import { formatError, getExitCode } from "./lib/errors.js";
5+
import { AuthError, formatError, getExitCode } from "./lib/errors.js";
56
import { error } from "./lib/formatters/colors.js";
7+
import { runInteractiveLogin } from "./lib/interactive-login.js";
68
import { withTelemetry } from "./lib/telemetry.js";
79
import {
810
abortPendingVersionCheck,
@@ -11,6 +13,61 @@ import {
1113
shouldSuppressNotification,
1214
} from "./lib/version-check.js";
1315

16+
/** Run CLI command with telemetry wrapper */
17+
async function runCommand(args: string[]): Promise<void> {
18+
await withTelemetry(async (span) =>
19+
run(app, args, buildContext(process, span))
20+
);
21+
}
22+
23+
/**
24+
* Execute command with automatic authentication.
25+
*
26+
* If the command fails due to missing authentication and we're in a TTY,
27+
* automatically run the interactive login flow and retry the command.
28+
*
29+
* @throws Re-throws any non-authentication errors from the command
30+
*/
31+
async function executeWithAutoAuth(args: string[]): Promise<void> {
32+
try {
33+
await runCommand(args);
34+
} catch (err) {
35+
// Auto-login for auth errors in interactive TTY environments
36+
// Use isatty(0) for reliable stdin TTY detection (process.stdin.isTTY can be undefined in Bun)
37+
// Errors can opt-out via skipAutoAuth (e.g., auth status command)
38+
if (
39+
err instanceof AuthError &&
40+
err.reason === "not_authenticated" &&
41+
!err.skipAutoAuth &&
42+
isatty(0)
43+
) {
44+
process.stderr.write(
45+
"Authentication required. Starting login flow...\n\n"
46+
);
47+
48+
const loginSuccess = await runInteractiveLogin(
49+
process.stdout,
50+
process.stderr,
51+
process.stdin
52+
);
53+
54+
if (loginSuccess) {
55+
process.stderr.write("\nRetrying command...\n\n");
56+
await runCommand(args);
57+
return;
58+
}
59+
60+
// Login failed or was cancelled - set exit code and return
61+
// (don't call process.exit() directly to allow finally blocks to run)
62+
process.exitCode = 1;
63+
return;
64+
}
65+
66+
// Re-throw non-auth errors to be handled by main
67+
throw err;
68+
}
69+
}
70+
1471
async function main(): Promise<void> {
1572
const args = process.argv.slice(2);
1673
const suppressNotification = shouldSuppressNotification(args);
@@ -21,12 +78,11 @@ async function main(): Promise<void> {
2178
}
2279

2380
try {
24-
await withTelemetry(async (span) =>
25-
run(app, args, buildContext(process, span))
26-
);
81+
await executeWithAutoAuth(args);
2782
} catch (err) {
2883
process.stderr.write(`${error("Error:")} ${formatError(err)}\n`);
29-
process.exit(getExitCode(err));
84+
process.exitCode = getExitCode(err);
85+
return;
3086
} finally {
3187
// Abort any pending version check to allow clean exit
3288
abortPendingVersionCheck();

src/commands/auth/login.ts

Lines changed: 13 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,13 @@ import * as Sentry from "@sentry/bun";
33
import { buildCommand, numberParser } from "@stricli/core";
44
import type { SentryContext } from "../../context.js";
55
import { getCurrentUser } from "../../lib/api-client.js";
6-
import { openBrowser } from "../../lib/browser.js";
7-
import { setupCopyKeyListener } from "../../lib/clipboard.js";
86
import { clearAuth, isAuthenticated, setAuthToken } from "../../lib/db/auth.js";
97
import { getDbPath } from "../../lib/db/index.js";
108
import { setUserInfo } from "../../lib/db/user.js";
119
import { AuthError } from "../../lib/errors.js";
1210
import { muted, success } from "../../lib/formatters/colors.js";
13-
import {
14-
formatDuration,
15-
formatUserIdentity,
16-
} from "../../lib/formatters/human.js";
17-
import { completeOAuthFlow, performDeviceFlow } from "../../lib/oauth.js";
18-
import { generateQRCode } from "../../lib/qrcode.js";
11+
import { formatUserIdentity } from "../../lib/formatters/human.js";
12+
import { runInteractiveLogin } from "../../lib/interactive-login.js";
1913
import type { SentryUser } from "../../types/index.js";
2014

2115
type LoginFlags = {
@@ -49,7 +43,7 @@ export const loginCommand = buildCommand({
4943
},
5044
},
5145
async func(this: SentryContext, flags: LoginFlags): Promise<void> {
52-
const { stdout } = this;
46+
const { stdout, stderr } = this;
5347

5448
// Check if already authenticated
5549
if (await isAuthenticated()) {
@@ -97,93 +91,18 @@ export const loginCommand = buildCommand({
9791
}
9892

9993
// Device Flow OAuth
100-
stdout.write("Starting authentication...\n\n");
101-
102-
let urlToCopy = "";
103-
// Object wrapper needed for TypeScript control flow analysis with async callbacks
104-
const keyListener = { cleanup: null as (() => void) | null };
105-
const stdin = process.stdin;
106-
107-
try {
108-
const tokenResponse = await performDeviceFlow(
109-
{
110-
onUserCode: async (
111-
userCode,
112-
verificationUri,
113-
verificationUriComplete
114-
) => {
115-
urlToCopy = verificationUriComplete;
116-
117-
// Try to open browser (best effort)
118-
const browserOpened = await openBrowser(verificationUriComplete);
119-
120-
if (browserOpened) {
121-
stdout.write("Opening in browser...\n\n");
122-
} else {
123-
// Show QR code as fallback when browser can't open
124-
stdout.write("Scan this QR code or visit the URL below:\n\n");
125-
const qr = await generateQRCode(verificationUriComplete);
126-
stdout.write(qr);
127-
stdout.write("\n");
128-
}
129-
130-
stdout.write(`URL: ${verificationUri}\n`);
131-
stdout.write(`Code: ${userCode}\n\n`);
132-
const copyHint = stdin.isTTY ? ` ${muted("(c to copy)")}` : "";
133-
stdout.write(
134-
`Browser didn't open? Use the url above to sign in${copyHint}\n\n`
135-
);
136-
stdout.write("Waiting for authorization...\n");
137-
138-
// Setup keyboard listener for 'c' to copy URL
139-
keyListener.cleanup = setupCopyKeyListener(
140-
stdin,
141-
() => urlToCopy,
142-
stdout
143-
);
144-
},
145-
onPolling: () => {
146-
stdout.write(".");
147-
},
148-
},
149-
flags.timeout * 1000
150-
);
151-
152-
// Clear the polling dots
153-
stdout.write("\n");
154-
155-
// Store the token
156-
await completeOAuthFlow(tokenResponse);
157-
158-
// Store user info from token response for telemetry and display
159-
const user = tokenResponse.user;
160-
if (user) {
161-
try {
162-
setUserInfo({
163-
userId: user.id,
164-
email: user.email,
165-
name: user.name,
166-
});
167-
} catch (error) {
168-
// Report to Sentry but don't block auth - user info is not critical
169-
Sentry.captureException(error);
170-
}
94+
const loginSuccess = await runInteractiveLogin(
95+
stdout,
96+
stderr,
97+
process.stdin,
98+
{
99+
timeout: flags.timeout * 1000,
171100
}
101+
);
172102

173-
stdout.write(`${success("✓")} Authentication successful!\n`);
174-
if (user) {
175-
stdout.write(` Logged in as: ${muted(formatUserIdentity(user))}\n`);
176-
}
177-
stdout.write(` Config saved to: ${getDbPath()}\n`);
178-
179-
if (tokenResponse.expires_in) {
180-
stdout.write(
181-
` Token expires in: ${formatDuration(tokenResponse.expires_in)}\n`
182-
);
183-
}
184-
} finally {
185-
// Always cleanup keyboard listener
186-
keyListener.cleanup?.();
103+
if (!loginSuccess) {
104+
// Error already displayed by runInteractiveLogin - just set exit code
105+
process.exitCode = 1;
187106
}
188107
},
189108
});

src/commands/auth/status.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,10 @@ export const statusCommand = buildCommand({
142142
stdout.write(`Config: ${getDbPath()}\n`);
143143

144144
if (!authenticated) {
145-
throw new AuthError("not_authenticated");
145+
// Skip auto-login - user explicitly ran status to check auth state
146+
throw new AuthError("not_authenticated", undefined, {
147+
skipAutoAuth: true,
148+
});
146149
}
147150

148151
stdout.write(`Status: Authenticated ${success("✓")}\n`);

src/lib/errors.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,16 +70,29 @@ export class ApiError extends CliError {
7070

7171
export type AuthErrorReason = "not_authenticated" | "expired" | "invalid";
7272

73+
/** Options for AuthError */
74+
export type AuthErrorOptions = {
75+
/** Skip auto-login flow when this error is caught (for auth commands) */
76+
skipAutoAuth?: boolean;
77+
};
78+
7379
/**
7480
* Authentication errors.
7581
*
7682
* @param reason - Type of auth failure
7783
* @param message - Custom message (uses default if not provided)
84+
* @param options - Additional options (e.g., skipAutoAuth for auth commands)
7885
*/
7986
export class AuthError extends CliError {
8087
readonly reason: AuthErrorReason;
88+
/** When true, the auto-login flow should not be triggered for this error */
89+
readonly skipAutoAuth: boolean;
8190

82-
constructor(reason: AuthErrorReason, message?: string) {
91+
constructor(
92+
reason: AuthErrorReason,
93+
message?: string,
94+
options?: AuthErrorOptions
95+
) {
8396
const defaultMessages: Record<AuthErrorReason, string> = {
8497
not_authenticated: "Not authenticated. Run 'sentry auth login' first.",
8598
expired:
@@ -89,6 +102,7 @@ export class AuthError extends CliError {
89102
super(message ?? defaultMessages[reason]);
90103
this.name = "AuthError";
91104
this.reason = reason;
105+
this.skipAutoAuth = options?.skipAutoAuth ?? false;
92106
}
93107
}
94108

0 commit comments

Comments
 (0)