DEV Community

Jangwook Kim
Jangwook Kim

Posted on • Originally published at jangwook.net

Building Automation Scripts with Bun Shell — From Setup to Real-World Patterns

I have a small recurring frustration when writing shell scripts. Bash works but breaks on Windows. Node.js child_process turns into callback soup. zx needs an extra package. So when Bun Shell came up, I figured it was just another zx clone. After actually running it, my opinion shifted a bit.

This article is based on real experiments I ran with Bun 1.3.14. Some things in the docs didn't match actual runtime behavior — I'm documenting those honestly.

What Bun Shell Is and Why It's Worth Knowing

Bun is a JavaScript runtime that also serves as a package manager, bundler, and test runner. The entire project is about collapsing a fragmented ecosystem into a single tool. Just as Python's uv consolidates pip, pyenv, and poetry into one binary, Bun merges npm/yarn/pnpm, a test runner, and a bundler into one.

Bun Shell is the natural extension of this philosophy into shell scripting. Install bun, and you can use the $ template literal to run shell commands directly inside TypeScript — no extra dependencies.

How It Differs From zx

Honestly, the API surface looks similar. Both use the $`command` syntax. The meaningful difference is architectural: Bun Shell doesn't depend on bash.

zx invokes the system's bash (or sh) under the hood. On Windows without bash, you need WSL or Git Bash. Bun Shell ships its own shell implementation written in Rust. It runs ls, rm, echo, cd, mkdir, and other common commands identically across Windows, macOS, and Linux — no bash required.

If your team includes Windows developers, that difference matters.

Installation

Installing Bun is a one-liner:

curl -fsSL https://bun.sh/install | bash
Enter fullscreen mode Exit fullscreen mode

After installation, the installer automatically appends the PATH entry to your shell config (~/.zshrc or ~/.bashrc). To apply it to the current session:

export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$PATH"
bun --version  # 1.3.14
Enter fullscreen mode Exit fullscreen mode

Initialize a new project:

mkdir my-scripts && cd my-scripts
bun init -y
Enter fullscreen mode Exit fullscreen mode

bun init generates package.json, tsconfig.json, and index.ts. TypeScript works out of the box — no ts-node or additional configuration needed.

Basic Patterns — Running Commands with $ Template Literals

The core syntax: import $ from the built-in bun module.

import { $ } from "bun";

// Execute a command
await $`echo "Hello from Bun Shell"`;

// Capture output
const files = await $`ls -la`.text();
console.log(files);

// JavaScript variable interpolation — automatically escaped
const filename = "my file.txt";  // note: has a space
await $`echo "${filename}" > output.txt`;
// → output.txt contains "my file.txt" (space handled correctly)
Enter fullscreen mode Exit fullscreen mode

The automatic escaping in variable interpolation actually works. I tested it with a filename containing a space and it was handled correctly without any manual quoting. This eliminates a whole class of bash bugs where forgetting to quote "${var}" causes unexpected word splitting.

Output Format Methods

// As a string
const text = await $`ls`.text();

// As a line-by-line array (Bun convenience method)
const lines = await $`ls`.lines();
// → ["file1.ts", "file2.ts", ...]

// As a Blob
const blob = await $`cat file.txt`.blob();
Enter fullscreen mode Exit fullscreen mode

.lines() is a quality-of-life method that parses output into an array per line. Cleaner than text().split('\n') and handles edge cases like trailing newlines.

Error Handling, Environment Variables, and Pipelines

Two Error Handling Patterns

When a command fails (exit code != 0), Bun Shell throws an exception by default.

// Pattern 1: try/catch
try {
  await $`ls /nonexistent-dir`;
} catch (e) {
  console.log("Error:", e.message); // "Failed with exit code 1"
}

// Pattern 2: .nothrow() — returns exitCode instead of throwing
const result = await $`ls /nonexistent-dir`.nothrow();
console.log(result.exitCode); // 1
console.log(result.stderr.toString()); // error message
Enter fullscreen mode Exit fullscreen mode

