Fix: Bun Shell Not Working — $ Template Quoting, Pipes, Exit Codes, and Cross-Platform Scripts
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix Bun Shell errors — $ template auto-escape vs raw strings, piping with pipe() vs |, throws on non-zero exit, cwd/env scoping, glob expansion differences, and Windows path handling.
The Error
You use Bun Shell to pass a filename with spaces and it splits into multiple arguments:
import { $ } from "bun";
const file = "my file.txt";
await $`cat ${file}`;
// Expected: cat the file.
// Actual: shell errors — "my" not found.Or piping with | doesn’t behave like bash:
await $`ls | grep .js`;
// Error: Pipe operator used outside of a pipeline.Or a non-zero exit crashes your script:
await $`grep "needle" haystack.txt`;
// Exits the script if grep finds nothing (exit code 1).Or environment variables don’t propagate:
process.env.NODE_ENV = "production";
await $`echo $NODE_ENV`;
// Prints empty.Why This Happens
Bun Shell is a JS-native shell implemented in Bun itself — not a wrapper around /bin/sh. It runs on Linux, macOS, and Windows with the same syntax. Three principles that surprise newcomers:
- Template variables are auto-escaped.
${file}is treated as a single argument, even iffilecontains spaces, quotes, or shell metacharacters. This is intentional — it eliminates an entire class of shell injection bugs. - Most bash syntax works, but not all. Pipes (
|), redirects (>,<,>>),&&,||, command substitution$(...), and globs are supported. Backticks aren’t. Process substitution<(...)isn’t. - Non-zero exit throws by default. Unlike bash where errors silently continue, Bun’s
$rejects the promise on a non-zero exit. Use.nothrow()or check.exitCodeto handle expected failures. - No
/bin/shinvolved. That’s why it works identically on Windows. But it also means~, sourcing scripts, and some bashisms don’t work the way you expect.
A second source of confusion is the rapid evolution of the Shell API across Bun versions. Bun Shell shipped in Bun 1.0.24 (January 2024) with a deliberately limited surface: tagged template $, .text(), .json(), basic piping. Bun 1.1 (April 2024) added .lines() for streaming, .quiet() and .nothrow() for output and error control, and improved the .env() and .cwd() builders. Bun 1.2 (early 2025) fleshed out Windows compatibility, added .stream() for raw ReadableStream access, and stabilised the parser around brace expansion. If you copy a snippet from a 2024 blog post that uses .lines(), it requires Bun >= 1.1; older .cmd()-style examples from the very first 1.0.24 docs won’t work at all.
A third cause is the conflation of “Bun Shell” with bun run. They are different things. bun run script.ts executes a TypeScript file via Bun’s runtime. Bun Shell is the $ tagged template you import from "bun" and use inside JS/TS code. A script written in TypeScript that uses $ is a Bun-Shell script and runs under Bun. But you cannot use $ from Node — even if you node script.ts via tsx, the import { $ } from "bun" will fail because Node has no bun module. This trips people who try to share a build script between Bun and Node environments.
Fix 1: Pass Variables With ${}, Not String Concatenation
The whole point of $ is safe interpolation:
import { $ } from "bun";
const file = "my file with spaces.txt";
await $`cat ${file}`;
// Works correctly — Bun escapes the path.For multiple arguments, Bun joins arrays:
const files = ["a.txt", "b.txt", "c.txt"];
await $`cat ${files}`;
// Equivalent to: cat 'a.txt' 'b.txt' 'c.txt'To pass an unescaped raw string (be very careful — this is the shell-injection escape hatch):
const rawFlags = "--verbose --color=auto"; // Multiple flags
await $`ls ${{ raw: rawFlags }}`;{ raw: ... } opts out of escaping. Only use it for strings you constructed yourself, never for user input.
Pro Tip: When in doubt, log what’s actually being executed:
const cmd = $`cat ${file}`;
console.log(cmd.toString()); // Prints the resolved command string.
await cmd;Fix 2: Pipe With .pipe() for JS Streams, | for Subprocess Pipes
In-shell piping with | works inside a single backtick expression:
await $`ls | grep .js`;
// Lists .js files in cwdTo pipe to another $ invocation in JS:
await $`ls`.pipe($`grep .js`);To capture output and pipe it to a JS function:
const output = await $`ls`.text();
for (const line of output.split("\n")) {
if (line.endsWith(".js")) console.log(line);
}.text(), .json(), .arrayBuffer(), .blob() all consume stdout:
const pkg = await $`cat package.json`.json();
console.log(pkg.version);Common Mistake: Using | between separate $ calls:
// Doesn't work:
await $`ls` | await $`grep .js`;
// Use:
await $`ls`.pipe($`grep .js`);
// Or:
await $`ls | grep .js`; // Both commands in one $ invocationFix 3: Handle Non-Zero Exit Codes
By default, Bun Shell rejects on non-zero exit. For commands where non-zero is expected (like grep returning 1 for no matches):
// Disable throw, check exit code manually:
const result = await $`grep "needle" haystack.txt`.nothrow();
if (result.exitCode === 0) {
console.log("found");
} else if (result.exitCode === 1) {
console.log("not found");
} else {
console.log("error:", result.stderr.toString());
}For commands that should fail loudly:
try {
await $`some-command`;
} catch (err) {
if (err.exitCode !== undefined) {
console.error("Exit:", err.exitCode);
console.error("Stderr:", err.stderr.toString());
} else {
throw err; // Real JS error, not a shell exit
}
}For a quieter run that suppresses output:
await $`make build`.quiet();
// stdout and stderr aren't forwarded to the parent.Fix 4: Set cwd and env Per Command
Don’t process.chdir — set cwd per command:
const result = await $`pwd`.cwd("/tmp").text();
console.log(result); // /tmpSame for env vars — they don’t inherit process.env mutations by default. Pass explicitly:
const env = { ...process.env, NODE_ENV: "production" };
await $`node build.js`.env(env);Or for one-off:
await $`echo $MY_VAR`.env({ MY_VAR: "hello" });Common Mistake: Setting process.env.NODE_ENV = "production" and expecting subsequent $ calls to see it. Bun’s $ snapshots the env when constructing the command. Either set the env before importing $, or pass it explicitly to each call.
Fix 5: Iterate Over Streamed Output
For long-running commands where you want each line as it arrives:
const proc = $`tail -f /var/log/app.log`;
for await (const line of proc.lines()) {
console.log("got:", line);
if (line.includes("error")) {
break; // Sends SIGTERM to the child
}
}.lines() yields each newline-terminated chunk. .stream() gives you the raw ReadableStream for byte-level work.
For commands that produce JSON-per-line:
for await (const line of $`docker events --format '{{json .}}'`.lines()) {
const event = JSON.parse(line);
console.log(event.Action);
}Pro Tip: For early termination, break out of the for await loop. Bun sends a signal to the child to stop. If you need to send a specific signal, use proc.kill("SIGTERM") explicitly.
Fix 6: Globs and File Patterns
Globs work within a single $ expression:
await $`ls *.ts`;But not across template interpolations:
const pattern = "*.ts";
await $`ls ${pattern}`;
// Literally globs for the file named "*.ts" — usually fails.For dynamic globs, use Bun’s Glob:
import { Glob } from "bun";
const files = await Array.fromAsync(new Glob("**/*.ts").scan());
await $`prettier --write ${files}`;Glob.scan() returns an async iterator of matching paths. Spread into a $ call and Bun handles each as a separate argument.
For shell-style brace expansion ({a,b,c}), Bun Shell supports it inline:
await $`echo {one,two,three}`;
// one two threeFix 7: Windows Compatibility
Bun Shell works on Windows without changing your scripts. But common Unix tools (grep, awk, sed, find) aren’t on Windows by default. Use cross-platform alternatives:
// Instead of: $`find . -name "*.ts" | xargs grep "TODO"`
// Use Bun primitives:
const glob = new Glob("**/*.ts");
for await (const file of glob.scan()) {
const text = await Bun.file(file).text();
if (text.includes("TODO")) console.log(file);
}Or rely on Node modules that ship cross-platform implementations.
For paths, Bun handles slashes correctly:
await $`cat ${"src/index.ts"}`;
// Works on both Unix and Windows.But avoid bashisms like && for shell logic across platforms — use JS:
// Cross-platform:
const buildOk = (await $`npm run build`.nothrow()).exitCode === 0;
if (buildOk) {
await $`npm publish`;
}Fix 8: Use as a Build Script Replacement
Bun Shell shines for build scripts that previously needed bash + Make or Node’s child_process:
#!/usr/bin/env bun
import { $ } from "bun";
console.log("Building...");
await $`tsc --build`;
await $`bun build src/index.ts --outdir dist --target node`;
console.log("Testing...");
await $`bun test`;
console.log("Linting...");
await $`oxlint`;
console.log("Done.");Make it executable:
chmod +x build.ts
./build.tsOr as a package.json script:
{
"scripts": {
"build": "bun run build.ts"
}
}Pro Tip: Replace zx scripts with Bun Shell where you can. Same ergonomics, no extra deps, faster startup, native Windows support.
Version History: Bun Shell From 1.0.24 to 1.2+ and the JS Shell Landscape
Bun Shell is one of the fastest-evolving parts of Bun, so the version you have matters more than usual.
Bun 1.0.24 (January 2024) — initial release. The $ tagged template landed as the first cross-platform JS-native shell. Basic features: template-variable interpolation with auto-escape, pipes within a single backtick expression, .text() and .json() consumers, and the throw-on-non-zero-exit default. Documentation noted it was experimental — APIs could change.
Bun 1.1 (April 2024) — .lines(), .quiet(), .nothrow(). The streaming line iterator (for await (const line of $\cmd`.lines())) shipped..quiet()suppressed parent output;.nothrow()made non-zero exit codes non-fatal..cwd()and.env()became chainable builders rather than constructor options. The error-object shape was standardised soerr.exitCode,err.stderr, anderr.stdout` are reliably present.
Bun 1.1.x patch series (mid-2024) — Windows hardening. Several patch releases tightened Windows behaviour: path separator handling, cmd.exe-style argument quoting bypass, and integration with Windows-native binaries. Glob expansion across drives became reliable.
Bun 1.2 (early 2025) — SQLite, stream, parser polish. Bun 1.2 added bun:sqlite as a built-in (not directly Shell-related but commonly used alongside it). For Shell specifically: .stream() exposed the raw ReadableStream, brace expansion ({a,b,c}) became spec-stable, and the parser learned several bash-quirk corner cases. Pipe chaining performance improved.
Bun 1.3+ (2025+) — beyond. Subsequent releases focused on (a) closing remaining bash compatibility gaps that real-world scripts hit, (b) improving subprocess signal handling (Ctrl-C propagation, SIGPIPE semantics), and (c) reducing memory overhead for long pipelines.
If you write a script that needs to work on Bun 1.0.24, you cannot use .lines() or .nothrow(). If your CI uses oven-sh/setup-bun@v2 with a floating version, expect “works on my laptop” mismatches when local is 1.2 and CI is 1.1.
vs zx (Google)
zx was the original JS-shell template by Google: await $\ls`in Node.js, ergonomic and cross-platform but always invoked a real shell (/bin/bashorsh`).
- zx requires a shell binary. Windows needs Git-Bash or WSL. Bun Shell needs neither.
- zx is per-call slower because each
$spawns a shell process. Bun Shell parses inline. - zx is the safer bet today if you must run under Node. Bun Shell only works under Bun.
- Migration: most zx scripts port to Bun Shell with little change beyond the import line — the auto-escape behaviour and
.then()semantics are similar.
vs dax (Deno / Node)
dax is a Deno-first shell library that also ships for Node. It pioneered several patterns (chainable builders, .cwd()/.env() per call) that Bun Shell later adopted.
- dax supports both Deno and Node with the same API.
- dax has richer high-level operations (a built-in
cd,which, prompt helpers). - Bun Shell is faster on Bun because it’s compiled in; dax always pays the Deno/Node call overhead.
vs execa (Node)
execa is the de facto Node child-process wrapper, lower-level than zx or Bun Shell.
- execa doesn’t auto-quote. You pass
execa('grep', ['needle', file])as an explicit args array. Safer in some ways (no shell injection possible) but less ergonomic. - execa has the widest production deployment because it’s been around the longest and most npm tools use it.
- Use execa when you need precise control over child-process spawning. Use Bun Shell when you want bash-like ergonomics on Bun.
vs child_process (Node built-in)
The lowest-level option. Spawn, exec, execFile. No quoting help, no chainable builders, no streaming utilities — but zero dependencies.
- Pick
child_processfor libraries that must run on plain Node with no install. Pick Bun Shell when you control the runtime.
Pinning strategy
For Bun Shell scripts that run in CI, pin the Bun version in package.json:
{
"engines": { "bun": ">=1.2.0" }
}And use a pinned action:
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.2.10"A floating bun-version: "latest" is a recipe for sudden behavioural shifts as the Shell API closes compatibility gaps.
Still Not Working?
A few less-obvious failures:
~doesn’t expand to home. Bun Shell isn’t bash. Use${process.env.HOME}orimport { homedir } from "node:os".source script.shdoes nothing. No bash support means nosource. Manuallyawait Bun.file("script.sh").text()and parse, or invoke the script via$\bash script.sh“ if you’re on a Unix box.command not foundfor a globally-installed binary. PATH may not include the binary’s directory. Passenv: { ...process.env, PATH: "..." }or invoke with the absolute path.- Output is buffered, not streamed.
.text()collects everything. For incremental output, use.lines()or.stream(). - stderr interleaved with stdout. By default both go to the parent. To capture stderr separately:
const { stdout, stderr, exitCode } = await $`some-cmd`.quiet();
// Both captured as Buffer; the parent doesn't see them.$template extracted to a function loses type inference. Bun Shell’s tagged template uses type narrowing on the template literal. Once wrapped in a helper, you lose some autocomplete. Type the helper explicitly if needed.- Long pipelines slow.
$ls | grep | sort | uniq“ runs as a single shell expression; very long chains have overhead. For complex pipelines, prefer breaking up with.text()/.lines()and processing in JS. - Different behavior in
bun test. Tests run in a Bun runtime but with restricted env. If a test expects shell calls to read env vars, set them in the test (process.env.X = "..."before the$call). $from a transpiledtsxfile under Node fails. Theimport { $ } from "bun"resolves to nothing under Node. Run the script withbun run script.ts, nottsx script.ts. There is no Node polyfill for Bun Shell.- Pipe between two
$calls hangs on Windows. Some Windows binaries don’t honour SIGPIPE the way Bun Shell expects. Bun 1.2 fixed several cases but a few edge tools (olderfindstr, certain MSI installers) still deadlock. Capture with.text()and pipe in JS as a workaround. $chained off a stale promise.await $\cmd`.text()consumes stdout; re-using the same builder for another consumer (.json()) throws "stream already consumed." Construct a new$`cmd“ for each consumer.
For related Bun, scripting, and process issues, see Bun not working, Bun test not working, Python subprocess not working, and Bash permission denied.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Bun Bundler Not Working — Targets, Format, Externals, Macros, and Code Splitting
How to fix bun build errors — target (browser/bun/node) mismatch, format esm/cjs/iife, externals not respected, Bun macros at compile time, splitting and chunks, plugin API, and Bun.build vs CLI.
Fix: Bun Test Not Working — Module Mocking, DOM Setup, Coverage, and Watch Mode
How to fix Bun test runner issues — mock.module not isolating, happy-dom setup for DOM tests, --coverage missing files, timer mocks, snapshot updates, TypeScript path aliases, and preload files.
Fix: Clack Not Working — Prompts Not Displaying, Spinners Stuck, or Cancel Not Handled
How to fix @clack/prompts issues — interactive CLI prompts, spinners, multi-select, confirm dialogs, grouped tasks, cancellation handling, and building CLI tools with beautiful output.
Fix: pnpm Catalog Protocol Not Working — Cannot Find Catalog, Resolution Errors, and Lockfile Issues
Fix pnpm 9.5+ catalog protocol errors — Cannot find catalog default, ERR_PNPM_CATALOG_ENTRY_INVALID_SPEC, stale lockfile state, and tool incompatibility with catalog: references in monorepos.