MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Bun Shell: Cross-Platform Shell Scripting in JavaScript

Write shell scripts in Bun: cross-platform commands, pipes, and environment variables.

BunShellJavaScriptCLI

By MinhVo

Introduction

The disconnect between shell scripting and JavaScript development has been a persistent pain point in modern web development. Developers write their application logic in TypeScript but resort to bash, PowerShell, or Python for automation scripts. This creates a split in the toolchain — different syntax, different error handling, different debugging tools, and platform-specific limitations that force teams to maintain separate scripts for each operating system.

Bun Shell bridges this gap by providing a complete shell scripting environment within the Bun JavaScript runtime. Using tagged template literals, developers can execute shell commands, pipe data between processes, manage environment variables, and handle errors — all using familiar JavaScript syntax. The commands work identically on macOS, Linux, and Windows, eliminating the platform compatibility headaches that have plagued shell scripting for decades.

JavaScript Shell Scripting

This article explores Bun Shell's capabilities in depth, from basic command execution to advanced patterns like parallel processing and streaming output, with practical examples you can use in your projects today.

Understanding Bun Shell: Core Concepts

Tagged Template Literal Syntax

Bun Shell uses the $ tagged template literal to execute commands. This syntax is not just syntactic sugar — it provides automatic argument escaping, variable interpolation, and type-safe output:

import { $ } from "bun";
 
// Simple command
const files = await $`ls -la`.text();
 
// With JavaScript interpolation (automatically escaped)
const dir = "src";
const result = await $`find ${dir} -name "*.ts"`.text();
 
// Pipes
const count = await $`find . -name "*.ts" | wc -l`.text();
console.log(`Found ${count.trim()} TypeScript files`);

The key insight is that each interpolated value is treated as a separate argument, not concatenated into a single string. This prevents shell injection attacks — if a variable contains spaces or special characters, it is properly escaped before being passed to the command.

Built-in Cross-Platform Commands

Bun Shell includes Zig implementations of common Unix commands that work on all platforms:

  • ls — list directory contents
  • cat — concatenate and print files
  • echo — display text
  • cp — copy files and directories
  • mv — move/rename files
  • rm — remove files and directories
  • mkdir — create directories
  • touch — create empty files or update timestamps
  • pwd — print working directory
  • cd — change directory
  • which — locate a command
  • exit — exit with status code
  • test — conditional evaluation
  • true / false — boolean values

These commands work identically on Windows without requiring WSL, Git Bash, or Cygwin. The rm -rf dist command works the same way on Windows as it does on Linux.

Output and Error Handling

Bun Shell provides multiple methods for consuming command output:

import { $ } from "bun";
 
// Get output as string
const text = await $`echo "hello"`.text();
 
// Get output as JSON
const data = await $`cat config.json`.json();
 
// Get output as buffer
const bytes = await $`cat image.png`.arrayBuffer();
 
// Get exit code
const { exitCode } = await $`which bun`.quiet();
console.log(exitCode === 0 ? "bun is installed" : "bun not found");

By default, Bun Shell throws an error when a command exits with a non-zero status. Use .nothrow() to suppress this behavior when you expect non-zero exit codes.

Cross-Platform Compatibility

Architecture and Design Patterns

Process Pipeline Architecture

Bun Shell implements process pipelines using OS-level pipes. When you write $cmd1 | cmd2``, Bun creates two child processes connected by a pipe. Data flows from cmd1's stdout to cmd2's stdin without buffering the entire output in memory. This is essential for processing large datasets or streaming logs.

The pipeline implementation supports arbitrarily long chains, so $cat file | grep pattern | sort | uniq -c`` works as expected, with each command running as a separate process connected by pipes.

Environment Variable Scoping

Bun Shell supports scoped environment variable modifications:

import { $ } from "bun";
 
// Global modification
$.env.NODE_ENV = "production";
 
// Scoped modification (only affects this command)
const result = await $`echo $NODE_ENV`.env("NODE_ENV", "staging").text();
console.log(result); // "staging"
 
// Original value is preserved
console.log(process.env.NODE_ENV); // "production"

This scoping mechanism prevents environment variable pollution between different parts of your script, which is a common source of bugs in traditional shell scripts.

Working Directory Management

