The Bun Shell: Cross-Platform Shell Scripting in JavaScript

For decades, running shell commands from JavaScript has been a painful experience. You either struggle with the verbose child_process API or install a dozen npm packages just to make rm -rf work on Windows. Bun Shell changes everything by embedding a full bash-like shell interpreter directly into the runtime, making shell scripting in JavaScript as natural as writing native code.

The Problem with Shell Scripts in JavaScript

JavaScript is the world's most popular scripting language, yet running shell commands from it remains surprisingly difficult. Consider a simple task like listing all .js files in a directory:

import { spawnSync } from "child_process";

const { status, stdout, stderr } = spawnSync("ls", ["-l", "*.js"], {
  encoding: "utf8",
});

That's a lot of boilerplate for something that should be a one-liner. The Node.js approach forces you to think about encoding, exit codes, error handling, and API complexity for even the simplest operations.

The Cross-Platform Nightmare

The situation worsens when you need your code to work across different operating systems. On Unix-like systems, rm -rf removes directories recursively. On Windows, that command doesn't exist at all. This is why rimraf, a JavaScript implementation of rm -rf, gets downloaded over 60 million times per week:

+---------------------------+
|  rimraf: 60M+/week        |
|  cross-env: 40M+/week     |
|  which/which: 30M+/week   |
+---------------------------+

Setting environment variables follows different syntax on each platform. FOO=bar node script.js works on macOS and Linux but fails on Windows, requiring the cross-env package with 40 million weekly downloads.

Even finding the location of a command differs: which on Unix versus where on Windows, spawning yet another dependency.

Performance Overhead

Starting a shell process isn't cheap. On a typical Linux machine:

$ hyperfine 'bash -c "echo hello"'
Time (mean): 7.3 ms ± 1.5 ms

Seven milliseconds just to echo "hello". If you're running multiple commands in a loop, those milliseconds add up quickly. The shell startup time can exceed the actual command execution time.

Introducing Bun Shell

Bun Shell is a bash-like shell interpreter embedded directly into Bun. It runs shell commands within the same process, eliminating startup overhead while providing a familiar, cross-platform shell experience.

import { $ } from "bun";

// Simple command execution
await $`ls *.js`;

// Capture output as text
const files = await $`ls *.js`.text();

No external processes. No platform-specific code paths. No npm packages for basic operations. Just clean, readable JavaScript.

Template Literal Syntax

Bun Shell uses tagged template literals, making it feel natural alongside modern JavaScript:

import { $ } from "bun";

const pattern = "*.ts";
const files = await $`ls ${pattern}`.text();

console.log(files);

The $ tag function processes the template literal, executing the shell command and returning a promise. This design choice means you can seamlessly interpolate JavaScript variables into your shell commands.

Built-in Safety

One of the most dangerous aspects of shell scripting is command injection. Bun Shell addresses this by automatically escaping all interpolated variables:

import { $ } from "bun";

const filename = "foo.js; rm -rf /";

// SAFE: Bun escapes this as a single literal string
const result = await $`ls ${filename}`;

console.log(result.exitCode); // 1 (file not found)
console.log(result.stderr.toString()); 
// ls: cannot access 'foo.js; rm -rf /': No such file or directory

The malicious payload never executes as a separate command. Bun Shell treats the entire interpolated value as a single argument, preventing injection attacks by default.

JavaScript Interop

Bun Shell goes beyond simple command execution by allowing JavaScript objects to flow naturally through shell pipelines.

Using Response as Stdin

You can pipe HTTP responses directly into shell commands:

import { $ } from "bun";

const response = await fetch("https://api.example.com/data.json");

// Pipe the response body through gzip
const compressed = await $`gzip -c < ${response}`.arrayBuffer();

This enables powerful patterns where you fetch data and immediately process it through standard Unix tools without intermediate buffers or temporary files.

Redirecting to JavaScript Objects

Capture command output directly into JavaScript buffers:

import { $ } from "bun";

const buffer = Buffer.alloc(1024);
await $`ls *.js > ${buffer}`;

console.log(buffer.toString("utf8"));

Or redirect to files using Bun.file():

import { $, file } from "bun";

// Redirect output to a file
await $`ls *.js > ${file("output.txt")}`;

// Or use a plain string path
await $`ls *.js > output.txt`;

Reading Output

Bun Shell provides several convenient methods for reading command output:

