fix: resolve @pnpm/exe platform binary in symlinked node_modules layout (#10971)

When running `pnpm self-update` with `@pnpm/exe`, `linkExePlatformBinary`
looked for the platform binary (e.g. `@pnpm/macos-arm64`) at the top level
of `node_modules`. In pnpm's symlinked layout, the platform package is not
hoisted — it only exists as a sibling of `@pnpm/exe` in the virtual store.

The fix resolves through the `@pnpm/exe` symlink to find the platform
binary as a sibling, which works for both symlinked and flat layouts.
This commit is contained in:
Zoltan Kochan
2026-03-15 15:18:46 +01:00
committed by GitHub
parent 57101e105c
commit 253858d6a7
4 changed files with 105 additions and 5 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/tools.plugin-commands-self-updater": patch
"pnpm": patch
---
Fixed `pnpm self-update` breaking when running `@pnpm/exe`. The platform binary (e.g., `@pnpm/macos-arm64`) was not found in pnpm's symlinked `node_modules` layout because it was looked up at the top level instead of as a sibling of `@pnpm/exe` in the virtual store.

View File

@@ -1,4 +1,4 @@
import * as selfUpdate from './selfUpdate.js'
export { installPnpm, installPnpmToStore } from './installPnpm.js'
export { installPnpm, installPnpmToStore, linkExePlatformBinary } from './installPnpm.js'
export { selfUpdate }

View File

@@ -308,18 +308,23 @@ async function installFromResolution (
// Its postinstall script links the correct binary into the @pnpm/exe package dir.
// Since scripts are disabled during install (to support systems without Node.js),
// we replicate that linking here.
function linkExePlatformBinary (installDir: string): void {
export function linkExePlatformBinary (installDir: string): void {
const platform = process.platform === 'win32'
? 'win'
: process.platform === 'darwin'
? 'macos'
: process.platform
const arch = platform === 'win' && process.arch === 'ia32' ? 'x86' : process.arch
const exePkgDir = path.join(installDir, 'node_modules', '@pnpm', 'exe')
if (!fs.existsSync(exePkgDir)) return
// In pnpm's symlinked node_modules layout, the platform package is not hoisted
// to the top-level node_modules. It's a dependency of @pnpm/exe and lives as a
// sibling in the virtual store. Resolve through the @pnpm/exe symlink to find it.
const exeRealDir = fs.realpathSync(exePkgDir)
const platformPkgDir = path.join(path.dirname(exeRealDir), `${platform}-${arch}`)
const executable = platform === 'win' ? 'pnpm.exe' : 'pnpm'
const platformPkgDir = path.join(installDir, 'node_modules', '@pnpm', `${platform}-${arch}`)
const src = path.join(platformPkgDir, executable)
if (!fs.existsSync(src)) return
const exePkgDir = path.join(installDir, 'node_modules', '@pnpm', 'exe')
const dest = path.join(exePkgDir, executable)
try {
fs.unlinkSync(dest)

View File

@@ -21,7 +21,7 @@ jest.unstable_mockModule('@pnpm/cli-meta', () => {
},
}
})
const { selfUpdate, installPnpm } = await import('@pnpm/tools.plugin-commands-self-updater')
const { selfUpdate, installPnpm, linkExePlatformBinary } = await import('@pnpm/tools.plugin-commands-self-updater')
afterEach(() => {
nock.cleanAll()
@@ -509,3 +509,92 @@ test('installPnpm without env lockfile uses resolution path', async () => {
expect(pnpmPkgJson.version).toBe('9.1.0')
expect(fs.existsSync(result.binDir)).toBe(true)
})
describe('linkExePlatformBinary', () => {
const platform = process.platform === 'win32'
? 'win'
: process.platform === 'darwin'
? 'macos'
: process.platform
const arch = platform === 'win' && process.arch === 'ia32' ? 'x86' : process.arch
const executable = platform === 'win' ? 'pnpm.exe' : 'pnpm'
const platformPkgName = `${platform}-${arch}`
test('links platform binary in pnpm symlinked node_modules layout', () => {
const dir = tempDir(false)
// Create a virtual store layout like pnpm produces:
// .pnpm/@pnpm+exe@1.0.0/node_modules/@pnpm/exe/ (the real @pnpm/exe dir)
// .pnpm/@pnpm+exe@1.0.0/node_modules/@pnpm/<platform>-<arch>/ (platform binary)
// node_modules/@pnpm/exe -> symlink to the virtual store entry
const vsExeDir = path.join(dir, 'node_modules', '.pnpm', '@pnpm+exe@1.0.0', 'node_modules', '@pnpm', 'exe')
const vsPlatformDir = path.join(dir, 'node_modules', '.pnpm', '@pnpm+exe@1.0.0', 'node_modules', '@pnpm', platformPkgName)
const topLevelExeDir = path.join(dir, 'node_modules', '@pnpm', 'exe')
// Create the virtual store directories
fs.mkdirSync(vsExeDir, { recursive: true })
fs.mkdirSync(vsPlatformDir, { recursive: true })
// Write the placeholder file (as published in the @pnpm/exe tarball)
fs.writeFileSync(path.join(vsExeDir, executable), 'This file intentionally left blank')
// Write a fake platform binary
const fakeBinaryContent = '#!/bin/sh\necho "fake pnpm binary"'
fs.writeFileSync(path.join(vsPlatformDir, executable), fakeBinaryContent)
// Create the top-level symlink: node_modules/@pnpm/exe -> virtual store
fs.mkdirSync(path.join(dir, 'node_modules', '@pnpm'), { recursive: true })
fs.symlinkSync(vsExeDir, topLevelExeDir)
// Run the function
linkExePlatformBinary(dir)
// The placeholder should be replaced with the platform binary content
const result = fs.readFileSync(path.join(topLevelExeDir, executable), 'utf8')
expect(result).toBe(fakeBinaryContent)
})
test('also works with flat node_modules layout', () => {
const dir = tempDir(false)
// In a flat layout (no symlinks), both packages are at the top level
const exeDir = path.join(dir, 'node_modules', '@pnpm', 'exe')
const platformDir = path.join(dir, 'node_modules', '@pnpm', platformPkgName)
fs.mkdirSync(exeDir, { recursive: true })
fs.mkdirSync(platformDir, { recursive: true })
fs.writeFileSync(path.join(exeDir, executable), 'This file intentionally left blank')
const fakeBinaryContent = '#!/bin/sh\necho "fake pnpm binary"'
fs.writeFileSync(path.join(platformDir, executable), fakeBinaryContent)
linkExePlatformBinary(dir)
const result = fs.readFileSync(path.join(exeDir, executable), 'utf8')
expect(result).toBe(fakeBinaryContent)
})
test('does nothing when @pnpm/exe is not installed', () => {
const dir = tempDir(false)
fs.mkdirSync(path.join(dir, 'node_modules'), { recursive: true })
// Should not throw
linkExePlatformBinary(dir)
})
test('does nothing when platform binary is not available', () => {
const dir = tempDir(false)
const exeDir = path.join(dir, 'node_modules', '@pnpm', 'exe')
fs.mkdirSync(exeDir, { recursive: true })
const placeholder = 'This file intentionally left blank'
fs.writeFileSync(path.join(exeDir, executable), placeholder)
linkExePlatformBinary(dir)
// Placeholder should remain unchanged
const result = fs.readFileSync(path.join(exeDir, executable), 'utf8')
expect(result).toBe(placeholder)
})
})