The Bun Shell $ — Bash-Shaped, Injection-Safe

Lesson 0004 · Glossary · 1 · 2 · 3 · Next: HTTP & API automation

The win, in one line: by the end of this lesson you'll write git log pipelines and ls | wc -l-style one-liners in Bun Shell — bash syntax you already know, but cross-platform and immune to shell injection.

Lesson 0003 taught the disciplined, typed way to run programs (Bun.spawnSync + exit codes). Bun Shell is the quick and familiar way: real bash-looking syntax — pipes, redirection, globs — in a $"..." template literal.

1. The $ tag: a cross-platform bash that never invokes bash

Bun Shell is Bun's own shell written in Rust. It runs in the same process and does not shell-out to /bin/bash — so its syntax behaves identically on Linux, macOS, and Windows. Import it from the bun module:

import { $ } from "bun";

const text = await $`echo "Hello World!"`.text();  // "Hello World!\n"
const n    = await $`echo "Hello World!" | wc -w`.text();  // "2\n"
const data = await $`echo '{"a":1}'`.json();       // { a: 1 } — like jq

await-ing gives you a Buffer; .text(), .json(), .lines(), .blob() give you typed output. Pipes work exactly as in bash. (Bun docs: shell)

2. Injection-safe interpolation

Where bash would let rm -rf / run if it landed inside a command, Bun Shell treats every interpolated variable as one literal, quoted string. It cannot be tricked into becoming more commands:

const userInput = "my-file.txt; rm -rf /";
await $`ls ${userInput}`;   // SAFE — treated as one directory name, NOT two commands

The escaping is on by default. That single behavior removes a whole class of script bugs you'd chase in bash. (Bun docs: shell — security)

3. Output control and failure

await $`echo "quiet me"`.quiet();            // don't print to stdout
const out = await $`echo "hold me"`.quiet().text();  // capture instead
const rev = await $`git rev-parse HEAD`.text();       // command substitution twin

// commands throw by default on nonzero exit (like set -e):
try {
  await $`false`.text();
} catch (err) {
  console.log("threw:", err.exitCode);        // 1
}
// .nothrow() disables that, and you check exitCode yourself:
const r = await $`false`.nothrow().quiet();
console.log("exit:", r.exitCode);             // 1

4. Globs, cwd, env — and J/S objects as streams

await $`pwd`.cwd("/tmp");                      // cd replacement (per-command)
await $`echo $GREETING`.env({ ...process.env, GREETING: "hi" });
await $`ls src/**/*.ts`;                        // globs native

// JavaScript objects can be stdin / stdout:
const buffer = Buffer.alloc(100);
await $`echo "Hi!" > ${buffer}`;               // redirect into a Buffer
await $`cat < ${new Response("body")}`.text();  // use a Response as stdin

When to use $ vs Bun.spawnSync

SituationReach forWhy
Quick one-liner, pipeline, glob, redirect$Bash shape you already know; concise
Need a typed exit code + guaranteed abort$ (it throws) or the 0003 sh() helperBun.spawnSync needs manual checks; $ throws by default
Streaming a long-running process’s stdoutBun.spawn + proc.stdout$`...`.lines() also streams, but spawn gives raw control
Feeding stdin to a child from your data$`cmd < ${data}`JS-object interop is seamless

Practice

Recall check (from memory, no peeking)

1. What does a Bun Shell command do on a nonzero exit by default?

It throws a ShellError carrying .exitCode — the set -e behavior is built in. Use .nothrow() to opt out.

2. What does Bun Shell do with interpolated strings?

Every interpolated value is treated as one literal, quoted string — injection-safe by default.

3. Which waits for Git’s latest commit hash as text?

const rev = await $\`git rev-parse HEAD\`.text() — command substitution, without $(...).

Hands-on: a daily pipeline, made safe

Take a pipeline you run in bash and rebuild it with $. A useful one: count and list the TypeScript files in a repo, by directory.

  1. Start: const tsv = await $\`ls **/*.ts | wc -l\`.text() and log it.
  2. Read the stat flag you use daily in bash — e.g. await $\`git status --short\`.text() — and capture it into a typed variable instead of printing to the terminal.
  3. Feed a JS value in as an argument and confirm it’s treated as one literal: const word = "hello world"; await $\`echo ${word}\`. Then prove safety by swapping word for "hello; echo injected" and watching only the literal print.
  4. Add .nothrow() to one command and check exitCode instead of letting it throw.

Check your work (immediate feedback):

bun run pipeline.ts
# expectation: the typed outputs print (count, git status, the literal string),
# and the final "hello; echo injected" prints literally — no "injected" line.
echo $?   # 0 — the .nothrow() run didn't abort the script

If the injection value printed as a single literal string, you’ve seen the whole point: a shell with bash’s ergonomics and none of its foot-guns.

Read this next

Primary source for this lesson: Bun Docs: Shell — read the Security section twice; it’s where the “bash-shaped but safe” promise is explained, including the one warning that matters (don’t hand a string to a real bash -c).

Next up: Lesson 0005 — HTTP & API automation: fetch + Bun.env/Bun.file to read an API, write JSON to disk, and guard on res.ok — the curl-automation twin.