From 529970ea150db01c4eda62d159e9697bb18c46a2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 14 May 2026 04:02:53 +0000 Subject: [PATCH 1/8] install: stop `bun add -g` from walking above the global install dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bun add -g` chdirs to /install/global then walks up looking for a package.json. If one exists in a parent directory (typically $HOME on Windows) with an old npm v1 package-lock.json next to it, bun treated that as the root project and aborted with 'Please upgrade package-lock.json to lockfileVersion 2 or 3'. Global installs are self-contained. Stop the walk — and the workspace-root hop that follows it — at the global install dir when `cli.global` is set. Fixes #30658. --- src/install/PackageManager.zig | 11 ++++++- test/cli/install/bun-add.test.ts | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/install/PackageManager.zig b/src/install/PackageManager.zig index d78c32759c9..4f8778fa803 100644 --- a/src/install/PackageManager.zig +++ b/src/install/PackageManager.zig @@ -631,6 +631,13 @@ pub fn init( .{ .mode = if (need_write) .read_write else .read_only }, ) catch |err| switch (err) { error.FileNotFound => { + // For global installs, the install dir is self-contained. + // Don't walk above it — a stray package.json/package-lock.json + // in a parent directory (e.g. $HOME) isn't the "root package" + // bun should be acting on. See #30658. + if (cli.global) { + break; + } if (std.fs.path.dirname(this_cwd)) |parent| { this_cwd = strings.withoutTrailingSlash(parent); continue; @@ -684,7 +691,9 @@ pub fn init( // Check if this is a workspace; if so, use root package var found = false; - if (subcommand.shouldChdirToRoot()) { + // Global installs are self-contained — don't hop up into a parent + // workspace that happens to live above the global dir. See #30658. + if (subcommand.shouldChdirToRoot() and !cli.global) { if (!created_package_json) { while (std.fs.path.dirname(this_cwd)) |parent| : (this_cwd = parent) { const parent_without_trailing_slash = strings.withoutTrailingSlash(parent); diff --git a/test/cli/install/bun-add.test.ts b/test/cli/install/bun-add.test.ts index a010e69beee..cfba75a19f9 100644 --- a/test/cli/install/bun-add.test.ts +++ b/test/cli/install/bun-add.test.ts @@ -2462,3 +2462,57 @@ it("should install tarball with tarball dependencies", async () => { await access(join(add_dir, "node_modules", "test-parent")); await access(join(add_dir, "node_modules", "test-child")); }); + +// https://github.com/oven-sh/bun/issues/30658 +// `bun add -g` chdirs to /install/global, which then used to +// walk up the filesystem looking for a package.json. If a stray package.json +// (with an old npm v1 package-lock.json next to it) existed in a parent +// directory — typically $HOME on Windows — bun would treat that as the root +// project and fail lockfile migration with +// "Please upgrade package-lock.json to lockfileVersion 2 or 3". +// Global installs must be self-contained and not reach outside the install dir. +it("`bun add -g` ignores package.json/package-lock.json above the global dir", async () => { + const home = tmpdirSync(); + // stray files in the parent of /.bun/install/global + await writeFile( + join(home, "package.json"), + JSON.stringify({ name: "stray-root", version: "1.0.0" }), + ); + await writeFile( + join(home, "package-lock.json"), + JSON.stringify({ + name: "stray-root", + version: "1.0.0", + lockfileVersion: 1, + requires: true, + dependencies: {}, + }), + ); + await mkdir(join(home, ".bun", "install", "global"), { recursive: true }); + + const { stdout, stderr, exited } = spawn({ + // point at an unreachable registry so we never touch the real network, + // but still exercise the full init/walk-up code path that used to fail. + cmd: [bunExe(), "add", "-g", "--registry=http://127.0.0.1:1/", "chalk"], + cwd: home, + stdout: "pipe", + stdin: "pipe", + stderr: "pipe", + env: { ...env, BUN_INSTALL: join(home, ".bun") }, + }); + + const err = await stderr.text(); + // the bug: the stray lockfile used to get picked up and migration failed here. + expect(err).not.toContain("Please upgrade package-lock.json"); + expect(err).not.toContain("lockfileVersion"); + await stdout.text(); + // exit code isn't asserted — the registry is unreachable on purpose, so the + // install itself is expected to fail. We only care that it failed *past* + // lockfile migration, not because of it. + await exited; + + // And the stray root's package.json must not have been mutated. + expect(await file(join(home, "package.json")).text()).toBe( + JSON.stringify({ name: "stray-root", version: "1.0.0" }), + ); +}); From c8c29ef5c1eb3a4a61a5785bfeb79e76b53dee40 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 04:04:55 +0000 Subject: [PATCH 2/8] [autofix.ci] apply automated fixes --- test/cli/install/bun-add.test.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/test/cli/install/bun-add.test.ts b/test/cli/install/bun-add.test.ts index cfba75a19f9..7b9deec854b 100644 --- a/test/cli/install/bun-add.test.ts +++ b/test/cli/install/bun-add.test.ts @@ -2474,10 +2474,7 @@ it("should install tarball with tarball dependencies", async () => { it("`bun add -g` ignores package.json/package-lock.json above the global dir", async () => { const home = tmpdirSync(); // stray files in the parent of /.bun/install/global - await writeFile( - join(home, "package.json"), - JSON.stringify({ name: "stray-root", version: "1.0.0" }), - ); + await writeFile(join(home, "package.json"), JSON.stringify({ name: "stray-root", version: "1.0.0" })); await writeFile( join(home, "package-lock.json"), JSON.stringify({ @@ -2512,7 +2509,5 @@ it("`bun add -g` ignores package.json/package-lock.json above the global dir", a await exited; // And the stray root's package.json must not have been mutated. - expect(await file(join(home, "package.json")).text()).toBe( - JSON.stringify({ name: "stray-root", version: "1.0.0" }), - ); + expect(await file(join(home, "package.json")).text()).toBe(JSON.stringify({ name: "stray-root", version: "1.0.0" })); }); From fdd915555afae8ece3dd6fbe8cbce871acec048d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 14 May 2026 04:14:48 +0000 Subject: [PATCH 3/8] test: cover workspace-root hop case too (#28247) --- test/cli/install/bun-add.test.ts | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/cli/install/bun-add.test.ts b/test/cli/install/bun-add.test.ts index 7b9deec854b..a7660eabf11 100644 --- a/test/cli/install/bun-add.test.ts +++ b/test/cli/install/bun-add.test.ts @@ -2511,3 +2511,42 @@ it("`bun add -g` ignores package.json/package-lock.json above the global dir", a // And the stray root's package.json must not have been mutated. expect(await file(join(home, "package.json")).text()).toBe(JSON.stringify({ name: "stray-root", version: "1.0.0" })); }); + +// https://github.com/oven-sh/bun/issues/28247 +// Same walk-up problem, different symptom: if a parent directory's +// package.json defines `workspaces` (plus any `workspace:*` deps), global +// install used to hop onto it as the workspace root and then fail to +// resolve those workspace-scoped deps: +// "error: Workspace dependency \"…\" not found" +// Global installs shouldn't participate in a parent workspace at all. +it("`bun add -g` ignores a workspaces package.json above the global dir", async () => { + const home = tmpdirSync(); + // parent package.json declares workspaces + a workspace-protocol dep + // that doesn't actually exist on disk — same shape as the reports. + await writeFile( + join(home, "package.json"), + JSON.stringify({ + name: "stray-root", + version: "1.0.0", + workspaces: ["packages/*"], + dependencies: { "@scope/nonexistent": "workspace:*" }, + }), + ); + await mkdir(join(home, ".bun", "install", "global"), { recursive: true }); + + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "add", "-g", "--registry=http://127.0.0.1:1/", "chalk"], + cwd: home, + stdout: "pipe", + stdin: "pipe", + stderr: "pipe", + env: { ...env, BUN_INSTALL: join(home, ".bun") }, + }); + + const err = await stderr.text(); + expect(err).not.toContain("Workspace dependency"); + expect(err).not.toContain("workspace:*"); + expect(err).not.toContain("failed to resolve"); + await stdout.text(); + await exited; +}); From f7781519217c0cd403fa04ce01442b25651c5541 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 14 May 2026 04:21:34 +0000 Subject: [PATCH 4/8] test: use tempDir harness fixture + assert non-zero exit Addresses coderabbit review feedback on PR #30659: tempDir gives us the Symbol.dispose cleanup contract, and asserting exit != 0 keeps the test from passing vacuously if the spawn fails to run at all. --- test/cli/install/bun-add.test.ts | 51 +++++++++++++++----------------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/test/cli/install/bun-add.test.ts b/test/cli/install/bun-add.test.ts index a7660eabf11..f65390e8ac8 100644 --- a/test/cli/install/bun-add.test.ts +++ b/test/cli/install/bun-add.test.ts @@ -1,7 +1,7 @@ import { file, spawn } from "bun"; import { afterAll, afterEach, beforeAll, beforeEach, expect, it, setDefaultTimeout } from "bun:test"; import { access, appendFile, copyFile, mkdir, readlink, rm, writeFile } from "fs/promises"; -import { bunExe, bunEnv as env, readdirSorted, tmpdirSync, toBeValidBin, toBeWorkspaceLink, toHaveBins } from "harness"; +import { bunExe, bunEnv as env, readdirSorted, tempDir, tmpdirSync, toBeValidBin, toBeWorkspaceLink, toHaveBins } from "harness"; import { join, relative, resolve } from "path"; import { check_npm_auth_type, @@ -2472,30 +2472,28 @@ it("should install tarball with tarball dependencies", async () => { // "Please upgrade package-lock.json to lockfileVersion 2 or 3". // Global installs must be self-contained and not reach outside the install dir. it("`bun add -g` ignores package.json/package-lock.json above the global dir", async () => { - const home = tmpdirSync(); - // stray files in the parent of /.bun/install/global - await writeFile(join(home, "package.json"), JSON.stringify({ name: "stray-root", version: "1.0.0" })); - await writeFile( - join(home, "package-lock.json"), - JSON.stringify({ + using home = tempDir("bun-add-global-parent-lockfile", { + // stray files in the parent of /.bun/install/global + "package.json": JSON.stringify({ name: "stray-root", version: "1.0.0" }), + "package-lock.json": JSON.stringify({ name: "stray-root", version: "1.0.0", lockfileVersion: 1, requires: true, dependencies: {}, }), - ); - await mkdir(join(home, ".bun", "install", "global"), { recursive: true }); + ".bun": { install: { global: {} } }, + }); const { stdout, stderr, exited } = spawn({ // point at an unreachable registry so we never touch the real network, // but still exercise the full init/walk-up code path that used to fail. cmd: [bunExe(), "add", "-g", "--registry=http://127.0.0.1:1/", "chalk"], - cwd: home, + cwd: `${home}`, stdout: "pipe", stdin: "pipe", stderr: "pipe", - env: { ...env, BUN_INSTALL: join(home, ".bun") }, + env: { ...env, BUN_INSTALL: join(`${home}`, ".bun") }, }); const err = await stderr.text(); @@ -2503,13 +2501,14 @@ it("`bun add -g` ignores package.json/package-lock.json above the global dir", a expect(err).not.toContain("Please upgrade package-lock.json"); expect(err).not.toContain("lockfileVersion"); await stdout.text(); - // exit code isn't asserted — the registry is unreachable on purpose, so the - // install itself is expected to fail. We only care that it failed *past* - // lockfile migration, not because of it. - await exited; + // The install itself fails (registry is unreachable on purpose) — but the + // failure must be *past* lockfile migration, not because of it. + expect(await exited).not.toBe(0); // And the stray root's package.json must not have been mutated. - expect(await file(join(home, "package.json")).text()).toBe(JSON.stringify({ name: "stray-root", version: "1.0.0" })); + expect(await file(join(`${home}`, "package.json")).text()).toBe( + JSON.stringify({ name: "stray-root", version: "1.0.0" }), + ); }); // https://github.com/oven-sh/bun/issues/28247 @@ -2520,27 +2519,25 @@ it("`bun add -g` ignores package.json/package-lock.json above the global dir", a // "error: Workspace dependency \"…\" not found" // Global installs shouldn't participate in a parent workspace at all. it("`bun add -g` ignores a workspaces package.json above the global dir", async () => { - const home = tmpdirSync(); - // parent package.json declares workspaces + a workspace-protocol dep - // that doesn't actually exist on disk — same shape as the reports. - await writeFile( - join(home, "package.json"), - JSON.stringify({ + using home = tempDir("bun-add-global-parent-workspaces", { + // parent package.json declares workspaces + a workspace-protocol dep + // that doesn't actually exist on disk — same shape as the reports. + "package.json": JSON.stringify({ name: "stray-root", version: "1.0.0", workspaces: ["packages/*"], dependencies: { "@scope/nonexistent": "workspace:*" }, }), - ); - await mkdir(join(home, ".bun", "install", "global"), { recursive: true }); + ".bun": { install: { global: {} } }, + }); const { stdout, stderr, exited } = spawn({ cmd: [bunExe(), "add", "-g", "--registry=http://127.0.0.1:1/", "chalk"], - cwd: home, + cwd: `${home}`, stdout: "pipe", stdin: "pipe", stderr: "pipe", - env: { ...env, BUN_INSTALL: join(home, ".bun") }, + env: { ...env, BUN_INSTALL: join(`${home}`, ".bun") }, }); const err = await stderr.text(); @@ -2548,5 +2545,5 @@ it("`bun add -g` ignores a workspaces package.json above the global dir", async expect(err).not.toContain("workspace:*"); expect(err).not.toContain("failed to resolve"); await stdout.text(); - await exited; + expect(await exited).not.toBe(0); }); From 918b06c3689a11d3b075d9e9328a889f0ebeb40a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 04:23:27 +0000 Subject: [PATCH 5/8] [autofix.ci] apply automated fixes --- test/cli/install/bun-add.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test/cli/install/bun-add.test.ts b/test/cli/install/bun-add.test.ts index f65390e8ac8..a5f7f53d52d 100644 --- a/test/cli/install/bun-add.test.ts +++ b/test/cli/install/bun-add.test.ts @@ -1,7 +1,16 @@ import { file, spawn } from "bun"; import { afterAll, afterEach, beforeAll, beforeEach, expect, it, setDefaultTimeout } from "bun:test"; import { access, appendFile, copyFile, mkdir, readlink, rm, writeFile } from "fs/promises"; -import { bunExe, bunEnv as env, readdirSorted, tempDir, tmpdirSync, toBeValidBin, toBeWorkspaceLink, toHaveBins } from "harness"; +import { + bunExe, + bunEnv as env, + readdirSorted, + tempDir, + tmpdirSync, + toBeValidBin, + toBeWorkspaceLink, + toHaveBins, +} from "harness"; import { join, relative, resolve } from "path"; import { check_npm_auth_type, From 12e5d108182d93c9aabfe03366d1f11d33d92e01 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 14 May 2026 04:28:47 +0000 Subject: [PATCH 6/8] test: narrow workspace negative assertion to the dep name 'failed to resolve' is generic enough that a registry/network error could trip it; checking the workspace dep name is the strong signal that bun touched the parent workspace at all. --- test/cli/install/bun-add.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/cli/install/bun-add.test.ts b/test/cli/install/bun-add.test.ts index a5f7f53d52d..edf2acf8163 100644 --- a/test/cli/install/bun-add.test.ts +++ b/test/cli/install/bun-add.test.ts @@ -2551,8 +2551,9 @@ it("`bun add -g` ignores a workspaces package.json above the global dir", async const err = await stderr.text(); expect(err).not.toContain("Workspace dependency"); - expect(err).not.toContain("workspace:*"); - expect(err).not.toContain("failed to resolve"); + // If bun touches the parent workspace at all, its deps (this one in particular) + // would appear in the error output. They must not. + expect(err).not.toContain("@scope/nonexistent"); await stdout.text(); expect(await exited).not.toBe(0); }); From 822868eafe1c2fa2b191a21bd691db7a25d2f128 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 14 May 2026 04:36:11 +0000 Subject: [PATCH 7/8] ci: retrigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit windows-x64-baseline-build-zig hit a Zig ICE in std/array_hash_map.zig (unrelated to this diff — the other *-build-zig lanes all compile fine). From 11aece1eb51d12c2593c86abf368c6aacf63a3bb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 14 May 2026 16:21:36 +0000 Subject: [PATCH 8/8] Port global-install walk-up guard to PackageManager.rs --- src/install/PackageManager.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 6a32aa74a38..85b749177f6 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1567,6 +1567,13 @@ pub fn init( ) { Ok(f) => break 'child f, Err(e) if e.get_errno() == bun_sys::E::ENOENT => { + // For global installs, the install dir is self-contained. + // Don't walk above it — a stray package.json/package-lock.json + // in a parent directory (e.g. $HOME) isn't the "root package" + // bun should be acting on. See #30658. + if cli.global { + break; + } if let Some(parent) = bun_core::dirname(this_cwd) { this_cwd = strings::without_trailing_slash(parent); continue; @@ -1637,7 +1644,9 @@ pub fn init( // PORT NOTE: reshaped — Zig uses withoutSuffixComptime(.., sep_str ++ "package.json") // Check if this is a workspace; if so, use root package - if subcommand.should_chdir_to_root() { + // Global installs are self-contained — don't hop up into a parent + // workspace that happens to live above the global dir. See #30658. + if subcommand.should_chdir_to_root() && !cli.global { if !created_package_json { while let Some(parent) = bun_core::dirname(this_cwd) { let parent_without_trailing_slash = strings::without_trailing_slash(parent);