b
LearnBun.org
Chapter 03 · Your First Real Bun Script
Chapter 3

Your First Real Bun Script

In Chapter 2 you built a server. In this chapter we step away from the browser and build a command-line tool — a small script you run in the terminal that does real work on your computer. By the end you will have a wordcount script that reads a file, counts its words, and prints the result. With a cow.

Along the way you will install your first package with bun add. That is the second of Bun's four pillars: the package manager.

Bun is not only for servers. If you only use it for web apps, you miss half the point.

What We're Building

Here is the finished tool in action:

$ bun run count sample.txt
 _____________
< 42 words >
 -------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

The script takes a filename, reads the file, counts the words, and prints the count inside a cow's speech bubble. Silly? A little. But it exercises everything a real script needs to do: read arguments, read a file, run logic on the contents, and use a package from the npm registry.

The cow is silly on purpose. Boring scripts are harder to remember.

Set Up the Project

Open your terminal and navigate to the learn-bun folder from Chapter 1:

$ cd learn-bun

Create the project:

$ bun init wordcount

Choose Blank and press Enter. Then move into the new folder:

$ cd wordcount

Same ritual as Chapter 2. You will repeat it at the start of every project.

Read Command-Line Arguments

A command-line tool needs to know what you typed after its name. When you run bun run index.ts README.md, the word README.md is an argument we need to read.

Bun gives you those arguments in Bun.argv.

Replace the contents of index.ts with:

// index.ts
const filename = Bun.argv[2];
console.log(`You asked for: ${filename}`);

Run it, passing the README.md that bun init created for you:

$ bun run index.ts README.md

You should see:

You asked for: README.md

Bun.argv is an array. The first two entries are always Bun itself and the path to your script. Your arguments start at index 2. We grab Bun.argv[2] because that is the first thing the user typed.

Try it without an argument:

$ bun run index.ts

You will see:

You asked for: undefined

We will handle that case shortly. First let's read a real file.

Read a File with Bun.file

Create a sample file to count:

$ echo "The quick brown fox jumps over the lazy dog." > sample.txt

Now update index.ts to read it:

// index.ts
const filename = Bun.argv[2];
const file = Bun.file(filename);
const contents = await file.text();
console.log(contents);

Run it:

$ bun run index.ts sample.txt

You should see:

The quick brown fox jumps over the lazy dog.

Bun.file() is Bun's built-in file API. You give it a path and it returns a file reference. Calling .text() reads the file as a string. No fs module, no package to install.

The await keyword is there because reading a file takes time. Bun returns a Promise, and await waits for it to finish before moving on. You will see await constantly in Bun code.

Count the Words

Now we count. Add a countWords function above the existing code:

// index.ts
function countWords(text: string): number {
  return text
    .trim()
    .split(/\s+/)
    .filter((word) => word.length > 0).length;
}

const filename = Bun.argv[2];
const file = Bun.file(filename);
const contents = await file.text();
const count = countWords(contents);
console.log(`${count} words`);

Run it:

$ bun run index.ts sample.txt

You should see:

9 words

Here is what countWords does. trim() removes whitespace from the start and end. split(/\s+/) breaks the string into pieces wherever there is one or more whitespace characters — spaces, tabs, newlines. filter() drops any empty pieces. length gives us the count.

countWords is a pure function. You give it a string, it returns a number. It does not read files, print anything, or touch the outside world. That makes it easy to reason about — and easy to test, which we will do in the next chapter.

Handle a Missing File

Try running the script with a file that does not exist:

$ bun run index.ts nope.txt

You will see an ugly error with a stack trace. That is not a good experience for someone using your tool.

Let's check that the file exists before reading it:

// index.ts
function countWords(text: string): number {
  return text
    .trim()
    .split(/\s+/)
    .filter((word) => word.length > 0).length;
}

const filename = Bun.argv[2];

if (!filename) {
  console.error("Usage: bun run index.ts <filename>");
  process.exit(1);
}

const file = Bun.file(filename);

if (!(await file.exists())) {
  console.error(`File not found: ${filename}`);
  process.exit(1);
}

const contents = await file.text();
const count = countWords(contents);
console.log(`${count} words`);

Run it again with a missing file:

$ bun run index.ts nope.txt

You should see:

File not found: nope.txt

And running with no argument at all:

$ bun run index.ts
Usage: bun run index.ts <filename>

Two small additions, one big quality jump. We use console.error instead of console.log to write to standard error, which is the conventional channel for error messages. process.exit(1) ends the script with a non-zero exit code, which tells the shell that something went wrong.

Install Your First Package with bun add

Now for the cow.

