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 aShellErrorby default, which is why wrapping the whole build inawaitis enough to stop the script on a failed step. rm -rf distandmkdir distare both Bun Shell builtins, so this cleanup step doesn't touch the OS's realrm/mkdir(or lack thereof) at all.Bun.build(...)does the actual bundling — Bun Shell handles the surrounding scripting, not the build itself. Checkingbuild.successand throwing on failure keeps the script's exit code meaningful for CI.$`echo ${banner} > dist/BUILD_INFO`writes a plain string via redirection. Becausebanneris 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 fromPATHlike 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 asBun.build()without any JavaScript orchestration, if the build itself doesn't need pre/post steps.