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
35 changes: 17 additions & 18 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,25 +147,24 @@ if (result.success) {

### Error Handling

```typescript
// Custom error classes extend Error
export class SentryApiError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = "SentryApiError";
this.status = status;
}
}
All CLI errors extend the `CliError` base class from `src/lib/errors.ts`:

// In commands: catch and write to stderr
try {
// ...
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`Error: ${message}\n`);
process.exitCode = 1;
}
```typescript
// Error hierarchy in src/lib/errors.ts
CliError (base)
├── ApiError (HTTP/API failures - status, detail, endpoint)
├── AuthError (authentication - reason: 'not_authenticated' | 'expired' | 'invalid')
├── ConfigError (configuration - suggestion?)
├── ValidationError (input validation - field?)
└── DeviceFlowError (OAuth flow - code)

// Usage: throw specific error types
import { ApiError, AuthError } from "../lib/errors.js";
throw new AuthError("not_authenticated");
throw new ApiError("Request failed", 404, "Not found");

// In commands: let errors propagate to central handler
// The bin.ts entry point catches and formats all errors consistently
```

### Async Config Functions
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,11 @@
import { run } from "@stricli/core";
import { app } from "./app.js";
import { buildContext } from "./context.js";
import { formatError, getExitCode } from "./lib/errors.js";

await run(app, process.argv.slice(2), buildContext(process));
try {
await run(app, process.argv.slice(2), buildContext(process));
} catch (error) {
process.stderr.write(`Error: ${formatError(error)}\n`);
process.exit(getExitCode(error));
}
53 changes: 23 additions & 30 deletions packages/cli/src/commands/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,43 +217,36 @@ export const apiCommand = buildCommand({
endpoint: string
): Promise<void> {
const { process } = this;
const { stdout, stderr } = process;
const { stdout } = process;

try {
const body =
flags.field?.length > 0 ? parseFields(flags.field) : undefined;
const headers =
flags.header?.length > 0 ? parseHeaders(flags.header) : undefined;
const body = flags.field?.length > 0 ? parseFields(flags.field) : undefined;
const headers =
flags.header?.length > 0 ? parseHeaders(flags.header) : undefined;

const response = await rawApiRequest(endpoint, {
method: flags.method,
body,
headers,
});
const response = await rawApiRequest(endpoint, {
method: flags.method,
body,
headers,
});

// Silent mode - only set exit code
if (flags.silent) {
if (response.status >= 400) {
process.exitCode = 1;
}
return;
// Silent mode - only set exit code
if (flags.silent) {
if (response.status >= 400) {
process.exitCode = 1;
}
return;
}

// Output headers if requested
if (flags.include) {
writeResponseHeaders(stdout, response.status, response.headers);
}
// Output headers if requested
if (flags.include) {
writeResponseHeaders(stdout, response.status, response.headers);
}

// Output body
writeResponseBody(stdout, response.body);
// Output body
writeResponseBody(stdout, response.body);

// Set exit code for error responses
if (response.status >= 400) {
process.exitCode = 1;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
stderr.write(`Error: ${message}\n`);
// Set exit code for error responses
if (response.status >= 400) {
process.exitCode = 1;
}
},
Expand Down
85 changes: 41 additions & 44 deletions packages/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
isAuthenticated,
setAuthToken,
} from "../../lib/config.js";
import { AuthError } from "../../lib/errors.js";
import { completeOAuthFlow, performDeviceFlow } from "../../lib/oauth.js";
import { generateQRCode } from "../../lib/qrcode.js";

Expand Down Expand Up @@ -68,7 +69,8 @@ export const loginCommand = buildCommand({
} catch {
// Token is invalid - clear it and throw
await clearAuth();
throw new Error(
throw new AuthError(
"invalid",
"Invalid API token. Please check your token and try again."
);
}
Expand All @@ -81,56 +83,51 @@ export const loginCommand = buildCommand({
// Device Flow OAuth
process.stdout.write("Starting authentication...\n\n");

try {
const tokenResponse = await performDeviceFlow(
{
onUserCode: async (
userCode,
verificationUri,
verificationUriComplete
) => {
const browserOpened = await openBrowser(verificationUriComplete);
if (browserOpened) {
process.stdout.write("Opening browser...\n\n");
}
const tokenResponse = await performDeviceFlow(
{
onUserCode: async (
userCode,
verificationUri,
verificationUriComplete
) => {
const browserOpened = await openBrowser(verificationUriComplete);
if (browserOpened) {
process.stdout.write("Opening browser...\n\n");
}

if (flags.qr) {
process.stdout.write(
"Scan this QR code or visit the URL below:\n\n"
);
const qr = await generateQRCode(verificationUriComplete);
process.stdout.write(qr);
process.stdout.write("\n");
}
if (flags.qr) {
process.stdout.write(
"Scan this QR code or visit the URL below:\n\n"
);
const qr = await generateQRCode(verificationUriComplete);
process.stdout.write(qr);
process.stdout.write("\n");
}

process.stdout.write(`URL: ${verificationUri}\n`);
process.stdout.write(`Code: ${userCode}\n\n`);
process.stdout.write("Waiting for authorization...\n");
},
onPolling: () => {
// Could add a spinner or dots here
process.stdout.write(".");
},
process.stdout.write(`URL: ${verificationUri}\n`);
process.stdout.write(`Code: ${userCode}\n\n`);
process.stdout.write("Waiting for authorization...\n");
},
flags.timeout * 1000
);
onPolling: () => {
// Could add a spinner or dots here
process.stdout.write(".");
},
},
flags.timeout * 1000
);

// Clear the polling dots
process.stdout.write("\n\n");
// Clear the polling dots
process.stdout.write("\n\n");

// Store the token
await completeOAuthFlow(tokenResponse);
// Store the token
await completeOAuthFlow(tokenResponse);

process.stdout.write("✓ Authentication successful!\n");
process.stdout.write(` Config saved to: ${getConfigPath()}\n`);
process.stdout.write("✓ Authentication successful!\n");
process.stdout.write(` Config saved to: ${getConfigPath()}\n`);

if (tokenResponse.expires_in) {
const hours = Math.round(tokenResponse.expires_in / 3600);
process.stdout.write(` Token expires in: ${hours} hours\n`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Authentication failed: ${message}`);
if (tokenResponse.expires_in) {
const hours = Math.round(tokenResponse.expires_in / 3600);
process.stdout.write(` Token expires in: ${hours} hours\n`);
}
},
});
16 changes: 9 additions & 7 deletions packages/cli/src/commands/auth/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
isAuthenticated,
readConfig,
} from "../../lib/config.js";
import { AuthError } from "../../lib/errors.js";
import { formatExpiration, maskToken } from "../../lib/formatters/human.js";
import type { SentryConfig, Writer } from "../../types/index.js";

