diff --git a/AGENTS.md b/AGENTS.md index 0a3afdd49..1bfd4b629 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–64 by workflow step — 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..bdf54bceb 100644 --- a/docs/src/content/docs/agent-guidance.md +++ b/docs/src/content/docs/agent-guidance.md @@ -36,6 +36,22 @@ 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 | +| 50–59 | Operation error | Report to user | +| 60–69 | Command-specific | Check stderr for details | + +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..715268c69 --- /dev/null +++ b/docs/src/content/docs/exit-codes.md @@ -0,0 +1,100 @@ +--- +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 | +| 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 + +### 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 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 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/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 72724f31d..f184c05f4 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -46,6 +46,22 @@ 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 | +| 50–59 | Operation error | Report to user | +| 60–69 | Command-specific | Check stderr for details | + +See [Exit Codes](/exit-codes/) for the complete reference. + ### Workflow Patterns #### Investigate an Issue diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 6fd2ecc15..373472d33 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,33 +360,47 @@ 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}` + 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}`, + `sentry log view ${org}/${project}/${id}`, + suggestions ); } // 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 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 idList = suffixed.map(({ id, suffix }) => `${id}${suffix}`); + let hint: string; + if (anyExpired) { + hint = + "Expired log IDs are no longer retrievable — check non-expired IDs and re-run"; + } else if (retentionDays) { + hint = `Make sure the log IDs are correct and were sent within the last ${retentionDays} days`; + } else { + hint = "Make sure the log IDs are correct"; + } + throw new ResolutionError( + `${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 3eb7a4f28..ab9a121e9 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}/${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/errors.ts b/src/lib/errors.ts index 8e3dd7f6f..fdb6fa0e3 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,75 @@ 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 (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; + /** * Base class for all CLI errors. * @@ -19,7 +107,7 @@ import { 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; @@ -55,19 +143,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 +183,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 +233,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 +255,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 +283,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 +417,8 @@ export class ContextError extends CliError { buildContextMessage(resource, command, resolvedAlternatives, { note, isAutoDetect, - }) + }), + EXIT.CONTEXT_MISSING ); this.name = "ContextError"; this.resource = resource; @@ -381,7 +477,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 +503,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 +519,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 +556,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 +582,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 +639,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; } @@ -557,8 +661,11 @@ export class TimeoutError extends CliError { export class WizardError extends CliError { readonly rendered: boolean; - constructor(message: string, options?: { rendered?: boolean }) { - super(message); + 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..162fef049 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, @@ -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, @@ -586,7 +589,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 +603,31 @@ 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 EXIT_PLATFORM_NOT_DETECTED: + return EXIT.CONFIG; + 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 EXIT_VERIFICATION_FAILED: + return EXIT.WIZARD_VERIFY; + default: + return EXIT.WIZARD; + } +} + function extractSuspendPayload( result: WorkflowRunResult, stepId: string 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/commands/log/view.func.test.ts b/test/commands/log/view.func.test.ts index 039ba04e4..c2e0209e2 100644 --- a/test/commands/log/view.func.test.ts +++ b/test/commands/log/view.func.test.ts @@ -20,7 +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, 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 +111,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 +126,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 +223,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,12 +239,12 @@ 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"); - // Each ID should appear in a markdown list item - expect(msg).toContain(` - \`${ID1}\``); - expect(msg).toContain(` - \`${ID2}\``); + expect(error).toBeInstanceOf(ResolutionError); + const msg = (error as ResolutionError).message; + expect(msg).toContain("not found in my-org/proj"); + // Each ID should appear in the suggestions + 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/api.test.ts b/test/e2e/api.test.ts index 5cc43aff6..2037d611d 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.OUTPUT_ERROR); }, { timeout: 15_000 } ); @@ -102,7 +103,7 @@ describe("sentry api", () => { "--silent", ]); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(EXIT.OUTPUT_ERROR); 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.OUTPUT_ERROR); }, { 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.OUTPUT_ERROR); }, { 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.OUTPUT_ERROR); }, { 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.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); }, @@ -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..cfbd000d2 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.RESOLUTION); 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..6d88528e9 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.RESOLUTION); 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..c0120f8e0 100644 --- a/test/lib/errors.test.ts +++ b/test/lib/errors.test.ts @@ -6,20 +6,25 @@ import { ConfigError, ContextError, DeviceFlowError, + EXIT, formatError, getExitCode, + HostScopeError, + OutputError, ResolutionError, SeerError, stringifyUnknown, + TimeoutError, UpgradeError, ValidationError, + WizardError, withAuthGuard, } 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"); }); @@ -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,114 @@ 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 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); + }); + + 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"));