Conversation
…thout Node.js When pnpm is installed as a standalone executable in environments without a system Node.js (e.g. Docker containers), the `@pnpm/exe` preinstall script (`node setup.js`) fails because `node` is not on PATH. This broke version switching via the `packageManager` field in package.json since v10.30.2, which changed `getCurrentPackageName()` to return `@pnpm/exe` instead of platform-specific package names like `@pnpm/linux-x64`. Install with `--ignore-scripts` and link the platform-specific binary in-process instead. The setup logic is inlined because setup.js can't be loaded at runtime: `require()` fails on ESM (pnpm v11+) and `import()` is intercepted by pkg's virtual filesystem in standalone executables. Closes #10687 Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
Pull request overview
This PR fixes version switching via the packageManager field in package.json when pnpm is installed as a standalone executable in environments without a system Node.js installation (e.g., Docker containers). The issue (#10687) was introduced in v10.30.2 when getCurrentPackageName() started returning @pnpm/exe for standalone executables, which caused the package's preinstall script (node setup.js) to fail because node wasn't available on PATH.
Changes:
- Replace
--allow-build=@pnpm/exewith--ignore-scriptsto skip all install scripts - Add
--config.strict-dep-builds=falseto prevent errors when scripts are ignored - Implement
linkExePlatformBinary()function to replicate the setup.js logic in-process without requiring external Node.js - Update changeset documenting the fix
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| tools/plugin-commands-self-updater/src/installPnpmToTools.ts | Adds in-process platform binary linking logic and modifies pnpm add command to use --ignore-scripts instead of allowing specific build scripts |
| .changeset/fix-exe-version-switch-no-nodejs.md | Changeset documenting the bug fix for version switching without Node.js |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
| } | ||
| fs.linkSync(src, dest) | ||
| fs.chmodSync(dest, 0o755) |
There was a problem hiding this comment.
The chmod operation runs on all platforms including Windows, but on Windows file permissions work differently (ACLs instead of Unix permissions) and fs.chmodSync has limited effect. Consider moving this line inside an OS check to skip it on Windows, similar to how the original setup.js doesn't include chmod at all. Alternatively, wrap it in a try-catch to handle potential errors on platforms where chmod might not work as expected.
| fs.chmodSync(dest, 0o755) | |
| if (platform !== 'win') { | |
| fs.chmodSync(dest, 0o755) | |
| } |
| const executable = platform === 'win' ? 'pnpm.exe' : 'pnpm' | ||
| const platformPkgDir = path.join(stageDir, 'node_modules', '@pnpm', `${platform}-${arch}`) |
There was a problem hiding this comment.
The executable name is hardcoded, but the original setup.js (pnpm/artifacts/exe/setup.js lines 16-17) reads it from the platform package's package.json bin field. While the current hardcoded values match what's in the packages today, reading from package.json would be more maintainable and robust if executable names ever change in the future. Consider reading the executable name from the platform package's package.json like the original implementation does.
| const executable = platform === 'win' ? 'pnpm.exe' : 'pnpm' | |
| const platformPkgDir = path.join(stageDir, 'node_modules', '@pnpm', `${platform}-${arch}`) | |
| const platformPkgDir = path.join(stageDir, 'node_modules', '@pnpm', `${platform}-${arch}`) | |
| let executable = platform === 'win' ? 'pnpm.exe' : 'pnpm' | |
| try { | |
| const platformPkgJsonPath = path.join(platformPkgDir, 'package.json') | |
| const platformPkgRaw = fs.readFileSync(platformPkgJsonPath, 'utf8') | |
| const platformPkg = JSON.parse(platformPkgRaw) as { bin?: string | Record<string, string> } | |
| const bin = platformPkg?.bin | |
| if (typeof bin === 'string') { | |
| executable = path.basename(bin) | |
| } else if (bin && typeof bin === 'object') { | |
| const binTargets = Object.values(bin) | |
| if (binTargets.length > 0 && typeof binTargets[0] === 'string') { | |
| executable = path.basename(binTargets[0] as string) | |
| } | |
| } | |
| } catch { | |
| // Fallback to the default hardcoded executable name if anything goes wrong. | |
| } |
| if (platform === 'win') { | ||
| const exePkgJsonPath = path.join(exePkgDir, 'package.json') | ||
| const exePkg = JSON.parse(fs.readFileSync(exePkgJsonPath, 'utf8')) | ||
| fs.writeFileSync(path.join(exePkgDir, 'pnpm'), 'This file intentionally left blank') |
There was a problem hiding this comment.
No validation that exePkg.bin exists before accessing exePkg.bin.pnpm on line 109. If the @pnpm/exe package.json is malformed or missing the bin field, this will throw "Cannot read property 'pnpm' of undefined" which is not very helpful for debugging. Consider adding validation like if (!exePkg.bin || !exePkg.bin.pnpm) with a clearer error message, or at least a comment explaining that this is expected to throw if the package structure is invalid.
| fs.writeFileSync(path.join(exePkgDir, 'pnpm'), 'This file intentionally left blank') | |
| fs.writeFileSync(path.join(exePkgDir, 'pnpm'), 'This file intentionally left blank') | |
| if (exePkg.bin == null) { | |
| exePkg.bin = {} | |
| } else if (typeof exePkg.bin !== 'object') { | |
| throw new Error(`Invalid "bin" field in ${exePkgJsonPath}: expected an object`) | |
| } |
| const executable = platform === 'win' ? 'pnpm.exe' : 'pnpm' | ||
| const platformPkgDir = path.join(stageDir, 'node_modules', '@pnpm', `${platform}-${arch}`) | ||
| const src = path.join(platformPkgDir, executable) | ||
| if (!fs.existsSync(src)) return |
There was a problem hiding this comment.
Silent return when platform binary doesn't exist could lead to incomplete installations. The original setup.js (pnpm/artifacts/exe/setup.js) would throw an error if the binary file doesn't exist (at line 19 when fs.linkSync tries to link a non-existent source file). The early return here makes the installation succeed even if the platform-specific binary is missing (e.g., if the platform package is corrupted). Consider throwing an error instead to make the installation fail explicitly, which would be consistent with the original behavior and make debugging easier.
| if (!fs.existsSync(src)) return | |
| if (!fs.existsSync(src)) { | |
| throw new Error(`Platform-specific pnpm binary not found at expected path: ${src}`) | |
| } |
…thout Node.js (#10696) When pnpm is installed as a standalone executable in environments without a system Node.js (e.g. Docker containers), the `@pnpm/exe` preinstall script (`node setup.js`) fails because `node` is not on PATH. This broke version switching via the `packageManager` field in package.json since v10.30.2, which changed `getCurrentPackageName()` to return `@pnpm/exe` instead of platform-specific package names like `@pnpm/linux-x64`. Install with `--ignore-scripts` and link the platform-specific binary in-process instead. The setup logic is inlined because setup.js can't be loaded at runtime: `require()` fails on ESM (pnpm v11+) and `import()` is intercepted by pkg's virtual filesystem in standalone executables. Closes #10687
When pnpm is installed as a standalone executable in environments without a system Node.js (e.g. Docker containers), the
@pnpm/exepreinstall script (node setup.js) fails becausenodeis not on PATH. This broke version switching via thepackageManagerfield in package.json since v10.30.2, which changedgetCurrentPackageName()to return@pnpm/exeinstead of platform-specific package names like@pnpm/linux-x64.Install with
--ignore-scriptsand run the setup script in-process usingcreateRequire, which uses the Node.js runtime already bundled in the standalone binary.Closes #10687