In practice, I reach for .nothrow() most often. Checking whether a file or command exists is cleaner this way:

const nodeResult = await $`node --version`.nothrow();
if (nodeResult.exitCode === 0) {
  console.log("Node.js:", nodeResult.stdout.toString().trim());
} else {
  console.log("Node.js is not installed");
}
Enter fullscreen mode Exit fullscreen mode

I verified this pattern works correctly in my experiments.

Environment Variables

// Set global defaults
$.env({ API_KEY: "secret123", PATH: process.env.PATH! });

// Apply locally to a single command
const result = await $`echo $LOCAL_VAR`
  .env({ LOCAL_VAR: "only this command", PATH: process.env.PATH! })
  .text();
Enter fullscreen mode Exit fullscreen mode

Watch out: the object you pass to .env() completely replaces the environment — it's not merged. If you forget PATH, subsequent commands won't find any executables.

Pipelines

// Built-in Bun Shell pipe
const sorted = await $`printf "banana\napple\ncherry\n" | sort`.text();
// → apple, banana, cherry

// Dedup + sort using file redirection
await Bun.write("input.txt", "banana\napple\ncherry\napple\n");
const unique = await $`sort < input.txt | uniq`.text();
Enter fullscreen mode Exit fullscreen mode

There's a trap here. On macOS, echo "banana\napple" does not interpret \n as a newline. Unlike Linux bash's echo -e, macOS's default echo treats backslash-n literally. Use printf instead.

This is an important nuance: Bun Shell runs without bash, but it still uses the OS's native commands. The OS-level behavior of echo remains unchanged.

Parallel Execution — Promise.all Is the Key

To run multiple commands in parallel with Bun Shell, use Promise.all. Commands written sequentially are executed sequentially.

// Sequential (~200ms)
await $`sleep 0.1`;
await $`sleep 0.1`;

// Parallel (~100ms)
await Promise.all([
  $`sleep 0.1`,
  $`sleep 0.1`,
]);
Enter fullscreen mode Exit fullscreen mode

When I measured this directly, sequential was around 471ms and parallel was around 263ms. More overhead than I expected — macOS process spawning has non-trivial cost. Still, for IO-heavy work the parallelization is meaningful.

A Practical Build Script

Build scripts are where Bun Shell shows its real value. You can blend shell operations with TypeScript logic in the same file:

import { $ } from "bun";

const DIST = "./dist";
const SRC = "./src";

async function build() {
  // Clean build
  await $`rm -rf ${DIST} && mkdir -p ${DIST}`;

  // Get TypeScript file list
  const tsFiles = await $`ls ${SRC}/*.ts`.text();
  const files = tsFiles.trim().split("\n");

  console.log(`Building ${files.length} files`);

  // Parallel compilation
  await Promise.all(
    files.map(async (f) => {
      const name = f.split("/").pop()!.replace(".ts", ".js");
      await $`bun build ${f} --outfile ${DIST}/${name}`;
    })
  );

  // Verify output
  const built = await $`ls ${DIST}/`.text();
  console.log("Build output:", built.trim().replace(/\n/g, ", "));
}

build().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Save this as scripts/build.ts and run it with bun run scripts/build.ts. No Node.js or ts-node needed. Wiring this build script into a GitHub Actions CI/CD pipeline is a natural next step once local automation is working.

Pitfalls I Found While Experimenting

Here's the honest part.

Pitfall 1: .stdin() API Doesn't Work in 1.3.14

You may have seen examples using $`command`.stdin("text"). In Bun 1.3.14, this API doesn't exist. You'll get a stdin is not a function runtime error.

Alternatives:

// ❌ Doesn't work in 1.3.14
await $`sort | uniq`.stdin("banana\napple\ncherry");

// ✅ Alternative 1: use a file
await Bun.write("/tmp/input.txt", "banana\napple\ncherry\n");
await $`sort < /tmp/input.txt | uniq`;

