Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/install/PackageManager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 10 additions & 1 deletion src/install/PackageManager.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
97 changes: 96 additions & 1 deletion test/cli/install/bun-add.test.ts
Original file line number Diff line number Diff line change
@@ -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, 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,
Expand Down Expand Up @@ -2462,3 +2471,89 @@ 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 <BUN_INSTALL>/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 () => {
using home = tempDir("bun-add-global-parent-lockfile", {
// stray files in the parent of <home>/.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: {},
}),
".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}`,
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();
// 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" }),
);
});

// 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 () => {
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:*" },
}),
".bun": { install: { global: {} } },
});
Comment on lines +2531 to +2541

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit: as written, this test would still pass if only the and !cli.global guard (PackageManager.zig:696) were reverted — <global>/package.json is not pre-created so the line-638 if (cli.global) break; handles the first pass, and on the retry pass workspaces: ["packages/*"] cannot match .bun/install/global so the hop never happens regardless. It is still a valid #28247 regression test (fails on main), so fine to leave as-is; but if you want it to independently pin the second guard, pre-create ".bun/install/global/package.json" in the fixture and use a glob that captures the global dir (e.g. [".bun/install/*"]).

Extended reasoning...

What this is

This is a test-coverage observation, not a correctness bug. The PR adds two Zig guards and two regression tests; the commit test: cover workspace-root hop case too (#28247) and the author's comment "added test coverage for that case too" suggest this second test is meant to pin the second guard (subcommand.shouldChdirToRoot() and !cli.global at PackageManager.zig:696). Tracing the fixture shows it does not isolate that guard — reverting only and !cli.global while keeping the line-638 if (cli.global) break; still lets this test pass.

Step-by-step trace (with only and !cli.global reverted)

  1. First init() (subcommand = .add, cli.global = true): chdirs to <home>/.bun/install/global, tries to open package.jsonFileNotFound. The fixture only creates ".bun": { install: { global: {} } } — an empty dir — so the first guard if (cli.global) break; fires. Since subcommand != .install, the function returns error.MissingPackageJSON.
  2. updatePackageJSONAndInstall.zig:528-530 catches it, calls attemptToCreatePackageJSON() (which writes package.json into the current cwd, i.e. the global dir, since init() already chdir'd there), then calls init() again.
  3. Second init(): opens the freshly-created <global>/package.json on the first iteration. created_package_json = false (it's a fresh init() call). The workspace-hop block now runs (since we reverted !cli.global). It walks up to <home>, opens <home>/package.json, finds workspaces: ["packages/*"].
  4. processNamesArray globs packages/* relative to <home>. The fixture creates no <home>/packages/ directory, so workspace_names is empty. The for (workspace_names.keys(), …) loop iterates zero times and falls through to break;. found stays false.
  5. fs.top_level_dir is set to child_cwd (the global dir). <home>/package.json is never used as the root, @scope/nonexistent never enters resolution, and the assertions expect(err).not.toContain("Workspace dependency") / not.toContain("@scope/nonexistent") pass.

Even if a <home>/packages/ dir existed, .bun/install/global would not match the packages/* glob, so the for-loop would still never set found = true.

Why this still has value as-is

To be clear — and addressing the counter-argument directly — this test does fail on main (where neither guard exists): on main the first walk-up loop ascends from the empty global dir to <home>/package.json, pins fs.top_level_dir there, loads its workspace:* dep, and emits Workspace dependency "@scope/nonexistent" not found. So it is a perfectly valid black-box regression test for #28247-as-reported, and the two tests do cover two distinct user-visible symptoms (lockfile-migration error vs. workspace-dep error) of the same walk-up bug. That is reasonable regression-test practice and I am not suggesting the test is wrong.

Why it's still worth mentioning

The PR makes two separate code changes and the second one (and !cli.global) currently has no test that would fail if it alone were removed. Given the commit message explicitly frames this test as covering "the workspace-root hop case", it seems worth flagging that the fixture as written never actually reaches a state where that guard is the deciding factor. If a future refactor accidentally drops !cli.global, this test will not catch it.

How to make it independently exercise the second guard (optional)

Two small fixture tweaks, both needed:

  • Pre-create the global package.json so the first init() succeeds without relying on the line-638 break + retry path:
    ".bun": { install: { global: { "package.json": JSON.stringify({ name: "g" }) } } },
  • Use a workspace glob that actually captures the global dir, e.g. workspaces: [".bun/install/*"] (or ["**"]), so that without !cli.global the for-loop matches child_cwd, sets found = true, hops to <home> as root, and surfaces @scope/nonexistent in stderr.

The second tweak is admittedly not a "realistic" workspace glob, which is why this is filed as a nit rather than a blocker — the !cli.global guard is largely defensive belt-and-suspenders on top of the first fix, and it's reasonable to decide that contrived white-box coverage isn't worth it. Either choice (tighten the fixture, or leave it and accept the second guard is covered only by code review) is fine.


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");
// 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);
});
Loading