fix: handle dangling symlink when linking node binary (#10972)

existsSync() follows symlinks and returns false for broken symlinks,
so a dangling symlink from a previous node install would not be removed
before creating the new symlink, causing an EEXIST error.

Use rimraf unconditionally instead, which handles both existing files
and non-existent paths gracefully.
This commit is contained in:
Zoltan Kochan
2026-03-15 22:43:59 +01:00
committed by GitHub
parent 09a999af04
commit 6e9cad3a47
3 changed files with 47 additions and 7 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/link-bins": patch
"pnpm": patch
---
Fixed `EEXIST` error when globally installing `node` while a dangling symlink exists from a previous installation.

View File

@@ -310,13 +310,10 @@ async function linkBin (cmd: CommandInfo, binsDir: string, opts?: LinkBinOptions
} else if (cmd.name === 'node') {
// On non-Windows, node should be symlinked directly to the binary
// instead of wrapped in a shell shim.
try {
if (existsSync(externalBinPath)) {
await rimraf(externalBinPath)
}
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
}
// Use rimraf unconditionally instead of existsSync check, because
// existsSync follows symlinks and returns false for broken symlinks,
// causing EEXIST when the dangling symlink still exists on disk.
await rimraf(externalBinPath)
await fs.symlink(cmd.path, externalBinPath, 'file')
return
}

View File

@@ -630,6 +630,43 @@ describe('node binary linking', () => {
expect(stat.isSymbolicLink()).toBe(true)
expect(fs.realpathSync(binLocation)).toBe(path.join(nodeBinDir, 'node'))
})
test('linkBinsOfPackages() replaces a dangling symlink when linking node binary', async () => {
const binTarget = temporaryDirectory()
const nodeDir = temporaryDirectory()
const nodeBinDir = path.join(nodeDir, 'bin')
fs.mkdirSync(nodeBinDir, { recursive: true })
fs.writeFileSync(path.join(nodeBinDir, 'node'), 'fake-node-binary', 'utf8')
// Create a dangling symlink at the target path (simulates a previous
// node install whose store entry was removed).
const binLocation = path.join(binTarget, 'node')
fs.mkdirSync(binTarget, { recursive: true })
const danglingTarget = path.join(temporaryDirectory(), 'non-existent-target')
fs.symlinkSync(danglingTarget, binLocation)
// Verify it's dangling: lstat succeeds but existsSync returns false
expect(fs.lstatSync(binLocation).isSymbolicLink()).toBe(true)
expect(fs.existsSync(binLocation)).toBe(false)
await linkBinsOfPackages(
[
{
location: nodeDir,
manifest: {
name: 'node',
version: '20.0.0',
bin: { node: 'bin/node' },
},
},
],
binTarget
)
const stat = fs.lstatSync(binLocation)
expect(stat.isSymbolicLink()).toBe(true)
expect(fs.realpathSync(binLocation)).toBe(path.join(nodeBinDir, 'node'))
})
}
testOnWindows('linkBinsOfPackages() hardlinks node.exe instead of creating a cmd-shim', async () => {