// ✅ Alternative 2: printf in the pipe
await $`printf "banana\napple\ncherry\n" | sort | uniq`;
Enter fullscreen mode Exit fullscreen mode

This was the most surprising thing I found. The API appears in some documentation but doesn't actually exist in the current stable release. Worth checking the version you're on before relying on it.

Pitfall 2: $.env() Replaces, Not Merges

// ❌ Dangerous: PATH disappears
$.env({ MY_VAR: "value" });
await $`ls`;  // might error

// ✅ Safe: explicitly include PATH
$.env({ MY_VAR: "value", PATH: process.env.PATH! });
Enter fullscreen mode Exit fullscreen mode

Pitfall 3: macOS echo Doesn't Interpret \n

Already covered above, but worth restating: Bun Shell uses the OS-native echo. On macOS, echo "a\nb" prints the literal string a\nb, not two lines. Use printf if you need newlines in pipe input.

// ❌ macOS: doesn't do what you think
await $`echo "apple\nbanana\ncherry" | sort`;
// → prints "apple\nbanana\ncherry" as one line

// ✅ Works everywhere
await $`printf "apple\nbanana\ncherry\n" | sort`;
Enter fullscreen mode Exit fullscreen mode

When to Use Bun Shell and When Not To

My conclusion: if your project is already Bun-based, Bun Shell is a natural fit. Otherwise, starting with zx is more practical.

Use Bun Shell when:

  • Your project already uses Bun as package manager — zero extra dependencies for shell scripting.
  • Your team includes Windows developers — no bash dependency on any platform.
  • You want to consolidate build/deploy scripts into TypeScript — same language, same file as your app logic.

Skip Bun Shell when:

  • Your project is Node.js + npm with no migration plans.
  • You have complex bash scripts with unknown bash-isms that might not translate.
  • zx already works and your team is comfortable with it.

I'd push back on the framing that Bun Shell is "better than zx." In terms of ecosystem maturity and download numbers, zx is ahead. Bun Shell is the right choice for Bun projects specifically — not a universal upgrade recommendation.

And honestly, the missing .stdin() API bothers me. Once that's stable, stdin-based pipe processing will be significantly cleaner. Until then, there's a workaround but it adds friction.

Deployment Considerations

A few things worth knowing before you put Bun Shell scripts into production.

Pin Your Bun Version

Differences between Bun minor versions can cause subtle behavioral changes. Pin the version in package.json and in CI:

// package.json
{
  "engines": {
    "bun": ">=1.3.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

For GitHub Actions:

- uses: oven-sh/setup-bun@v2
  with:
    bun-version: "1.3.14"
Enter fullscreen mode Exit fullscreen mode

Without pinning, a minor update could silently break things if an API changes.

Error Logging Pattern

When using .nothrow(), capture stderr explicitly and exit non-zero on failure so CI recognizes the script as broken:

const result = await $`some-command`.nothrow();
if (result.exitCode !== 0) {
  console.error(`Command failed (${result.exitCode}): ${result.stderr.toString().trim()}`);
  process.exit(1);  // make CI fail visibly
}
Enter fullscreen mode Exit fullscreen mode

Without process.exit(1), a failed command might silently pass the pipeline. That's the kind of bug that surfaces at 2am on a release day.

Wrapping Up

After actually installing and running it, Bun Shell's developer experience is better than I expected. Automatic variable escaping, the .nothrow() pattern, .lines() for line-by-line output — these are thoughtful details you don't see in zx.

That said, it's still 1.x and some APIs are not stable. I'd recommend validating thoroughly in your actual environment before putting Bun Shell scripts into production CI/CD. The same applies if you're integrating with Claude Code hooks or other automation pipelines.

Bun is moving fast and the Shell API will stabilize. There's no urgent reason to drop zx, but for new Bun projects, the built-in shell deserves a first look.


Experiment environment:

  • Bun: 1.3.14 (macOS arm64)
  • Sandbox: /tmp/bun-lab-final/
  • Date: 2026-05-25

Top comments (0)