From 91dd1edcc447df6e5cb7d49704bdb90b7ac71ea3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:28:46 +0000 Subject: [PATCH 1/3] install: remove dangling links from the global node_modules/.bin on `bun remove -g` A global command links only the packages it names into the global bin dir. Every other top-level package links into the node_modules/.bin of the global dir. `bun remove` swept dangling links only in `options.bin_path`, which is the global bin dir in a global install, so the link in node_modules/.bin stayed behind. In a global install, also run `prune::prune_bins` on the node_modules of the global dir. It removes only the dangling entries of .bin, symlinks on POSIX and .bunx/.exe shim pairs on Windows. --- .../updatePackageJSONAndInstall.rs | 9 ++++ src/install/prune.rs | 4 +- test/cli/install/bun-remove.test.ts | 43 ++++++++++++++++++- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/install/PackageManager/updatePackageJSONAndInstall.rs b/src/install/PackageManager/updatePackageJSONAndInstall.rs index bb66c4a8f5dd..592e72a2257a 100644 --- a/src/install/PackageManager/updatePackageJSONAndInstall.rs +++ b/src/install/PackageManager/updatePackageJSONAndInstall.rs @@ -777,6 +777,15 @@ pub(super) fn remove_leftover_node_modules( } } } + + if manager.options.global { + // `bin_path` is the global bin dir, and only the packages a command names link there. + // Every other top-level package links into the `node_modules/.bin` of the global dir. + // Sweep only `.bin`: the global `node_modules` is also the `bun link` registry. + if let Ok(node_modules) = cwd.open_at(b"node_modules") { + crate::prune::prune_bins(&node_modules); + } + } } pub fn update_package_json_and_install_and_cli( diff --git a/src/install/prune.rs b/src/install/prune.rs index 668cd540e4a8..21609d711923 100644 --- a/src/install/prune.rs +++ b/src/install/prune.rs @@ -1995,7 +1995,7 @@ fn unlink_links(dir: &Dir, should_unlink: &dyn Fn(&Dir, &[u8], &[u8]) -> bool) { } #[cfg(not(windows))] -fn prune_bins(dir: &Dir) { +pub(crate) fn prune_bins(dir: &Dir) { let Some(bin) = open_real_subdir(dir, b".bin") else { return; }; @@ -2015,7 +2015,7 @@ fn prune_bins(dir: &Dir) { // `.bunx` layout: windows-shim/BinLinkingShim.rs (target path is relative to this node_modules folder). #[cfg(windows)] -fn prune_bins(dir: &Dir) { +pub(crate) fn prune_bins(dir: &Dir) { let Some(bin) = open_real_subdir(dir, b".bin") else { return; }; diff --git a/test/cli/install/bun-remove.test.ts b/test/cli/install/bun-remove.test.ts index 3e3be4cd8a97..137b8a637260 100644 --- a/test/cli/install/bun-remove.test.ts +++ b/test/cli/install/bun-remove.test.ts @@ -349,11 +349,11 @@ const localBin = (name: string) => ({ [`${name}/cli.js`]: "#!/usr/bin/env node\n", }); -async function run(dir: string, ...args: string[]) { +async function runWithEnv(extraEnv: Record, dir: string, ...args: string[]) { await using proc = spawn({ cmd: [bunExe(), ...args], cwd: dir, - env: { ...env, BUN_INSTALL_CACHE_DIR: join(dir, ".bun-cache") }, + env: { ...env, BUN_INSTALL_CACHE_DIR: join(dir, ".bun-cache"), ...extraEnv }, stdout: "pipe", stderr: "pipe", }); @@ -361,10 +361,16 @@ async function run(dir: string, ...args: string[]) { return { stdout, stderr, exitCode }; } +const run = (dir: string, ...args: string[]) => runWithEnv({}, dir, ...args); + const remove = (dir: string, ...names: string[]) => run(dir, "remove", ...names); const binEntries = (dir: string) => readdirSync(join(dir, "node_modules", ".bin")).sort(); +// On Windows a bin is a `.bunx` and `.exe` shim pair, not a symlink. +const binFiles = (...names: string[]) => + (isWindows ? names.flatMap(name => [`${name}.bunx`, `${name}.exe`]) : names).sort(); + it.concurrent("bun remove drops every duplicate key of the removed package", async () => { using dir = tempDir("bun-remove-dup", { ...local("foo"), @@ -460,6 +466,39 @@ for (const linker of ["hoisted", "isolated"] as const) { }); expect(exitCode).toBe(0); }); + + it.concurrent(`bun remove -g leaves no dangling link in the global node_modules/.bin (${linker})`, async () => { + using dir = tempDir(`bun-remove-global-bin-${linker}`, { + ...localBin("what-bin"), + ...localBin("other-bin"), + ...local("linked"), + }); + const globalEnv = { BUN_INSTALL: join(String(dir), "global") }; + const globalDir = join(globalEnv.BUN_INSTALL, "install", "global"); + const runGlobal = (...args: string[]) => runWithEnv(globalEnv, String(dir), ...args, "-g", "--linker", linker); + + // The second add does not name what-bin, so it links what-bin into the node_modules/.bin of the global dir. + for (const name of ["what-bin", "other-bin"]) { + const { stderr, exitCode } = await runGlobal("add", join(String(dir), name)); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + } + expect(binEntries(globalDir)).toEqual(expect.arrayContaining(binFiles("what-bin"))); + + // The global node_modules is also the `bun link` registry. + { + const { stderr, exitCode } = await runWithEnv(globalEnv, join(String(dir), "linked"), "link"); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + } + + const { stderr, exitCode } = await runGlobal("remove", "what-bin"); + expect(stderr).not.toContain("error:"); + expect(binEntries(globalDir)).toStrictEqual(binFiles("other-bin")); + expect(existsSync(join(globalDir, "node_modules", "what-bin"))).toBe(false); + expect(existsSync(join(globalDir, "node_modules", "linked", "package.json"))).toBe(true); + expect(exitCode).toBe(0); + }); } it.concurrent("bun r is an alias of bun remove", async () => { From 9b02db68ec4af2c55eb80ef27295b326f349e7e1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:25:20 +0000 Subject: [PATCH 2/3] test: pin every global-dir variable in the bun remove -g test An inherited BUN_INSTALL_GLOBAL_DIR or BUN_INSTALL_BIN, or a globalDir in the home bunfig, takes precedence over BUN_INSTALL. Set all three so the test cannot reach the real global folder of the machine that runs it. --- test/cli/install/bun-remove.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/cli/install/bun-remove.test.ts b/test/cli/install/bun-remove.test.ts index 137b8a637260..ed8ba13e5728 100644 --- a/test/cli/install/bun-remove.test.ts +++ b/test/cli/install/bun-remove.test.ts @@ -473,8 +473,14 @@ for (const linker of ["hoisted", "isolated"] as const) { ...localBin("other-bin"), ...local("linked"), }); - const globalEnv = { BUN_INSTALL: join(String(dir), "global") }; - const globalDir = join(globalEnv.BUN_INSTALL, "install", "global"); + const bunInstall = join(String(dir), "global"); + const globalDir = join(bunInstall, "install", "global"); + // Every global-dir variable is set so an inherited one can never point the test at the developer's real global folder. + const globalEnv = { + BUN_INSTALL: bunInstall, + BUN_INSTALL_GLOBAL_DIR: globalDir, + BUN_INSTALL_BIN: join(bunInstall, "bin"), + }; const runGlobal = (...args: string[]) => runWithEnv(globalEnv, String(dir), ...args, "-g", "--linker", linker); // The second add does not name what-bin, so it links what-bin into the node_modules/.bin of the global dir. From efaa1e90f1f7b24226e0b6dea62fb29c10c030b8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:28:37 +0000 Subject: [PATCH 3/3] install: shorten the comment on the global .bin sweep The test already asserts that a `bun link` registration survives `bun remove -g`, so the comment does not need to carry that rule. --- src/install/PackageManager/updatePackageJSONAndInstall.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/install/PackageManager/updatePackageJSONAndInstall.rs b/src/install/PackageManager/updatePackageJSONAndInstall.rs index 592e72a2257a..215e9b244ad6 100644 --- a/src/install/PackageManager/updatePackageJSONAndInstall.rs +++ b/src/install/PackageManager/updatePackageJSONAndInstall.rs @@ -779,9 +779,7 @@ pub(super) fn remove_leftover_node_modules( } if manager.options.global { - // `bin_path` is the global bin dir, and only the packages a command names link there. - // Every other top-level package links into the `node_modules/.bin` of the global dir. - // Sweep only `.bin`: the global `node_modules` is also the `bun link` registry. + // `bin_path` is the global bin dir, so the sweep above misses `node_modules/.bin`. if let Ok(node_modules) = cwd.open_at(b"node_modules") { crate::prune::prune_bins(&node_modules); }