Hedronite Lesson · Polyglot-Dev / TypeScript · Fri 2026-09-11

Bun as Package Manager

Install writes the tree and the lockfile. The lockfile pins what you got. Scripts name the commands you run on purpose.

Lesson Class: Asr (Bun/TypeScript track)
Focus: bun install · bun.lock · frozen-lockfile · bun ci · package.json scripts · trustedDependencies
Code Blocks: clean blocks, explanation in prose
Done-criteria: install, lockfile, scripts
Grounding: bun.com/docs install + lockfile + runtime scripts · Cherny secondary shelf only
The install
bun install fills deps; dependency lifecycle scripts stay off until trustedDependencies.
The lock
Commit bun.lock; bun ci / --frozen-lockfile fail on package.json drift.
The scripts
bun run names and runs package.json scripts with pre/post hooks and clear flag placement.
Install writes the tree and the lockfile. The lockfile pins what you got. Scripts name the commands you run on purpose.

<!-- hal:authoritative:yaml -->

Install writes the tree and the lockfile. The lockfile pins what you got. Scripts name the commands you run on purpose.

§I — Frame

Asr session 03 for the Bun/TypeScript track. Dual-fire peer with Nix session 08 on 2026-09-11. Session 01 proved bun run on .ts without a separate transpile step. Session 02 seated strict types, interfaces, and narrowing under tsc --noEmit. Today the track turns to Bun as a Node-compatible package manager: install, lockfile, and package.json scripts.

Done-criteria from the syllabus: install, lockfile, scripts.

Primary cites: live Bun docs — bun install, Lockfile, and the Runtime page's package.json scripts section at https://bun.com/docs/runtime. Cherny stays secondary shelf presence only; this fire is not language depth. No Bun.serve. No moduleResolution: bundler deep dive. No dumping into Polyglot-Dev/Web/.

Home: .

If Bun is not on the machine today, read the commands and their documented effects here. Do not treat a missing binary as a reason to invent npm-only workflows for this lesson.

§II — bun install in an existing project

Bun's CLI includes a Node-compatible package manager meant to replace npm, yarn, and pnpm for install speed while keeping package.json as the project contract. If the project already has a package.json, you can run:

bun install

That command installs dependencies, devDependencies, and optionalDependencies. Bun installs peerDependencies by default (Yarn-like). It runs the project's own {pre|post}install and {pre|post}prepare scripts at the appropriate time. For security, Bun does not execute lifecycle scripts of installed dependencies unless those packages are listed in trustedDependencies.

{
  "name": "my-app",
  "version": "1.0.0",
  "trustedDependencies": ["my-trusted-package"]
}

Re-install after editing that field so Bun reads trust and runs the allowed lifecycle scripts. Lifecycle scripts for trusted packages run in parallel; --concurrent-scripts caps concurrency.

Add a package explicitly:

bun install react
bun install [email protected]
bun install react@latest

Global CLIs use -g / --global. Production installs omit devDependencies:

bun install --production

--production implies --frozen-lockfile. It controls what this install adds. Dev packages already present in node_modules from an earlier install stay until you prune them (bun prune --production per the docs).

For reproducible CI installs, prefer the explicit frozen path:

bun install --frozen-lockfile
# or
bun ci

bun ci is equivalent to bun install --frozen-lockfile. It installs exact versions from bun.lock and fails if package.json disagrees with the lockfile. Bun does not enable frozen mode automatically just because you are in CI — pass the flag or use bun ci. Commit bun.lock so CI has something to freeze against.

Other install levers you should recognize by name today (not exhaustively drill): --dry-run, --omit dev|peer|optional, --prefer-offline / --offline, workspaces + --filter, and linker strategies (hoisted vs isolated). Hold workspaces and isolated installs as "exists; later monorepo depth." The Done-criteria for session 03 are install, lockfile, scripts — not a full monorepo course.

Contrast with session 01 carefully. Session 01 used bun run to execute TypeScript. Session 03 uses the same binary family to populate node_modules and to dispatch named scripts. The mental model is one toolchain with three faces: runtime, installer, script runner. Mixing those faces without naming which one you meant is how "it worked on my machine" reports get written.

Also contrast with npm habits you may bring in. npm install often runs dependency postinstall scripts freely. Bun's default refuses that for installed dependencies until trust is declared. If a native addon fails to build because its install script never ran, check trustedDependencies before assuming Bun is broken. That is a security posture, not a missing feature.

§III — bun.lock as the pin

bun install writes a lockfile named bun.lock at the project root. Bun's docs answer the commit question in one word: Yes — commit it.

Without a lockfile, the next install resolves floating ranges again. With a lockfile, the next install can reproduce the same graph. --frozen-lockfile / bun ci make disagreement a hard error instead of a quiet drift.

Useful lockfile controls:

# Write/update the lockfile without filling node_modules
bun install --lockfile-only

# Install without saving a lockfile (opt-out; not the default habit)
bun install --no-save