import { $ } from "bun";
 
// Set working directory for all subsequent commands
$.cwd("/tmp");
 
// Or set per-command
await $`ls`.cwd("/home/user").text();
 
// Revert to original
$.cwd(process.cwd());

Step-by-Step Implementation

Project Setup Script

#!/usr/bin/env bun
// setup.ts - Cross-platform project setup
 
import { $ } from "bun";
 
async function setup() {
  console.log("Setting up development environment...");
 
  // Check prerequisites
  const bunVersion = await $`bun --version`.text();
  console.log(`Bun version: ${bunVersion.trim()}`);
 
  // Install dependencies
  console.log("Installing dependencies...");
  await $`bun install`;
 
  // Set up database
  console.log("Setting up database...");
  await $`bun run db:migrate`;
  await $`bun run db:seed`;
 
  // Build the project
  console.log("Building project...");
  await $`bun run build`;
 
  // Run tests
  console.log("Running tests...");
  await $`bun test`;
 
  console.log("Setup complete!");
}
 
setup().catch((err) => {
  console.error("Setup failed:", err.message);
  process.exit(1);
});

Log Analysis Script

#!/usr/bin/env bun
// analyze-logs.ts - Parse and analyze application logs
 
import { $ } from "bun";
 
interface LogEntry {
  timestamp: string;
  level: string;
  message: string;
}
 
async function analyzeLogs(logFile: string) {
  const content = await $`cat ${logFile}`.text();
  const lines = content.trim().split("\n");
 
  const entries: LogEntry[] = lines.map((line) => {
    const [timestamp, level, ...messageParts] = line.split(" ");
    return {
      timestamp,
      level: level?.replace(/[[\]]/g, ""),
      message: messageParts.join(" "),
    };
  });
 
  const errorCount = entries.filter((e) => e.level === "ERROR").length;
  const warnCount = entries.filter((e) => e.level === "WARN").length;
 
  console.log(`Total entries: ${entries.length}`);
  console.log(`Errors: ${errorCount}`);
  console.log(`Warnings: ${warnCount}`);
 
  // Find most common errors
  const errors = entries.filter((e) => e.level === "ERROR");
  const errorMessages = errors.map((e) => e.message);
  const uniqueErrors = [...new Set(errorMessages)];
 
  console.log("\nUnique errors:");
  for (const error of uniqueErrors) {
    const count = errorMessages.filter((m) => m === error).length;
    console.log(`  (${count}x) ${error}`);
  }
}
 
analyzeLogs(process.argv[2] ?? "app.log");

Git Workflow Automation

#!/usr/bin/env bun
// release.ts - Automated release workflow
 
import { $ } from "bun";
 
async function release() {
  // Ensure clean working directory
  const status = await $`git status --porcelain`.text();
  if (status.trim()) {
    console.error("Working directory not clean. Commit changes first.");
    process.exit(1);
  }
 
  // Get current version
  const pkg = await Bun.file("package.json").json();
  const currentVersion = pkg.version;
  console.log(`Current version: ${currentVersion}`);
 
  // Bump version
  const [major, minor, patch] = currentVersion.split(".").map(Number);
  const newVersion = `${major}.${minor}.${patch + 1}`;
  console.log(`New version: ${newVersion}`);
 
  // Update package.json
  pkg.version = newVersion;
  await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n");
 
  // Run tests
  console.log("Running tests...");
  await $`bun test`;
 
  // Build
  console.log("Building...");
  await $`bun run build`;
 
  // Commit and tag
  await $`git add -A`;
  await $`git commit -m ${`release: v${newVersion}`}`;
  await $`git tag v${newVersion}`;
 
  console.log(`Release v${newVersion} ready. Push with: git push && git push --tags`);
}
 
release().catch((err) => {
  console.error("Release failed:", err.message);
  process.exit(1);
});

File Synchronization Script

#!/usr/bin/env bun
// sync.ts - Sync files between environments
 
import { $ } from "bun";
 
