fix(exe): route pn/pnpx/pnx through .exe hardlinks on Windows (#11486) (#11501)

* test(exe): add Windows-only repro for #11486 (pn/pnpx/pnx aliases)

Captures the user-reported failure on a fresh Windows CI: when the
@pnpm/exe install rewrites bin entries to point at .cmd files,
@zkochan/cmd-shim's Bash shim does `exec cmd /C ...target.cmd`, MSYS2
mangles the lone `/C` into a Windows path, and cmd.exe falls into
interactive mode (printing its banner instead of running the alias).

These tests will fail on `windows-latest` until the follow-up commit
points the bin entries at .exe hardlinks of the SEA binary.

* fix(exe): route pn/pnpx/pnx through .exe hardlinks on Windows (#11486)

The @pnpm/exe install rewrote bin to point pn/pnpx/pnx at .cmd files,
which cmd-shim wraps as `exec cmd /C ...target.cmd "$@"` in its Bash
shim. MSYS2 / Git Bash mangles the lone `/C` into a Windows path
before cmd.exe sees it, so cmd.exe finds no /C or /K and falls into
interactive mode — the user sees its banner instead of `pnpm dlx`.

Hardlink pn.exe / pnpx.exe / pnx.exe to the SEA pnpm.exe (in setup.js
preinstall and in self-update's linkExePlatformBinary) and rewrite
those bin entries to the .exe names. cmd-shim emits a direct exec for
.exe sources, taking cmd.exe out of the chain entirely. The SEA reads
process.execPath's basename and prepends `dlx` when launched as
pnpx / pnx.

* test(exe): make Windows alias tests robust to local-dev environments

Two follow-ups from Copilot review on #11501:

* Use `'junction'` instead of `'dir'` for the detect-libc symlink on
  Windows. Non-junction directory symlinks need Developer Mode or
  admin, which the existing failure-path tests already skip on Windows
  for; junctions don't.
* Probe \`bash --version\` before running the Git Bash / MSYS2 alias
  test, and skip cleanly if it isn't on PATH (local Windows dev
  machines often lack it; CI windows-latest ships it). Fold the status
  check into the assertion so a non-zero exit surfaces in the diff.

* test(exe): wire @pnpm/exe into the recursive test runner

The setup.test.ts in this package wasn't running in CI — `@pnpm/exe`
had no `.test` script, so `pn -r .test` (what `test-pkgs-all` runs)
silently skipped it. The existing tests there have apparently been
dead since they were added; the Windows alias repro added in 1e93a1d
inherited the same gap.

Add `.test` (jest invocation, matching every other workspace
package's shape) and a `test` alias so it's picked up by the
recursive runner. meta-updater's @pnpm/exe / artifacts branch
short-circuits before adding test scripts; preserve that behavior by
hand-writing them rather than restructuring the rule.
This commit is contained in:
Zoltan Kochan
2026-05-06 22:45:44 +02:00
parent cc0b30ea98
commit d0982fcad1
8 changed files with 234 additions and 8 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/engine.pm.commands": patch
"pnpm": patch
---
Fixed the `pn`, `pnpx`, and `pnx` aliases failing in Git Bash / MSYS2 on Windows when pnpm was installed via `@pnpm/exe` (or after `pnpm self-update`) [#11486](https://github.com/pnpm/pnpm/issues/11486). Running `pnpx` (or `pnx`) printed the cmd.exe banner and dropped the user into an interactive command prompt instead of running `pnpm dlx`. The `bin` field rewrite on Windows was pointing those aliases at `.cmd` files; cmd-shim's Bash shim for a `.cmd` target wraps it in `exec cmd /C ...`, and MSYS2 mangles `/C` into a Windows path before cmd.exe sees it. The aliases are now `.exe` hardlinks of the SEA binary, which detects which name it was launched as via `process.execPath` and prepends `dlx` for `pnpx` / `pnx`.

View File

@@ -397,12 +397,24 @@ export function linkExePlatformBinary (installDir: string): void {
forceLink(src, dest)
if (platform === 'win32') {
// Aliases (pn / pnpx / pnx) need to be .exe hardlinks of the SEA binary,
// not the .cmd wrappers we ship in the tarball. cmd-shim's Bash shim for
// a .cmd target wraps it in `exec cmd /C ...`, and MSYS2 / Git Bash
// mangles `/C` into a Windows path — cmd.exe then falls into interactive
// mode and prints its banner instead of running the alias. .exe sources
// sidestep cmd-shim's wrapper. The SEA binary detects which name it was
// launched as via process.execPath and prepends `dlx` for pnpx / pnx.
// See https://github.com/pnpm/pnpm/issues/11486.
for (const alias of ['pn', 'pnpx', 'pnx']) {
forceLink(src, path.join(exePkgDir, `${alias}.exe`))
}
const exePkgJsonPath = path.join(exePkgDir, 'package.json')
const exePkg = JSON.parse(fs.readFileSync(exePkgJsonPath, 'utf8'))
exePkg.bin.pnpm = 'pnpm.exe'
exePkg.bin.pn = 'pn.cmd'
exePkg.bin.pnpx = 'pnpx.cmd'
exePkg.bin.pnx = 'pnx.cmd'
exePkg.bin.pn = 'pn.exe'
exePkg.bin.pnpx = 'pnpx.exe'
exePkg.bin.pnx = 'pnx.exe'
fs.writeFileSync(exePkgJsonPath, JSON.stringify(exePkg, null, 2))
}
}

View File

@@ -908,6 +908,54 @@ describe('linkExePlatformBinary', () => {
const result = fs.readFileSync(path.join(exeDir, executable), 'utf8')
expect(result).toBe(fakeBinaryContent)
})
// Regression coverage for https://github.com/pnpm/pnpm/issues/11486 — the
// `pn` / `pnpx` / `pnx` aliases were broken in MSYS2 / Git Bash on Windows.
// Root cause: linkExePlatformBinary pointed those bin entries at .cmd files,
// and @zkochan/cmd-shim's Bash shim for a .cmd source bounces through
// `exec cmd /C "...target.cmd" "$@"`. MSYS2's argument-conversion runtime
// mangles the lone `/C` switch into a Windows path before cmd.exe sees it,
// so cmd.exe finds no /C or /K and falls into interactive mode (printing its
// banner instead of running the alias). Routing the aliases through .exe
// hardlinks of the SEA binary takes cmd.exe out of the chain entirely.
const winOnlyTest = platform === 'win32' ? test : test.skip
winOnlyTest('rewrites bin to .exe entries and hardlinks pn/pnpx/pnx aliases to pnpm.exe (issue #11486)', () => {
const dir = tempDir(false)
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')
// Match the published bin field from pnpm/artifacts/exe/package.json
fs.writeFileSync(path.join(exeDir, 'package.json'), JSON.stringify({
bin: { pnpm: 'pnpm', pn: 'pn', pnpx: 'pnpx', pnx: 'pnx' },
}))
// The platform binary needs to be a real file so fs.linkSync can hardlink
// it. Content doesn't matter.
fs.writeFileSync(path.join(platformDir, executable), 'fake-pnpm-exe')
linkExePlatformBinary(dir)
const rewritten = JSON.parse(fs.readFileSync(path.join(exeDir, 'package.json'), 'utf8'))
expect(rewritten.bin).toEqual({
pnpm: 'pnpm.exe',
pn: 'pn.exe',
pnpx: 'pnpx.exe',
pnx: 'pnx.exe',
})
const pnpmIno = fs.statSync(path.join(exeDir, 'pnpm.exe')).ino
for (const name of ['pn', 'pnpx', 'pnx']) {
const aliasPath = path.join(exeDir, `${name}.exe`)
expect(fs.existsSync(aliasPath)).toBe(true)
// Hardlinked to pnpm.exe, so the SEA's argv[0] basename detection can
// tell `pnpx` apart from `pnpm` and inject `dlx` accordingly.
expect(fs.statSync(aliasPath).ino).toBe(pnpmIno)
}
})
})
describe('exePlatformPkgDirName', () => {

3
pnpm-lock.yaml generated
View File

@@ -7869,6 +7869,9 @@ importers:
'@pnpm/jest-config':
specifier: workspace:*
version: link:../../../__utils__/jest-config
'@zkochan/cmd-shim':
specifier: 'catalog:'
version: 9.0.2
execa:
specifier: 'catalog:'
version: safe-execa@0.3.0

View File

@@ -30,7 +30,9 @@
"scripts": {
"preinstall": "node setup.js",
"prepare": "node prepare.js",
"build-artifacts": "pn --filter=pnpm prepublishOnly && node ./scripts/build-artifacts.ts"
"build-artifacts": "pn --filter=pnpm prepublishOnly && node ./scripts/build-artifacts.ts",
".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest",
"test": "pn .test"
},
"dependencies": {
"@reflink/reflink": "catalog:",
@@ -49,6 +51,7 @@
"@jest/globals": "catalog:",
"@pnpm/exe": "workspace:*",
"@pnpm/jest-config": "workspace:*",
"@zkochan/cmd-shim": "catalog:",
"execa": "catalog:"
},
"pnpm": {

View File

@@ -71,12 +71,26 @@ if (platform === 'win32') {
// be the real binary so the shim can execute it.
linkSync(bin, path.resolve(ownDir, 'pnpm'))
// Aliases (pn / pnpx / pnx) need to be .exe hardlinks of the SEA binary,
// not the .cmd / .ps1 wrappers we ship in the tarball. Reason: in MSYS2 /
// Git Bash, cmd-shim's Bash shim for a .cmd target does
// exec cmd /C "...target.cmd" "$@"
// and MSYS2 mangles the lone `/C` switch into a Windows path before
// cmd.exe sees it — cmd.exe then finds no /C or /K and falls into
// interactive mode, printing its banner instead of the alias. .exe
// sources sidestep cmd-shim's wrapper. The SEA binary detects which name
// it was launched as via process.execPath and prepends `dlx` for
// pnpx / pnx. See https://github.com/pnpm/pnpm/issues/11486.
for (const alias of ['pn', 'pnpx', 'pnx']) {
linkSync(bin, path.resolve(ownDir, `${alias}.exe`))
}
const pkgJsonPath = path.resolve(ownDir, 'package.json')
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
pkg.bin.pnpm = 'pnpm.exe'
pkg.bin.pn = 'pn.cmd'
pkg.bin.pnpx = 'pnpx.cmd'
pkg.bin.pnx = 'pnx.cmd'
pkg.bin.pn = 'pn.exe'
pkg.bin.pnpx = 'pnpx.exe'
pkg.bin.pnx = 'pnx.exe'
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2))
}

View File

@@ -4,6 +4,7 @@ import os from 'node:os'
import path from 'node:path'
import { describe, expect, test } from '@jest/globals'
import { cmdShim } from '@zkochan/cmd-shim'
import { familySync } from 'detect-libc'
// @ts-expect-error — JS helper without type declarations
@@ -144,3 +145,124 @@ failurePathTest('setup.js exits 1 with the missing platform package name when ru
// assert on that.
expect(result.stderr).toContain(expectedPkgName === '@pnpm/macos-x64' ? '11423' : expectedPkgName)
})
// Build a sandboxed @pnpm/exe install with a real .exe playing the part of
// pnpm.exe (we use the running node binary — setup.js only hardlinks it) and
// run setup.js. Returns the sandbox directory.
function buildWinSetupSandbox (): string {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-exe-fix11486-'))
fs.copyFileSync(path.join(exeDir, 'setup.js'), path.join(sandbox, 'setup.js'))
fs.copyFileSync(path.join(exeDir, 'prepare.js'), path.join(sandbox, 'prepare.js'))
fs.copyFileSync(path.join(exeDir, 'platform-pkg-name.js'), path.join(sandbox, 'platform-pkg-name.js'))
fs.writeFileSync(path.join(sandbox, 'package.json'), JSON.stringify({
name: '@pnpm/exe',
type: 'module',
bin: { pnpm: 'pnpm', pn: 'pn', pnpx: 'pnpx', pnx: 'pnx' },
}))
const platformPkgName = exePlatformPkgName(platform, process.arch, familySync())
const platformDir = path.join(sandbox, 'node_modules', platformPkgName)
fs.mkdirSync(platformDir, { recursive: true })
fs.writeFileSync(path.join(platformDir, 'package.json'), JSON.stringify({
name: platformPkgName, version: '0.0.0',
}))
// Hardlink the test's own node.exe as the platform binary. setup.js then
// hardlinks it again into the sandbox @pnpm/exe dir; downstream tests can
// invoke the resulting `pnpx.exe` (etc.) and assert the alias actually ran.
fs.linkSync(process.execPath, path.join(platformDir, 'pnpm.exe'))
// platform-pkg-name.js calls into detect-libc; make the package resolvable
// from the sandbox. On Windows, use a junction — non-junction directory
// symlinks require Developer Mode or admin privileges, which Windows CI and
// most local Windows dev setups don't have. (See the failure-path tests
// higher in this file: they skip on Windows for the same reason.)
fs.symlinkSync(
path.join(exeDir, 'node_modules', 'detect-libc'),
path.join(sandbox, 'node_modules', 'detect-libc'),
isWindows ? 'junction' : 'dir'
)
execFileSync(process.execPath, [path.join(sandbox, 'prepare.js')], { cwd: sandbox })
execFileSync(process.execPath, [path.join(sandbox, 'setup.js')], { cwd: sandbox })
return sandbox
}
const winSetupTest = isWindows ? test : test.skip
// Regression coverage for https://github.com/pnpm/pnpm/issues/11486.
// See the matching describe block in
// engine/pm/commands/test/self-updater/selfUpdate.test.ts for the full
// rationale; this one covers the @pnpm/exe preinstall path that handles
// fresh `npm install -g @pnpm/exe`.
winSetupTest('setup.js (Windows) rewrites bin to .exe entries and hardlinks pn/pnpx/pnx aliases (issue #11486)', () => {
const sandbox = buildWinSetupSandbox()
const pkg = JSON.parse(fs.readFileSync(path.join(sandbox, 'package.json'), 'utf8'))
expect(pkg.bin).toEqual({
pnpm: 'pnpm.exe',
pn: 'pn.exe',
pnpx: 'pnpx.exe',
pnx: 'pnx.exe',
})
const pnpmIno = fs.statSync(path.join(sandbox, 'pnpm.exe')).ino
for (const name of ['pn', 'pnpx', 'pnx']) {
const aliasPath = path.join(sandbox, `${name}.exe`)
expect(fs.existsSync(aliasPath)).toBe(true)
expect(fs.statSync(aliasPath).ino).toBe(pnpmIno)
}
})
// The Bash-shim end-to-end repro depends on Git Bash / MSYS2. CI runners
// (windows-latest) ship Git Bash on PATH, but local Windows dev machines
// often don't, so probe before running and skip the test cleanly otherwise
// (rather than spawning bash and getting an opaque ENOENT).
const bashAvailable = (() => {
if (!isWindows) return false
const probe = spawnSync('bash', ['--version'], { encoding: 'utf8', timeout: 5_000 })
return probe.status === 0
})()
const winBashTest = bashAvailable ? test : test.skip
winBashTest('aliases run from Bash (Git Bash / MSYS2) without dropping into interactive cmd.exe (issue #11486)', async () => {
const sandbox = buildWinSetupSandbox()
const pkg = JSON.parse(fs.readFileSync(path.join(sandbox, 'package.json'), 'utf8'))
// Mirror what `pnpm self-update` does in the global bin: feed each bin
// entry into @zkochan/cmd-shim and let it write the Bash / cmd / pwsh
// shims. Using cmd-shim here (the same lib pnpm's bin linker uses) is what
// lets this repro the real-world chain rather than just asserting the
// package.json shape.
const binDir = path.join(sandbox, 'global-bin')
await Promise.all(Object.entries(pkg.bin).map(([name, target]) =>
cmdShim(path.join(sandbox, target as string), path.join(binDir, name), { createPwshFile: true })
))
for (const alias of ['pn', 'pnpx', 'pnx']) {
const shim = path.join(binDir, alias).replace(/\\/g, '/')
// The shim's target is hardlinked to node.exe in this test (it's the
// SEA pnpm.exe in production), so `-e "..."` lets us assert the alias
// really ran our snippet — a successful assertion implies the cmd.exe
// hop got bypassed.
const result = spawnSync('bash', ['-c', `'${shim}' -e "process.stdout.write('${alias}_OK')"`], {
encoding: 'utf8',
timeout: 30_000,
})
// Pre-fix symptom: cmd-shim's Bash shim for a .cmd target does
// `exec cmd /C ...`. MSYS2 mangles `/C` into a Windows path before
// cmd.exe sees it; cmd.exe finds no /C or /K and falls into interactive
// mode, printing its banner instead of running the alias.
expect({
alias,
status: result.status,
banner: /Microsoft Windows/.test(result.stdout + result.stderr),
stdout: result.stdout,
}).toEqual({
alias,
status: 0,
banner: false,
stdout: `${alias}_OK`,
})
}
})

View File

@@ -1,9 +1,11 @@
'use strict'
import path from 'node:path'
// Avoid "Possible EventEmitter memory leak detected" warnings
// because it breaks pnpm's CLI output
process.setMaxListeners(0)
const argv = process.argv.slice(2)
const argv = buildArgv()
; (async () => {
await runPnpm()
@@ -18,3 +20,19 @@ async function runPnpm (): Promise<void> {
await errorHandler(err)
}
}
// Resolve `pnpx` / `pnx` aliases of the SEA binary. On Windows the @pnpm/exe
// install hardlinks pnpx.exe / pnx.exe to pnpm.exe; when invoked via either
// of those names, `process.execPath` reflects the launch path, so we read its
// basename and prepend `dlx` to argv. The non-SEA entry points (bin/pnpx.mjs,
// shell scripts in pnpm setup) inject `dlx` themselves before reaching this
// file, so this only triggers for the SEA path. See
// https://github.com/pnpm/pnpm/issues/11486.
function buildArgv (): string[] {
const userArgv = process.argv.slice(2)
const execName = path.basename(process.execPath, path.extname(process.execPath)).toLowerCase()
if (execName === 'pnpx' || execName === 'pnx') {
return ['dlx', ...userArgv]
}
return userArgv
}