Vercel Logo

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:

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.

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.

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:

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

Expected:

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

Then an invalid URL:

pnpm review not-a-url
echo "Exit: $?"
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):

pnpm review https://github.com/this-does-not-exist/nope
echo "Exit: $?"
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

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

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();

Was this helpful?

supported.