import { $ } from "bun";

// As text
const text = await $`echo "Hello World!"`.text();

// As JSON
const data = await $`echo '{"name": "bun"}'`.json();

// Line by line (async iterator)
for await (const line of $`cat log.txt`.lines()) {
  console.log(line);
}

// As a Blob
const blob = await $`cat image.png`.blob();

Cross-Platform Built-in Commands

Bun Shell implements common shell commands natively, ensuring they work identically on Windows, macOS, and Linux:

+-------------------+----------------------------------------+
| Command           | Description                            |
+-------------------+----------------------------------------+
| cd                | Change working directory               |
| ls                | List files (supports -l flag)          |
| rm                | Remove files and directories           |
| echo              | Print text                             |
| pwd               | Print working directory                |
| cat               | Concatenate and display files          |
| touch             | Update file timestamps or create files |
| mkdir             | Create directories                     |
| which             | Locate a command                       |
| mv                | Move files and directories             |
| exit              | Exit with a status code                |
| true / false      | Return success/failure exit codes      |
| yes               | Output a string repeatedly             |
| seq               | Generate a sequence of numbers         |
| dirname           | Extract directory from path            |
| basename          | Extract filename from path             |
+-------------------+----------------------------------------+

These commands work identically across all platforms, eliminating the need for packages like rimraf, cross-env, and mkdirp.

Shell Features

Bun Shell supports the shell features you'd expect from bash:

Piping

Chain commands together with pipes:

import { $ } from "bun";

// Count words in the output of ls
const count = await $`ls *.js | wc -w`.text();

// Filter results
for await (const line of $`cat log.txt | grep error`.lines()) {
  console.log(line);
}

Redirection

Full support for input/output redirection:

import { $ } from "bun";

// Redirect stdout to file
await $`echo "Hello" > greeting.txt`;

// Redirect stderr to file
await $`bun run script.ts 2> errors.txt`;

// Redirect stderr to stdout
await $`bun run script.ts 2>&1`;

// Append to file
await $`echo "More text" >> log.txt`;

Environment Variables

Set and use environment variables naturally:

import { $ } from "bun";

// Inline environment variable
await $`NODE_ENV=production bun run build`;

// With JavaScript interpolation
const env = "staging";
await $`NODE_ENV=${env} bun run deploy`;

// Override environment for a command
await $`echo $FOO`.env({ ...process.env, FOO: "bar" });

Command Substitution

Use the output of one command in another:

import { $ } from "bun";

// Get git commit hash
await $`echo Current commit: $(git rev-parse HEAD)`;

// Use in a variable
await $`
  REV=$(git rev-parse HEAD)
  docker build -t myapp:$REV .
  echo Built docker image "myapp:$REV"
`;

Glob Patterns

Native support for glob expansion:

import { $ } from "bun";

// Match all TypeScript files
await $`ls **/*.ts`;

// Brace expansion
await $`ls src/{components,utils}/*.ts`;

Error Handling

By default, non-zero exit codes throw a ShellError with detailed information:

import { $ } from "bun";

try {
  await $`some-command-that-fails`;
} catch (err) {
  console.log(`Exit code: ${err.exitCode}`);
  console.log(`Stdout: ${err.stdout.toString()}`);
  console.log(`Stderr: ${err.stderr.toString()}`);
}

Disable throwing with .nothrow() for manual exit code handling:

import { $ } from "bun";

const { exitCode, stdout, stderr } = await $`some-command`.nothrow().quiet();

if (exitCode !== 0) {
  console.log(`Command failed with code ${exitCode}`);
}

Node.js Comparison

The contrast between Bun Shell and Node.js's approach to shell scripting is stark. Here's how the same tasks compare:

Running a Simple Command

Node.js:

import { exec } from "child_process";
import { promisify } from "util";

const execAsync = promisify(exec);

const { stdout, stderr } = await execAsync("ls *.js");
console.log(stdout);

Bun:

import { $ } from "bun";

const files = await $`ls *.js`.text();
console.log(files);

Removing a Directory Recursively

Node.js (cross-platform):

// Install rimraf first: npm install rimraf
import rimraf from "rimraf";

await rimraf("./dist");

Or using native fs:

import { rm } from "fs/promises";

await rm("./dist", { recursive: true, force: true });

Bun:

import { $ } from "bun";

await $`rm -rf ./dist`;

Setting Environment Variables