async function syncFiles(source: string, destination: string) {
  console.log(`Syncing ${source} -> ${destination}`);
 
  // Create destination if it doesn't exist
  await $`mkdir -p ${destination}`;
 
  // Copy files with progress
  const files = (await $`find ${source} -type f`.text()).trim().split("\n");
  console.log(`Found ${files.length} files`);
 
  for (const file of files) {
    const relativePath = file.replace(source, "");
    const destPath = `${destination}${relativePath}`;
    const destDir = destPath.substring(0, destPath.lastIndexOf("/"));
 
    await $`mkdir -p ${destDir}`;
    await $`cp ${file} ${destPath}`;
  }
 
  console.log(`Synced ${files.length} files`);
}
 
const [source, destination] = process.argv.slice(2);
if (!source || !destination) {
  console.error("Usage: bun run sync.ts <source> <destination>");
  process.exit(1);
}
 
syncFiles(source, destination);

Automation Architecture

Real-World Use Cases

CI/CD Pipeline Scripts

Replace platform-specific CI scripts with Bun Shell scripts that work on GitHub Actions, GitLab CI, CircleCI, and local development machines. A single ci.ts script can handle linting, testing, building, and deploying without platform-specific conditionals.

Development Server Management

Manage multiple development services (API server, database, Redis, frontend dev server) with a single Bun Shell script. Start all services in parallel, monitor their output, and handle graceful shutdown on SIGINT.

Database Migration Automation

Write database migration scripts that work across platforms. Check pending migrations, apply them in order, verify schema changes, and roll back if something fails — all in TypeScript with proper error handling.

Deployment Scripts

Create deployment scripts that handle SSH connections, file transfers, service restarts, and health checks. Bun Shell's structured error handling ensures that deployment failures are caught and reported clearly, with automatic rollback on critical errors.

Best Practices for Production

  1. Always use template literal interpolation: Never concatenate strings to build commands. Bun Shell's template literal syntax automatically escapes arguments, preventing shell injection vulnerabilities.

  2. Use .nothrow() for expected failures: When checking if a file exists or a service is running, use .nothrow() to prevent the script from crashing on expected non-zero exit codes.

  3. Set explicit working directories: Use $.cwd() or the .cwd() method on individual commands to avoid confusion about the current working directory.

  4. Parse output with .text() and .json(): Use the typed output methods to consume command results. This provides better error handling than parsing raw buffers.

  5. Use .quiet() for commands whose output you don't need: Suppressing output for background commands reduces noise and improves performance.

  6. Handle signals gracefully: Register SIGINT and SIGTERM handlers to clean up child processes when your script is interrupted.

  7. Use .timeout() for long-running commands: Prevent scripts from hanging indefinitely by setting timeouts on commands that might block.

  8. Write testable scripts: Extract logic into functions that can be tested independently. Use Bun's test runner to verify your automation scripts work correctly.

Common Pitfalls and Solutions

PitfallImpactSolution
String concatenation for commandsShell injection vulnerabilityAlways use template literal interpolation
Not handling non-zero exitsSilent script failuresUse try/catch or .nothrow() explicitly
Assuming bash-specific syntaxSyntax errors on WindowsUse Bun Shell's supported syntax only
Large output bufferingMemory exhaustionStream output with .stdout instead of .text()
Forgetting .quiet() on background tasksNoisy outputUse .quiet() for commands you don't need output from

Debugging Bun Shell Scripts

When a Bun Shell script fails, the error object contains the command, exit code, stdout, and stderr. Use this information to diagnose the issue:

import { $ } from "bun";
 
try {
  await $`failing-command`;
} catch (error) {
  console.error("Command failed:", error.message);
  console.error("Exit code:", error.exitCode);
  console.error("stderr:", error.stderr?.toString());
}

Performance Optimization

Parallel Command Execution

Run independent commands in parallel to reduce total execution time:

import { $ } from "bun";
 
// Sequential (slow)
await $`bun run lint`;
await $`bun test`;
await $`bun run build`;
 
// Parallel (fast)
await Promise.all([
  $`bun run lint`,
  $`bun test`,
  $`bun run build`,
]);

Streaming for Large Output

import { $ } from "bun";
 
// Stream instead of buffer for large files
const proc = $`cat huge-file.csv`.quiet();
const reader = proc.stdout.getReader();
const decoder = new TextDecoder();
 
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const chunk = decoder.decode(value);
  // Process chunk incrementally
  processChunk(chunk);
}

Comparison with Alternatives

