본문으로 건너뛰기 Bun Shell complete guide — cross-platform scripting

Bun Shell complete guide — cross-platform scripting

Bun Shell complete guide — cross-platform scripting

이 글의 핵심

How to write cross-platform scripts with Bun Shell ($): pipes, redirections, environment variables, globs, error handling, and differences from Bash.

Key points

Bun Shell is a cross-platform shell built into the Bun runtime. From JavaScript or TypeScript you can run external commands with Bash-like syntax, handle pipes, redirections, environment variables, and globs, and connect stdin/stdout/stderr to JS objects such as Response or Buffer. It is especially useful for teams that want the same scripts on Windows without extra helpers like rimraf or cross-env.

This article covers how the $ tagged template works, builtins vs PATH, pipes and redirections, environment variables and globs, integration with Node and Bun, error handling with ShellError and .nothrow(), and a practical comparison with Bash.


1. What is Bun Shell?

Bun Shell does not shell out to the system /bin/sh or cmd.exe as-is. It runs a small Bash-like language inside the Bun process (implemented in Zig). String interpolation is therefore designed to reduce shell injection risks.

Main characteristics:

  • Cross-platform: You can use the same patterns on Windows, Linux, and macOS. Common commands such as ls, cd, and rm are builtins; everything else is resolved from PATH.
  • Bash-like style: Redirections (>, 2>, >>, 2>&1, etc.), pipes (|), environment assignments, and command substitution with $(...).
  • JavaScript interop: You can wire Response, Blob, ArrayBuffer, Bun.file(), and more to stdin, stdout, and stderr.
  • Globbing: Patterns such as **, *, and {a,b} are handled at the shell level.

As the official docs and blog note, the API draws inspiration from projects like zx and dax, with the goal of simplifying script automation inside the Bun ecosystem.


2. Getting started: the $ tagged template

The basic pattern is to import $ from bun and run commands with a tagged template literal:

import { $ } from "bun";

await $`echo "Hello, Bun Shell!"`;

By default, output goes to the standard console. To hide it, chain .quiet():

await $`echo "quiet"`.quiet();

To capture output as a string, use .text(). It is designed to work well with “quiet” capture semantics:

const welcome = await $`echo "Hello World!"`.text();
console.log(welcome); // "Hello World!\n"

To avoid printing to the terminal and get stdout/stderr as Buffer, use .quiet() and destructure the awaited result:

const { stdout, stderr } = await $`echo "Hello!"`.quiet();
console.log(stdout); // Buffer

In short, you choose print vs string vs buffer via method chaining—this is the default Bun Shell workflow.


3. Syntax and commands

3.1 Builtins and PATH

For cross-platform compatibility, Bun Shell provides some commands as builtins. Examples include cd, ls, rm, echo, pwd, cat, touch, mkdir, which, mv, exit, true, false, yes, seq, dirname, and basename. Executables not implemented as builtins are run from the OS PATH.

Builtins may not match the system tool in every edge case; for example, mv may not implement cross-device moves in some scenarios. Validate deployment scripts on each target OS.

3.2 Command substitution $(...)

To embed another command’s output, use $(...) like Bash:

await $`echo "Current commit: $(git rev-parse HEAD)"`;

Multi-line scripts can assign to shell variables and reuse them:

await $`
  REV=$(git rev-parse HEAD)
  echo "Build tag: $REV"
`;

Note: backtick command substitution inside JavaScript template literals does not always behave as you expect in Bun Shell; the docs recommend using $(...) instead.

3.3 $.braces and $.escape

To preview brace expansion as an array of strings, use $.braces:

import { $ } from "bun";

const expanded = await $.braces(`echo {1,2,3}`);
// => ["echo 1", "echo 2", "echo 3"]

To escape user or dynamic input before passing it to the shell, use $.escape. To skip escaping intentionally, you can wrap with { raw: '...' }, but you then own how the shell interprets the string.


4. Pipes and redirection

4.1 Pipe |

Piping one command’s stdout to the next stdin works like Bash:

const wordCount = await $`echo "Hello World!" | wc -w`.text();

Piping a fetch Response into stdin is a common pattern from the official docs:

const response = await fetch("https://example.com");
const byteLen = await $`cat < ${response} | wc -c`.text();

4.2 Redirection operators

Typical operators include:

OperatorMeaning
<Redirect stdin
>, 1>Redirect stdout (overwrite)
2>Redirect stderr
&>Redirect stdout and stderr together
>>, 1>>Append stdout
2>>Append stderr
&>>Append both
2>&1Merge stderr into stdout
1>&2Send stdout to stderr

You can redirect to JavaScript values, not only files—for example writing stdout into a Buffer or reading a Response body as stdin:

const buf = Buffer.alloc(256);
await $`echo "Hello" > ${buf}`;

