mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-22 21:53:02 -04:00
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:
6
.changeset/fix-exe-self-update-symlink.md
Normal file
6
.changeset/fix-exe-self-update-symlink.md
Normal 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.
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user