FeatureBun ShellbashNode.js child_processshelljsexeca
Cross-platformYesNoYesYesYes
Shell syntaxTemplate literalsNativeFunction callsFunction callsFunction calls
Automatic escapingYesManualManualManualManual
Built-in commandsYesYesNoPartialNo
Error handlingtry/catchset -eCallbacksCallbacksPromises
TypeScript supportNativeNoTypes availableTypes availableTypes available
PerformanceFast (Zig)FastModerateSlowModerate

Advanced Patterns

Custom Command Aliases

import { $ } from "bun";
 
// Create reusable command patterns
const git = $`git`;
const docker = $`docker`;
 
await git`status`;
await git`log --oneline -10`;
await docker`ps -a`;

Conditional Execution

import { $ } from "bun";
 
// Conditional based on platform
if (process.platform === "win32") {
  await $`dir`;
} else {
  await $`ls -la`;
}
 
// Conditional based on command availability
const hasGit = (await $`which git`.nothrow().quiet()).exitCode === 0;
if (hasGit) {
  const hash = await $`git rev-parse HEAD`.text();
  console.log(`Current commit: ${hash.trim()}`);
}

Testing Strategies

Testing Shell Scripts

import { test, expect, beforeEach } from "bun:test";
import { $ } from "bun";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
 
let testDir: string;
 
beforeEach(async () => {
  testDir = await mkdtemp(join(tmpdir(), "bun-shell-test-"));
});
 
test("build script creates output directory", async () => {
  // Setup
  await Bun.write(join(testDir, "src/index.ts"), "export default 1;");
 
  // Execute
  const result = await $`bun build src/index.ts --outdir dist`.cwd(testDir).text();
 
  // Verify
  const distFiles = await $`ls dist/`.cwd(testDir).text();
  expect(distFiles).toContain("index.js");
});

Future Outlook

Bun Shell is still evolving, with the Bun team actively adding new built-in commands and improving Windows compatibility. Future versions may include interactive shell mode, more sophisticated glob support, and integration with Bun's package manager for running scripts defined in package.json.

The broader trend toward unified JavaScript toolchains means Bun Shell will likely become the default scripting solution for Bun projects. As the ecosystem matures, we can expect more libraries and frameworks to ship with Bun Shell scripts instead of bash, further reducing platform fragmentation.

Testing Bun Shell Scripts

Testing Bun Shell scripts follows standard JavaScript testing patterns. Use Bun's built-in test runner to write unit tests for shell script functions. Mock external commands by replacing them with test doubles that return predictable output. Test error handling by simulating command failures and verifying that your scripts handle them gracefully. Use temporary directories for tests that modify the file system and clean up after each test. Integration tests should verify that your scripts work with real commands and file system operations in a controlled environment.

Bun Shell Scripting Best Practices

When writing Bun Shell scripts for production use, always validate input parameters before processing them. Use Bun's built-in argument parsing to handle flags and options cleanly. Structure your scripts with error handling at every step to prevent silent failures that are difficult to debug. Use Bun's file system APIs for file operations instead of shell commands when possible, as they provide better error messages and type safety. Test your scripts with edge cases including empty inputs, missing files, and network failures to ensure robustness.

Conclusion

Bun Shell brings shell scripting into the JavaScript ecosystem with a clean, cross-platform API that eliminates the platform compatibility headaches of traditional shell scripting. By using tagged template literals, it provides automatic argument escaping, type-safe output, and structured error handling — all while maintaining the readability of shell commands.

Key takeaways:

  1. Cross-platform by default: Bun Shell works identically on macOS, Linux, and Windows without requiring platform-specific tools or compatibility layers.
  2. Secure by design: Template literal interpolation automatically escapes arguments, preventing shell injection vulnerabilities that plague traditional shell scripts.
  3. JavaScript-native: Error handling with try/catch, output parsing with .text() and .json(), and TypeScript support make Bun Shell scripts as maintainable as any other JavaScript code.
  4. Fast execution: Bun Shell's Zig-based implementation provides performance comparable to native shell commands.
  5. Rich built-in commands: Common Unix commands are implemented natively, ensuring consistent behavior across platforms.

Start by converting one automation script to Bun Shell and experience the benefits of platform-agnostic shell scripting in JavaScript.