1- // bun2nix: resolve `catalog:` specifiers to exact versions for offline install.
1+ // bun2nix: prepare the project for offline install.
22//
3- // bun re-resolves `catalog:` dependency specifiers against the npm registry on
4- // every `bun install`, even with a fully populated cache and
5- // `--frozen-lockfile` / `--offline`. In the Nix sandbox this fails. The
6- // lockfile already records the exact resolved version for every package, so
7- // rewrite every `catalog:` reference (in bun.lock's `workspaces` section and
8- // in every workspace package.json) to that exact version (or `workspace:*`
9- // for workspace packages) before `bun install` runs.
3+ // Both steps exist because bun re-resolves any dependency whose recorded
4+ // state drifted from package.json — and re-resolving git/github/
5+ // remote-tarball deps downloads them unconditionally, which fails in the Nix
6+ // sandbox no matter how complete the cache is:
7+ //
8+ // 1. Resolve `catalog:` specifiers to exact versions (older bun re-resolves
9+ // them against the registry on every `bun install`). Non-registry catalog
10+ // values (github:/git+/tarball URLs) are left as `catalog:` — bun resolves
11+ // those natively from the lockfile's catalog section, and rewriting them
12+ // would itself register as a changed spec and force a re-resolve.
13+ // 2. Detect `trustedDependencies` / `patchedDependencies` drift between the
14+ // root package.json and bun.lock, and fail with an actionable message.
15+ // Projects routinely commit a bun.lock whose copies of these sections lag
16+ // package.json; any mismatch makes bun distrust the lockfile mapping
17+ // wholesale, and the resulting re-resolution surfaces as a wall of
18+ // ConnectionRefused errors long after the actual cause.
1019//
1120// Invoked as: bun resolve-catalog.ts <bunRoot>
1221
@@ -29,6 +38,8 @@ interface BunLock {
2938 catalog ?: Deps ;
3039 catalogs ?: Record < string , Deps > ;
3140 packages ?: Record < string , [ string , ...unknown [ ] ] > ;
41+ trustedDependencies ?: string [ ] ;
42+ patchedDependencies ?: Deps ;
3243}
3344
3445const depSections = [
@@ -42,7 +53,7 @@ const root = process.argv[2] ?? ".";
4253const lockPath = join ( root , "bun.lock" ) ;
4354
4455if ( ! existsSync ( lockPath ) ) process . exit ( 0 ) ;
45- if ( ! readFileSync ( lockPath , "utf8" ) . includes ( '"catalog:' ) ) process . exit ( 0 ) ;
56+ const hasCatalogRefs = readFileSync ( lockPath , "utf8" ) . includes ( '"catalog:' ) ;
4657
4758// bun.lock is JSON-with-trailing-commas. Bun's module loader has a built-in
4859// JSONC parser (used for tsconfig.json / bun.lock) that we can reach via
@@ -71,6 +82,20 @@ for (const [name, entry] of Object.entries(packages)) {
7182 resolved [ name ] = spec . slice ( prefix . length ) ;
7283}
7384
85+ // A spec whose resolution is not a plain registry version. Rewriting a
86+ // `catalog:` reference to one of these would change the dependency's spec
87+ // string and force bun to re-resolve (= re-download) it; bun resolves these
88+ // natively from the lockfile's catalog section, so leave them alone.
89+ function isNonRegistrySpec ( spec : string ) : boolean {
90+ return (
91+ spec . startsWith ( "github:" ) ||
92+ spec . startsWith ( "git+" ) ||
93+ spec . startsWith ( "http://" ) ||
94+ spec . startsWith ( "https://" ) ||
95+ spec . startsWith ( "file:" )
96+ ) ;
97+ }
98+
7499function cresolve ( name : string , spec : string ) : string {
75100 const cname = spec . slice ( "catalog:" . length ) ;
76101 const table = cname === "" ? catalog : ( catalogs [ cname ] ?? { } ) ;
@@ -79,6 +104,8 @@ function cresolve(name: string, spec: string): string {
79104 if ( typeof cv === "string" && cv . startsWith ( "workspace:" ) ) return cv ;
80105 if ( typeof rv === "string" && rv . startsWith ( "workspace:" ) )
81106 return "workspace:*" ;
107+ if ( typeof cv === "string" && isNonRegistrySpec ( cv ) ) return spec ;
108+ if ( typeof rv === "string" && isNonRegistrySpec ( rv ) ) return spec ;
82109 if ( typeof rv === "string" ) return rv ;
83110 if ( typeof cv === "string" ) return cv ;
84111 return spec ;
@@ -99,25 +126,79 @@ function rewriteDeps(holder: DepHolder): boolean {
99126 return changed ;
100127}
101128
102- console . log ( "bun2nix: resolving catalog: specifiers from bun.lock" ) ;
103-
104- // Rewrite the lockfile's workspaces section.
105129let lockChanged = false ;
106- for ( const ws of Object . values ( workspaces ) ) {
107- if ( rewriteDeps ( ws ) ) lockChanged = true ;
130+
131+ if ( hasCatalogRefs ) {
132+ console . log ( "bun2nix: resolving catalog: specifiers from bun.lock" ) ;
133+
134+ // Rewrite the lockfile's workspaces section.
135+ for ( const ws of Object . values ( workspaces ) ) {
136+ if ( rewriteDeps ( ws ) ) lockChanged = true ;
137+ }
138+
139+ // Rewrite every workspace package.json (root "" + each workspace dir).
140+ for ( const wsDir of Object . keys ( workspaces ) ) {
141+ const pkgJson = join ( root , wsDir , "package.json" ) ;
142+ if ( ! existsSync ( pkgJson ) ) continue ;
143+ const text = readFileSync ( pkgJson , "utf8" ) ;
144+ if ( ! text . includes ( '"catalog:' ) ) continue ;
145+ const pkg = JSON . parse ( text ) as DepHolder ;
146+ if ( rewriteDeps ( pkg ) ) {
147+ writeFileSync ( pkgJson , JSON . stringify ( pkg , null , 2 ) + "\n" ) ;
148+ }
149+ }
108150}
151+
109152if ( lockChanged ) {
110153 writeFileSync ( lockPath , JSON . stringify ( lock , null , 2 ) + "\n" ) ;
111154}
112155
113- // Rewrite every workspace package.json (root "" + each workspace dir).
114- for ( const wsDir of Object . keys ( workspaces ) ) {
115- const pkgJson = join ( root , wsDir , "package.json" ) ;
116- if ( ! existsSync ( pkgJson ) ) continue ;
117- const text = readFileSync ( pkgJson , "utf8" ) ;
118- if ( ! text . includes ( '"catalog:' ) ) continue ;
119- const pkg = JSON . parse ( text ) as DepHolder ;
120- if ( rewriteDeps ( pkg ) ) {
121- writeFileSync ( pkgJson , JSON . stringify ( pkg , null , 2 ) + "\n" ) ;
156+ // Fail fast on trustedDependencies / patchedDependencies drift between the
157+ // root package.json and bun.lock. Compared as a set / as key-value pairs so
158+ // pure ordering differences don't trip the check.
159+ const rootPkgPath = join ( root , "package.json" ) ;
160+ if ( existsSync ( rootPkgPath ) ) {
161+ const rootPkg = JSON . parse ( readFileSync ( rootPkgPath , "utf8" ) ) as {
162+ trustedDependencies ?: string [ ] ;
163+ patchedDependencies ?: Deps ;
164+ } ;
165+
166+ const drift : string [ ] = [ ] ;
167+
168+ const pkgTrusted = [ ...( rootPkg . trustedDependencies ?? [ ] ) ] . sort ( ) ;
169+ const lockTrusted = [ ...( lock . trustedDependencies ?? [ ] ) ] . sort ( ) ;
170+ if ( JSON . stringify ( pkgTrusted ) !== JSON . stringify ( lockTrusted ) ) {
171+ const missing = pkgTrusted . filter ( ( n ) => ! lockTrusted . includes ( n ) ) ;
172+ const extra = lockTrusted . filter ( ( n ) => ! pkgTrusted . includes ( n ) ) ;
173+ drift . push (
174+ `trustedDependencies differ` +
175+ ( missing . length
176+ ? `; missing from bun.lock: ${ missing . join ( ", " ) } `
177+ : "" ) +
178+ ( extra . length ? `; only in bun.lock: ${ extra . join ( ", " ) } ` : "" ) ,
179+ ) ;
180+ }
181+
182+ const pkgPatched = rootPkg . patchedDependencies ?? { } ;
183+ const lockPatched = lock . patchedDependencies ?? { } ;
184+ const patchKeys = [
185+ ...new Set ( [ ...Object . keys ( pkgPatched ) , ...Object . keys ( lockPatched ) ] ) ,
186+ ] . sort ( ) ;
187+ const patchDiffs = patchKeys . filter ( ( k ) => pkgPatched [ k ] !== lockPatched [ k ] ) ;
188+ if ( patchDiffs . length ) {
189+ drift . push ( `patchedDependencies differ for: ${ patchDiffs . join ( ", " ) } ` ) ;
190+ }
191+
192+ if ( drift . length ) {
193+ console . error ( `
194+ bun2nix: error: bun.lock is out of sync with package.json:
195+ ${ drift . map ( ( d ) => ` - ${ d } ` ) . join ( "\n" ) }
196+
197+ bun re-resolves dependencies when these sections drift, and re-resolving
198+ git/github/tarball dependencies requires network access, which is not
199+ available in the Nix sandbox. Run \`bun install\` to refresh bun.lock,
200+ commit the result, and regenerate bun.nix.
201+ ` ) ;
202+ process . exit ( 1 ) ;
122203 }
123204}
0 commit comments