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
22 changes: 20 additions & 2 deletions src/install/isolated_install/Installer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
Comment thread
robobun marked this conversation as resolved.
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];
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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())
Expand All @@ -2391,7 +2409,7 @@ impl<'a> Installer<'a> {
);
}

bin_linker.link(false);
bin_linker.link(global);
}

if let Some(err) = bin_linker.err {
Expand Down
80 changes: 80 additions & 0 deletions test/cli/install/isolated-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3018,3 +3018,83 @@ describe("hoist", () => {
);
});
});

describe("global install", () => {
// Regression: `bun add -g --linker isolated <pkg-with-bin>` used to leave
// the bin in `<BUN_INSTALL>/install/global/node_modules/.bin/` instead of
// linking it into `<BUN_INSTALL>/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 `<BUN_INSTALL>/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);
});
});