diff --git a/dist/index.js b/dist/index.js index 0ef9ee8..d7396a0 100644 Binary files a/dist/index.js and b/dist/index.js differ diff --git a/src/install-pnpm/ensureAliasLinks.test.ts b/src/install-pnpm/ensureAliasLinks.test.ts index 7e23b62..f4b7d9a 100644 --- a/src/install-pnpm/ensureAliasLinks.test.ts +++ b/src/install-pnpm/ensureAliasLinks.test.ts @@ -12,16 +12,14 @@ async function createTempDir (): Promise { async function setupStandaloneFixture (binDir: string): Promise { const exeDir = path.join(binDir, '..', '@pnpm', 'exe') await mkdir(exeDir, { recursive: true }) - await writeFile(path.join(exeDir, 'pn'), '#!/bin/sh\necho pn\n', { mode: 0o755 }) - await writeFile(path.join(exeDir, 'pnpx'), '#!/bin/sh\necho pnpx\n', { mode: 0o755 }) - await writeFile(path.join(exeDir, 'pnx'), '#!/bin/sh\necho pnx\n', { mode: 0o755 }) + // Only the pnpm binary exists — pn/pnpx/pnx may not exist after self-update + await writeFile(path.join(exeDir, 'pnpm'), '#!/bin/sh\necho pnpm\n', { mode: 0o755 }) } async function setupNonStandaloneFixture (binDir: string): Promise { const pnpmBinDir = path.join(binDir, '..', 'pnpm', 'bin') await mkdir(pnpmBinDir, { recursive: true }) await writeFile(path.join(pnpmBinDir, 'pnpm.cjs'), 'console.log("pnpm")\n') - await writeFile(path.join(pnpmBinDir, 'pnpx.cjs'), 'console.log("pnpx")\n') } describe('ensureAliasLinks', () => { @@ -34,78 +32,85 @@ describe('ensureAliasLinks', () => { }) describe('standalone mode', () => { - it('creates symlinks on unix when targets exist', async () => { + it('creates pn as symlink to pnpm binary on unix', async () => { await setupStandaloneFixture(binDir) await ensureAliasLinks(binDir, true, 'linux') - expect(existsSync(path.join(binDir, 'pn'))).toBe(true) - expect(existsSync(path.join(binDir, 'pnpx'))).toBe(true) - expect(existsSync(path.join(binDir, 'pnx'))).toBe(true) - const pnTarget = await readlink(path.join(binDir, 'pn')) - expect(pnTarget).toBe(path.join('..', '@pnpm', 'exe', 'pn')) + expect(pnTarget).toBe(path.join('..', '@pnpm', 'exe', 'pnpm')) }) - it('creates .cmd and .ps1 shims on windows when targets exist', async () => { + it('creates pnpx and pnx as shell scripts calling pnpm dlx on unix', async () => { + await setupStandaloneFixture(binDir) + + await ensureAliasLinks(binDir, true, 'linux') + + for (const name of ['pnpx', 'pnx']) { + const content = await readFile(path.join(binDir, name), 'utf8') + expect(content).toContain('pnpm') + expect(content).toContain('dlx') + expect(content).toContain('exec') + } + }) + + it('creates .cmd and .ps1 shims on windows', async () => { await setupStandaloneFixture(binDir) await ensureAliasLinks(binDir, true, 'win32') - // Should create .cmd shims, not extensionless symlinks - expect(existsSync(path.join(binDir, 'pn.cmd'))).toBe(true) - expect(existsSync(path.join(binDir, 'pnx.cmd'))).toBe(true) - expect(existsSync(path.join(binDir, 'pn.ps1'))).toBe(true) - expect(existsSync(path.join(binDir, 'pnx.ps1'))).toBe(true) + // pn shims + const pnCmd = await readFile(path.join(binDir, 'pn.cmd'), 'utf8') + expect(pnCmd).toContain('pnpm') + expect(pnCmd).toContain('%*') + expect(pnCmd).not.toContain('dlx') - // Should not create extensionless symlinks on windows + const pnPs1 = await readFile(path.join(binDir, 'pn.ps1'), 'utf8') + expect(pnPs1).toContain('pnpm') + expect(pnPs1).toContain('@args') + + // pnpx/pnx shims call pnpm dlx + const pnpxCmd = await readFile(path.join(binDir, 'pnpx.cmd'), 'utf8') + expect(pnpxCmd).toContain('pnpm') + expect(pnpxCmd).toContain('dlx') + + // Should not create extensionless files on windows expect(existsSync(path.join(binDir, 'pn'))).toBe(false) - expect(existsSync(path.join(binDir, 'pnx'))).toBe(false) - - const cmdContent = await readFile(path.join(binDir, 'pn.cmd'), 'utf8') - expect(cmdContent).toContain(path.join('..', '@pnpm', 'exe', 'pn')) - expect(cmdContent).toContain('%*') - - const ps1Content = await readFile(path.join(binDir, 'pn.ps1'), 'utf8') - expect(ps1Content).toContain(path.join('..', '@pnpm', 'exe', 'pn')) - expect(ps1Content).toContain('@args') }) }) describe('non-standalone mode', () => { - it('creates symlinks on unix when targets exist', async () => { + it('creates pn as symlink to pnpm.cjs on unix', async () => { await setupNonStandaloneFixture(binDir) await ensureAliasLinks(binDir, false, 'linux') - expect(existsSync(path.join(binDir, 'pn'))).toBe(true) - expect(existsSync(path.join(binDir, 'pnpx'))).toBe(true) - expect(existsSync(path.join(binDir, 'pnx'))).toBe(true) - const pnTarget = await readlink(path.join(binDir, 'pn')) expect(pnTarget).toBe(path.join('..', 'pnpm', 'bin', 'pnpm.cjs')) - - // pnx should point to pnpx.cjs (same as pnpx) - const pnxTarget = await readlink(path.join(binDir, 'pnx')) - expect(pnxTarget).toBe(path.join('..', 'pnpm', 'bin', 'pnpx.cjs')) }) - it('creates .cmd shims on windows when targets exist', async () => { + it('creates pnpx/pnx scripts on unix', async () => { + await setupNonStandaloneFixture(binDir) + + await ensureAliasLinks(binDir, false, 'linux') + + const content = await readFile(path.join(binDir, 'pnpx'), 'utf8') + expect(content).toContain('pnpm.cjs') + expect(content).toContain('dlx') + }) + + it('creates .cmd shims on windows', async () => { await setupNonStandaloneFixture(binDir) await ensureAliasLinks(binDir, false, 'win32') - expect(existsSync(path.join(binDir, 'pn.cmd'))).toBe(true) - const cmdContent = await readFile(path.join(binDir, 'pn.cmd'), 'utf8') - expect(cmdContent).toContain(path.join('..', 'pnpm', 'bin', 'pnpm.cjs')) + expect(cmdContent).toContain(path.join('pnpm', 'bin', 'pnpm.cjs')) }) }) - describe('skips when targets do not exist', () => { - it('creates no links when target directory is empty (v10)', async () => { - // Don't create any fixture files — simulates pnpm v10 without aliases - + describe('skips when pnpm binary does not exist', () => { + it('creates no links on unix', async () => { await ensureAliasLinks(binDir, true, 'linux') expect(existsSync(path.join(binDir, 'pn'))).toBe(false) @@ -113,25 +118,23 @@ describe('ensureAliasLinks', () => { expect(existsSync(path.join(binDir, 'pnx'))).toBe(false) }) - it('creates no shims on windows when targets do not exist', async () => { + it('creates no shims on windows', async () => { await ensureAliasLinks(binDir, true, 'win32') expect(existsSync(path.join(binDir, 'pn.cmd'))).toBe(false) - expect(existsSync(path.join(binDir, 'pnx.cmd'))).toBe(false) }) }) - describe('overwrites existing broken links', () => { - it('replaces existing file with symlink on unix', async () => { + describe('overwrites existing broken shims', () => { + it('replaces npm broken shim with symlink on unix', async () => { await setupStandaloneFixture(binDir) - // Simulate npm's broken shim (points to .tools/ placeholder) - await writeFile(path.join(binDir, 'pn'), '#!/bin/sh\nexec broken\n') + // Simulate npm's broken shim pointing to .tools/ placeholder + await writeFile(path.join(binDir, 'pn'), '#!/bin/sh\nexec .tools/broken "$@"\n') await ensureAliasLinks(binDir, true, 'linux') - // Should be replaced with a symlink to the real target const target = await readlink(path.join(binDir, 'pn')) - expect(target).toBe(path.join('..', '@pnpm', 'exe', 'pn')) + expect(target).toBe(path.join('..', '@pnpm', 'exe', 'pnpm')) }) it('replaces existing .cmd shims on windows', async () => { @@ -141,7 +144,7 @@ describe('ensureAliasLinks', () => { await ensureAliasLinks(binDir, true, 'win32') const content = await readFile(path.join(binDir, 'pn.cmd'), 'utf8') - expect(content).toContain(path.join('..', '@pnpm', 'exe', 'pn')) + expect(content).toContain('pnpm') }) }) }) diff --git a/src/install-pnpm/ensureAliasLinks.ts b/src/install-pnpm/ensureAliasLinks.ts index 004f5e0..8221e3f 100644 --- a/src/install-pnpm/ensureAliasLinks.ts +++ b/src/install-pnpm/ensureAliasLinks.ts @@ -2,32 +2,16 @@ import { unlink, writeFile, symlink } from 'fs/promises' import { existsSync } from 'fs' import path from 'path' -interface AliasDefinition { - name: string - target: string +function shScript (command: string): string { + return `#!/bin/sh\nexec ${command} "$@"\n` } -function getAliases (standalone: boolean): AliasDefinition[] { - if (standalone) { - return [ - { name: 'pn', target: path.join('..', '@pnpm', 'exe', 'pn') }, - { name: 'pnpx', target: path.join('..', '@pnpm', 'exe', 'pnpx') }, - { name: 'pnx', target: path.join('..', '@pnpm', 'exe', 'pnx') }, - ] - } - return [ - { name: 'pn', target: path.join('..', 'pnpm', 'bin', 'pnpm.cjs') }, - { name: 'pnpx', target: path.join('..', 'pnpm', 'bin', 'pnpx.cjs') }, - { name: 'pnx', target: path.join('..', 'pnpm', 'bin', 'pnpx.cjs') }, - ] +function cmdShim (command: string): string { + return `@ECHO off\r\n${command} %*\r\n` } -function cmdShim (target: string): string { - return `@ECHO off\r\n"%~dp0\\${target}" %*\r\n` -} - -function pwshShim (target: string): string { - return `#!/usr/bin/env pwsh\n& "$PSScriptRoot\\${target}" @args\n` +function pwshShim (command: string): string { + return `#!/usr/bin/env pwsh\n${command} @args\n` } async function forceSymlink (target: string, linkPath: string): Promise { @@ -35,27 +19,52 @@ async function forceSymlink (target: string, linkPath: string): Promise { await symlink(target, linkPath) } +async function forceWriteFile (filePath: string, content: string, mode?: number): Promise { + try { await unlink(filePath) } catch {} + await writeFile(filePath, content, { mode }) +} + /** * Create pn/pnpx/pnx alias links in the bin directory. - * On Unix, creates symlinks. On Windows, creates .cmd and .ps1 shims. - * Only creates links when the target file actually exists (pnpm v11+). * - * Existing links are always replaced because npm may have created shims - * pointing to an isolated .tools/ copy that has stale placeholder files. + * pn is an alias for pnpm, so it symlinks (or shims) to the pnpm binary. + * pnpx/pnx are aliases for "pnpm dlx", created as shell scripts. + * + * This does NOT rely on the @pnpm/exe package having pn/pnx files, because + * pnpm self-update only replaces the pnpm binary — it doesn't update other + * files in the package. The aliases are created by pointing pn directly to + * the pnpm binary, and pnpx/pnx as scripts that exec "pnpm dlx". + * + * Only creates links when the pnpm binary exists in the expected location + * (i.e. the package has been installed). This is always true after bootstrap. */ export async function ensureAliasLinks (binDir: string, standalone: boolean, platform: NodeJS.Platform = process.platform): Promise { - const aliases = getAliases(standalone) const isWindows = platform === 'win32' - for (const { name, target } of aliases) { - const resolvedTarget = path.resolve(binDir, target) - if (!existsSync(resolvedTarget)) continue + // Determine the pnpm binary path relative to binDir + const pnpmTarget = standalone + ? path.join('..', '@pnpm', 'exe', 'pnpm') + : path.join('..', 'pnpm', 'bin', 'pnpm.cjs') - if (isWindows) { - await writeFile(path.join(binDir, `${name}.cmd`), cmdShim(target)) - await writeFile(path.join(binDir, `${name}.ps1`), pwshShim(target)) - } else { - await forceSymlink(target, path.join(binDir, name)) + const resolvedPnpm = path.resolve(binDir, pnpmTarget) + if (!existsSync(resolvedPnpm)) return + + if (isWindows) { + // pn → calls pnpm directly + await writeFile(path.join(binDir, 'pn.cmd'), cmdShim(`"%~dp0\\${pnpmTarget}"`)) + await writeFile(path.join(binDir, 'pn.ps1'), pwshShim(`& "$PSScriptRoot\\${pnpmTarget}"`)) + // pnpx/pnx → calls pnpm dlx + for (const name of ['pnpx', 'pnx']) { + await writeFile(path.join(binDir, `${name}.cmd`), cmdShim(`"%~dp0\\${pnpmTarget}" dlx`)) + await writeFile(path.join(binDir, `${name}.ps1`), pwshShim(`& "$PSScriptRoot\\${pnpmTarget}" dlx`)) + } + } else { + // pn → symlink to pnpm binary + await forceSymlink(pnpmTarget, path.join(binDir, 'pn')) + // pnpx/pnx → shell scripts that exec pnpm dlx + for (const name of ['pnpx', 'pnx']) { + const pnpmPath = `"$(dirname "$0")/${pnpmTarget}"` + await forceWriteFile(path.join(binDir, name), shScript(`${pnpmPath} dlx`), 0o755) } } } diff --git a/src/install-pnpm/run.ts b/src/install-pnpm/run.ts index 7939267..84a6583 100644 --- a/src/install-pnpm/run.ts +++ b/src/install-pnpm/run.ts @@ -59,17 +59,6 @@ export async function runSelfInstaller(inputs: Inputs): Promise { if (exitCode !== 0) { return exitCode } - - // self-update replaces package files but does not re-run preinstall - // scripts, so setup.js (which hardlinks pn/pnpx/pnx to the binary) - // needs to be run explicitly. - if (standalone) { - const exeDir = path.join(dest, 'node_modules', '@pnpm', 'exe') - const setupScript = path.join(exeDir, 'setup.js') - if (existsSync(setupScript)) { - await runCommand(process.execPath, [setupScript], { cwd: exeDir }) - } - } } // Create pn/pnx alias bin links if the installed version supports them