Bun v1.2 made text-based bun.lock the default. Older projects may still have binary bun.lockb. Migrate with the documented one-liner (bun install --save-text-lockfile --frozen-lockfile --lockfile-only), then delete bun.lockb.

When bun.lock is absent, Bun can migrate from yarn.lock, modern package-lock.json, or pnpm-lock.yaml during install. It leaves the original lockfile in place until you remove it after verification. Treat migration as a bridge, not as a reason to keep two lockfiles forever.

Name the discipline: package.json states intent (ranges, scripts, trust). bun.lock states the resolved graph you actually shipped. CI freezes the graph. Local bun install without frozen mode may refresh the lockfile when ranges move — read the diff before you commit it.

Offline and cache notes belong beside the lockfile, not instead of it. With a warm cache, --prefer-offline uses cached metadata and only fetches what is missing; --offline refuses the network entirely and errors if a required package is absent from cache. A complete restored cache plus --offline --frozen-lockfile is the docs' picture of a deterministic, network-free CI install. You do not need to configure that today. You do need to know that the lockfile is what makes "the same graph" a meaningful phrase.

§IV — Scripts: name the command, then bun run it

Session 01 already ran source files with bun run index.ts. The package-manager half is named scripts in package.json:

{
  "scripts": {
    "clean": "rm -rf dist && echo 'Done.'",
    "dev": "bun server.ts",
    "test": "bun test"
  }
}

Execute them with:

bun run clean
bun run dev

bun run with no arguments lists available scripts. Bun runs the script command in a subshell (bash/sh/zsh on Linux and macOS; Bun Shell on Windows for bash-like syntax). Lifecycle hooks apply: bun run clean runs preclean and postclean when defined; a failing pre* stops the main script.

Short form bun <name> also works when it does not collide with a built-in Bun command. When in doubt, use explicit bun run <name>.

Resolution order under bun run (from the Runtime docs):

  1. package.json scripts
  2. Source files
  3. Binaries from project packages
  4. System commands (bun run only)

Put Bun flags such as --watch immediately after bun, before run, when you mean them for Bun itself:

bun --watch run index.tsx   # Bun watches
bun run dev --watch         # --watch is passed through to the script

Local CLIs often ship with a #!/usr/bin/env node shebang. By default Bun respects that and may execute them with Node. Force Bun with:

bun run --bun vite

That flag is part of script discipline on a Bun-first track: know whether the script ran under Bun or Node.

Session 01's split still holds beside this fire: bun run executes. tsc --noEmit (when you care about types) still checks. Installing TypeScript as a devDependency and wiring a "typecheck": "tsc --noEmit" script is a normal package-manager move. Do not confuse a green bun run test with a green typecheck unless the script actually runs the checker.

A minimal ship-shaped package.json for this track often looks like:

{
  "name": "asr-demo",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "bun run src/index.ts",
    "test": "bun test",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "@types/bun": "latest",
    "typescript": "^5"
  }
}

Install once (bun install), commit bun.lock, run bun run typecheck and bun run test as named scripts. That is the package-manager loop. Session 02 taught what typecheck is proving. Session 03 teaches how the project invokes that proof through the lockfile-backed install.

§V — Self-check

Proof 1. Install. In a throwaway directory with a minimal package.json, run bun install (optionally after bun add of one dependency). Confirm node_modules appears and bun.lock is written.

Proof 2. Lockfile commit habit. Open bun.lock. Say whether it belongs in git (yes). Run bun install --frozen-lockfile after editing a dependency range in package.json without updating the lockfile, and confirm Bun errors instead of silently healing.

Proof 3. Scripts. Add a "hello" script that prints a fixed string. Run bun run to list it, then bun run hello to execute it. Add prehello / posthello and observe order.

Proof 4. Trust boundary. State what Bun refuses by default for dependency lifecycle scripts, and how trustedDependencies opts a package in.

Proof 5. CI phrase. Write the one-liner you want in CI (bun ci or bun install --frozen-lockfile) and the precondition (committed bun.lock).

If any proof fails, re-read the matching Bun docs page. Do not fall back to "just use npm" as the lesson escape. The track is Bun's manager with package.json compatibility.

Closing

Session 03 seats Bun as the install tool beside Bun as the runtime. You ran bun install, named what it installs by default, and drew the trust line around dependency lifecycle scripts. You treated bun.lock as the committed pin and bun ci / --frozen-lockfile as the disagreement detector. You defined package.json scripts and executed them with bun run, including pre/post hooks and the flag placement rule for Bun versus script args.

Name the mechanism when someone asks what "Bun as package manager" means on this track: one CLI installs the graph, writes the lockfile you commit, and runs the scripts you name — without giving installed packages a free postinstall by default.

Session 04 is modules and moduleResolution: bundler. Do not start it in this folder. Do not write it today.

Examine well. Install is the first proof. Lockfile is the second. Frozen CI is the third. Named scripts are the fourth. Trust for lifecycle scripts is the fifth. The door is a project that installs clean under bun ci and runs its scripts under bun run.

Related