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
7 changes: 7 additions & 0 deletions src/install/PackageManager/updatePackageJSONAndInstall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,13 @@ pub(super) fn remove_leftover_node_modules(
}
}
}

if manager.options.global {
// `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);
}
}
}

pub fn update_package_json_and_install_and_cli(
Expand Down
4 changes: 2 additions & 2 deletions src/install/prune.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -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;
};
Expand Down
49 changes: 47 additions & 2 deletions test/cli/install/bun-remove.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,22 +349,28 @@ const localBin = (name: string) => ({
[`${name}/cli.js`]: "#!/usr/bin/env node\n",
});

async function run(dir: string, ...args: string[]) {
async function runWithEnv(extraEnv: Record<string, string>, 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",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
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"),
Expand Down Expand Up @@ -460,6 +466,45 @@ 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 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.
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 () => {
Expand Down
Loading