Cowsay is a tiny package that prints text inside an ASCII speech bubble. We could write our own, but this is a chance to install our first package. If you know npm install cowsay, this is the same idea with Bun managing the install and lockfile.

Run:

$ bun add cowsay

You should see something like:

installed cowsay@1.6.0

1 package installed

One command changed three parts of the project.

$ ls

You should see a few new things alongside the original files:

bun.lock        node_modules/   package.json    sample.txt
index.ts        ...

bun.lock is the lockfile. It records the exact version of cowsay (and everything cowsay depends on) that got installed. Commit it to Git so anyone else who clones your project gets the same versions.

node_modules/ is where the package code actually lives. You will never edit anything in here. It is rebuilt from the lockfile whenever you run bun install.

Open package.json and you will see a new section:

{
  ...
  "dependencies": {
    "cowsay": "^1.6.0"
  }
}

That is how Bun remembers which packages your project uses. The ^1.6.0 means "1.6.0 or any compatible newer version."

A quick note on bun add variants:

  • bun add cowsay — adds it as a regular dependency (your code uses it at runtime)
  • bun add -d typescript — adds it as a dev dependency (only needed during development)
  • bun add -g some-tool — installs it globally on your machine

We want cowsay at runtime, so the plain bun add cowsay is right.

Use the Package

Now import cowsay and use it. Update index.ts:

// index.ts
import { say } from "cowsay";

function countWords(text: string): number {
  return text
    .trim()
    .split(/\s+/)
    .filter((word) => word.length > 0).length;
}

const filename = Bun.argv[2];

if (!filename) {
  console.error("Usage: bun run index.ts <filename>");
  process.exit(1);
}

const file = Bun.file(filename);

if (!(await file.exists())) {
  console.error(`File not found: ${filename}`);
  process.exit(1);
}

const contents = await file.text();
const count = countWords(contents);
console.log(say({ text: `${count} words` }));

Run it:

$ bun run index.ts sample.txt

You should see:

 __________
< 9 words >
 ----------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

The cow speaks. We imported the say function from cowsay, passed it our word count, and printed the result.

This is the whole loop. You wanted a feature. You installed a package. You imported what you needed. You used it. That is what bun add is for.

Add a Script to package.json

Just like Chapter 2, let's add a script so we don't have to type bun run index.ts every time. Open package.json and add a scripts section:

{
  "name": "wordcount",
  "module": "index.ts",
  "type": "module",
  "private": true,
  "scripts": {
    "count": "bun run index.ts"
  },
  "devDependencies": {
    "@types/bun": "latest"
  },
  "peerDependencies": {
    "typescript": "^5"
  },
  "dependencies": {
    "cowsay": "^1.6.0"
  }
}

Now you can run:

$ bun run count sample.txt

Anything you type after the script name gets passed through to the underlying command. So bun run count sample.txt becomes bun run index.ts sample.txt.

Run a Package Without Installing It with bunx

Sometimes you want to run a package once without making it part of your project. Maybe you want to scaffold a new app with a generator, or run a one-off formatter.

That's what bunx is for. Try it:

$ bunx cowsay "Hello from bunx"

You should see a cow saying hello — even though if cowsay weren't already installed in this project, bunx would download it temporarily, run it, and forget about it.

The rule of thumb:

  • If your code imports a package, use bun add. The package becomes part of your project.
  • If you just want to run a package's command-line tool, use bunx. Nothing gets added to your project.

Our wordcount script imports cowsay, so bun add is correct here. But you will reach for bunx constantly for one-off tools.

Save Your Work with Git

Time to commit. Initialize the repository:

$ git init

Bun's generated .gitignore already excludes node_modules/. Add the rest:

$ git add .
$ git commit -m "Word count CLI tool with cowsay"

Both bun.lock and package.json are committed — that is how anyone else who clones your project ends up with the exact same packages installed.

Conclusion

You now have a small command-line tool that does real work:

  • reads a filename from Bun.argv
  • reads the file with Bun.file
  • counts words in a pure countWords function
  • handles missing input and missing files cleanly
  • uses an npm package installed with bun add

The important part is not the cow. The important part is the loop: install a package, import it, use it, and commit the lockfile so the project can be rebuilt later.

In the next chapter, we will pull countWords into its own file and test it with Bun's test runner.

Bun for Beginners
Get the rest of the book.
Part II — eight chapters that turn the routing groundwork from Part I into a real Notes app. Part III ships in a few weeks (production deploy + ops).
·Forms, validation, and application state
·SQLite persistence and user accounts
·Sessions, login, and authorization
·Edit, delete, search, and pagination
Feedback