diff --git a/src/install/isolated_install/Installer.rs b/src/install/isolated_install/Installer.rs index edb25516b394..dde26d42d5c0 100644 --- a/src/install/isolated_install/Installer.rs +++ b/src/install/isolated_install/Installer.rs @@ -2307,6 +2307,11 @@ impl<'a> Installer<'a> { Which::Staging, ); + // `bun add -g`: root-level update requests link into `options.bin_path` + // (~/.bun/bin); everything else stays in the parent's local `.bin/`. + let link_into_global_bin = + self.manager().options.global && parent_entry_id == StoreEntryId::ROOT; + for dep in entry_deps[parent_entry_id.get() as usize].slice() { let node_id = entry_node_ids[dep.entry_id.get() as usize]; let dep_id = node_dep_ids[node_id.get() as usize]; @@ -2347,6 +2352,19 @@ impl<'a> Installer<'a> { ); } + let global = if !link_into_global_bin { + false + } else { + 'global: { + for request in self.manager().update_requests.iter() { + if request.package_id == pkg_id { + break 'global true; + } + } + break 'global false; + } + }; + // see the matching note in `Step::LinkBinaries` — // `target_node_modules_path` may alias `node_modules_path` and // the Linker field is a raw `*const AbsPath` to permit that. @@ -2375,7 +2393,7 @@ impl<'a> Installer<'a> { skipped_due_to_missing_bin: false, }; - bin_linker.link(false); + bin_linker.link(global); if target_node_modules_path.is_some() && (bin_linker.skipped_due_to_missing_bin || bin_linker.err.is_some()) @@ -2391,7 +2409,7 @@ impl<'a> Installer<'a> { ); } - bin_linker.link(false); + bin_linker.link(global); } if let Some(err) = bin_linker.err { diff --git a/test/cli/install/isolated-install.test.ts b/test/cli/install/isolated-install.test.ts index a4ff2a70903f..d8a2bd6c998f 100644 --- a/test/cli/install/isolated-install.test.ts +++ b/test/cli/install/isolated-install.test.ts @@ -3018,3 +3018,83 @@ describe("hoist", () => { ); }); }); + +describe("global install", () => { + // Regression: `bun add -g --linker isolated ` used to leave + // the bin in `/install/global/node_modules/.bin/` instead of + // linking it into `/bin/`, so the command wasn't on $PATH. + // https://github.com/oven-sh/bun/issues/30450 + // + // File-path deps here so the test doesn't depend on the registry — the bug + // is in the isolated installer's `linkDependencyBins` pass and fires the + // same way regardless of source. + test("links requested-package bins into BUN_INSTALL/bin", async () => { + using dir = tempDir("isolated-global-bin-", { + "pkg-with-bin/package.json": JSON.stringify({ + name: "pkg-with-bin", + version: "1.0.0", + bin: { "bin-from-pkg": "./cli.js" }, + }), + "pkg-with-bin/cli.js": "#!/usr/bin/env node\nconsole.log('ok');\n", + }); + const bunInstall = join(String(dir), "bun-install"); + const globalBinDir = join(bunInstall, "bin"); + + await using proc = spawn({ + cmd: [bunExe(), "add", "-g", "--linker=isolated", join(String(dir), "pkg-with-bin")], + cwd: String(dir), + env: { ...bunEnv, BUN_INSTALL: bunInstall }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("error:"); + expect(stdout).toContain("pkg-with-bin"); + expect(exitCode).toBe(0); + + // POSIX: `bin-from-pkg` symlink. Windows: `bin-from-pkg.exe` shim. + const hasBin = existsSync(join(globalBinDir, "bin-from-pkg")) || existsSync(join(globalBinDir, "bin-from-pkg.exe")); + expect(hasBin).toBe(true); + }); + + // A *non-global* `bun install` must never link bins into `/bin/` + // — the global-bin link is gated on `options.global`. A local install with + // the isolated linker puts the bin in the project's `node_modules/.bin/` only. + // Guards the `options.global` half of the gate. + test("non-global install does not link bins into BUN_INSTALL/bin", async () => { + using dir = tempDir("isolated-global-bin-local-", { + "proj/package.json": JSON.stringify({ + name: "proj", + version: "1.0.0", + dependencies: { "dep-pkg": "file:../dep-pkg" }, + }), + "proj/bunfig.toml": `[install]\nlinker = "isolated"\n`, + "dep-pkg/package.json": JSON.stringify({ + name: "dep-pkg", + version: "1.0.0", + bin: { "dep-bin": "./dep.js" }, + }), + "dep-pkg/dep.js": "#!/usr/bin/env node\nconsole.log('dep');\n", + }); + const projDir = join(String(dir), "proj"); + const bunInstall = join(String(dir), "bun-install"); + const globalBinDir = join(bunInstall, "bin"); + + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: projDir, + env: { ...bunEnv, BUN_INSTALL: bunInstall }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + // The bin is linked into the project, not the global bin dir. + const localBin = process.platform === "win32" ? "dep-bin.bunx" : "dep-bin"; + expect(existsSync(join(projDir, "node_modules", ".bin", localBin))).toBe(true); + expect(existsSync(join(globalBinDir, "dep-bin"))).toBe(false); + expect(existsSync(join(globalBinDir, "dep-bin.exe"))).toBe(false); + }); +});