From 832738f2c78a82e45be5b2035e9af61f7120992a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 29 Apr 2026 04:59:03 +0000 Subject: [PATCH 1/9] feat: standardize exit codes across all CLI commands Add semantic exit codes to every CliError subclass so scripts, CI pipelines, and AI agents can react to failure categories without parsing stderr. Exit codes are grouped into decades inspired by HTTP status semantics: - 1x: Auth & identity (401/403 family) - 2x: Input & config (400/404/422 family) - 3x: API & network (502/504 family) - 4x: Feature/billing (402/451 family) - 5x: Operations (upgrade, OAuth) - 6x: Command-specific All codes stay below 128 to avoid collision with Unix signal exits. Closes #392 --- AGENTS.md | 30 ++-- docs/src/content/docs/agent-guidance.md | 14 ++ docs/src/content/docs/exit-codes.md | 89 ++++++++++++ plugins/sentry-cli/skills/sentry-cli/SKILL.md | 14 ++ src/lib/errors.ts | 128 ++++++++++++++++-- test/commands/cli/fix.test.ts | 12 +- test/e2e/api.test.ts | 23 ++-- test/e2e/auth.test.ts | 7 +- test/e2e/event.test.ts | 9 +- test/e2e/issue.test.ts | 7 +- test/e2e/log.test.ts | 9 +- test/e2e/project.test.ts | 17 +-- test/e2e/trace.test.ts | 9 +- test/lib/command.test.ts | 6 +- test/lib/errors.test.ts | 101 +++++++++++++- 15 files changed, 403 insertions(+), 72 deletions(-) create mode 100644 docs/src/content/docs/exit-codes.md diff --git a/AGENTS.md b/AGENTS.md index 0a3afdd49..7855c9513 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -563,18 +563,28 @@ All CLI errors extend the `CliError` base class from `src/lib/errors.ts`: ```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?) -├── ContextError (missing context - resource, command, alternatives) -├── ResolutionError (value provided but not found - resource, headline, hint, suggestions) -├── ValidationError (input validation - field?) -├── DeviceFlowError (OAuth flow - code) -├── SeerError (Seer AI - reason: 'not_enabled' | 'no_budget' | 'ai_disabled') -└── UpgradeError (upgrade - reason: 'unknown_method' | 'network_error' | 'execution_failed' | 'version_not_found') +// Exit codes are defined in the EXIT constant object — use EXIT.* constants +// when constructing errors, never hardcode numeric exit codes outside errors.ts. +CliError (base, exitCode=1) +├── HostScopeError (exitCode=13) +├── ApiError (exitCode=30 — HTTP/API failures) +├── AuthError (exitCode=10–12 by reason — 'not_authenticated' | 'expired' | 'invalid') +├── ConfigError (exitCode=20 — configuration/DSN) +├── OutputError (exitCode=60 — data rendered, but operation failed) +├── ContextError (exitCode=22 — missing context) +├── ResolutionError (exitCode=23 — value provided but not found) +├── ValidationError (exitCode=21 — input validation) +├── DeviceFlowError (exitCode=51 — OAuth flow) +├── SeerError (exitCode=40–42 by reason — 'not_enabled' | 'no_budget' | 'ai_disabled') +├── TimeoutError (exitCode=31 — operation timed out) +├── UpgradeError (exitCode=50 — upgrade failures) +└── WizardError (exitCode=61 — init wizard error) ``` +> Exit code ranges: 1x=auth, 2x=input/config, 3x=API/network, 4x=feature/billing, +> 5x=operations, 6x=command-specific. See `EXIT` in `src/lib/errors.ts` and +> https://cli.sentry.dev/exit-codes/ for the full reference. + **Choosing between ContextError, ResolutionError, and ValidationError:** | Scenario | Error Class | Example | diff --git a/docs/src/content/docs/agent-guidance.md b/docs/src/content/docs/agent-guidance.md index c31391243..26740acf8 100644 --- a/docs/src/content/docs/agent-guidance.md +++ b/docs/src/content/docs/agent-guidance.md @@ -36,6 +36,20 @@ The `sentry` CLI follows conventions from well-known tools — if you're familia - Never store or log authentication tokens — the CLI manages credentials automatically - If the CLI reports the wrong org/project, override with explicit `/` arguments +## Exit Codes + +The CLI uses semantic exit codes. Key ranges for agents: + +| Range | Meaning | Agent Action | +|-------|---------|-------------| +| 0 | Success | Proceed normally | +| 10–19 | Auth error | Prompt user to run `sentry auth login` | +| 20–29 | Input error | Check command arguments and retry | +| 30–39 | API error | Retry or report to user | +| 40–49 | Feature unavailable | Inform user about plan/settings | + +See [Exit Codes](/exit-codes/) for the complete reference. + ## Workflow Patterns ### Investigate an Issue diff --git a/docs/src/content/docs/exit-codes.md b/docs/src/content/docs/exit-codes.md new file mode 100644 index 000000000..6398180cf --- /dev/null +++ b/docs/src/content/docs/exit-codes.md @@ -0,0 +1,89 @@ +--- +title: Exit Codes +description: Exit code reference for scripting and automation with the Sentry CLI +--- + +The CLI uses semantic exit codes so scripts, CI pipelines, and AI agents can +react to failure categories without parsing stderr. + +## Exit Code Ranges + +| Range | Category | Description | +|-------|----------|-------------| +| 0 | Success | Command completed successfully | +| 1 | General | Unexpected or unclassified error | +| 10–19 | Auth | Authentication and authorization failures | +| 20–29 | Input | Configuration, validation, and resolution errors | +| 30–39 | API | Sentry API and network errors | +| 40–49 | Feature | Feature availability and billing issues | +| 50–59 | Operations | Upgrade and OAuth flow errors | +| 60–69 | Command | Command-specific non-standard exits | + +## Complete Reference + +| Code | Name | Description | +|------|------|-------------| +| 0 | Success | Command completed successfully | +| 1 | General Error | Unexpected error or unclassified failure | +| 10 | Not Authenticated | No credentials found — run `sentry auth login` | +| 11 | Token Expired | Auth token expired — re-authenticate | +| 12 | Token Invalid | Auth token rejected by the server | +| 13 | Host Scope | Request blocked — credentials don't match the target host | +| 20 | Config Error | Configuration or DSN problem | +| 21 | Validation Error | Invalid input (malformed ID, bad flag value, etc.) | +| 22 | Missing Context | Required context (org, project) could not be determined | +| 23 | Not Found | A user-provided identifier could not be resolved | +| 30 | API Error | Sentry API returned an error response | +| 31 | Timeout | Operation exceeded its time limit | +| 40 | Seer Not Enabled | Seer is not enabled for the organization | +| 41 | Seer No Budget | Seer requires a paid plan | +| 42 | AI Disabled | AI features disabled by organization admin | +| 50 | Upgrade Error | CLI upgrade operation failed | +| 51 | Device Flow Error | OAuth device authorization flow failed | +| 60 | Output Error | Command produced output but the operation failed | +| 61 | Wizard Error | Interactive setup wizard encountered an error | + +## Scripting Examples + +### Bash + +```bash +sentry issue list my-org/ +code=$? + +case $code in + 0) echo "Success" ;; + 1?) echo "Auth problem (code $code) — run: sentry auth login" ;; + 2?) echo "Input/config problem (code $code)" ;; + 3?) echo "API/network error (code $code)" ;; + 4?) echo "Feature not available (code $code)" ;; + *) echo "Failed with exit code $code" ;; +esac +``` + +### Python + +```python +import subprocess + +result = subprocess.run(["sentry", "issue", "list", "my-org/"], capture_output=True) + +if result.returncode == 0: + print("Success") +elif 10 <= result.returncode <= 19: + print("Auth error — run: sentry auth login") +elif 20 <= result.returncode <= 29: + print("Input/config error") +elif result.returncode == 30: + print("API error") +``` + +## Notes + +- Exit codes below 128 are safe from collision with Unix signal exits (128+N). +- The `sentry init` wizard uses its own exit codes (10, 20, 30, 50) for + remote workflow step failures. These are internal to the wizard and wrapped + into exit code 61 (Wizard Error) before reaching the process exit. +- [Stricli](https://bloomberg.github.io/stricli/) (the CLI framework) uses + negative exit codes (-5 to -1) for framework-level errors like unknown + commands or invalid arguments. These appear as 251–255 in unsigned form. diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 72724f31d..cc6253c50 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -46,6 +46,20 @@ The `sentry` CLI follows conventions from well-known tools — if you're familia - Never store or log authentication tokens — the CLI manages credentials automatically - If the CLI reports the wrong org/project, override with explicit `/` arguments +### Exit Codes + +The CLI uses semantic exit codes. Key ranges for agents: + +| Range | Meaning | Agent Action | +|-------|---------|-------------| +| 0 | Success | Proceed normally | +| 10–19 | Auth error | Prompt user to run `sentry auth login` | +| 20–29 | Input error | Check command arguments and retry | +| 30–39 | API error | Retry or report to user | +| 40–49 | Feature unavailable | Inform user about plan/settings | + +See [Exit Codes](/exit-codes/) for the complete reference. + ### Workflow Patterns #### Investigate an Issue diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 8e3dd7f6f..dd0cfe535 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -2,6 +2,25 @@ * CLI Error Hierarchy * * Unified error classes for consistent error handling across the CLI. + * + * ## Exit Code Ranges + * + * Each error class maps to a semantic exit code so scripts and agents can + * react to failure categories without parsing stderr. Codes are grouped + * into decades inspired by HTTP status semantics: + * + * | Range | Category | HTTP Analogy | + * |-------|-------------------|----------------------| + * | 0 | Success | 200 OK | + * | 1 | General error | 500 Internal | + * | 10–19 | Auth & identity | 401/403 | + * | 20–29 | Input & config | 400/404/422 | + * | 30–39 | API & network | 502/503/504 | + * | 40–49 | Feature/billing | 402/451 | + * | 50–59 | Operations | — | + * | 60–69 | Command-specific | — | + * + * @see https://cli.sentry.dev/exit-codes/ for full reference */ import { @@ -10,6 +29,69 @@ import { buildSeerSettingsUrl, } from "./sentry-urls.js"; +// --------------------------------------------------------------------------- +// Exit code constants +// --------------------------------------------------------------------------- + +/** + * Semantic exit codes for all CLI error classes. + * + * Grouped into decades so scripts can match on ranges: + * `if code >= 10 && code < 20 → auth problem`. + * + * All codes stay below 128 to avoid collision with Unix signal exits (128+N). + */ +export const EXIT = { + /** Catch-all for unexpected errors */ + GENERAL: 1, + + // 10–19: Auth & identity (HTTP 401/403 family) + /** Not authenticated — run `sentry auth login` */ + AUTH_NOT_AUTHENTICATED: 10, + /** Token expired — re-authenticate */ + AUTH_EXPIRED: 11, + /** Token invalid / rejected */ + AUTH_INVALID: 12, + /** Request blocked by host-scope trust check */ + AUTH_HOST_SCOPE: 13, + + // 20–29: Input & config (HTTP 400/404/422 family) + /** Configuration or DSN error */ + CONFIG: 20, + /** Input validation failed */ + VALIDATION: 21, + /** Required context (org, project, etc.) missing */ + CONTEXT_MISSING: 22, + /** User-provided value could not be resolved */ + RESOLUTION: 23, + + // 30–39: API & network (HTTP 502/503/504 family) + /** Sentry API returned an error */ + API: 30, + /** Operation timed out */ + TIMEOUT: 31, + + // 40–49: Feature / billing (HTTP 402/451 family) + /** Seer not enabled for the organization */ + SEER_NOT_ENABLED: 40, + /** Seer requires a paid plan */ + SEER_NO_BUDGET: 41, + /** AI features disabled by org admin */ + SEER_AI_DISABLED: 42, + + // 50–59: Operations + /** CLI upgrade failed */ + UPGRADE: 50, + /** OAuth device flow error */ + DEVICE_FLOW: 51, + + // 60–69: Command-specific + /** Command produced output but should exit non-zero */ + OUTPUT_ERROR: 60, + /** Init wizard error */ + WIZARD: 61, +} as const; + /** * Base class for all CLI errors. * @@ -55,19 +137,21 @@ export class HostScopeError extends CliError { tokenHost?: string | undefined ) { if (destinationUrl === undefined) { - super(sourceOrMessage); + super(sourceOrMessage, EXIT.AUTH_HOST_SCOPE); } else if (tokenHost === undefined) { super( `${sourceOrMessage}: ${destinationUrl}\n` + "Refusing to route requests to this host because no Sentry credentials are configured for it.\n" + - `To use this host, run: sentry auth login --url ${destinationUrl}` + `To use this host, run: sentry auth login --url ${destinationUrl}`, + EXIT.AUTH_HOST_SCOPE ); } else { super( `${sourceOrMessage}: ${destinationUrl}\n` + `Refusing to route requests here because it doesn't match the host your Sentry credentials are for (${tokenHost}).\n` + `To use this host, run: sentry auth login --url ${destinationUrl}\n` + - "To keep using your current credentials, remove this URL override." + "To keep using your current credentials, remove this URL override.", + EXIT.AUTH_HOST_SCOPE ); } this.name = "HostScopeError"; @@ -93,7 +177,7 @@ export class ApiError extends CliError { detail?: string, endpoint?: string ) { - super(message); + super(message, EXIT.API); this.name = "ApiError"; this.status = status; this.detail = detail; @@ -143,7 +227,12 @@ export class AuthError extends CliError { "Authentication expired. Run 'sentry auth login' to re-authenticate.", invalid: "Invalid authentication token.", }; - super(message ?? defaultMessages[reason]); + const exitCodes: Record = { + not_authenticated: EXIT.AUTH_NOT_AUTHENTICATED, + expired: EXIT.AUTH_EXPIRED, + invalid: EXIT.AUTH_INVALID, + }; + super(message ?? defaultMessages[reason], exitCodes[reason]); this.name = "AuthError"; this.reason = reason; this.skipAutoAuth = options?.skipAutoAuth ?? false; @@ -160,7 +249,7 @@ export class ConfigError extends CliError { readonly suggestion?: string; constructor(message: string, suggestion?: string) { - super(message); + super(message, EXIT.CONFIG); this.name = "ConfigError"; this.suggestion = suggestion; } @@ -188,7 +277,7 @@ export class OutputError extends CliError { readonly data: unknown; constructor(data: unknown) { - super("", 1); + super("", EXIT.OUTPUT_ERROR); this.name = "OutputError"; this.data = data; } @@ -322,7 +411,8 @@ export class ContextError extends CliError { buildContextMessage(resource, command, resolvedAlternatives, { note, isAutoDetect, - }) + }), + EXIT.CONTEXT_MISSING ); this.name = "ContextError"; this.resource = resource; @@ -381,7 +471,10 @@ export class ResolutionError extends CliError { hint: string, suggestions: string[] = [] ) { - super(buildResolutionMessage(resource, headline, hint, suggestions)); + super( + buildResolutionMessage(resource, headline, hint, suggestions), + EXIT.RESOLUTION + ); this.name = "ResolutionError"; this.resource = resource; this.headline = headline; @@ -404,7 +497,7 @@ export class ValidationError extends CliError { readonly field?: string; constructor(message: string, field?: string) { - super(message); + super(message, EXIT.VALIDATION); this.name = "ValidationError"; this.field = field; } @@ -420,7 +513,7 @@ export class DeviceFlowError extends CliError { readonly code: string; constructor(code: string, description?: string) { - super(description ?? code); + super(description ?? code, EXIT.DEVICE_FLOW); this.name = "DeviceFlowError"; this.code = code; } @@ -457,7 +550,7 @@ export class UpgradeError extends CliError { offline_cache_miss: "Cannot upgrade offline — no pre-downloaded update is available.", }; - super(message ?? defaultMessages[reason]); + super(message ?? defaultMessages[reason], EXIT.UPGRADE); this.name = "UpgradeError"; this.reason = reason; } @@ -483,7 +576,12 @@ export class SeerError extends CliError { no_budget: "Seer requires a paid plan.", ai_disabled: "AI features are disabled for this organization.", }; - super(messages[reason]); + const exitCodes: Record = { + not_enabled: EXIT.SEER_NOT_ENABLED, + no_budget: EXIT.SEER_NO_BUDGET, + ai_disabled: EXIT.SEER_AI_DISABLED, + }; + super(messages[reason], exitCodes[reason]); this.name = "SeerError"; this.reason = reason; this.orgSlug = orgSlug; @@ -535,7 +633,7 @@ export class TimeoutError extends CliError { readonly hint?: string; constructor(message: string, hint?: string) { - super(message); + super(message, EXIT.TIMEOUT); this.name = "TimeoutError"; this.hint = hint; } @@ -558,7 +656,7 @@ export class WizardError extends CliError { readonly rendered: boolean; constructor(message: string, options?: { rendered?: boolean }) { - super(message); + super(message, EXIT.WIZARD); this.name = "WizardError"; this.rendered = options?.rendered ?? true; } diff --git a/test/commands/cli/fix.test.ts b/test/commands/cli/fix.test.ts index 3856a6c33..634de40c2 100644 --- a/test/commands/cli/fix.test.ts +++ b/test/commands/cli/fix.test.ts @@ -21,7 +21,7 @@ import { generatePreMigrationTableDDL, initSchema, } from "../../../src/lib/db/schema.js"; -import { OutputError } from "../../../src/lib/errors.js"; +import { EXIT, OutputError } from "../../../src/lib/errors.js"; import { useTestConfigDir } from "../../helpers.js"; /** @@ -346,7 +346,7 @@ describe("sentry cli fix", () => { // Schema failure is rendered as an issue with repair details expect(output).toContain("Schema"); expect(output).toContain("Try deleting the database"); - expect(exitCode).toBe(1); + expect(exitCode).toBe(EXIT.OUTPUT_ERROR); // Should NOT say "No issues found" expect(output).not.toContain("No issues found"); }); @@ -363,7 +363,7 @@ describe("sentry cli fix", () => { expect(output).toContain("Schema"); expect(output).toContain("Try deleting the database"); - expect(exitCode).toBe(1); + expect(exitCode).toBe(EXIT.OUTPUT_ERROR); }); test("schema check failure with permission issues does not print schema error", async () => { @@ -520,7 +520,7 @@ describe("sentry cli fix — ownership detection", () => { // Not uid 0, so we can't chown — expect instructions expect(output).toContain("sudo chown"); expect(output).toContain("sudo sentry cli fix"); - expect(exitCode).toBe(1); + expect(exitCode).toBe(EXIT.OUTPUT_ERROR); }); test("dry-run reports ownership issues with chown instructions", async () => { @@ -614,7 +614,7 @@ describe("sentry cli fix — ownership detection", () => { chmodSync(join(getOwnershipTestDir(), "cli.db"), 0o600); const { exitCode: code } = await runFixWithUid(false, () => 9999); - expect(code).toBe(1); + expect(code).toBe(EXIT.OUTPUT_ERROR); }); test("skips permission check when ownership repair fails", async () => { @@ -662,7 +662,7 @@ describe("sentry cli fix — ownership detection", () => { try { const { output, exitCode } = await runFixWithUid(false, () => 0); - expect(exitCode).toBe(1); + expect(exitCode).toBe(EXIT.OUTPUT_ERROR); // The instructions mention the inability to determine UID expect(output).toContain("Could not determine a non-root UID"); } finally { diff --git a/test/e2e/api.test.ts b/test/e2e/api.test.ts index 5cc43aff6..3ec8d4189 100644 --- a/test/e2e/api.test.ts +++ b/test/e2e/api.test.ts @@ -13,6 +13,7 @@ import { expect, test, } from "bun:test"; +import { EXIT } from "../../src/lib/errors.js"; import { createE2EContext, type E2EContext } from "../fixture.js"; import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; import { createSentryMockServer, TEST_TOKEN } from "../mocks/routes.js"; @@ -47,7 +48,7 @@ describe("sentry api", () => { test("requires authentication", async () => { const result = await ctx.run(["api", "organizations/"]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.AUTH_NOT_AUTHENTICATED); expect(result.stderr + result.stdout).toMatch(/not authenticated|login/i); }); @@ -73,7 +74,7 @@ describe("sentry api", () => { const result = await ctx.run(["api", "nonexistent-endpoint-12345/"]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.API); }, { timeout: 15_000 } ); @@ -102,7 +103,7 @@ describe("sentry api", () => { "--silent", ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.API); expect(result.stdout).toBe(""); }, { timeout: 15_000 } @@ -122,7 +123,7 @@ describe("sentry api", () => { ]); // Method not allowed or similar error - just checking it processes the flag - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.API); }, { timeout: 15_000 } ); @@ -159,7 +160,7 @@ describe("sentry api", () => { const result = await ctx.run(["api", "organizations/", "-X", "POST"]); // POST on list endpoint typically returns 405 or similar error - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.API); }, { timeout: 15_000 } ); @@ -233,7 +234,7 @@ describe("sentry api", () => { ]); // Will fail with 404 or similar, but the flag should be processed - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.API); }, { timeout: 15_000 } ); @@ -250,7 +251,7 @@ describe("sentry api", () => { "/nonexistent/file.json", ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.VALIDATION); expect(result.stderr + result.stdout).toMatch(/file not found/i); }, { timeout: 15_000 } @@ -301,7 +302,7 @@ describe("sentry api", () => { // Should get a server error (405 Method Not Allowed or 400 Bad Request), // not a client-side error about body handling - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.API); // The error should be from the API, not a TypeError about body expect(result.stdout + result.stderr).not.toMatch(/cannot have body/i); }, @@ -324,7 +325,7 @@ describe("sentry api", () => { "-", ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.VALIDATION); expect(result.stderr + result.stdout).toMatch( /--data.*--input|--input.*--data/i ); @@ -348,7 +349,7 @@ describe("sentry api", () => { "slug=my-org", ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.VALIDATION); expect(result.stderr + result.stdout).toMatch( /--data.*--field|--field.*--data/i ); @@ -372,7 +373,7 @@ describe("sentry api", () => { "slug=my-org", ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.VALIDATION); expect(result.stderr + result.stdout).toMatch( /--data.*--field|--field.*--data/i ); diff --git a/test/e2e/auth.test.ts b/test/e2e/auth.test.ts index ae8e55bdd..4868c6952 100644 --- a/test/e2e/auth.test.ts +++ b/test/e2e/auth.test.ts @@ -13,6 +13,7 @@ import { expect, test, } from "bun:test"; +import { EXIT } from "../../src/lib/errors.js"; import { createE2EContext, type E2EContext } from "../fixture.js"; import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; import { createSentryMockServer, TEST_TOKEN } from "../mocks/routes.js"; @@ -47,7 +48,7 @@ describe("sentry auth status", () => { // Error message may be in stdout or stderr depending on CLI framework const output = result.stdout + result.stderr; expect(output).toMatch(/not authenticated/i); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.AUTH_NOT_AUTHENTICATED); }); test("shows authenticated with valid token", async () => { @@ -109,7 +110,7 @@ describe("sentry auth login --token", () => { "invalid-token-12345", ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.AUTH_HOST_SCOPE); expect(result.stderr + result.stdout).toMatch( /invalid|unauthorized|error/i ); @@ -122,7 +123,7 @@ describe("sentry auth whoami", () => { const output = result.stdout + result.stderr; expect(output).toMatch(/not authenticated/i); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.AUTH_NOT_AUTHENTICATED); }); test("shows current user identity", async () => { diff --git a/test/e2e/event.test.ts b/test/e2e/event.test.ts index a9b932dd1..13d9fb1a3 100644 --- a/test/e2e/event.test.ts +++ b/test/e2e/event.test.ts @@ -13,6 +13,7 @@ import { expect, test, } from "bun:test"; +import { EXIT } from "../../src/lib/errors.js"; import { createE2EContext, type E2EContext } from "../fixture.js"; import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; import { @@ -55,7 +56,7 @@ describe("sentry event view", () => { "abc123def456abc123def456abc123de", ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.AUTH_NOT_AUTHENTICATED); expect(result.stderr + result.stdout).toMatch(/not authenticated|login/i); }); @@ -68,7 +69,7 @@ describe("sentry event view", () => { "abc123def456abc123def456abc123de", ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.CONTEXT_MISSING); expect(result.stderr + result.stdout).toMatch(/organization|project/i); }); @@ -77,7 +78,7 @@ describe("sentry event view", () => { const result = await ctx.run(["event", "view", "abc123"]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.VALIDATION); expect(result.stderr + result.stdout).toMatch( /invalid event id|32-character hexadecimal/i ); @@ -94,7 +95,7 @@ describe("sentry event view", () => { "abc123def456abc123def456abc123de", ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.RESOLUTION); expect(result.stderr + result.stdout).toMatch(/not found|error|404/i); }); }); diff --git a/test/e2e/issue.test.ts b/test/e2e/issue.test.ts index 0322c3d90..97f41f04b 100644 --- a/test/e2e/issue.test.ts +++ b/test/e2e/issue.test.ts @@ -13,6 +13,7 @@ import { expect, test, } from "bun:test"; +import { EXIT } from "../../src/lib/errors.js"; import { createE2EContext, type E2EContext } from "../fixture.js"; import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; import { @@ -53,7 +54,7 @@ describe("sentry issue list", () => { `${TEST_ORG}/${TEST_PROJECT}`, ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.AUTH_NOT_AUTHENTICATED); expect(result.stderr + result.stdout).toMatch(/not authenticated|login/i); }); @@ -123,7 +124,7 @@ describe("sentry issue view", () => { test("requires authentication", async () => { const result = await ctx.run(["issue", "view", "12345"]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.AUTH_NOT_AUTHENTICATED); expect(result.stderr + result.stdout).toMatch(/not authenticated|login/i); }); @@ -132,7 +133,7 @@ describe("sentry issue view", () => { const result = await ctx.run(["issue", "view", "99999999999"]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.RESOLUTION); expect(result.stderr + result.stdout).toMatch(/not found|error/i); }); }); diff --git a/test/e2e/log.test.ts b/test/e2e/log.test.ts index 05081d9a5..703ed4eb6 100644 --- a/test/e2e/log.test.ts +++ b/test/e2e/log.test.ts @@ -13,6 +13,7 @@ import { expect, test, } from "bun:test"; +import { EXIT } from "../../src/lib/errors.js"; import { createE2EContext, type E2EContext } from "../fixture.js"; import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; import { @@ -55,7 +56,7 @@ describe("sentry log list", () => { `${TEST_ORG}/${TEST_PROJECT}`, ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.AUTH_NOT_AUTHENTICATED); expect(result.stderr + result.stdout).toMatch(/not authenticated|login/i); }); @@ -210,7 +211,7 @@ describe("sentry log view", () => { TEST_LOG_ID, ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.AUTH_NOT_AUTHENTICATED); expect(result.stderr + result.stdout).toMatch(/not authenticated|login/i); }); @@ -219,7 +220,7 @@ describe("sentry log view", () => { const result = await ctx.run(["log", "view", TEST_LOG_ID]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.CONTEXT_MISSING); expect(result.stderr + result.stdout).toMatch(/organization|project/i); }); @@ -268,7 +269,7 @@ describe("sentry log view", () => { "deadbeefdeadbeefdeadbeefdeadbeef", ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.VALIDATION); expect(result.stderr + result.stdout).toMatch(/not found|no log/i); }); }); diff --git a/test/e2e/project.test.ts b/test/e2e/project.test.ts index 685d1e803..a37fc0d4e 100644 --- a/test/e2e/project.test.ts +++ b/test/e2e/project.test.ts @@ -13,6 +13,7 @@ import { expect, test, } from "bun:test"; +import { EXIT } from "../../src/lib/errors.js"; import { createE2EContext, type E2EContext } from "../fixture.js"; import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; import { @@ -50,7 +51,7 @@ describe("sentry org list", () => { test("requires authentication", async () => { const result = await ctx.run(["org", "list"]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.AUTH_NOT_AUTHENTICATED); expect(result.stderr + result.stdout).toMatch(/not authenticated|login/i); }); @@ -88,7 +89,7 @@ describe("sentry project list", () => { test("requires authentication", async () => { const result = await ctx.run(["project", "list"]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.AUTH_NOT_AUTHENTICATED); expect(result.stderr + result.stdout).toMatch(/not authenticated|login/i); }); @@ -140,7 +141,7 @@ describe("sentry org view", () => { test("requires authentication", async () => { const result = await ctx.run(["org", "view", TEST_ORG]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.AUTH_NOT_AUTHENTICATED); expect(result.stderr + result.stdout).toMatch(/not authenticated|login/i); }); @@ -179,7 +180,7 @@ describe("sentry org view", () => { const result = await ctx.run(["org", "view", "nonexistent-org-12345"]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.API); expect(result.stderr + result.stdout).toMatch(/not found|error|404/i); }, { timeout: 15_000 } @@ -194,7 +195,7 @@ describe("sentry project view", () => { `${TEST_ORG}/${TEST_PROJECT}`, ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.AUTH_NOT_AUTHENTICATED); expect(result.stderr + result.stdout).toMatch(/not authenticated|login/i); }); @@ -203,7 +204,7 @@ describe("sentry project view", () => { const result = await ctx.run(["project", "view"]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.CONTEXT_MISSING); expect(result.stderr + result.stdout).toMatch(/organization|project/i); }); @@ -212,7 +213,7 @@ describe("sentry project view", () => { const result = await ctx.run(["project", "view", `${TEST_ORG}/`]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.CONTEXT_MISSING); // Should show error with usage hint const output = result.stderr + result.stdout; expect(output).toMatch(/specific project is required/i); @@ -306,7 +307,7 @@ describe("sentry project view", () => { `${TEST_ORG}/nonexistent-project-12345`, ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.API); expect(result.stderr + result.stdout).toMatch(/not found|error|404/i); }, { timeout: 15_000 } diff --git a/test/e2e/trace.test.ts b/test/e2e/trace.test.ts index e483f35cd..b5204fda4 100644 --- a/test/e2e/trace.test.ts +++ b/test/e2e/trace.test.ts @@ -13,6 +13,7 @@ import { expect, test, } from "bun:test"; +import { EXIT } from "../../src/lib/errors.js"; import { createE2EContext, type E2EContext } from "../fixture.js"; import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; import { @@ -54,7 +55,7 @@ describe("sentry trace list", () => { `${TEST_ORG}/${TEST_PROJECT}`, ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.AUTH_NOT_AUTHENTICATED); expect(result.stderr + result.stdout).toMatch(/not authenticated|login/i); }); @@ -135,7 +136,7 @@ describe("sentry trace view", () => { TEST_TRACE_ID, ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.AUTH_NOT_AUTHENTICATED); expect(result.stderr + result.stdout).toMatch(/not authenticated|login/i); }); @@ -144,7 +145,7 @@ describe("sentry trace view", () => { const result = await ctx.run(["trace", "view", TEST_TRACE_ID]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.CONTEXT_MISSING); expect(result.stderr + result.stdout).toMatch(/organization|project/i); }); @@ -190,7 +191,7 @@ describe("sentry trace view", () => { "00000000000000000000000000000000", ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.API); expect(result.stderr + result.stdout).toMatch(/not found|no trace/i); }); }); diff --git a/test/lib/command.test.ts b/test/lib/command.test.ts index 4341fd34a..0873e3a01 100644 --- a/test/lib/command.test.ts +++ b/test/lib/command.test.ts @@ -25,7 +25,7 @@ import { numberParser, VERBOSE_FLAG, } from "../../src/lib/command.js"; -import { OutputError } from "../../src/lib/errors.js"; +import { EXIT, OutputError } from "../../src/lib/errors.js"; import { CommandOutput } from "../../src/lib/formatters/output.js"; import { LOG_LEVEL_NAMES, logger, setLogLevel } from "../../src/lib/logger.js"; import { resolveOrgAndProject } from "../../src/lib/resolve-target.js"; @@ -1387,7 +1387,7 @@ describe("buildCommand return-based output", () => { expect.unreachable("Expected OutputError to be thrown"); } catch (err) { expect(err).toBeInstanceOf(OutputError); - expect((err as OutputError).exitCode).toBe(1); + expect((err as OutputError).exitCode).toBe(EXIT.OUTPUT_ERROR); } // Output was rendered BEFORE the throw expect(ctx.output.join("")).toContain("Error: not found"); @@ -1436,7 +1436,7 @@ describe("buildCommand return-based output", () => { expect.unreachable("Expected OutputError to be thrown"); } catch (err) { expect(err).toBeInstanceOf(OutputError); - expect((err as OutputError).exitCode).toBe(1); + expect((err as OutputError).exitCode).toBe(EXIT.OUTPUT_ERROR); } const jsonOutput = JSON.parse(ctx.output.join("")); expect(jsonOutput).toEqual({ error: "not found" }); diff --git a/test/lib/errors.test.ts b/test/lib/errors.test.ts index ec6f45163..635c9706c 100644 --- a/test/lib/errors.test.ts +++ b/test/lib/errors.test.ts @@ -6,13 +6,18 @@ import { ConfigError, ContextError, DeviceFlowError, + EXIT, formatError, getExitCode, + HostScopeError, + OutputError, ResolutionError, SeerError, stringifyUnknown, + TimeoutError, UpgradeError, ValidationError, + WizardError, withAuthGuard, } from "../../src/lib/errors.js"; @@ -250,7 +255,7 @@ describe("ResolutionError", () => { ); expect(err).toBeInstanceOf(CliError); expect(err.name).toBe("ResolutionError"); - expect(err.exitCode).toBe(1); + expect(err.exitCode).toBe(EXIT.RESOLUTION); }); }); @@ -473,6 +478,100 @@ describe("getExitCode", () => { }); }); +describe("exit codes", () => { + test("CliError base defaults to EXIT.GENERAL (1)", () => { + expect(new CliError("err").exitCode).toBe(EXIT.GENERAL); + }); + + test("AuthError maps reasons to exit codes", () => { + expect(new AuthError("not_authenticated").exitCode).toBe( + EXIT.AUTH_NOT_AUTHENTICATED + ); + expect(new AuthError("expired").exitCode).toBe(EXIT.AUTH_EXPIRED); + expect(new AuthError("invalid").exitCode).toBe(EXIT.AUTH_INVALID); + }); + + test("HostScopeError has exit code AUTH_HOST_SCOPE", () => { + // Freeform message form + expect(new HostScopeError("blocked").exitCode).toBe(EXIT.AUTH_HOST_SCOPE); + // URL mismatch form (no tokenHost) + expect( + new HostScopeError("Request", "https://other.sentry.io").exitCode + ).toBe(EXIT.AUTH_HOST_SCOPE); + // URL mismatch form (with tokenHost) + expect( + new HostScopeError( + "Request", + "https://other.sentry.io", + "https://sentry.io" + ).exitCode + ).toBe(EXIT.AUTH_HOST_SCOPE); + }); + + test("ApiError has exit code API", () => { + expect(new ApiError("fail", 500).exitCode).toBe(EXIT.API); + }); + + test("ConfigError has exit code CONFIG", () => { + expect(new ConfigError("bad config").exitCode).toBe(EXIT.CONFIG); + }); + + test("ValidationError has exit code VALIDATION", () => { + expect(new ValidationError("bad input").exitCode).toBe(EXIT.VALIDATION); + }); + + test("ContextError has exit code CONTEXT_MISSING", () => { + expect(new ContextError("Organization", "sentry org list").exitCode).toBe( + EXIT.CONTEXT_MISSING + ); + }); + + test("ResolutionError has exit code RESOLUTION", () => { + expect( + new ResolutionError("X", "not found", "sentry x view").exitCode + ).toBe(EXIT.RESOLUTION); + }); + + test("DeviceFlowError has exit code DEVICE_FLOW", () => { + expect(new DeviceFlowError("slow_down").exitCode).toBe(EXIT.DEVICE_FLOW); + }); + + test("UpgradeError has exit code UPGRADE", () => { + expect(new UpgradeError("network_error").exitCode).toBe(EXIT.UPGRADE); + }); + + test("SeerError maps reasons to exit codes", () => { + expect(new SeerError("not_enabled").exitCode).toBe(EXIT.SEER_NOT_ENABLED); + expect(new SeerError("no_budget").exitCode).toBe(EXIT.SEER_NO_BUDGET); + expect(new SeerError("ai_disabled").exitCode).toBe(EXIT.SEER_AI_DISABLED); + }); + + test("TimeoutError has exit code TIMEOUT", () => { + expect(new TimeoutError("timed out").exitCode).toBe(EXIT.TIMEOUT); + }); + + test("OutputError has exit code OUTPUT_ERROR", () => { + expect(new OutputError({ items: [] }).exitCode).toBe(EXIT.OUTPUT_ERROR); + }); + + test("WizardError has exit code WIZARD", () => { + expect(new WizardError("wizard failed").exitCode).toBe(EXIT.WIZARD); + }); + + test("EXIT values are all unique", () => { + const values = Object.values(EXIT); + expect(new Set(values).size).toBe(values.length); + }); + + test("EXIT values are all positive integers below 128", () => { + for (const [, code] of Object.entries(EXIT)) { + expect(code).toBeGreaterThan(0); + expect(code).toBeLessThan(128); + expect(Number.isInteger(code)).toBe(true); + } + }); +}); + describe("withAuthGuard", () => { test("returns ok result on success", async () => { const result = await withAuthGuard(() => Promise.resolve("hello")); From 75357d023d52168b2e051718c42a4f32351e0bf2 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 29 Apr 2026 05:07:33 +0000 Subject: [PATCH 2/9] fix(test): correct exit code expectations for sentry api and trace view sentry api wraps API errors in OutputError (renders response to stdout), so the exit code is OUTPUT_ERROR (60), not API (30). Trace view gets 200 with empty array for non-existent traces, so it throws ValidationError (21), not ApiError (30). --- test/e2e/api.test.ts | 12 ++++++------ test/e2e/trace.test.ts | 4 +++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/test/e2e/api.test.ts b/test/e2e/api.test.ts index 3ec8d4189..2037d611d 100644 --- a/test/e2e/api.test.ts +++ b/test/e2e/api.test.ts @@ -74,7 +74,7 @@ describe("sentry api", () => { const result = await ctx.run(["api", "nonexistent-endpoint-12345/"]); - expect(result.exitCode).toBe(EXIT.API); + expect(result.exitCode).toBe(EXIT.OUTPUT_ERROR); }, { timeout: 15_000 } ); @@ -103,7 +103,7 @@ describe("sentry api", () => { "--silent", ]); - expect(result.exitCode).toBe(EXIT.API); + expect(result.exitCode).toBe(EXIT.OUTPUT_ERROR); expect(result.stdout).toBe(""); }, { timeout: 15_000 } @@ -123,7 +123,7 @@ describe("sentry api", () => { ]); // Method not allowed or similar error - just checking it processes the flag - expect(result.exitCode).toBe(EXIT.API); + expect(result.exitCode).toBe(EXIT.OUTPUT_ERROR); }, { timeout: 15_000 } ); @@ -160,7 +160,7 @@ describe("sentry api", () => { const result = await ctx.run(["api", "organizations/", "-X", "POST"]); // POST on list endpoint typically returns 405 or similar error - expect(result.exitCode).toBe(EXIT.API); + expect(result.exitCode).toBe(EXIT.OUTPUT_ERROR); }, { timeout: 15_000 } ); @@ -234,7 +234,7 @@ describe("sentry api", () => { ]); // Will fail with 404 or similar, but the flag should be processed - expect(result.exitCode).toBe(EXIT.API); + expect(result.exitCode).toBe(EXIT.OUTPUT_ERROR); }, { timeout: 15_000 } ); @@ -302,7 +302,7 @@ describe("sentry api", () => { // Should get a server error (405 Method Not Allowed or 400 Bad Request), // not a client-side error about body handling - expect(result.exitCode).toBe(EXIT.API); + expect(result.exitCode).toBe(EXIT.OUTPUT_ERROR); // The error should be from the API, not a TypeError about body expect(result.stdout + result.stderr).not.toMatch(/cannot have body/i); }, diff --git a/test/e2e/trace.test.ts b/test/e2e/trace.test.ts index b5204fda4..dbfe66608 100644 --- a/test/e2e/trace.test.ts +++ b/test/e2e/trace.test.ts @@ -191,7 +191,9 @@ describe("sentry trace view", () => { "00000000000000000000000000000000", ]); - expect(result.exitCode).toBe(EXIT.API); + // Trace API returns 200 with empty array for non-existent traces, + // so the command throws ValidationError (not ApiError) + expect(result.exitCode).toBe(EXIT.VALIDATION); expect(result.stderr + result.stdout).toMatch(/not found|no trace/i); }); }); From 6d439a616eda44cb91dbe07ad0adb9d9c66db057 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 29 Apr 2026 05:33:32 +0000 Subject: [PATCH 3/9] fix: use EXIT.GENERAL in base CliError, fix not-found errors to ResolutionError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CliError base constructor now uses EXIT.GENERAL instead of hardcoded 1 - trace/view: 'no trace found' throws ResolutionError (exit 23) instead of ValidationError (exit 21) — the user provided a valid ID that couldn't be resolved, not malformed input - log/view: same fix for throwNotFoundError — 'log not found' is a resolution failure, not a validation failure - exit-codes.md: document sentry api OutputError behavior, fix Python example to use range checks - agent-guidance.md: add 50-69 ranges to exit code table --- docs/src/content/docs/agent-guidance.md | 2 + docs/src/content/docs/exit-codes.md | 11 ++++- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 2 + src/commands/log/view.ts | 41 ++++++++++++------- src/commands/trace/view.ts | 18 +++++--- src/lib/errors.ts | 2 +- test/commands/log/view.func.test.ts | 23 +++++++---- test/commands/log/view.test.ts | 4 +- test/commands/trace/view.func.test.ts | 14 ++++--- test/e2e/log.test.ts | 2 +- test/e2e/trace.test.ts | 4 +- test/lib/errors.test.ts | 4 +- 12 files changed, 82 insertions(+), 45 deletions(-) diff --git a/docs/src/content/docs/agent-guidance.md b/docs/src/content/docs/agent-guidance.md index 26740acf8..bdf54bceb 100644 --- a/docs/src/content/docs/agent-guidance.md +++ b/docs/src/content/docs/agent-guidance.md @@ -47,6 +47,8 @@ The CLI uses semantic exit codes. Key ranges for agents: | 20–29 | Input error | Check command arguments and retry | | 30–39 | API error | Retry or report to user | | 40–49 | Feature unavailable | Inform user about plan/settings | +| 50–59 | Operation error | Report to user | +| 60–69 | Command-specific | Check stderr for details | See [Exit Codes](/exit-codes/) for the complete reference. diff --git a/docs/src/content/docs/exit-codes.md b/docs/src/content/docs/exit-codes.md index 6398180cf..2b6a80f3a 100644 --- a/docs/src/content/docs/exit-codes.md +++ b/docs/src/content/docs/exit-codes.md @@ -74,13 +74,20 @@ elif 10 <= result.returncode <= 19: print("Auth error — run: sentry auth login") elif 20 <= result.returncode <= 29: print("Input/config error") -elif result.returncode == 30: - print("API error") +elif 30 <= result.returncode <= 39: + print("API/network error") +elif 40 <= result.returncode <= 49: + print("Feature not available") ``` ## Notes - Exit codes below 128 are safe from collision with Unix signal exits (128+N). +- The `sentry api` command renders API error responses to stdout and exits + with code 60 (Output Error), not 30 (API Error). This matches the `gh api` + convention — the error response body is useful output. Parse the HTTP status + from `--verbose` output or the JSON error body if you need to distinguish + API error categories. - The `sentry init` wizard uses its own exit codes (10, 20, 30, 50) for remote workflow step failures. These are internal to the wizard and wrapped into exit code 61 (Wizard Error) before reaching the process exit. diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index cc6253c50..f184c05f4 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -57,6 +57,8 @@ The CLI uses semantic exit codes. Key ranges for agents: | 20–29 | Input error | Check command arguments and retry | | 30–39 | API error | Retry or report to user | | 40–49 | Feature unavailable | Inform user about plan/settings | +| 50–59 | Operation error | Report to user | +| 60–69 | Command-specific | Check stderr for details | See [Exit Codes](/exit-codes/) for the complete reference. diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 6fd2ecc15..3150b566d 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -16,7 +16,11 @@ import { } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { ContextError, ValidationError } from "../../lib/errors.js"; +import { + ContextError, + ResolutionError, + ValidationError, +} from "../../lib/errors.js"; import { formatLogDetails } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -345,7 +349,7 @@ function retentionSuffix(logId: string): string { * @param logIds - Requested IDs * @param org - Organization slug * @param project - Project slug - * @throws {ValidationError} Always + * @throws {ResolutionError} Always */ function throwNotFoundError( logIds: string[], @@ -356,18 +360,20 @@ function throwNotFoundError( // edit in `retention.ts` keeps this message in sync with the // deterministic retention-aware path. const retentionDays = RETENTION_DAYS.log; - const genericHint = retentionDays - ? `Make sure the log IDs are correct and were sent within the last ${retentionDays} days.` - : "Make sure the log IDs are correct."; if (logIds.length === 1) { const id = logIds[0] ?? ""; const suffix = retentionSuffix(id); - const hint = suffix - ? `This log is no longer retrievable.${suffix}` - : genericHint.replace("log IDs are correct", "log ID is correct"); - throw new ValidationError( - `No log found with ID "${id}" in ${org}/${project}.\n\n${hint}` + const suggestions = suffix + ? [`This log is no longer retrievable.${suffix}`] + : retentionDays + ? [`Make sure the log ID is correct and was sent within the last ${retentionDays} days`] + : ["Make sure the log ID is correct"]; + throw new ResolutionError( + `Log '${id}'`, + `not found in ${org}/${project}`, + `sentry log view ${org}/${project}/${id}`, + suggestions ); } @@ -378,11 +384,16 @@ function throwNotFoundError( .map(({ id, suffix }) => ` - \`${id}\`${suffix}`) .join("\n"); const anyExpired = suffixed.some(({ suffix }) => suffix !== ""); - const hint = anyExpired - ? "Expired log IDs are no longer retrievable. Check non-expired IDs and re-run." - : genericHint; - throw new ValidationError( - `No logs found with any of the following IDs in ${org}/${project}:\n${annotated}\n\n${hint}` + const suggestions = anyExpired + ? ["Expired log IDs are no longer retrievable — check non-expired IDs and re-run"] + : retentionDays + ? [`Make sure the log IDs are correct and were sent within the last ${retentionDays} days`] + : ["Make sure the log IDs are correct"]; + throw new ResolutionError( + "Logs", + `not found in ${org}/${project}:\n${annotated}`, + `sentry log view ${org}/${project}/ `, + suggestions ); } diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index 3eb7a4f28..dc8b109f8 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -21,7 +21,11 @@ import { } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { ContextError, ValidationError } from "../../lib/errors.js"; +import { + ContextError, + ResolutionError, + ValidationError, +} from "../../lib/errors.js"; import { computeTraceSummary, formatSimpleSpanTree, @@ -558,10 +562,14 @@ export const viewCommand = buildCommand({ }); if (spans.length === 0) { - throw new ValidationError( - `No trace found with ID "${traceId}".\n\n` + - "The ID format is valid but no matching trace exists in this project. " + - "Check that you are querying the right org/project, or the trace may be past your plan's retention window." + throw new ResolutionError( + `Trace '${traceId}'`, + "not found", + `sentry trace view ${org}/${traceId}`, + [ + "Check that you are querying the right org/project", + "The trace may be past your plan's retention window", + ] ); } diff --git a/src/lib/errors.ts b/src/lib/errors.ts index dd0cfe535..38d1ac7f4 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -101,7 +101,7 @@ export const EXIT = { export class CliError extends Error { readonly exitCode: number; - constructor(message: string, exitCode = 1) { + constructor(message: string, exitCode: number = EXIT.GENERAL) { super(message); this.name = "CliError"; this.exitCode = exitCode; diff --git a/test/commands/log/view.func.test.ts b/test/commands/log/view.func.test.ts index 039ba04e4..d459b606b 100644 --- a/test/commands/log/view.func.test.ts +++ b/test/commands/log/view.func.test.ts @@ -20,7 +20,10 @@ import { viewCommand } from "../../../src/commands/log/view.js"; import * as apiClient from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as browser from "../../../src/lib/browser.js"; -import { ContextError, ValidationError } from "../../../src/lib/errors.js"; +import { + ContextError, + ResolutionError, +} from "../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { DetailedSentryLog } from "../../../src/types/sentry.js"; @@ -111,7 +114,7 @@ describe("viewCommand.func", () => { expect(output).toContain(ID1); }); - test("throws ValidationError when log not found", async () => { + test("throws ResolutionError when log not found", async () => { getLogsSpy.mockResolvedValue([]); const { context } = createMockContext(); @@ -126,9 +129,11 @@ describe("viewCommand.func", () => { ); expect.unreachable("Should have thrown"); } catch (error) { - expect(error).toBeInstanceOf(ValidationError); - expect((error as ValidationError).message).toContain(ID1); - expect((error as ValidationError).message).toContain("No log found"); + expect(error).toBeInstanceOf(ResolutionError); + expect((error as ResolutionError).message).toContain(ID1); + expect((error as ResolutionError).message).toContain( + "not found in my-org/proj" + ); } }); }); @@ -221,7 +226,7 @@ describe("viewCommand.func", () => { expect(parsed[0]["sentry.item_id"]).toBe(ID1); }); - test("throws ValidationError when no logs found for multiple IDs", async () => { + test("throws ResolutionError when no logs found for multiple IDs", async () => { getLogsSpy.mockResolvedValue([]); const { context } = createMockContext(); @@ -237,9 +242,9 @@ describe("viewCommand.func", () => { ); expect.unreachable("Should have thrown"); } catch (error) { - expect(error).toBeInstanceOf(ValidationError); - const msg = (error as ValidationError).message; - expect(msg).toContain("No logs found"); + expect(error).toBeInstanceOf(ResolutionError); + const msg = (error as ResolutionError).message; + expect(msg).toContain("Logs not found in my-org/proj:"); // Each ID should appear in a markdown list item expect(msg).toContain(` - \`${ID1}\``); expect(msg).toContain(` - \`${ID2}\``); diff --git a/test/commands/log/view.test.ts b/test/commands/log/view.test.ts index b77c27ad6..b96be7728 100644 --- a/test/commands/log/view.test.ts +++ b/test/commands/log/view.test.ts @@ -581,8 +581,8 @@ describe("viewCommand.func", () => { ); expect.unreachable("Should have thrown"); } catch (err) { - expect(err).toBeInstanceOf(ValidationError); - const msg = (err as ValidationError).message; + expect(err).toBeInstanceOf(ResolutionError); + const msg = (err as ResolutionError).message; // Retention-aware wording replaces the generic "was sent within 90 days" expect(msg).toContain("past the 90-day log retention"); expect(msg).not.toContain("was sent within the last 90 days"); diff --git a/test/commands/trace/view.func.test.ts b/test/commands/trace/view.func.test.ts index c1e9526df..0ba5c1977 100644 --- a/test/commands/trace/view.func.test.ts +++ b/test/commands/trace/view.func.test.ts @@ -28,7 +28,11 @@ import * as apiClient from "../../../src/lib/api-client.js"; import * as browser from "../../../src/lib/browser.js"; import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js"; import { setOrgRegion } from "../../../src/lib/db/regions.js"; -import { ContextError, ValidationError } from "../../../src/lib/errors.js"; +import { + ContextError, + ResolutionError, + ValidationError, +} from "../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { TraceSpan } from "../../../src/types/sentry.js"; @@ -206,7 +210,7 @@ describe("viewCommand.func", () => { expect(output).toContain("Filtered to project"); }); - test("throws ValidationError when no spans found", async () => { + test("throws ResolutionError when no spans found", async () => { getDetailedTraceSpy.mockResolvedValue([]); const { context } = createMockContext(); @@ -219,7 +223,7 @@ describe("viewCommand.func", () => { "test-org/test-project", "00000000000000000000000000000000" ) - ).rejects.toThrow(ValidationError); + ).rejects.toThrow(ResolutionError); }); test("error message contains trace ID when not found", async () => { @@ -237,8 +241,8 @@ describe("viewCommand.func", () => { ); expect.unreachable("Should have thrown"); } catch (error) { - expect(error).toBeInstanceOf(ValidationError); - expect((error as ValidationError).message).toContain( + expect(error).toBeInstanceOf(ResolutionError); + expect((error as ResolutionError).message).toContain( "deadbeef12345678deadbeef12345678" ); } diff --git a/test/e2e/log.test.ts b/test/e2e/log.test.ts index 703ed4eb6..cfbd000d2 100644 --- a/test/e2e/log.test.ts +++ b/test/e2e/log.test.ts @@ -269,7 +269,7 @@ describe("sentry log view", () => { "deadbeefdeadbeefdeadbeefdeadbeef", ]); - expect(result.exitCode).toBe(EXIT.VALIDATION); + expect(result.exitCode).toBe(EXIT.RESOLUTION); expect(result.stderr + result.stdout).toMatch(/not found|no log/i); }); }); diff --git a/test/e2e/trace.test.ts b/test/e2e/trace.test.ts index dbfe66608..6d88528e9 100644 --- a/test/e2e/trace.test.ts +++ b/test/e2e/trace.test.ts @@ -191,9 +191,7 @@ describe("sentry trace view", () => { "00000000000000000000000000000000", ]); - // Trace API returns 200 with empty array for non-existent traces, - // so the command throws ValidationError (not ApiError) - expect(result.exitCode).toBe(EXIT.VALIDATION); + expect(result.exitCode).toBe(EXIT.RESOLUTION); expect(result.stderr + result.stdout).toMatch(/not found|no trace/i); }); }); diff --git a/test/lib/errors.test.ts b/test/lib/errors.test.ts index 635c9706c..045e03062 100644 --- a/test/lib/errors.test.ts +++ b/test/lib/errors.test.ts @@ -22,9 +22,9 @@ import { } from "../../src/lib/errors.js"; describe("CliError", () => { - test("has default exit code of 1", () => { + test("has default exit code of EXIT.GENERAL", () => { const err = new CliError("Something went wrong"); - expect(err.exitCode).toBe(1); + expect(err.exitCode).toBe(EXIT.GENERAL); expect(err.message).toBe("Something went wrong"); }); From 20b1cbf4ce72c0bd60d39fa2e8c52cbb0823aeaa Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 29 Apr 2026 05:36:47 +0000 Subject: [PATCH 4/9] style: fix formatting (lint:fix) --- src/commands/log/view.ts | 12 +++++++++--- test/commands/log/view.func.test.ts | 5 +---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 3150b566d..c37799c32 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -367,7 +367,9 @@ function throwNotFoundError( const suggestions = suffix ? [`This log is no longer retrievable.${suffix}`] : retentionDays - ? [`Make sure the log ID is correct and was sent within the last ${retentionDays} days`] + ? [ + `Make sure the log ID is correct and was sent within the last ${retentionDays} days`, + ] : ["Make sure the log ID is correct"]; throw new ResolutionError( `Log '${id}'`, @@ -385,9 +387,13 @@ function throwNotFoundError( .join("\n"); const anyExpired = suffixed.some(({ suffix }) => suffix !== ""); const suggestions = anyExpired - ? ["Expired log IDs are no longer retrievable — check non-expired IDs and re-run"] + ? [ + "Expired log IDs are no longer retrievable — check non-expired IDs and re-run", + ] : retentionDays - ? [`Make sure the log IDs are correct and were sent within the last ${retentionDays} days`] + ? [ + `Make sure the log IDs are correct and were sent within the last ${retentionDays} days`, + ] : ["Make sure the log IDs are correct"]; throw new ResolutionError( "Logs", diff --git a/test/commands/log/view.func.test.ts b/test/commands/log/view.func.test.ts index d459b606b..fab9d3dca 100644 --- a/test/commands/log/view.func.test.ts +++ b/test/commands/log/view.func.test.ts @@ -20,10 +20,7 @@ import { viewCommand } from "../../../src/commands/log/view.js"; import * as apiClient from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as browser from "../../../src/lib/browser.js"; -import { - ContextError, - ResolutionError, -} from "../../../src/lib/errors.js"; +import { ContextError, ResolutionError } from "../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { DetailedSentryLog } from "../../../src/types/sentry.js"; From 652c68969add4c6a320686751fe40752bce75426 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 29 Apr 2026 05:41:37 +0000 Subject: [PATCH 5/9] fix(lint): replace nested ternaries with if-else in log/view.ts --- src/commands/log/view.ts | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index c37799c32..792b75322 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -364,13 +364,16 @@ function throwNotFoundError( if (logIds.length === 1) { const id = logIds[0] ?? ""; const suffix = retentionSuffix(id); - const suggestions = suffix - ? [`This log is no longer retrievable.${suffix}`] - : retentionDays - ? [ - `Make sure the log ID is correct and was sent within the last ${retentionDays} days`, - ] - : ["Make sure the log ID is correct"]; + let suggestions: string[]; + if (suffix) { + suggestions = [`This log is no longer retrievable.${suffix}`]; + } else if (retentionDays) { + suggestions = [ + `Make sure the log ID is correct and was sent within the last ${retentionDays} days`, + ]; + } else { + suggestions = ["Make sure the log ID is correct"]; + } throw new ResolutionError( `Log '${id}'`, `not found in ${org}/${project}`, @@ -386,15 +389,18 @@ function throwNotFoundError( .map(({ id, suffix }) => ` - \`${id}\`${suffix}`) .join("\n"); const anyExpired = suffixed.some(({ suffix }) => suffix !== ""); - const suggestions = anyExpired - ? [ - "Expired log IDs are no longer retrievable — check non-expired IDs and re-run", - ] - : retentionDays - ? [ - `Make sure the log IDs are correct and were sent within the last ${retentionDays} days`, - ] - : ["Make sure the log IDs are correct"]; + let suggestions: string[]; + if (anyExpired) { + suggestions = [ + "Expired log IDs are no longer retrievable — check non-expired IDs and re-run", + ]; + } else if (retentionDays) { + suggestions = [ + `Make sure the log IDs are correct and were sent within the last ${retentionDays} days`, + ]; + } else { + suggestions = ["Make sure the log IDs are correct"]; + } throw new ResolutionError( "Logs", `not found in ${org}/${project}:\n${annotated}`, From a132a11bfcccb10d8280764eb4550cdcfc7aff89 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 29 Apr 2026 05:49:12 +0000 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20address=20BugBot=20review=20?= =?UTF-8?q?=E2=80=94=20multi-line=20headline=20and=20missing=20project=20s?= =?UTF-8?q?lug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - log/view: restructure multi-log ResolutionError to keep headline single-line ('2 log(s) not found in org/project') and move IDs to suggestions list, avoiding awkward period placement - trace/view: include project slug in ResolutionError hint so the suggested command doesn't immediately fail with ContextError --- src/commands/log/view.ts | 22 ++++++++++------------ src/commands/trace/view.ts | 2 +- test/commands/log/view.func.test.ts | 8 ++++---- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 792b75322..64c2c5d76 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -389,23 +389,21 @@ function throwNotFoundError( .map(({ id, suffix }) => ` - \`${id}\`${suffix}`) .join("\n"); const anyExpired = suffixed.some(({ suffix }) => suffix !== ""); - let suggestions: string[]; + const idList = suffixed.map(({ id, suffix }) => `${id}${suffix}`); + let hint: string; if (anyExpired) { - suggestions = [ - "Expired log IDs are no longer retrievable — check non-expired IDs and re-run", - ]; + hint = + "Expired log IDs are no longer retrievable — check non-expired IDs and re-run"; } else if (retentionDays) { - suggestions = [ - `Make sure the log IDs are correct and were sent within the last ${retentionDays} days`, - ]; + hint = `Make sure the log IDs are correct and were sent within the last ${retentionDays} days`; } else { - suggestions = ["Make sure the log IDs are correct"]; + hint = "Make sure the log IDs are correct"; } throw new ResolutionError( - "Logs", - `not found in ${org}/${project}:\n${annotated}`, - `sentry log view ${org}/${project}/ `, - suggestions + `${idList.length} log(s)`, + `not found in ${org}/${project}`, + hint, + idList.map((id) => `ID: ${id}`) ); } diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index dc8b109f8..4f9c002c5 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -565,7 +565,7 @@ export const viewCommand = buildCommand({ throw new ResolutionError( `Trace '${traceId}'`, "not found", - `sentry trace view ${org}/${traceId}`, + `sentry trace view ${org}/${project}/${traceId}`, [ "Check that you are querying the right org/project", "The trace may be past your plan's retention window", diff --git a/test/commands/log/view.func.test.ts b/test/commands/log/view.func.test.ts index fab9d3dca..c2e0209e2 100644 --- a/test/commands/log/view.func.test.ts +++ b/test/commands/log/view.func.test.ts @@ -241,10 +241,10 @@ describe("viewCommand.func", () => { } catch (error) { expect(error).toBeInstanceOf(ResolutionError); const msg = (error as ResolutionError).message; - expect(msg).toContain("Logs not found in my-org/proj:"); - // Each ID should appear in a markdown list item - expect(msg).toContain(` - \`${ID1}\``); - expect(msg).toContain(` - \`${ID2}\``); + expect(msg).toContain("not found in my-org/proj"); + // Each ID should appear in the suggestions + expect(msg).toContain(ID1); + expect(msg).toContain(ID2); } }); }); From 1525d69657ae83082d42f74e558a048f5eb0bbcb Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 29 Apr 2026 05:56:54 +0000 Subject: [PATCH 7/9] feat: map init wizard exit codes to semantic EXIT.* constants Add wizard sub-codes in the 60s range (62=deps, 63=codemod, 64=verify) so scripts can distinguish wizard failure categories. The remote workflow's internal codes (20, 30, 40/41, 50) are mapped to EXIT.* constants in mapWorkflowExitCode() before reaching process exit. Platform-not-detected (workflow code 20) maps to EXIT.CONFIG (20) since it's fundamentally a project configuration issue. --- AGENTS.md | 2 +- docs/src/content/docs/exit-codes.md | 10 ++++++--- src/lib/errors.ts | 15 +++++++++++--- src/lib/init/constants.ts | 4 +++- src/lib/init/wizard-runner.ts | 32 +++++++++++++++++++++++++++-- test/lib/errors.test.ts | 16 ++++++++++++++- 6 files changed, 68 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7855c9513..1bfd4b629 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -578,7 +578,7 @@ CliError (base, exitCode=1) ├── SeerError (exitCode=40–42 by reason — 'not_enabled' | 'no_budget' | 'ai_disabled') ├── TimeoutError (exitCode=31 — operation timed out) ├── UpgradeError (exitCode=50 — upgrade failures) -└── WizardError (exitCode=61 — init wizard error) +└── WizardError (exitCode=61–64 by workflow step — init wizard error) ``` > Exit code ranges: 1x=auth, 2x=input/config, 3x=API/network, 4x=feature/billing, diff --git a/docs/src/content/docs/exit-codes.md b/docs/src/content/docs/exit-codes.md index 2b6a80f3a..715268c69 100644 --- a/docs/src/content/docs/exit-codes.md +++ b/docs/src/content/docs/exit-codes.md @@ -42,6 +42,9 @@ react to failure categories without parsing stderr. | 51 | Device Flow Error | OAuth device authorization flow failed | | 60 | Output Error | Command produced output but the operation failed | | 61 | Wizard Error | Interactive setup wizard encountered an error | +| 62 | Wizard Deps | Wizard dependency installation failed | +| 63 | Wizard Codemod | Wizard codemod plan or apply failed | +| 64 | Wizard Verify | User stopped wizard after verification step | ## Scripting Examples @@ -88,9 +91,10 @@ elif 40 <= result.returncode <= 49: convention — the error response body is useful output. Parse the HTTP status from `--verbose` output or the JSON error body if you need to distinguish API error categories. -- The `sentry init` wizard uses its own exit codes (10, 20, 30, 50) for - remote workflow step failures. These are internal to the wizard and wrapped - into exit code 61 (Wizard Error) before reaching the process exit. +- The `sentry init` wizard maps its internal workflow exit codes to CLI + exit codes: platform not detected → 20 (Config), dependency install + failed → 62 (Wizard Deps), codemod failed → 63 (Wizard Codemod), + verification stopped → 64 (Wizard Verify), other → 61 (Wizard). - [Stricli](https://bloomberg.github.io/stricli/) (the CLI framework) uses negative exit codes (-5 to -1) for framework-level errors like unknown commands or invalid arguments. These appear as 251–255 in unsigned form. diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 38d1ac7f4..fdb6fa0e3 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -88,8 +88,14 @@ export const EXIT = { // 60–69: Command-specific /** Command produced output but should exit non-zero */ OUTPUT_ERROR: 60, - /** Init wizard error */ + /** Init wizard error (generic) */ WIZARD: 61, + /** Init wizard: dependency installation failed */ + WIZARD_DEPS: 62, + /** Init wizard: codemod plan or apply failed */ + WIZARD_CODEMOD: 63, + /** Init wizard: user stopped after verification */ + WIZARD_VERIFY: 64, } as const; /** @@ -655,8 +661,11 @@ export class TimeoutError extends CliError { export class WizardError extends CliError { readonly rendered: boolean; - constructor(message: string, options?: { rendered?: boolean }) { - super(message, EXIT.WIZARD); + constructor( + message: string, + options?: { rendered?: boolean; exitCode?: number } + ) { + super(message, options?.exitCode ?? EXIT.WIZARD); this.name = "WizardError"; this.rendered = options?.rendered ?? true; } diff --git a/src/lib/init/constants.ts b/src/lib/init/constants.ts index 5f7349cb3..782a3696c 100644 --- a/src/lib/init/constants.ts +++ b/src/lib/init/constants.ts @@ -11,7 +11,9 @@ export const MAX_OUTPUT_BYTES = 65_536; // 64KB stdout/stderr truncation export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000; // 2 minutes export const API_TIMEOUT_MS = 120_000; // 2 minutes timeout for Mastra API calls -// Exit codes returned by the remote workflow +// Exit codes returned by the remote workflow. +// These are internal to the workflow protocol — they're mapped to EXIT.* +// constants (from src/lib/errors.ts) before reaching process exit. export const EXIT_PLATFORM_NOT_DETECTED = 20; export const EXIT_DEPENDENCY_INSTALL_FAILED = 30; export const EXIT_VERIFICATION_FAILED = 50; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 2a1c31c90..de3a26df6 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -12,7 +12,7 @@ import { MastraClient } from "@mastra/client-js"; import { captureException, getTraceData } from "@sentry/node-core/light"; import { formatBanner } from "../banner.js"; import { CLI_VERSION } from "../constants.js"; -import { WizardError } from "../errors.js"; +import { EXIT, WizardError } from "../errors.js"; import { terminalLink } from "../formatters/colors.js"; import { colorTag, @@ -586,7 +586,11 @@ function handleFinalResult( spinState.running = false; } formatError(result); - throw new WizardError("Workflow returned an error"); + + // Map workflow-internal exit codes to semantic EXIT.* constants + const workflowCode = result.result?.exitCode; + const exitCode = mapWorkflowExitCode(workflowCode); + throw new WizardError("Workflow returned an error", { exitCode }); } if (spinState.running) { @@ -596,6 +600,30 @@ function handleFinalResult( formatResult(result); } +/** + * Map a workflow-internal exit code to a semantic EXIT.* constant. + * + * The remote workflow uses its own code scheme (20=platform not detected, + * 30=deps failed, 40/41=codemod failed, 50=verification). We translate + * these into the CLI's decade-based exit codes so scripts can distinguish + * wizard failure categories. + */ +function mapWorkflowExitCode(workflowCode: number | undefined): number { + switch (workflowCode) { + case 20: + return EXIT.CONFIG; + case 30: + return EXIT.WIZARD_DEPS; + case 40: + case 41: + return EXIT.WIZARD_CODEMOD; + case 50: + return EXIT.WIZARD_VERIFY; + default: + return EXIT.WIZARD; + } +} + function extractSuspendPayload( result: WorkflowRunResult, stepId: string diff --git a/test/lib/errors.test.ts b/test/lib/errors.test.ts index 045e03062..c0120f8e0 100644 --- a/test/lib/errors.test.ts +++ b/test/lib/errors.test.ts @@ -554,10 +554,24 @@ describe("exit codes", () => { expect(new OutputError({ items: [] }).exitCode).toBe(EXIT.OUTPUT_ERROR); }); - test("WizardError has exit code WIZARD", () => { + test("WizardError defaults to exit code WIZARD", () => { expect(new WizardError("wizard failed").exitCode).toBe(EXIT.WIZARD); }); + test("WizardError accepts custom exit code for workflow sub-codes", () => { + expect( + new WizardError("deps failed", { exitCode: EXIT.WIZARD_DEPS }).exitCode + ).toBe(EXIT.WIZARD_DEPS); + expect( + new WizardError("codemod failed", { exitCode: EXIT.WIZARD_CODEMOD }) + .exitCode + ).toBe(EXIT.WIZARD_CODEMOD); + expect( + new WizardError("verify stopped", { exitCode: EXIT.WIZARD_VERIFY }) + .exitCode + ).toBe(EXIT.WIZARD_VERIFY); + }); + test("EXIT values are all unique", () => { const values = Object.values(EXIT); expect(new Set(values).size).toBe(values.length); From 78c68036bbeddb55adc1b9a986487293b2a6e294 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 29 Apr 2026 06:02:19 +0000 Subject: [PATCH 8/9] fix(lint): remove unused annotated variable in log/view.ts --- src/commands/log/view.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 64c2c5d76..373472d33 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -383,11 +383,8 @@ function throwNotFoundError( } // Multiple IDs — compute the retention suffix once per ID so both the - // inline annotation and the "any expired?" check reuse the same decode. + // ID list and the "any expired?" check reuse the same decode. const suffixed = logIds.map((id) => ({ id, suffix: retentionSuffix(id) })); - const annotated = suffixed - .map(({ id, suffix }) => ` - \`${id}\`${suffix}`) - .join("\n"); const anyExpired = suffixed.some(({ suffix }) => suffix !== ""); const idList = suffixed.map(({ id, suffix }) => `${id}${suffix}`); let hint: string; From faf2fe807dd8258f6d46d5fb591021b74a8b8c82 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 29 Apr 2026 06:10:01 +0000 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20address=20BugBot=20=E2=80=94=20guard?= =?UTF-8?q?=20undefined=20project,=20use=20named=20constants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - trace/view: guard project with fallback placeholder in hint to avoid interpolating 'undefined' as a literal string - wizard-runner: use EXIT_PLATFORM_NOT_DETECTED, EXIT_DEPENDENCY_INSTALL_FAILED, EXIT_VERIFICATION_FAILED constants instead of magic numbers --- src/commands/trace/view.ts | 2 +- src/lib/init/wizard-runner.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index 4f9c002c5..ab9a121e9 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -565,7 +565,7 @@ export const viewCommand = buildCommand({ throw new ResolutionError( `Trace '${traceId}'`, "not found", - `sentry trace view ${org}/${project}/${traceId}`, + `sentry trace view ${org}/${project ?? ""}/${traceId}`, [ "Check that you are querying the right org/project", "The trace may be past your plan's retention window", diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index de3a26df6..162fef049 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -28,6 +28,9 @@ import { } from "./clack-utils.js"; import { API_TIMEOUT_MS, + EXIT_DEPENDENCY_INSTALL_FAILED, + EXIT_PLATFORM_NOT_DETECTED, + EXIT_VERIFICATION_FAILED, MASTRA_API_URL, SENTRY_DOCS_URL, VERIFY_CHANGES_STEP, @@ -610,14 +613,15 @@ function handleFinalResult( */ function mapWorkflowExitCode(workflowCode: number | undefined): number { switch (workflowCode) { - case 20: + case EXIT_PLATFORM_NOT_DETECTED: return EXIT.CONFIG; - case 30: + case EXIT_DEPENDENCY_INSTALL_FAILED: return EXIT.WIZARD_DEPS; + // 40/41 are server-side only (codemod plan/apply) — not in constants.ts case 40: case 41: return EXIT.WIZARD_CODEMOD; - case 50: + case EXIT_VERIFICATION_FAILED: return EXIT.WIZARD_VERIFY; default: return EXIT.WIZARD;