const res = new Response("body text");
const out = await $`cat < ${res}`.text();

This avoids temp files when shuttling data in memory.


5. Environment variables and globbing

5.1 Environment variables

Like Bash, you can prefix a command with KEY=value for a one-off environment:

await $`NODE_ENV=production bun -e 'console.log(process.env.NODE_ENV)'`;

Interpolating ${variable} in the template escapes by default, so strings with semicolons are not split into separate commands—a first line of defense against malicious input.

To override the environment for a single invocation, use .env({ ... }):

await $`echo $FOO`.env({ ...process.env, FOO: "bar" });

To set a default environment for subsequent $ calls, use $.env(...); call $.env() with no arguments to reset defaults, as documented.

Change the working directory with .cwd("/path"), or set a global default with $.cwd.

5.2 Globbing

Bun Shell handles glob patterns such as **, *, and {a,b} natively. That keeps Bash-like expressions when passing many files or whole trees. For very large or tricky patterns, building the file list in Node with a glob library and passing explicit arguments can be easier to debug.


6. Integration with Node.js and Bun

6.1 Typical pattern in Bun

$ is only available via import { $ } from "bun"—so this API assumes the Bun runtime. For automation scripts, CLIs, or build tools run with Bun, put bun run tools/deploy.ts in package.json scripts and use $ inside that file.

6.2 Relationship to Node.js

Node’s official runtime does not ship the same $ API. In Node you typically use node:child_process (spawn / execFile) or libraries like zx. If the team standard is Bun, you can keep cross-platform automation in a single TypeScript codebase instead of separate shell scripts.

If you only need Bun Shell from an npm script for one line, you can wrap bun -e '...', but maintenance is usually easier in a dedicated .ts file.

6.3 The .sh loader and deployment

Running bun ./script.sh interprets .sh files with Bun Shell—even without a shebang, which matters on Windows PowerShell. Production still requires Bun installed, so pin the Bun version in CI images and developer docs.


7. Error handling

By default, a non-zero exit code throws. The error type is documented as ShellError, with fields such as exitCode, stdout, and stderr:

import { $ } from "bun";

try {
  await $`command-that-may-fail`.text();
} catch (err: any) {
  console.error("code:", err.exitCode);
  console.error(err.stdout?.toString());
  console.error(err.stderr?.toString());
}

To avoid throwing and branch manually, use .nothrow():

const { stdout, stderr, exitCode } = await $`command`.nothrow().quiet();
if (exitCode !== 0) {
  // recovery logic
}

To change the default globally, use $.nothrow() or $.throws(false) / $.throws(true). Keep the default (throw) for CI when fail fast is desired; use .nothrow() for tools that only collect logs.

Convenience readers include .text(), .json(), .lines(), and .blob() for parsing or streaming pipeline output.


8. Bash vs Bun Shell

AspectBash (and typical POSIX sh)Bun Shell
RuntimeOS shell processInterpreter inside the Bun process
Cross-platformWindows often needs WSL, Git Bash, etc.Same syntax goal including Windows
Security modelEasy to inject via string concatenationInterpolated values escaped by default
JS interopMostly stringsDirect Buffer, Response, etc.
Full Bash compatibilityBaselineSome Bash features missing or differ
ConcurrencyUsually sequential pipesDocs note some operations may run concurrently (implementation detail)

When to use Bash vs Bun Shell:

  • If the server or container entrypoint is already bash and ops only allows shell, keep Bash.
  • In frontend/full-stack monorepos, Bun Shell helps keep build, deploy, and checks in TypeScript.
  • With many Windows developers, Bun Shell plus builtins helps avoid breaking shared npm scripts.

9. Security in depth

Default interpolation is safer, but if you explicitly spawn a new shell (e.g. bash -c), Bun cannot control parsing after that. External programs (e.g. git) may treat an argument as their own flags—validate application-level inputs such as --upload-pack= yourself. The official “argument injection” examples illustrate this distinction.


10. Summary

Bun Shell brings cross-platform scripting into TypeScript with familiar pipes, redirections, environment variables, and $(...) substitution. Learning $, output helpers like .text() and .lines(), and the error model with .nothrow() and ShellError covers most automation. It is not 100% Bash—validate important deploy scripts on each OS, and for security-sensitive input consider how each external command parses its arguments.


FAQ

Q. Can I use $ with only Node.js installed?
A. No. import { $ } from "bun" is Bun-only. On Node use child_process, zx, or similar.

Q. Do ls, rm, etc. work on Windows?
A. Bun Shell provides them as builtins, so within the documented behavior you can use the same style without relying on PowerShell for those commands. Tools from PATH must still be valid executables for each OS.

Q. How do I avoid exceptions on non-zero exit codes?
A. Add .nothrow() to that call, or set $.nothrow() globally and inspect exitCode.