From 1e93a1d8c14ffedb5ed6b3b88695ae5476d495c4 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Wed, 6 May 2026 21:31:03 +0200 Subject: [PATCH] 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. --- .../test/self-updater/selfUpdate.test.ts | 48 +++++++++ pnpm-lock.yaml | 3 + pnpm/artifacts/exe/package.json | 1 + pnpm/artifacts/exe/test/setup.test.ts | 101 ++++++++++++++++++ 4 files changed, 153 insertions(+) diff --git a/engine/pm/commands/test/self-updater/selfUpdate.test.ts b/engine/pm/commands/test/self-updater/selfUpdate.test.ts index 14c7288f05..8a9377bd0c 100644 --- a/engine/pm/commands/test/self-updater/selfUpdate.test.ts +++ b/engine/pm/commands/test/self-updater/selfUpdate.test.ts @@ -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', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 664bdf3a7a..ce3b75299f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7909,6 +7909,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 diff --git a/pnpm/artifacts/exe/package.json b/pnpm/artifacts/exe/package.json index a71e05f1c5..5ac4cce229 100644 --- a/pnpm/artifacts/exe/package.json +++ b/pnpm/artifacts/exe/package.json @@ -49,6 +49,7 @@ "@jest/globals": "catalog:", "@pnpm/exe": "workspace:*", "@pnpm/jest-config": "workspace:*", + "@zkochan/cmd-shim": "catalog:", "execa": "catalog:" }, "pnpm": { diff --git a/pnpm/artifacts/exe/test/setup.test.ts b/pnpm/artifacts/exe/test/setup.test.ts index 04012551f9..d7082ff941 100644 --- a/pnpm/artifacts/exe/test/setup.test.ts +++ b/pnpm/artifacts/exe/test/setup.test.ts @@ -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,103 @@ 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 on Linux; symlink the real + // package so the resolver finds it from the sandbox. + fs.symlinkSync( + path.join(exeDir, 'node_modules', 'detect-libc'), + path.join(sandbox, 'node_modules', 'detect-libc'), + '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) + } +}) + +winSetupTest('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, banner: /Microsoft Windows/.test(result.stdout + result.stderr) }) + .toEqual({ alias, banner: false }) + expect({ alias, stdout: result.stdout }) + .toEqual({ alias, stdout: `${alias}_OK` }) + } +})