mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-22 21:53:02 -04:00
* 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:
6
.changeset/exe-windows-aliases.md
Normal file
6
.changeset/exe-windows-aliases.md
Normal 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`.
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user