$ — Bash-Shaped, Injection-Safegit 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.
$ tag: a cross-platform bash that never invokes bashBun 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)
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)
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
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
$ vs Bun.spawnSync| Situation | Reach for | Why |
|---|---|---|
| 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() helper | Bun.spawnSync needs manual checks; $ throws by default |
| Streaming a long-running process’s stdout | Bun.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 |
ShellError carrying .exitCode — the set -e behavior is built in. Use .nothrow() to opt out.const rev = await $\`git rev-parse HEAD\`.text() — command substitution, without $(...).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.
const tsv = await $\`ls **/*.ts | wc -l\`.text() and log it.await $\`git status --short\`.text() — and capture it into a typed variable instead of printing to the terminal.const word = "hello world"; await $\`echo ${word}\`. Then prove safety by swapping word for "hello; echo injected" and watching only the literal print..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.
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.