Skip to content

Commit c8f2a32

Browse files
committed
fix(nix): fail fast on lockfile drift instead of letting bun hit the network
Two hook fixes for failure modes found installing opencode offline. Both stem from the same bun behavior: any drift between package.json and bun.lock makes bun re-resolve the affected dependencies, and re-resolving a git/github/remote-tarball dependency downloads it unconditionally — fatal in the sandbox no matter how complete the cache is. - Detect trustedDependencies / patchedDependencies drift between the root package.json and bun.lock and fail with an actionable message (refresh bun.lock, regenerate bun.nix). Projects routinely commit a lockfile whose copies of these sections lag package.json (opencode's does); without the check the drift surfaces as a wall of ConnectionRefused errors at resolve time with no hint of the cause. - Leave catalog: references pointing at non-registry specs (github:, git+, tarball URLs, file:) unrewritten. bun resolves those natively from the lockfile's catalog section; rewriting them to their resolution registered as a changed spec and forced a re-resolve. The hook now runs the prep script whenever bun.lock exists (previously only when it contained catalog: refs), so the drift check covers every project.
1 parent 0f2a1f0 commit c8f2a32

2 files changed

Lines changed: 113 additions & 32 deletions

File tree

nix/mk-derivation/hook.sh

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,14 @@ function bunPatchPhase {
8383
runHook postBunPatchPhase
8484
}
8585

86-
# bun re-resolves `catalog:` dependency specifiers against the npm registry on
87-
# every `bun install`, even with a fully populated cache. In the Nix sandbox
88-
# this fails. The lockfile already records the exact resolved version for
89-
# every package, so rewrite every `catalog:` reference (in bun.lock's
90-
# `workspaces` section and in every workspace package.json) to that exact
91-
# version (or `workspace:*` for workspace packages) before `bun install`.
92-
function bunResolveCatalogRefs {
93-
if ! [ -f bun.lock ] || ! grep -q '"catalog:' bun.lock 2>/dev/null; then
86+
# Prepare the project for offline install: rewrite `catalog:` references to
87+
# the exact versions recorded in bun.lock (bun re-resolves them against the
88+
# registry otherwise, which fails in the sandbox), and fail fast if bun.lock
89+
# has drifted from package.json (drift makes bun re-resolve, with the same
90+
# result). Runs whenever a lockfile exists — the drift check applies to every
91+
# project, not just those using catalogs.
92+
function bunPrepareOfflineInstall {
93+
if ! [ -f bun.lock ]; then
9494
return 0
9595
fi
9696
# --config=/dev/null: ignore the project's bunfig.toml, which may remap
@@ -103,7 +103,7 @@ function bunNodeModulesInstallPhase {
103103
pushd "$bunRoot" || exit 1
104104
runHook preBunNodeModulesInstallPhase
105105

106-
bunResolveCatalogRefs
106+
bunPrepareOfflineInstall
107107

108108
# Remove patchedDependencies from package.json and bun.lock since we
109109
# pre-patch packages during the Nix build. This ensures bun looks for

nix/mk-derivation/resolve-catalog.ts

Lines changed: 104 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
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

3445
const depSections = [
@@ -42,7 +53,7 @@ const root = process.argv[2] ?? ".";
4253
const lockPath = join(root, "bun.lock");
4354

4455
if (!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+
7499
function 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.
105129
let 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+
109152
if (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

Comments
 (0)