2
mirror of https://github.com/pnpm/action-setup.git synced 2026-04-19 06:25:12 +00:00

fix: handle Windows with .cmd/.ps1 shims and add tests

- Extract ensureAliasLinks to its own module for testability
- On Windows, create .cmd and .ps1 shims instead of symlinks
- On Unix, create symlinks (as before)
- Skip alias creation when targets don't exist (pnpm v10)
- Add vitest and 8 tests covering unix/windows/skip/no-overwrite
This commit is contained in:
Zoltan Kochan
2026-03-26 18:41:05 +01:00
parent 747414e7da
commit 11687bb3d2
6 changed files with 1088 additions and 160 deletions

View File

@@ -0,0 +1,145 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { ensureAliasLinks } from './ensureAliasLinks'
import { mkdtemp, mkdir, writeFile, readFile, readlink } from 'fs/promises'
import { existsSync } from 'fs'
import path from 'path'
import os from 'os'
async function createTempDir (): Promise<string> {
return mkdtemp(path.join(os.tmpdir(), 'alias-links-test-'))
}
async function setupStandaloneFixture (binDir: string): Promise<void> {
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 })
}
async function setupNonStandaloneFixture (binDir: string): Promise<void> {
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', () => {
let binDir: string
beforeEach(async () => {
const tmpDir = await createTempDir()
binDir = path.join(tmpDir, 'node_modules', '.bin')
await mkdir(binDir, { recursive: true })
})
describe('standalone mode', () => {
it('creates symlinks on unix when targets exist', 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'))
})
it('creates .cmd and .ps1 shims on windows when targets exist', 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)
// Should not create extensionless symlinks 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 () => {
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 () => {
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'))
})
})
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
await ensureAliasLinks(binDir, true, 'linux')
expect(existsSync(path.join(binDir, 'pn'))).toBe(false)
expect(existsSync(path.join(binDir, 'pnpx'))).toBe(false)
expect(existsSync(path.join(binDir, 'pnx'))).toBe(false)
})
it('creates no shims on windows when targets do not exist', 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('does not overwrite existing links', () => {
it('preserves existing symlinks on unix', async () => {
await setupStandaloneFixture(binDir)
await writeFile(path.join(binDir, 'pn'), 'existing content')
await ensureAliasLinks(binDir, true, 'linux')
const content = await readFile(path.join(binDir, 'pn'), 'utf8')
expect(content).toBe('existing content')
})
it('preserves existing .cmd shims on windows', async () => {
await setupStandaloneFixture(binDir)
await writeFile(path.join(binDir, 'pn.cmd'), 'existing shim')
await ensureAliasLinks(binDir, true, 'win32')
const content = await readFile(path.join(binDir, 'pn.cmd'), 'utf8')
expect(content).toBe('existing shim')
})
})
})

View File

@@ -0,0 +1,62 @@
import { writeFile, symlink } from 'fs/promises'
import { existsSync } from 'fs'
import path from 'path'
interface AliasDefinition {
name: string
target: string
}
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 (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`
}
/**
* 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+).
*/
export async function ensureAliasLinks (binDir: string, standalone: boolean, platform: NodeJS.Platform = process.platform): Promise<void> {
const aliases = getAliases(standalone)
const isWindows = platform === 'win32'
for (const { name, target } of aliases) {
const resolvedTarget = path.resolve(binDir, target)
if (!existsSync(resolvedTarget)) continue
if (isWindows) {
const cmdPath = path.join(binDir, `${name}.cmd`)
if (!existsSync(cmdPath)) {
await writeFile(cmdPath, cmdShim(target))
}
const ps1Path = path.join(binDir, `${name}.ps1`)
if (!existsSync(ps1Path)) {
await writeFile(ps1Path, pwshShim(target))
}
} else {
const link = path.join(binDir, name)
if (!existsSync(link)) {
await symlink(target, link)
}
}
}
}

View File

@@ -8,6 +8,7 @@ import { Inputs } from '../inputs'
import { parse as parseYaml } from 'yaml'
import pnpmLock from './bootstrap/pnpm-lock.json'
import exeLock from './bootstrap/exe-lock.json'
import { ensureAliasLinks } from './ensureAliasLinks'
const BOOTSTRAP_PNPM_PACKAGE_JSON = JSON.stringify({ private: true, dependencies: { pnpm: pnpmLock.packages['node_modules/pnpm'].version } })
const BOOTSTRAP_EXE_PACKAGE_JSON = JSON.stringify({ private: true, dependencies: { '@pnpm/exe': exeLock.packages['node_modules/@pnpm/exe'].version } })
@@ -122,19 +123,6 @@ Please specify it by one of the following ways:
- in the package.json with the key "packageManager"`)
}
async function ensureAliasLinks(pnpmHome: string, standalone: boolean): Promise<void> {
const aliases = standalone
? { pn: path.join('..', '@pnpm', 'exe', 'pn'), pnpx: path.join('..', '@pnpm', 'exe', 'pnpx'), pnx: path.join('..', '@pnpm', 'exe', 'pnx') }
: { pn: path.join('..', 'pnpm', 'bin', 'pnpm.cjs'), pnpx: path.join('..', 'pnpm', 'bin', 'pnpx.cjs'), pnx: path.join('..', 'pnpm', 'bin', 'pnpx.cjs') }
for (const [name, target] of Object.entries(aliases)) {
const link = path.join(pnpmHome, name)
const resolvedTarget = path.resolve(pnpmHome, target)
if (!existsSync(link) && existsSync(resolvedTarget)) {
await symlink(target, link)
}
}
}
function runCommand(cmd: string, args: string[], opts: { cwd: string }): Promise<number> {
return new Promise<number>((resolve, reject) => {
const cp = spawn(cmd, args, {