Run Programs — Capturing Output & Exit Codes

Lesson 0003 · Glossary · Lesson 1 · Lesson 2 · Next: Bun Shell $

The win, in one line: by the end of this lesson you'll have replaced backticks $(...) and $? with a typed helper that runs another program, captures its output, and aborts on failure — the Bun twin of set -e.

Scripts rarely live alone — they run git, docker, ffmpeg. Bun's spawn APIs give you the exit code and stdout as typed values, in a way that can't be corrupted by shell quoting.

1. Commands are arrays, not strings

You give Bun.spawnSync a command as an array of strings — program first, then each argument. No shell parses it, so no quoting bugs and no injection. (Bun docs: child processes)

Bun.spawnSync(["git", "status", "--porcelain"]);  // program, then its args
Bun.spawnSync(["sh", "-c", "echo ok"]);           // only if you truly need a shell

2. Two APIs: blocking and async

Bun.spawnSyncBun.spawn
Styleblocking — your script waitsasync — returns a Subprocess you can await
stdoutBuffer or undefinedReadableStream — await proc.stdout.text()
StateexitCode, success immediatelyawait proc.exited → exit code
Use forthe 95% — daily scriptsparallel long-running jobs

By default stdout is piped to you and stderr is inherited (passed straight to your terminal). To also capture stderr as data, pass stderr: "pipe".

const r = Bun.spawnSync(["git", "status", "--porcelain"], { stderr: "pipe" });
r.stdout.toString();   // captured stdout as a string
r.exitCode;            // 0 = success — the "exit code" you used to read from $?
r.success;             // true iff exitCode === 0

3. Abort-on-failure: the set -e twin

The single highest-value habit: make your script stop when a child fails, and hand the child's exit code onward so the caller sees failure too. This is set -e plus $?, in three lines:

function sh(cmd: string[]) {
  const r = Bun.spawnSync(cmd, { stderr: "pipe" });
  if (!r.success) {
    process.stderr.write(r.stderr.toString());   // show what went wrong
    process.exit(r.exitCode ?? 1);                // propagate the real code
  }
  return r.stdout.toString();                     // success: you get the output
}

const dirty = sh(["git", "status", "--porcelain"]);
if (dirty.trim()) console.error("uncommitted changes:"), console.log(dirty);
sh(["npm", "test"]);          // anything after this only runs if tests passed

Chain calls sequentially and every later line is guarded — that's your cmd1 && cmd2 && cmd3.

4. Options you'll reach for

Bun.spawnSync(["ls"], { cwd: "/tmp" });      // run somewhere else (like cd path)
Bun.spawnSync(["sh", "-c", "echo $MSG"], {
  env: { ...process.env, MSG: "hi" },        // add env vars — merge process.env!
});
Bun.spawnSync(["sleep", "30"], { timeout: 500 }); // exitCode null on timeout
Passing env replaces the environment entirely, so spread process.env in first. And beware: a command can't be found by name if you drop PATH.

The bash → Bun map (this layer)

bashBun
out=$(cmd)const out = sh(["cmd", ...]) — already trimmed to a string
$?r.exitCode / r.success
cmd && nextthe sh() helper — next is unreachable after a failure
set -esame helper: it exits with the child's code
cd dir && cmd{ cwd: "dir" }
MYVAR=x cmd{ env: { ...process.env, MYVAR: "x" } }

Practice

Recall check (from memory, no peeking)

1. What is the first element of the command array?

The program to run. Each following element is one argument, passed verbatim — no shell, no quoting.

2. Which values tell you a child failed?

r.success (false) and r.exitCode (non-zero). Check them on every spawn — never assume success.

3. What does the sh() helper do on failure?

It writes the child's stderr to your stderr and calls process.exit(exitCode) — the script stops and the caller sees the real code, like set -e.

Hands-on: a mini set -e

Build run.ts around the sh() helper above (copy it in), then:

  1. Chain three commands with console.log to show their output — e.g. console.log(sh(["echo", "step one"])), console.log(sh(["echo", "step two"])), sh(["false"]), console.log(sh(["echo", "step three"])).
  2. Run it. Notice "step three" never prints: the false failure stopped the chain, and the exit code is 1.
  3. Replace ["false"] with something real you run daily, and confirm the exit code equals the child's: ./run.ts; echo "exit: $?"
  4. Bonus: wrap sh(["git", "status", "--porcelain"]) to warn if there are uncommitted changes before running a slow command.

Check your work (immediate feedback):

./run.ts        # prints "step one", "step two", then stops — "step three" is skipped
./run.ts; echo "exit: $?"   # prints: exit: 1 — propagated the failure code

If the final echo $? printed 1, your script is honest about failure — exactly what CI and cron need, and exactly what bash gave you with set -e.

Read this next

Primary source for this lesson: Bun Docs: Child Processes. The "Input stream" and "Exit handling" sections are the detail you'll want once these commands take arguments.

And yes — there's an even more bash-shaped way to write these commands: Bun Shell, the $ tag, with pipes and redirection built in. That's our next lesson, built on the exit-code discipline you just practised.