Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
9dadf729c3 fix(install): resolve symlink before uninstall in global link
When running `bun link -g <package>`, the source symlink and destination
could be in the same directory (~/.bun/install/global/node_modules/). The
previous code order would delete the symlink via uninstallBeforeInstall()
before resolving its realpath, causing a FileNotFound error.

This fix moves the realpath resolution before the uninstall step to ensure
we capture the target path while the symlink still exists.

Fixes #25746

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:02:58 +00:00
2 changed files with 99 additions and 3 deletions

View File

@@ -1239,9 +1239,6 @@ pub const PackageInstall = struct {
pub fn installFromLink(this: *@This(), skip_delete: bool, destination_dir: std.fs.Dir) Result {
const dest_path = this.destination_dir_subpath;
// If this fails, we don't care.
// we'll catch it the next error
if (!skip_delete and !strings.eqlComptime(dest_path, ".")) this.uninstallBeforeInstall(destination_dir);
const subdir = std.fs.path.dirname(dest_path);
@@ -1249,9 +1246,17 @@ pub const PackageInstall = struct {
// cache_dir_subpath in here is actually the full path to the symlink pointing to the linked package
const symlinked_path = this.cache_dir_subpath;
var to_buf: bun.PathBuffer = undefined;
// Resolve realpath BEFORE uninstall, because when linking a package globally
// (e.g. `bun link -g my-cli`), the source symlink and destination may be in
// the same directory (~/.bun/install/global/node_modules/). In that case,
// uninstallBeforeInstall would delete the symlink we need to read from.
const to_path = this.cache_dir.realpath(symlinked_path, &to_buf) catch |err|
return Result.fail(err, .linking_dependency, @errorReturnTrace());
// If this fails, we don't care.
// we'll catch it the next error
if (!skip_delete and !strings.eqlComptime(dest_path, ".")) this.uninstallBeforeInstall(destination_dir);
const dest = std.fs.path.basename(dest_path);
// When we're linking on Windows, we want to avoid keeping the source directory handle open
if (comptime Environment.isWindows) {

View File

@@ -0,0 +1,91 @@
// https://github.com/oven-sh/bun/issues/25746
// `bun link -g <package>` creates a broken symlink because the installation
// process deletes the source symlink before reading from it.
import { spawn } from "bun";
import { afterEach, beforeEach, expect, it } from "bun:test";
import { mkdir, writeFile } from "fs/promises";
import { bunEnv, bunExe, stderrForInstall, tempDir } from "harness";
import { join } from "path";
let link_dir: string;
let globalDir: string;
let originalHome: string | undefined;
beforeEach(async () => {
link_dir = tempDir("bun-link-global", {});
globalDir = tempDir("bun-global-dir", {});
originalHome = bunEnv.HOME;
// Override HOME so bun link uses our test global directory
bunEnv.HOME = globalDir;
});
afterEach(async () => {
if (originalHome !== undefined) {
bunEnv.HOME = originalHome;
}
});
it("should successfully link a package globally with bun link -g", async () => {
// Create a test package with a bin entry
await mkdir(join(link_dir, "bin"), { recursive: true });
await writeFile(
join(link_dir, "package.json"),
JSON.stringify({
name: "my-test-cli",
version: "1.0.0",
bin: {
"my-test-cli": "./bin/cli.js",
},
}),
);
await writeFile(join(link_dir, "bin", "cli.js"), '#!/usr/bin/env node\nconsole.log("hello from my-test-cli");');
// Step 1: Register the package with `bun link`
const proc1 = spawn({
cmd: [bunExe(), "link"],
cwd: link_dir,
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const [stdout1, stderr1, exitCode1] = await Promise.all([
new Response(proc1.stdout).text(),
new Response(proc1.stderr).text(),
proc1.exited,
]);
expect(stderrForInstall(stderr1).split(/\r?\n/)).toEqual([""]);
expect(stdout1).toContain('Success! Registered "my-test-cli"');
expect(exitCode1).toBe(0);
// Step 2: Link globally with `bun link -g my-test-cli`
// This was failing with "FileNotFound: failed linking dependency/workspace to node_modules for package my-test-cli"
const proc2 = spawn({
cmd: [bunExe(), "link", "-g", "my-test-cli"],
cwd: link_dir,
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const [stdout2, stderr2, exitCode2] = await Promise.all([
new Response(proc2.stdout).text(),
new Response(proc2.stderr).text(),
proc2.exited,
]);
// The install should succeed without FileNotFound errors
const stderrFiltered = stderrForInstall(stderr2);
expect(stderrFiltered).not.toContain("FileNotFound");
expect(stderrFiltered).not.toContain("failed linking dependency");
expect(stdout2).toContain("installed my-test-cli@link:my-test-cli");
expect(exitCode2).toBe(0);
// Verify the global package can be read (symlink is valid)
const globalNodeModules = join(globalDir, ".bun", "install", "global", "node_modules");
const packageJson = await Bun.file(join(globalNodeModules, "my-test-cli", "package.json")).json();
expect(packageJson.name).toBe("my-test-cli");
expect(packageJson.version).toBe("1.0.0");
});