Fix: Bun Shell Not Working — $ Template Quoting, Pipes, Exit Codes, and Cross-Platform Scripts
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.
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.
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).
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: ESLint Flat Config Not Working — eslint.config.js, ignores, Plugins, and Migration
How to fix ESLint flat config errors — eslint.config.js not found, .eslintrc.json ignored after upgrade, ignores replacing .eslintignore, plugin object form, typescript-eslint integration, monorepo configs, and ESLINT_USE_FLAT_CONFIG.