Skip to content

Fix: Bun Shell Not Working — $ Template Quoting, Pipes, Exit Codes, and Cross-Platform Scripts

FixDevs ·

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 if file contains 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 .exitCode to handle expected failures.
  • No /bin/sh involved. 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 cwd

To 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 $ invocation

Fix 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);  // /tmp

Same 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 three

Fix 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.ts

Or 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} or import { homedir } from "node:os".
  • source script.sh does nothing. No bash support means no source. Manually await Bun.file("script.sh").text() and parse, or invoke the script via $\bash script.sh“ if you’re on a Unix box.
  • command not found for a globally-installed binary. PATH may not include the binary’s directory. Pass env: { ...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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles