10LOC
Bunintermediate

Bun.$ shell scripting for cross-platform build scripts

Published October 2, 2026

import { $ } from "bun";

await $`rm -rf dist`;
await $`mkdir dist`;

const build = await Bun.build({ entrypoints: ["./src/index.ts"], outdir: "./dist" });
if (!build.success) throw new AggregateError(build.logs, "Build failed");

const banner = `built ${build.outputs.length} files at ${new Date().toISOString()}`;
await $`echo ${banner} > dist/BUILD_INFO`;
console.log(await $`ls dist`.text());

What

import { $ } from "bun" gives template-literal access to a bash-like shell — $`rm -rf dist` — that Bun implements itself rather than shelling out to /bin/sh or cmd.exe, so the same script runs unmodified on Windows, macOS, and Linux.

Why it matters

Cross-platform npm scripts traditionally reach for rimraf instead of rm -rf and cross-env instead of inline FOO=bar, because rm doesn't exist on Windows and inline env vars use different syntax in cmd.exe. Bun Shell sidesteps this by re-implementing a small set of common commands (rm, mkdir, ls, echo, cat, mv, and others) natively in Rust, so they behave identically everywhere Bun runs — no per-platform dependency, and no shelling out to a real /bin/sh that might not exist. It also escapes every interpolated JavaScript value by default, so building a command from a variable doesn't open a shell injection hole the way naive string concatenation into child_process.exec would.

How it works

  • Each await $`...` call runs one command and resolves once it exits; a non-zero exit code throws a ShellError by default, which is why wrapping the whole build in await is enough to stop the script on a failed step.
  • rm -rf dist and mkdir dist are both Bun Shell builtins, so this cleanup step doesn't touch the OS's real rm/mkdir (or lack thereof) at all.
  • Bun.build(...) does the actual bundling — Bun Shell handles the surrounding scripting, not the build itself. Checking build.success and throwing on failure keeps the script's exit code meaningful for CI.
  • $`echo ${banner} > dist/BUILD_INFO` writes a plain string via redirection. Because banner is an interpolated JS variable rather than shell syntax, Bun passes it as one literal argument — no quoting gymnastics needed even though it contains spaces and colons.

Gotchas

  • Bun Shell's builtin command list is intentionally small (see the docs for the full list). Any command not on it — git, docker, tar — is resolved from PATH like a normal shell, which means it stops being guaranteed cross-platform the moment you rely on one.
  • Backtick command substitution (`cmd`) doesn't work due to how template literals parse raw strings — use $(cmd) instead, exactly like modern bash.
  • .quiet() suppresses a command's stdout/stderr; .text() calls .quiet() for you automatically, which is easy to forget when debugging why a command produced no console output.

Related

  • Bun.spawn() — lower-level subprocess control (manual stdio piping, IPC) for cases too dynamic for a shell template literal.
  • bun build (the CLI) does the same bundling as Bun.build() without any JavaScript orchestration, if the build itself doesn't need pre/post steps.