Node.js (cross-platform):

// Install cross-env: npm install cross-env
// In package.json:
// "build": "cross-env NODE_ENV=production webpack"

Bun:

import { $ } from "bun";

await $`NODE_ENV=production bun run build`;

Piping Commands

Node.js:

import { spawn } from "child_process";

const ls = spawn("ls", ["*.js"]);
const grep = spawn("grep", ["pattern"]);

ls.stdout.pipe(grep.stdin);

let output = "";
grep.stdout.on("data", (data) => {
  output += data.toString();
});

await new Promise((resolve) => {
  grep.on("close", resolve);
});

console.log(output);

Bun:

import { $ } from "bun";

const output = await $`ls *.js | grep pattern`.text();
console.log(output);

Packages Required in Node.js

To achieve what Bun Shell provides out of the box, you'd need to install:

+------------------+ Purpose                        + Weekly Downloads +
+------------------+--------------------------------+------------------+
| rimraf           | Cross-platform rm -rf          | 60M+             |
| cross-env        | Cross-platform env variables   | 40M+             |
| which            | Find command location          | 30M+             |
| mkdirp           | Create directories recursively | 25M+             |
| glob             | File pattern matching          | 35M+             |
| execa            | Better child_process API       | 50M+             |
| npm-run-all      | Run multiple scripts           | 5M+              |
+------------------+--------------------------------+------------------+

These packages collectively account for hundreds of millions of weekly downloads, solving problems that shouldn't exist in 2024.

Shell Scripts as Files

Bun Shell can execute .sh files directly, using Bun Shell as the interpreter instead of /bin/sh:

# script.sh
echo "Hello from Bun Shell!"
echo "Current directory: $(pwd)"
ls *.ts | wc -l
bun script.sh

These scripts work identically on Windows, making them truly cross-platform without any modifications.

Implementation

Bun Shell is written in Zig and embedded directly into Bun's binary. It includes:

  • A hand-written lexer
  • A recursive descent parser
  • An interpreter that executes commands concurrently

Unlike spawning a subprocess with /bin/sh, Bun Shell runs within the same process, avoiding the overhead of process creation and enabling seamless JavaScript interop through shared memory.

+-------------------+
|  JavaScript Code  |
|  $`ls *.js`       |
+---------+---------+
          |
          v
+-------------------+
|   Bun Shell       |
|   (in-process)    |
+---------+---------+
          |
    +-----+-----+
    |           |
    v           v
+-------+   +-------+
| Built-in   | System|
| Commands   | Commands|
+-------+   +-------+

This architecture provides both performance and safety benefits. Commands run faster (no process spawn overhead) and the shell has full control over how JavaScript values are interpolated and escaped.

Practical Examples

Build Pipeline

import { $ } from "bun";

// Clean and rebuild
await $`rm -rf dist`;
await $`mkdir -p dist`;

// Bundle and minify
await $`bun build ./src/index.ts --outdir ./dist --minify`;

// Generate checksum
const hash = await $`sha256sum dist/index.js`.text();
console.log(`Build complete: ${hash.split(" ")[0]}`);

Git Automation

import { $ } from "bun";

const branch = await $`git rev-parse --abbrev-ref HEAD`.text();
const commit = await $`git rev-parse --short HEAD`.text();

console.log(`Deploying ${branch.trim()} (${commit.trim()})`);

await $`
  docker build -t myapp:${commit} .
  docker push myapp:${commit}
`;

Log Processing

import { $ } from "bun";

// Find and count errors in logs
const errorCount = await $`cat logs/*.log | grep -c "ERROR"`.text();

// Extract unique IP addresses
const ips = await $`cat access.log | awk '{print $1}' | sort -u`.text();

console.log(`Found ${errorCount.trim()} errors from unique IPs:\n${ips}`);

Conclusion

Bun Shell represents a fundamental shift in how JavaScript runtimes approach shell scripting. Instead of forcing developers to choose between verbose native APIs and a constellation of npm packages, Bun embeds a complete shell interpreter that just works.

The result is cleaner code, fewer dependencies, and better performance. Simple commands become one-liners. Cross-platform compatibility is guaranteed. Security against command injection is built in by default.

For teams building CLI tools, build scripts, or deployment pipelines, Bun Shell eliminates an entire category of complexity. It's one of those features that, once you use it, you wonder how you ever lived without it.

Resources