Expand Down Expand Up @@ -66,7 +67,10 @@ async function writeDefaults(stdout: Writer): Promise<void> {
/**
* Verify credentials by fetching organizations
*/
async function verifyCredentials(stdout: Writer): Promise<void> {
async function verifyCredentials(
stdout: Writer,
stderr: Writer
): Promise<void> {
stdout.write("\nVerifying credentials...\n");

try {
Expand All @@ -84,7 +88,7 @@ async function verifyCredentials(stdout: Writer): Promise<void> {
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
stdout.write(`\n✗ Could not verify credentials: ${message}\n`);
stderr.write(`\n✗ Could not verify credentials: ${message}\n`);
}
}

Expand All @@ -106,23 +110,21 @@ export const statusCommand = buildCommand({
},
async func(this: SentryContext, flags: StatusFlags): Promise<void> {
const { process } = this;
const { stdout } = process;
const { stdout, stderr } = process;

const config = await readConfig();
const authenticated = await isAuthenticated();

stdout.write(`Config file: ${getConfigPath()}\n\n`);

if (!authenticated) {
throw new Error(
"Not authenticated. Run 'sentry auth login' to authenticate."
);
throw new AuthError("not_authenticated");
}

stdout.write("Status: Authenticated ✓\n\n");

writeTokenInfo(stdout, config, flags.showToken);
await writeDefaults(stdout);
await verifyCredentials(stdout);
await verifyCredentials(stdout, stderr);
},
});
34 changes: 9 additions & 25 deletions packages/cli/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
SentryProject,
} from "../types/index.js";
import { getAuthToken } from "./config.js";
import { ApiError, AuthError } from "./errors.js";

const DEFAULT_SENTRY_URL = "https://sentry.io";

Expand Down Expand Up @@ -50,22 +51,6 @@ function normalizePath(endpoint: string): string {
*/
const SHORT_ID_PATTERN = /[a-zA-Z]/;

// ─────────────────────────────────────────────────────────────────────────────
// Error Handling
// ─────────────────────────────────────────────────────────────────────────────

export class SentryApiError extends Error {
readonly status: number;
readonly detail?: string;

constructor(message: string, status: number, detail?: string) {
super(message);
this.name = "SentryApiError";
this.status = status;
this.detail = detail;
}
}

// ─────────────────────────────────────────────────────────────────────────────
// Request Helpers
// ─────────────────────────────────────────────────────────────────────────────
Expand All @@ -79,16 +64,14 @@ type ApiRequestOptions = {
/**
* Create a configured ky instance with retry, timeout, and authentication.
*
* @throws {SentryApiError} When not authenticated (status 401)
* @throws {AuthError} When not authenticated
* @throws {ApiError} When API request fails
*/
async function createApiClient(): Promise<KyInstance> {
const token = await getAuthToken();

if (!token) {
throw new SentryApiError(
"Not authenticated. Run 'sentry auth login' first.",
401
);
throw new AuthError("not_authenticated");
}

return ky.create({
Expand Down Expand Up @@ -117,7 +100,7 @@ async function createApiClient(): Promise<KyInstance> {
} catch {
detail = text;
}
throw new SentryApiError(
throw new ApiError(
`API request failed: ${response.status} ${response.statusText}`,
response.status,
detail
Expand All @@ -140,7 +123,7 @@ function buildSearchParams(
params?: Record<string, string | number | boolean | undefined>
): URLSearchParams | undefined {
if (!params) {
return undefined;
return;
}

const searchParams = new URLSearchParams();
Expand All @@ -163,7 +146,8 @@ function buildSearchParams(
* @param endpoint - API endpoint path (e.g., "/organizations/")
* @param options - Request options including method, body, and query params
* @returns Parsed JSON response
* @throws {SentryApiError} On authentication failure or API errors
* @throws {AuthError} When not authenticated
* @throws {ApiError} On API errors
*/
export async function apiRequest<T>(
endpoint: string,
Expand All @@ -189,7 +173,7 @@ export async function apiRequest<T>(
* @param endpoint - API endpoint path (e.g., "/organizations/")
* @param options - Request options including method, body, params, and custom headers
* @returns Response status, headers, and parsed body
* @throws {SentryApiError} Only on authentication failure (not on API errors)
* @throws {AuthError} Only on authentication failure (not on API errors)
*/
export async function rawApiRequest(
endpoint: string,
Expand Down
Loading