Skip to main content
← All Articles · · 11 min read

Bun Shell Scripting Guide: Replace Bash Scripts with $.cmd() and Bun APIs

Learn how to replace complex bash scripts with Bun's built-in shell API. Covers $.cmd(), Bun.file(), spawn(), and practical real-world automation scripts.

bunshell-scriptingautomationjavascriptbashdeveloper-tools

Bash scripting gets the job done—until it doesn’t. The quoting rules are arcane, error handling is verbose, and debugging is painful. Once your scripts grow beyond a few lines, you’re fighting the language more than solving problems.

Bun’s built-in shell API changes that. You get JavaScript ergonomics, proper error handling, async/await, and access to the full Node.js/Bun ecosystem—all while writing shell-style commands that look and behave like the real thing.

This guide covers everything you need to migrate from bash to Bun shell scripting: the core APIs, practical patterns, and real automation scripts you can use today.


Why Bun Shell Instead of Bash?

Before diving into the API, here’s the honest comparison:

ConcernBashBun Shell
Cross-platformNo (WSL required on Windows)Yes (Windows, macOS, Linux)
Error handlingset -e and $?Try/catch, .exitCode
String interpolationComplex quoting rulesTemplate literals
Data processingawk, sed, jqNative JS/TS
Package ecosystemNonenpm/Bun packages
IDE supportMinimalFull TypeScript
TestingDifficultJest/Bun test

Bash still wins for small one-liners and universal compatibility. But for anything beyond 20 lines, Bun shell scripts are easier to write, read, and maintain.


Setup

You need Bun installed:

# macOS/Linux
curl -fsSL https://bun.sh/install | bash

# Windows
powershell -c "irm bun.sh/install.ps1|iex"

Create a script file:

touch deploy.ts
chmod +x deploy.ts

Add the shebang:

#!/usr/bin/env bun

Run it directly:

./deploy.ts
# or
bun run deploy.ts

The $ Shell API

The $ template literal is Bun’s core shell primitive. It executes shell commands and returns a ShellOutput object.

Basic Usage

import { $ } from "bun";

// Run a command
await $`echo "Hello, World!"`;

// Capture output
const result = await $`git log --oneline -5`;
console.log(result.stdout.toString());

// Check exit code
const ls = await $`ls -la`;
console.log(ls.exitCode); // 0 = success

Handling Errors

By default, $ throws on non-zero exit codes:

import { $ } from "bun";

try {
  await $`cat /nonexistent/file`;
} catch (err) {
  console.error("Exit code:", err.exitCode);
  console.error("Stderr:", err.stderr.toString());
}

Use .nothrow() to suppress throws and handle errors manually:

const result = await $`git status`.nothrow();

if (result.exitCode !== 0) {
  console.log("Not a git repo");
}

Variable Interpolation (Safe by Default)

Shell injection is a common bash vulnerability. Bun’s $ automatically escapes interpolated values:

const filename = "file with spaces.txt";
await $`cat ${filename}`;  // Safe: automatically quoted

// Equivalent to: cat "file with spaces.txt"

For dynamic command parts (use with caution):

import { $, ShellPromise } from "bun";

const flag = "--all";
await $`git log ${{ raw: flag }}`;  // raw: bypasses escaping

$.cmd() — Programmatic Command Building

When you need to build commands dynamically, $.cmd() gives you an array-style interface:

import { $ } from "bun";

// Build command from array
const args = ["log", "--oneline", "-10"];
const result = await $.cmd(["git", ...args]);
console.log(result.stdout.toString());

This is especially useful when arguments come from user input or config files:

interface DeployConfig {
  environment: string;
  tag: string;
  flags: string[];
}

async function deploy(config: DeployConfig) {
  const cmd = [
    "kubectl",
    "set",
    "image",
    `deployment/${config.environment}`,
    `app=${config.tag}`,
    ...config.flags,
  ];

  const result = await $.cmd(cmd).nothrow();

  if (result.exitCode !== 0) {
    throw new Error(`Deploy failed: ${result.stderr.toString()}`);
  }

  return result.stdout.toString().trim();
}

Piping with $.cmd()

Chain commands using .pipe():

import { $ } from "bun";

// Equivalent to: ps aux | grep node | awk '{print $2}'
const pids = await $`ps aux`
  .pipe($`grep node`)
  .pipe($`awk '{print $2}'`);

console.log("Node PIDs:", pids.stdout.toString());

Bun.file() — File Operations

Bun.file() provides a high-performance API for reading and writing files—much faster than Node.js fs and easier to use than bash’s file operations.

Reading Files

// Read as text
const config = await Bun.file("config.json").text();
const parsed = JSON.parse(config);

// Read as JSON directly
const pkg = await Bun.file("package.json").json();
console.log("Version:", pkg.version);

// Read as stream (for large files)
const stream = Bun.file("large.log").stream();
const reader = stream.getReader();

Writing Files

