---
title: "Predictable Exit Codes"
description: "Wrap the lifecycle call in `try/catch`, set `process.exitCode` for each failure mode, and standardize on 0 / 1 / 2 so CI jobs can fail on the right things and pass on the right things."
canonical_url: "https://vercel.com/academy/vercel-sandbox/predictable-exit-codes"
md_url: "https://vercel.com/academy/vercel-sandbox/predictable-exit-codes.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-05-17T11:53:52.685Z"
content_type: "lesson"
course: "vercel-sandbox"
course_title: "Vercel Sandbox"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Predictable Exit Codes

# Exit Codes That CI Can Read

A CLI that always exits 0 is a CLI that always passes in CI. Even when it didn't.

This is one of those small things that bites you exactly once, on a Friday afternoon, when a "successful" review run shipped broken code to production. We're going to fix it now. Three exit codes, one rule each:

- **0** — the review actually ran and the Sandbox came back
- **1** — the review tried to run but something inside it threw
- **2** — the user gave us bad input (we never even started)

## Outcome

Wrap the lifecycle call in `try/catch`, set `process.exitCode` for each failure mode, and standardize on 0 / 1 / 2 across the CLI.

## Fast Track

1. Set `process.exitCode = 2` on validation failure.
2. Wrap `runSandboxLifecycle` in `try/catch`.
3. Set `process.exitCode = 1` in the catch block.

## Hands-on exercise

Open `src/cli.ts` and add the exit-code handling:

```ts
import { Command } from 'commander';
import { runSandboxLifecycle } from './sandbox-lifecycle';

function isValidGitHubRepoUrl(input: string): boolean {
  return /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?$/.test(input);
}

const program = new Command();

program
  .name('repo-review')
  .description('Clone and review a GitHub repository in a Sandbox')
  .version('0.1.0');

program
  .command('review <repoUrl>')
  .description('Run a Sandbox review against a GitHub repository URL')
  .action(async (repoUrl: string) => {
    if (!isValidGitHubRepoUrl(repoUrl)) {
      console.error(`Invalid GitHub repository URL: ${repoUrl}`);
      console.error('Expected format: https://github.com/<owner>/<repo>');
      process.exitCode = 2;
      return;
    }

    console.log(`Reviewing ${repoUrl}...`);

    try {
      const result = await runSandboxLifecycle(repoUrl);
      console.log(`Sandbox: ${result.sandboxId}`);
      console.log(`Clone exit code: ${result.cloneExitCode}`);
      console.log(`Files:\n${result.files}`);
      console.log(`README preview:\n${result.readmePreview}`);
    } catch (error) {
      console.error('Review failed:', error instanceof Error ? error.message : error);
      process.exitCode = 1;
    }
  });

program.parse();
```

A few intentional details here.

We're using `process.exitCode` instead of `process.exit(N)`. `process.exit` kills the process immediately, which means any pending async work (like a `sandbox.stop()` that hasn't finished) gets cut off. `process.exitCode` sets the value Node uses when it exits naturally, which gives the lifecycle's `finally` block time to clean up.

We're also formatting the error message instead of dumping the whole `Error` object. CI logs are easier to read when the first line is the message, not a stack trace. The stack will still print to stderr from Node's default behavior if the throw is unhandled, but our handled case keeps it tidy.

\*\*Warning: Troubleshooting: still exits 0 on failure\*\*

If `pnpm review <bad-url>` is still exiting 0, check that you set `process.exitCode` and not just `console.error`'d. They're independent.

\*\*Note: Troubleshooting: why not throw?\*\*

Throwing in the action callback causes commander to print an unhandled rejection and exit with code 1, which sort of works. But it logs noise that CI tools then have to filter out. `try/catch` + `process.exitCode` is cleaner.

## Try It

Run all three cases and check exit codes after each:

```bash
pnpm review https://github.com/vercel/examples
echo "Exit: $?"
```

Expected:

```txt
Reviewing https://github.com/vercel/examples...
Sandbox: sbx_7N2k4A...
... (success output)
Exit: 0
```

Then an invalid URL:

```bash
pnpm review not-a-url
echo "Exit: $?"
```

```txt
Invalid GitHub repository URL: not-a-url
Expected format: https://github.com/<owner>/<repo>
Exit: 2
```

Then a repo that doesn't exist (the clone will fail and throw inside the lifecycle):

```bash
pnpm review https://github.com/this-does-not-exist/nope
echo "Exit: $?"
```

```txt
Reviewing https://github.com/this-does-not-exist/nope...
Review failed: Clone failed: fatal: repository '...' not found
Exit: 1
```

Three different problems, three different exit codes. CI can finally tell them apart.

## Commit

```bash
git add src/cli.ts
git commit -m "feat(cli): standardize exit codes for input vs runtime failures"
```

## Done-When

- [ ] Successful review exits 0
- [ ] Invalid URL exits 2 without booting a Sandbox
- [ ] Runtime failure (failed clone) exits 1
- [ ] `process.exit(...)` is not used anywhere (we use `process.exitCode`)

## Solution

```ts title="src/cli.ts"
import { Command } from 'commander';
import { runSandboxLifecycle } from './sandbox-lifecycle';

function isValidGitHubRepoUrl(input: string): boolean {
  return /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?$/.test(input);
}

const program = new Command();

program
  .name('repo-review')
  .description('Clone and review a GitHub repository in a Sandbox')
  .version('0.1.0');

program
  .command('review <repoUrl>')
  .description('Run a Sandbox review against a GitHub repository URL')
  .action(async (repoUrl: string) => {
    if (!isValidGitHubRepoUrl(repoUrl)) {
      console.error(`Invalid GitHub repository URL: ${repoUrl}`);
      console.error('Expected format: https://github.com/<owner>/<repo>');
      process.exitCode = 2;
      return;
    }

    console.log(`Reviewing ${repoUrl}...`);

    try {
      const result = await runSandboxLifecycle(repoUrl);
      console.log(`Sandbox: ${result.sandboxId}`);
      console.log(`Clone exit code: ${result.cloneExitCode}`);
      console.log(`Files:\n${result.files}`);
      console.log(`README preview:\n${result.readmePreview}`);
    } catch (error) {
      console.error('Review failed:', error instanceof Error ? error.message : error);
      process.exitCode = 1;
    }
  });

program.parse();
```


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
