I deleted 1,400 lines of bash and 23 scripts, replaced by 600 lines of Bun Shell TypeScript
Bun Shell ($
...) gives typed args, automatic escaping, .text(), .json(), .lines() out of the boxjq, awk, and most GNU coreutils dependencies are gone, scripts now boot in around 30ms
Bash still wins for interactive REPLs and very long pipelines, everything else moved to Bun
I used to keep a folder called scripts/ in every project. It always rotted. Quoting bugs, missing set -e, jq versions that did not match across machines. Last quarter I migrated 23 of them to Bun Shell and deleted 1,400 lines of bash in one afternoon.
Why bash scripts always rot in a one-person studio
I write a lot of glue code. Push a build, post the changelog, mirror images to a CDN, diff two env files, tail a log and grep for a regex. For a long time those lived as .sh files. They worked on the day I wrote them and broke six weeks later for reasons I could never reproduce on the first try.
Bash has four problems I kept hitting. Quoting is the obvious one. "$file" versus $file versus '$file' matters and the difference is invisible until a path with a space shows up. set -euo pipefail is mandatory and nobody remembers it on script number 12. Errors are swallowed silently, then you discover three weeks later that your nightly job has been writing empty files. And there are no types, so passing --dry-run versus -n versus --dry is a coin flip every time.
The portability story is worse. My laptop has GNU coreutils via Homebrew. The CI runner has BSD coreutils. sed -i takes different arguments. date formats are different. jq was 1.6 on one box and 1.7 on another and one of my scripts depended on a 1.7-only flag without me realising it. Every cross-platform fix added another if [[ "$OSTYPE" == "darwin"* ]] branch.
The killer for a solo studio is that I cannot afford to debug a 60-line shell script at 23:00. I want a script to either run or fail loudly with a stack trace pointing at the exact line. Bash gives you neither.
I tried Python for a while. The startup time of a venv plus boto3 plus requests is around 800ms cold, which is fine until you call it inside a hot loop. I tried zx (Node + JS template literals). It worked but it dragged Node, npm, and a node_modules directory into every script folder.
Then Bun 1.2 replaced Node in every new RAXXO project and I tried Bun Shell almost by accident.
The Bun Shell mental model
Bun ships a built-in shell as a tagged template. You import $ from bun and write commands inline:
import { $ } from "bun";
const branch = await $`git rev-parse --abbrev-ref HEAD`.text();
console.log(`Deploying ${branch.trim()}`);
Three things make this different from spawning bash. Arguments are escaped automatically when you interpolate them, so $mv ${userInput} /tmp`cannot be tricked into running; rm -rf /. The return value is a chainable promise with.text(),.json(),.lines(),.blob(),.arrayBuffer(), plus modifiers like.quiet(),.nothrow(),.cwd(path), and.env({...}). And it works the same way on macOS, Linux, and Windows because Bun ships its own minimal shell, not a wrapper around/bin/sh`.
The handful of patterns I use most:
`javascript
// Read JSON from a command directly
const tags = await $git tag --list.lines();
// Suppress output, do not throw on non-zero
const result = await $grep ERROR app.log.quiet().nothrow();
if (result.exitCode === 0) await alert(result.stdout.toString());
// Pipe between commands, still inside template
await $cat data.csv | sort -u > clean.csv;
`
Cold startup on my M2 is around 30ms for a Bun script that does one shell call, versus around 250ms for the equivalent zx script and around 800ms for Python with boto3. For a script that runs inside a watch loop or a git hook, that gap matters.
The thing I did not expect: I no longer need jq, awk, sed, cut, or tr for most jobs. Bun ships Bun.file(), JSON.parse, regex, and .replaceAll(). A bash one-liner like cat events.json | jq '.[].user' | sort -u | wc -l becomes:
`javascript
const events = await Bun.file("events.json").json();
const users = new Set(events.map(e => e.user));
console.log(users.size);
`
Same length, no external binaries, runs identically on the CI runner.
Five real scripts I migrated
I went through the scripts/ folders across my active repos. 23 candidates, all under 200 lines each. I kept 5 representative ones to show what the migration actually looks like.
The first one was a deploy notifier. It ran after every Vercel deploy, pulled the commit message, posted to a Buffer queue for X and LinkedIn, and dropped a card into a private status page. The bash version was 87 lines with three curl calls, two jq filters, and a heredoc for the JSON payload. The Bun version is 34 lines:
`javascript
import { $ } from "bun";
const sha = (await $git rev-parse HEAD.text()).trim();
const msg = (await $git log -1 --pretty=%s.text()).trim();
await fetch("https://api.bufferapp.com/1/updates/create.json", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: Bearer ${process.env.BUFFER_TOKEN} },
body: JSON.stringify({ text: Shipped ${sha.slice(0,7)}: ${msg}, profile_ids: [process.env.BUFFER_PROFILE] })
});
`
The second was an image batch resizer. Bash version called ImageMagick in a find -exec loop and crashed on filenames with parentheses. The Bun version uses Bun.glob() to walk the tree and shells out to magick per file with proper escaping. 41 lines became 18.
The third was a log tailer that watched a Caddy access log and posted to a webhook when a 5xx burst happened. Bash needed tail -F, awk, grep, and a state file in /tmp. Bun does it with for await (const line of $tail -F access.log.lines()) and an in-memory counter.
The fourth was an S3 mirror. The bash version hardcoded aws-cli paths. The Bun version uses the Shopify Files API for theme assets and Bun's native S3 client (Bun.s3.write()) for everything else. No external CLI, no credentials in argv.
The fifth was an env diff between dev and prod, which previously needed comm, sort, and a temp directory. Now it is two Bun.file().text() calls and a Set comparison.
Total: 1,400 lines of bash, plus a requirements.txt of jq, awk, gnu-sed, aws-cli, and imagemagick shrunk to 600 lines of TypeScript and one dependency (Bun itself). The CI image dropped from 380MB to 95MB once I removed the system tools none of the scripts needed any more. Build time on cold cache went from 51 seconds to 18.
Where bash still wins
I am not going to pretend Bun Shell replaces every shell job. Three places where I still reach for bash.
Interactive REPL work. When I am poking at a server, ssh'd in, exploring file structures, the bash REPL with tab completion is faster than writing a script. Bun is a script runtime, not an interactive shell, and bun repl is a JavaScript REPL, not a shell one. If I am exploring, I am still in zsh.
Very long pipelines with niche tools. I have one pipeline that goes dtrace | grep | awk | sort | uniq -c | sort -rn | head while profiling a slow process on macOS. Rewriting that as TypeScript adds nothing. Six binaries chained with pipes is bash's home turf. I keep that one as a .sh file and call it from Bun when I need to.
POSIX-only environments. A few CI runners and the occasional Docker base image do not have Bun installed and I cannot add it. For those I keep a tiny bootstrap script in bash that installs Bun, then hands off. The bootstrap is 12 lines and has not changed in six months.
The workaround for the long-pipeline case is straightforward: Bun Shell can call any binary, including bash itself. If I really want a 200-character pipeline, I write await $bash -c "${pipeline}"`` and Bun handles spawning, output capture, and error codes. I keep one or two of those per project, well commented, and treat them as the exception.
I also keep my pre-commit hook in bash. It has been six lines for two years and rewriting it would be vanity.
One nice side effect of the migration: my scripts/ folder now ships with the rest of the project's TypeScript. The same tsconfig.json lints it. The same Biome rules format it. When I rename a function used in a script and a test file, the LSP catches both. Bash never gave me that.
Bottom Line
I will not go back. Bun Shell hit the spot Python and zx never quite did: typed arguments, automatic escaping, no node_modules, ~30ms cold start, and the same script working on macOS, Linux, and the CI runner without conditional branches.
The migration was not a heroic rewrite. I did one script per coffee, kept the bash version in git history, and stopped when the remaining ones were either trivial pre-commit hooks or genuinely better as pipelines.
If you are running a solo studio, I would start with your deploy or notifier script. Those are the ones that break at 23:00 on a Friday and cost the most to debug.
For the rest of how Bun fits into my stack, Bun's test runner replaced Vitest in my new projects covers the testing side, and Studio shows the projects this stack actually ships.
Top comments (0)