// Write text
await Bun.write("output.log", "Build completed at " + new Date());

// Write JSON
const report = { status: "success", timestamp: Date.now() };
await Bun.write("report.json", JSON.stringify(report, null, 2));

// Append (write doesn't support append natively — use this pattern)
const existing = await Bun.file("app.log").text().catch(() => "");
await Bun.write("app.log", existing + "\n" + "New log line");

File Operations in Scripts

import { $ } from "bun";

async function rotateLog(logPath: string) {
  const file = Bun.file(logPath);

  // Check if exists
  if (!(await file.exists())) {
    console.log("Log file not found, skipping rotation");
    return;
  }

  // Read content
  const content = await file.text();
  const lines = content.split("\n");

  // Keep last 1000 lines
  if (lines.length > 1000) {
    const trimmed = lines.slice(-1000).join("\n");
    await Bun.write(logPath, trimmed);
    console.log(`Trimmed log from ${lines.length} to 1000 lines`);
  }

  // Archive if > 5MB
  const { size } = file;
  if (size > 5 * 1024 * 1024) {
    const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
    await $`mv ${logPath} ${logPath}.${timestamp}.bak`;
    console.log(`Archived ${logPath} (${(size / 1024 / 1024).toFixed(1)}MB)`);
  }
}

Bun.spawn() — Long-Running Processes

For processes you need to monitor or control (servers, watchers, background tasks), use Bun.spawn():

import { $ } from "bun";

// Start a process and capture output
const proc = Bun.spawn(["node", "server.js"], {
  stdout: "pipe",
  stderr: "pipe",
  env: {
    ...process.env,
    NODE_ENV: "production",
    PORT: "3000",
  },
});

// Read stdout as it streams
const reader = proc.stdout.getReader();
const decoder = new TextDecoder();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  process.stdout.write(decoder.decode(value));
}

// Wait for process to finish
const exitCode = await proc.exited;
console.log("Exit code:", exitCode);

Process Management Pattern

interface ManagedProcess {
  proc: ReturnType<typeof Bun.spawn>;
  kill: () => void;
}

function startService(name: string, cmd: string[]): ManagedProcess {
  const proc = Bun.spawn(cmd, {
    stdout: "inherit",
    stderr: "inherit",
    onExit: (proc, exitCode) => {
      console.log(`${name} exited with code ${exitCode}`);
    },
  });

  return {
    proc,
    kill: () => proc.kill(),
  };
}

// Usage
const api = startService("API", ["bun", "run", "api/server.ts"]);
const worker = startService("Worker", ["bun", "run", "workers/index.ts"]);

// Graceful shutdown on SIGTERM
process.on("SIGTERM", () => {
  api.kill();
  worker.kill();
  process.exit(0);
});

Real-World Scripts

1. Deployment Script

This replaces a typical deploy.sh that orchestrates git, Docker, and Kubernetes:

#!/usr/bin/env bun
import { $ } from "bun";

const ENV = process.argv[2] || "staging";
const VALID_ENVS = ["staging", "production"];

if (!VALID_ENVS.includes(ENV)) {
  console.error(`Invalid environment: ${ENV}. Use: ${VALID_ENVS.join(", ")}`);
  process.exit(1);
}

async function deploy() {
  console.log(`🚀 Deploying to ${ENV}...`);

  // Get current git info
  const branch = (await $`git rev-parse --abbrev-ref HEAD`).stdout.toString().trim();
  const sha = (await $`git rev-parse --short HEAD`).stdout.toString().trim();
  const tag = `${ENV}-${sha}`;

  console.log(`📌 Branch: ${branch}, SHA: ${sha}`);

  // Run tests
  console.log("🧪 Running tests...");
  await $`bun test`;

  // Build Docker image
  console.log(`🐳 Building image: ${tag}`);
  await $`docker build -t myapp:${tag} .`;
  await $`docker push registry.example.com/myapp:${tag}`;

  // Deploy to Kubernetes
  console.log("☸️ Updating deployment...");
  await $`kubectl set image deployment/myapp app=registry.example.com/myapp:${tag} --namespace=${ENV}`;
  await $`kubectl rollout status deployment/myapp --namespace=${ENV} --timeout=120s`;

  // Write deployment log
  const log = {
    env: ENV,
    tag,
    branch,
    sha,
    timestamp: new Date().toISOString(),
    deployedBy: (await $`git config user.email`).stdout.toString().trim(),
  };

  await Bun.write(`deployments/${ENV}-latest.json`, JSON.stringify(log, null, 2));
  console.log(`✅ Deployed ${tag} to ${ENV}`);
}

deploy().catch((err) => {
  console.error("❌ Deploy failed:", err.message);
  process.exit(1);
});

2. CI Environment Setup Script

Replace your .sh CI setup scripts with a typed, readable alternative:

#!/usr/bin/env bun
import { $ } from "bun";

interface EnvCheck {
  name: string;
  required: boolean;
}

const ENV_CHECKS: EnvCheck[] = [
  { name: "DATABASE_URL", required: true },
  { name: "API_KEY", required: true },
  { name: "CACHE_URL", required: false },
];

async function validateEnv() {
  const missing = ENV_CHECKS
    .filter(({ name, required }) => required && !process.env[name])
    .map(({ name }) => name);

  if (missing.length > 0) {
    console.error(`Missing required env vars: ${missing.join(", ")}`);
    process.exit(1);
  }
  console.log("✅ Environment validated");
}

async function installDeps() {
  console.log("📦 Installing dependencies...");
  await $`bun install --frozen-lockfile`;
}

async function generatePrismaClient() {
  if (await Bun.file("prisma/schema.prisma").exists()) {
    console.log("🔧 Generating Prisma client...");
    await $`bunx prisma generate`;
    await $`bunx prisma migrate deploy`;
  }
}

async function buildApp() {
  console.log("🏗️ Building...");
  await $`bun run build`;

  // Verify build output exists
  if (!(await Bun.file("dist/index.js").exists())) {
    throw new Error("Build failed: dist/index.js not found");
  }
}

// Run all setup steps
await validateEnv();
await installDeps();
await generatePrismaClient();
await buildApp();
console.log("🎉 CI setup complete");

3. Log Analyzer

A script that would require awk/sed/grep pipelines in bash:

#!/usr/bin/env bun
import { $ } from "bun";

const LOG_FILE = process.argv[2] || "app.log";
const file = Bun.file(LOG_FILE);

if (!(await file.exists())) {
  console.error(`File not found: ${LOG_FILE}`);
  process.exit(1);
}

const content = await file.text();
const lines = content.split("\n").filter(Boolean);

// Parse structured logs (JSON format)
const errors = lines
  .map((line) => {
    try {
      return JSON.parse(line);
    } catch {
      return null;
    }
  })
  .filter((entry) => entry?.level === "error");

// Group by error code
const byCode: Record<string, number> = {};
for (const err of errors) {
  const code = err.code || "UNKNOWN";
  byCode[code] = (byCode[code] || 0) + 1;
}

// Print report
console.log(`\n📊 Error Report for ${LOG_FILE}`);
console.log(`Total errors: ${errors.length} / ${lines.length} lines\n`);

Object.entries(byCode)
  .sort(([, a], [, b]) => b - a)
  .forEach(([code, count]) => {
    const pct = ((count / errors.length) * 100).toFixed(1);
    console.log(`  ${code.padEnd(20)} ${count.toString().padStart(5)}  (${pct}%)`);
  });

// Save report
const report = { file: LOG_FILE, total: lines.length, errors: errors.length, byCode };
await Bun.write("error-report.json", JSON.stringify(report, null, 2));
console.log("\n✅ Report saved to error-report.json");

Linting Your Shell Scripts

Before shipping any shell script to CI, use the shell script linter to catch common mistakes: undefined variables, dangerous patterns, and quoting issues in your embedded bash commands.


Environment Variables

Access and validate env vars cleanly:

// Single var with fallback
const port = process.env.PORT ?? "3000";

// Type-safe env parsing
function requireEnv(key: string): string {
  const val = process.env[key];
  if (!val) throw new Error(`Missing required env var: ${key}`);
  return val;
}

const dbUrl = requireEnv("DATABASE_URL");
const apiKey = requireEnv("API_KEY");

Migrating from Bash: Quick Reference

BashBun Shell
$(git rev-parse HEAD)(await $`git rev-parse HEAD`).stdout.toString().trim()
cat file.txtawait Bun.file("file.txt").text()
echo "text" > file.txtawait Bun.write("file.txt", "text")
if [ -f file ]; thenif (await Bun.file("file").exists())
`command
command 2>/dev/nullawait $`command`.quiet()
set -eAutomatic (each $ throws on failure)
FOO=bar ./scriptawait $({ env: { FOO: "bar" } })command“

Performance Notes

Bun’s shell API adds minimal overhead compared to native shell. For scripts that run hundreds of shell commands in a loop, you’ll notice some process spawn overhead—but for typical automation (< 50 commands), it’s negligible.

Where Bun genuinely wins: file I/O. Bun.file() reads and writes are significantly faster than Node.js fs equivalents, and much faster than shelling out to cat, tee, or awk.


Summary

Bun’s shell API doesn’t replace bash for quick one-liners, but it’s a genuine upgrade for anything more complex:

  • $ template literal for shell commands with safe interpolation
  • $.cmd() for dynamic command building from arrays
  • Bun.file() for high-performance file operations
  • Bun.spawn() for long-running process management
  • Full TypeScript support, real error handling, and the entire npm ecosystem

Start by migrating your most painful bash scripts—deployment, CI setup, log processing. Once you see how much cleaner the error handling is, you won’t go back.

Further reading:

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.