refactor: move cmd-shim into the pnpm monorepo (#14893)

Import cmd-shim source, tests, and snapshots from pnpm/cmd-shim at
83f9b9449c2adeec26e1cc2d03ef0fe82d2e41ca. Maintaining the shim alongside
its consumers removes the need for coordinated changes across repositories.

Publish the package as `@pnpm/bins.cmd-shim` and replace external dependencies
with workspace links. Preserve the original BSD-2-Clause license, including
its copyright notice, and keep the manifest updater from replacing it with MIT.
Reuse `@pnpm/fs.graceful-fs` and integrate the existing Node.js tests with the
workspace test scripts. Retain the existing snapshots and align the version
with the TypeScript workspace's required 1100.x band.
This commit is contained in:
Zoltan Kochan authored and GitHub committed 2026-09-14 21:30:59 +02:00
1 parent a8ade4902f
commit 224aed744b
26 files changed
+3246 -43

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
"@pnpm/bins.cmd-shim": patch
"@pnpm/bins.linker": patch
"pnpm": patch
---
The `@zkochan/cmd-shim` package is now available as `@pnpm/bins.cmd-shim`.
+3
View File
@@ -178,10 +178,13 @@ async function runScriptTask (pkg) {
try {
if (pkg.manifest.name === 'pd') {
await runCommand('node', ['pd.js', '--version'], { cwd: pkg.path })
} else if (pkg.manifest.name === '@pnpm/bins.cmd-shim') {
await runCommand('node', ['--test', 'test/test.js', 'test/e2e.test.js'], { cwd: pkg.path })
} else {
throw new Error(`Unsupported non-Jest .test script in ${relDir}: ${pkg.manifest.scripts['.test']}`)
}
} catch (err) {
console.error(err)
status = 'failure'
exitCode = err.exitCode ?? 1
}
+4 -3
View File
@@ -370,6 +370,7 @@ async function updateManifest (workspaceDir: string, manifest: ProjectManifest,
let scripts: Record<string, string>
let preset = '@pnpm/jest-config'
switch (manifest.name) {
case '@pnpm/bins.cmd-shim':
case '@pnpm/lockfile.types':
scripts = { ...manifest.scripts }
break
@@ -486,7 +487,7 @@ async function updateManifest (workspaceDir: string, manifest: ProjectManifest,
repository = `https://github.com/pnpm/pnpm/tree/main/${relative}`
}
if (scripts.lint) {
if (fs.existsSync(path.join(dir, 'test'))) {
if (fs.existsSync(path.join(dir, 'test')) && manifest.name !== '@pnpm/bins.cmd-shim') {
scripts.lint = 'eslint "src/**/*.ts" "test/**/*.ts"'
} else {
scripts.lint = 'eslint "src/**/*.ts"'
@@ -513,7 +514,7 @@ async function updateManifest (workspaceDir: string, manifest: ProjectManifest,
}
delete manifest.dependencies['@types/ramda']
}
if (scripts.test) {
if (scripts.test && manifest.name !== '@pnpm/bins.cmd-shim') {
Object.assign(manifest, {
jest: {
...(manifest as any).jest, // eslint-disable-line
@@ -531,7 +532,7 @@ async function updateManifest (workspaceDir: string, manifest: ProjectManifest,
files,
funding: 'https://opencollective.com/pnpm',
homepage,
license: 'MIT',
license: manifest.license ?? 'MIT',
repository,
scripts,
exports: {
+8 -1
View File
@@ -70,6 +70,7 @@
"cves",
"cwsay",
"cyclonedx",
"cygdrive",
"deburr",
"dedup",
"denoland",
@@ -184,6 +185,7 @@
"killcb",
"kochan",
"koorchik",
"LASTEXITCODE",
"ldid",
"ldni",
"ledgered",
@@ -227,6 +229,7 @@
"msgpackr",
"msvc",
"msys",
"MSYSTEM",
"mtimes",
"musleabihf",
"mycomp",
@@ -241,6 +244,7 @@
"newversion",
"nistp",
"NOASSERTION",
"nodepath",
"nodetouch",
"noent",
"nonexec",
@@ -278,6 +282,7 @@
"parseable",
"partialmatch",
"pathext",
"pathsep",
"pegjs",
"pids",
"pidtree",
@@ -293,7 +298,7 @@
"pnpmtest",
"pnpr",
"polyfilling",
"portugu\u00eas",
"português",
"posix",
"postbuild",
"postfoo",
@@ -383,6 +388,7 @@
"semistrict",
"serp",
"serverjs",
"SETLOCAL",
"shasums",
"sheetjs",
"shlex",
@@ -481,6 +487,7 @@
"worktree",
"worktrees",
"wrappy",
"wslpath",
"xattr",
"xattrs",
"xmarw",
+44 -18
View File
@@ -383,9 +383,6 @@ catalogs:
'@yarnpkg/pnp':
specifier: ^4.1.7
version: 4.1.7
'@zkochan/cmd-shim':
specifier: ^9.0.8
version: 9.0.8
'@zkochan/retry':
specifier: ^0.2.0
version: 0.2.0
@@ -617,6 +614,9 @@ catalogs:
mdast-util-to-string:
specifier: ^4.0.0
version: 4.0.0
memfs:
specifier: ^3.5.3
version: 3.5.3
memoize:
specifier: ^11.0.0
version: 11.0.0
@@ -1473,11 +1473,36 @@ importers:
specifier: workspace:*
version: link:../../core/logger
pnpm11/bins/cmd-shim:
dependencies:
'@pnpm/fs.graceful-fs':
specifier: workspace:*
version: link:../../fs/graceful-fs
cmd-extension:
specifier: 'catalog:'
version: 2.0.0
devDependencies:
'@pnpm/bins.cmd-shim':
specifier: workspace:*
version: 'link:'
'@types/node':
specifier: 'catalog:'
version: 22.19.19
memfs:
specifier: 'catalog:'
version: 3.5.3
tempy:
specifier: 'catalog:'
version: 3.0.0
pnpm11/bins/linker:
dependencies:
'@pnpm/bins.resolver':
specifier: workspace:*
version: link:../resolver
'@pnpm/bins.cmd-shim':
specifier: workspace:*
version: link:../cmd-shim
'@pnpm/error':
specifier: workspace:*
version: link:../../core/error
@@ -1496,9 +1521,6 @@ importers:
'@pnpm/workspace.project-manifest-reader':
specifier: workspace:*
version: link:../../workspace/project-manifest-reader
'@zkochan/cmd-shim':
specifier: 'catalog:'
version: 9.0.8
'@zkochan/rimraf':
specifier: 'catalog:'
version: 4.0.0
@@ -8232,15 +8254,15 @@ importers:
'@jest/globals':
specifier: 'catalog:'
version: 30.4.1(supports-color@10.2.2)
'@pnpm/bins.cmd-shim':
specifier: workspace:*
version: link:../../../bins/cmd-shim
'@pnpm/exe':
specifier: workspace:*
version: 'link:'
'@pnpm/jest-config':
specifier: workspace:*
version: link:../../../__utils__/jest-config
'@zkochan/cmd-shim':
specifier: 'catalog:'
version: 9.0.8
execa:
specifier: 'catalog:'
version: safe-execa@0.3.0
@@ -12928,10 +12950,6 @@ packages:
resolution: {integrity: sha512-E5mgrRS8Kk80n19Xxmrx5qO9UG03FyZd8Me5gxYi++VPZsOv8+OsclA+0Fth4KTDCrQ/FkJryNFKJ6/642lo4g==}
engines: {node: '>=18.12'}
'@zkochan/cmd-shim@9.0.8':
resolution: {integrity: sha512-PfbYtsZwUMs7YleBJ1XD+BaKQ99MGHxAw0XZCDiUId9Yok4zNg60N3hCALnh8KsjBI/ocMstjDDTFThNweIJAw==}
engines: {node: '>=22.13'}
'@zkochan/diable@1.0.2':
resolution: {integrity: sha512-LvXkwkWyrsRulnXVfp0BfuEQqV6I2j0l3kQwvBHKFMI6Sg5j2GrUCLsEKqYB3jlxM+0ofScvlE4vB6knevdBmg==}
@@ -14297,6 +14315,9 @@ packages:
resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
fs-monkey@1.1.0:
resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==}
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@@ -15399,6 +15420,10 @@ packages:
resolution: {integrity: sha512-qFCFUDs7U3b8mBDPyz5EToEKoAkgCzqquIgi9nkkR9bixxOVOre+09lbuH7+9Kn2NFpm56M3GUWVbU2hQgdACA==}
engines: {node: '>=10'}
memfs@3.5.3:
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
engines: {node: '>= 4.0.0'}
memoize@11.0.0:
resolution: {integrity: sha512-cjsfZaC9b1clqPeIVMbb5dLHSXgdgGWGxdAU3oTUUkHiwWTKTBNnSmcqWJncNjYtBi3S8Rp0c5GIiyGztR8TRA==}
engines: {node: '>=22'}
@@ -20609,11 +20634,6 @@ snapshots:
graceful-fs: 4.2.11(patch_hash=68ebc232025360cb3dcd3081f4067f4e9fc022ab6b6f71a3230e86c7a5b337d1)
is-windows: 1.0.2
'@zkochan/cmd-shim@9.0.8':
dependencies:
cmd-extension: 1.0.2
graceful-fs: 4.2.11(patch_hash=68ebc232025360cb3dcd3081f4067f4e9fc022ab6b6f71a3230e86c7a5b337d1)
'@zkochan/diable@1.0.2':
dependencies:
spawno: 2.1.2
@@ -22168,6 +22188,8 @@ snapshots:
dependencies:
minipass: 7.1.3
fs-monkey@1.1.0: {}
fs.realpath@1.0.0: {}
fsevents@2.3.3:
@@ -23535,6 +23557,10 @@ snapshots:
map-age-cleaner: 0.1.3
mimic-fn: 3.1.0
memfs@3.5.3:
dependencies:
fs-monkey: 1.1.0
memoize@11.0.0:
dependencies:
mimic-function: 5.0.1
+1 -1
View File
@@ -225,7 +225,6 @@ catalog:
'@yarnpkg/lockfile': ^1.1.0
'@yarnpkg/nm': 4.1.1
'@yarnpkg/pnp': ^4.1.7
'@zkochan/cmd-shim': ^9.0.8
'@zkochan/retry': ^0.2.0
'@zkochan/rimraf': ^4.0.0
'@zkochan/table': ^2.0.1
@@ -318,6 +317,7 @@ catalog:
lru-cache: ^11.5.2
make-empty-dir: ^4.0.0
mdast-util-to-string: ^4.0.0
memfs: ^3.5.3
memoize: ^11.0.0
micromatch: ^4.0.8
msgpackr: 2.1.0
+27
View File
@@ -0,0 +1,27 @@
Copyright (c) Isaac Z. Schlueter ("Author")
All rights reserved.
The BSD License
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS
BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+48
View File
@@ -0,0 +1,48 @@
# @pnpm/bins.cmd-shim
> Used in pnpm for command line application support
The cmd-shim used in [pnpm](https://github.com/pnpm/pnpm) to create executable scripts.
## Installation
```sh
npm install --save @pnpm/bins.cmd-shim
```
## API
### `cmdShim(src, to, opts?): Promise<void>`
Create a cmd shim at `to` for the command line program at `src`.
e.g.
```javascript
import { cmdShim } from '@pnpm/bins.cmd-shim'
cmdShim('/path/to/cli.js', '/usr/bin/command-name')
.catch(err => console.error(err))
```
### `cmdShimIfExists(src, to, opts?): Promise<void>`
Creates shims like `cmdShim`, but ignores all creation errors, including a missing source file.
#### Arguments:
- `opts.preserveSymlinks` - _Boolean_ - if true, `--preserve-symlinks` is added to the options passed to NodeJS.
- `opts.nodePath` - `string | string[]` - sets the [NODE_PATH](https://nodejs.org/api/cli.html#cli_node_path_path) env variable.
- `opts.prependToPath` - _String_ - prepends the passed path to PATH before executing the Node.js program.
- `opts.nodeExecPath` - _String_ - sets the path to the Node.js executable.
- `opts.createCmdFile` - _Boolean_ - is `true` on Windows by default. If true, creates a cmd file.
- `opts.createPwshFile` - _Boolean_ - is `true` by default. If true, creates a powershell file.
- `opts.progArgs` - `string[]` - optional arguments prepended to the CLI arguments
```javascript
import { cmdShim } from '@pnpm/bins.cmd-shim'
cmdShim('/path/to/cli.js', '/usr/bin/command-name', { preserveSymlinks: true })
.catch(err => console.error(err))
```
## License
[BSD-2-Clause](./LICENSE) © [Zoltan Kochan](http://kochan.io)
+46
View File
@@ -0,0 +1,46 @@
{
"name": "@pnpm/bins.cmd-shim",
"version": "1100.0.0",
"description": "Used in pnpm for command line application support",
"keywords": [
"pnpm",
"pnpm11"
],
"license": "BSD-2-Clause",
"funding": "https://opencollective.com/pnpm",
"repository": "https://github.com/pnpm/pnpm/tree/main/pnpm11/bins/cmd-shim",
"homepage": "https://github.com/pnpm/pnpm/tree/main/pnpm11/bins/cmd-shim#readme",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": "./lib/index.js"
},
"files": [
"lib",
"!*.map"
],
"scripts": {
"lint": "eslint \"src/**/*.ts\"",
"compile": "tsgo --build && pn lint --fix",
"test": "pn compile && pn .test",
".test": "node --test test/test.js test/e2e.test.js",
"prepublishOnly": "tsgo --build"
},
"dependencies": {
"@pnpm/fs.graceful-fs": "workspace:*",
"cmd-extension": "catalog:"
},
"devDependencies": {
"@pnpm/bins.cmd-shim": "workspace:*",
"@types/node": "catalog:",
"memfs": "catalog:",
"tempy": "catalog:"
},
"engines": {
"node": ">=22.13"
}
}
+663
View File
@@ -0,0 +1,663 @@
import fs from 'node:fs'
import path from 'node:path'
import util from 'node:util'
import gfsPromises from '@pnpm/fs.graceful-fs'
import { cmdExtension as CMD_EXTENSION } from 'cmd-extension'
export interface Options {
/**
* If a PowerShell script should be created.
*
* @default true
*/
createPwshFile?: boolean
/**
* If a Windows Command Prompt script should be created.
*
* @default true on Windows, false on other platforms
*/
createCmdFile?: boolean
/**
* If symbolic links should be preserved.
*
* @default false
*/
preserveSymlinks?: boolean
/**
* The path to the executable file.
*/
prog?: string
/**
* The arguments to initialize the `node` process with.
*/
args?: string
/**
* The arguments to initialize the target process with, before the actual CLI arguments
*/
progArgs?: string[]
/**
* The value of the $NODE_PATH environment variable.
*
* The single `string` format is only kept for legacy compatibility,
* and the array form should be preferred.
*/
nodePath?: string | string[]
/**
* fs implementation to use. Must implement node's `fs` module interface.
*/
fs?: typeof import('fs')
/*
* Path to the Node.js executable
*/
nodeExecPath?: string
prependToPath?: string
}
/**
* @internal
*/
type InternalOptions = Options & Required<Pick<Options, keyof typeof DEFAULT_OPTIONS>> & {
fs_: FsPromises
}
type FsPromises = Pick<typeof fs.promises, 'chmod' | 'mkdir' | 'readFile' | 'stat' | 'unlink' | 'writeFile'>
/**
* Callback functions to generate scripts for shims.
* @param src Path to the executable or script.
* @param to Path to the shim(s) that is going to be created.
* @param opts Options.
* @return Generated script for shim.
*/
type ShimGenerator = (src: string, to: string, opts: InternalOptions) => string
interface ShimGenExtTuple {
generator: ShimGenerator
extension: string
}
const isWindows = process.platform === 'win32'
const isCygwin = () => isWindows && (process.env.TERM === 'CYGWIN' || process.env.MSYSTEM !== undefined)
// Interpreter paths may contain whitespace other than spaces and tabs.
// eslint-disable-next-line regexp/no-super-linear-backtracking
const shebangExpr = /^#!\s*(?:\/usr\/bin\/env(?:\s+-S)?\s*)?([^ \t]+)(.*)$/
const DEFAULT_OPTIONS = {
createPwshFile: true,
createCmdFile: isWindows,
}
/**
* Map from extensions of files that this module is frequently used for to their runtime.
* @type {Map<string, string>}
*/
const extensionToProgramMap = new Map([
['.js', 'node'],
['.cjs', 'node'],
['.mjs', 'node'],
['.cmd', 'cmd'],
['.bat', 'cmd'],
['.ps1', 'pwsh'], // not 'powershell'
['.sh', 'sh'],
])
function ingestOptions (opts?: Options): InternalOptions {
const opts_ = {...DEFAULT_OPTIONS, ...opts} as InternalOptions
opts_.fs_ = opts_.fs ? opts_.fs.promises : (gfsPromises as unknown as FsPromises)
return opts_
}
/**
* Try to create shims.
*
* @param src Path to program (executable or script).
* @param to Path to shims.
* Don't add an extension if you will create multiple types of shims.
* @param opts Options.
* @throws If `src` is missing.
*/
export async function cmdShim (src: string, to: string, opts?: Options): Promise<void> {
const opts_ = ingestOptions(opts)
await cmdShim_(src, to, opts_)
}
/**
* Try to create shims.
*
* Resolves even when shim creation fails, including when `src` is missing.
*
* @param src Path to program (executable or script).
* @param to Path to shims.
* Don't add an extension if you will create multiple types of shims.
* @param opts Options.
*/
export function cmdShimIfExists (src: string, to: string, opts?: Options): Promise<void> {
return cmdShim(src, to, opts).catch(() => {})
}
/**
* Check whether a shim's content points at the given source path.
*
* @param shimContent The text content of the shim file.
* @param src The expected source path (the executable the shim should point to).
* @return `true` if the shim contains a matching target marker.
*/
export function isShimPointingAt (shimContent: string, src: string): boolean {
return shimContent.includes(`# ${shimTarget(src)}\n`)
}
/**
* Try to unlink, but ignore errors.
* Any problems will surface later.
*
* @param path File to be removed.
*/
function rm (path: string, opts: InternalOptions): Promise<void> {
return opts.fs_.unlink(path).catch(() => {})
}
async function cmdShim_ (src: string, to: string, opts: InternalOptions) {
const srcRuntimeInfo = await searchScriptRuntime(src, opts)
await writeShimsPreCommon(to, opts)
return writeAllShims(src, to, srcRuntimeInfo, opts)
}
function writeShimsPreCommon (target: string, opts: InternalOptions) {
return opts.fs_.mkdir(path.dirname(target), { recursive: true })
}
/**
* Replaces the shell shim and the enabled CMD/PowerShell siblings with executable files.
* Resolves when all writes and permission changes finish; rejects if any fail.
*/
function writeAllShims (src: string, to: string, srcRuntimeInfo: RuntimeInfo, opts: Options) {
const opts_ = ingestOptions(opts)
const generatorAndExts: ShimGenExtTuple[] = [{ generator: generateShShim, extension: '' }]
if (opts_.createCmdFile) {
generatorAndExts.push({ generator: generateCmdShim, extension: CMD_EXTENSION })
}
if (opts_.createPwshFile) {
generatorAndExts.push({ generator: generatePwshShim, extension: '.ps1' })
}
return Promise.all(
generatorAndExts.map((generatorAndExt) => writeShim(src, to + generatorAndExt.extension, srcRuntimeInfo, generatorAndExt.generator, opts_))
)
}
function writeShimPre (target: string, opts: InternalOptions) {
return rm(target, opts)
}
function writeShimPost (target: string, opts: InternalOptions) {
return chmodShim(target, opts)
}
interface RuntimeInfo {
program: string | null
additionalArgs: string
}
async function searchScriptRuntime (target: string, opts: InternalOptions): Promise<RuntimeInfo> {
try {
const data = await opts.fs_.readFile(target, 'utf8')
// First, check if the bin is a #! of some sort.
const firstLine = (data as string).trim().split(/\r*\n/)[0]
const shebang = firstLine.match(shebangExpr)
if (!shebang) {
// If not, infer script type from its extension.
// If the inference fails, it's something that'll be compiled, or some other
// sort of script, and just call it directly.
const targetExtension = path.extname(target).toLowerCase()
// undefined if extension is unknown but it's converted to null.
const program = extensionToProgramMap.get(targetExtension) || null
// CMD requires executing batch files with the `/C` flag
const additionalArgs = program === 'cmd' ? '/C' : ''
return {
program,
additionalArgs,
}
}
return {
program: shebang[1],
additionalArgs: shebang[2],
}
} catch (err) {
if (!isWindows || !util.types.isNativeError(err) || !('code' in err) || err.code !== 'ENOENT') throw err
if (await opts.fs_.stat(`${target}${getExeExtension()}`)) {
return {
program: null,
additionalArgs: '',
}
}
throw err
}
}
export function getExeExtension (): string {
let cmdExtension
if (process.env.PATHEXT) {
cmdExtension = process.env.PATHEXT
.split(path.delimiter)
.find(ext => ext.toLowerCase() === '.exe')
}
return cmdExtension || '.exe'
}
/**
* Prefix bare `/C` / `/K` switches with an extra `/` so MSYS / Git Bash
* passes them through to cmd.exe unchanged instead of converting them to
* `C:\` / `K:\` paths. See {@link generateShShim} for context.
*/
function escapeMsysCmdSwitches (args: string): string {
return args.replace(/(^|\s)\/([CK])(\s|$)/gi, '$1//$2$3')
}
/**
* Replaces `to` with an executable shim. Rejects if writing or setting permissions fails.
*/
async function writeShim (src: string, to: string, srcRuntimeInfo: RuntimeInfo, generateShimScript: ShimGenerator, opts: InternalOptions) {
const defaultArgs = opts.preserveSymlinks ? '--preserve-symlinks' : ''
const args = [srcRuntimeInfo.additionalArgs, defaultArgs].filter(arg => arg).join(' ')
opts = Object.assign({}, opts, {
prog: srcRuntimeInfo.program,
args: args,
})
await writeShimPre(to, opts)
await opts.fs_.writeFile(to, generateShimScript(src, to, opts), 'utf8')
return writeShimPost(to, opts)
}
/**
* Generate the content of a shim for CMD.
*
* @param src Path to the executable or script.
* @param to Path to the shim to be created.
* It is highly recommended to end with `.cmd` (or `.bat`).
* @param opts Options.
* @return The content of shim.
*/
function generateCmdShim (src: string, to: string, opts: InternalOptions): string {
const shTarget = path.relative(path.dirname(to), src)
let target = shTarget.split('/').join('\\')
const quotedPathToTarget = path.isAbsolute(target) ? `"${target}"` : `"%~dp0\\${target}"`
let longProg
let prog = opts.prog
let args = opts.args || ''
const nodePath = normalizePathEnvVar(opts.nodePath).win32
const prependToPath = normalizePathEnvVar(opts.prependToPath).win32
if (!prog) {
prog = quotedPathToTarget
args = ''
target = ''
} else if (prog === 'node' && opts.nodeExecPath) {
prog = `"${opts.nodeExecPath}"`
target = quotedPathToTarget
} else {
longProg = `"%~dp0\\${prog}.exe"`
target = quotedPathToTarget
}
let progArgs = opts.progArgs ? `${opts.progArgs.join(' ')} ` : ''
let cmd = '@SETLOCAL\r\n'
if (prependToPath) {
cmd += `@SET "PATH=${prependToPath}:%PATH%"\r\n`
}
if (nodePath) {
cmd += `\
@IF NOT DEFINED NODE_PATH (\r
@SET "NODE_PATH=${nodePath}"\r
) ELSE (\r
@SET "NODE_PATH=${nodePath};%NODE_PATH%"\r
)\r
`
}
if (longProg) {
cmd += `\
@IF EXIST ${longProg} (\r
${longProg} ${args} ${target} ${progArgs}%*\r
) ELSE (\r
@SET PATHEXT=%PATHEXT:;.JS;=;%\r
${prog} ${args} ${target} ${progArgs}%*\r
)\r
`
} else {
cmd += `@${prog} ${args} ${target} ${progArgs}%*\r\n`
}
return cmd
}
/**
* Generate the content of a shim for (Ba)sh in, for example, Cygwin and MSYS(2).
*
* @param src Path to the executable or script.
* @param to Path to the shim to be created.
* It is highly recommended to end with `.sh` or to contain no extension.
* @param opts Options.
* @return The content of shim.
*/
function generateShShim (src: string, to: string, opts: InternalOptions): string {
let shTarget = path.relative(path.dirname(to), src)
let shProg = opts.prog && opts.prog.split('\\').join('/')
let shLongProg: string | undefined
let shLongProgExe = ''
let shProgExe = ''
let shProgHasExe = false
shTarget = shTarget.split('\\').join('/')
const quotedPathToTarget = path.isAbsolute(shTarget) ? `"${shTarget}"` : `"$basedir/${shTarget}"`
const quotedPathToTargetWin = path.isAbsolute(shTarget) ? `"${shTarget}"` : `"$basedir_win/${shTarget}"`
let shTargetWin = ''
let args = opts.args || ''
const isCmdRuntime = opts.prog === 'cmd' || opts.prog === 'cmd.exe'
const shNodePath = normalizePathEnvVar(opts.nodePath).posix
if (!shProg) {
shProg = quotedPathToTarget
args = ''
shTarget = ''
} else if (opts.prog === 'node' && opts.nodeExecPath) {
shProg = `"${opts.nodeExecPath}"`
shTarget = /\.exe$/.test(opts.nodeExecPath) ? quotedPathToTargetWin : quotedPathToTarget
} else {
shProgHasExe = /\.exe$/i.test(shProg)
shProgExe = shProgHasExe ? shProg : `${shProg}.exe`
shLongProg = `"$basedir/${shProg}"`
shLongProgExe = `"$basedir/${shProgExe}"`
shTarget = quotedPathToTarget
shTargetWin = quotedPathToTargetWin
}
let progArgs = opts.progArgs ? `${opts.progArgs.join(' ')} ` : ''
let sh = `\
#!/bin/sh
# Resolve $0 through symlinks so basedir is the shim's real directory.
# Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim.
#
# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and
# uname go through \`command -p\`, which searches the system default path instead.
# A dependency's bin cannot stand in for one of them and take over the shim
# before it reaches its target. Directories come from \`\${link%/*}\`, which needs
# no helper at all.
link="$0"
# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup
# and stands for a file in the current directory.
case "$link" in
*/*|*\\\\*) ;;
*) link="./$link" ;;
esac
hops=0
while [ -L "$link" ] && [ "$hops" -lt 40 ]; do
hops=$((hops+1))
target=$(command -p readlink "$link")
case "$target" in
/*) link="$target" ;;
*) link="\${link%/*}/$target" ;;
esac
done
basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g')
basedir="\${basedir%/*}"
basedir_win="$basedir"
exe=""
msys=""
case \`command -p uname -a\` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir_win=\`cygpath -w "$basedir"\`
fi
exe=".exe"
msys="true"
;;
*WSL2*)
if command -v wslpath > /dev/null 2>&1; then
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
basedir_win="$basedir"
else
exe=".exe"
fi
fi
;;
esac
`
if (opts.prependToPath) {
sh += `\
export PATH="${opts.prependToPath}:$PATH"
`
}
if (shNodePath) {
sh += `\
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="${shNodePath}"
else
export NODE_PATH="${shNodePath}:$NODE_PATH"
fi
`
}
const generateExecBlock = (execArgs: string) => {
if (shLongProg) {
if (shProgHasExe) {
return `\
if [ -x ${shLongProgExe} ]; then
exec ${shLongProgExe} ${execArgs} ${shTargetWin} ${progArgs}"$@"
else
exec ${shProgExe} ${execArgs} ${shTargetWin} ${progArgs}"$@"
fi
`
} else {
return `\
if [ -n "$exe" ] && [ -x ${shLongProgExe} ]; then
exec ${shLongProgExe} ${execArgs} ${shTargetWin} ${progArgs}"$@"
elif [ -x ${shLongProg} ]; then
exec ${shLongProg} ${execArgs} ${shTarget} ${progArgs}"$@"
elif command -v ${shProg} >/dev/null 2>&1; then
exec ${shProg} ${execArgs} ${shTarget} ${progArgs}"$@"
elif [ -n "$exe" ] && command -v ${shProgExe} >/dev/null 2>&1; then
exec ${shProgExe} ${execArgs} ${shTargetWin} ${progArgs}"$@"
else
exec ${shProg} ${execArgs} ${shTarget} ${progArgs}"$@"
fi
`
}
} else {
return `\
exec ${shProg} ${execArgs} ${shTarget} ${progArgs}"$@"
exit $?
`
}
}
const msysArgs = isCmdRuntime ? escapeMsysCmdSwitches(args) : args
if (msysArgs !== args) {
sh += `\
if [ -n "$msys" ]; then
${indentShellBlock(generateExecBlock(msysArgs))}
else
${indentShellBlock(generateExecBlock(args))}
fi
`
} else {
sh += generateExecBlock(args)
}
// Marker used by consumers to detect whether the shim is up-to-date
// without parsing the script content.
sh += `# ${shimTarget(src)}\n`
return sh
}
function indentShellBlock (script: string): string {
return script.split('\n').map(line => line ? ` ${line}` : line).join('\n')
}
/**
* Generate the content of a shim for PowerShell.
*
* @param src Path to the executable or script.
* @param to Path to the shim to be created.
* It is highly recommended to end with `.ps1`.
* @param opts Options.
* @return The content of shim.
*/
function generatePwshShim (src: string, to: string, opts: InternalOptions): string {
let shTarget = path.relative(path.dirname(to), src)
const shProg = opts.prog && opts.prog.split('\\').join('/')
let pwshProg = shProg && `"${shProg}$exe"`
let pwshLongProg
shTarget = shTarget.split('\\').join('/')
const quotedPathToTarget = path.isAbsolute(shTarget) ? `"${shTarget}"` : `"$basedir/${shTarget}"`
let args = opts.args || ''
let normalizedNodePathEnvVar = normalizePathEnvVar(opts.nodePath)
const nodePath = normalizedNodePathEnvVar.win32
const shNodePath = normalizedNodePathEnvVar.posix
let normalizedPrependPathEnvVar = normalizePathEnvVar(opts.prependToPath)
const prependPath = normalizedPrependPathEnvVar.win32
const shPrependPath = normalizedPrependPathEnvVar.posix
if (!pwshProg) {
pwshProg = quotedPathToTarget
args = ''
shTarget = ''
} else if (opts.prog === 'node' && opts.nodeExecPath) {
pwshProg = `"${opts.nodeExecPath}"`
shTarget = quotedPathToTarget
} else {
pwshLongProg = `"$basedir/${opts.prog}$exe"`
shTarget = quotedPathToTarget
}
let progArgs = opts.progArgs ? `${opts.progArgs.join(' ')} ` : ''
let pwsh = `\
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
${(nodePath || prependPath) ? '$pathsep=":"\n' : ''}\
${nodePath ? `\
$env_node_path=$env:NODE_PATH
$new_node_path="${nodePath}"
` : ''}\
${prependPath ? `\
$env_path=$env:PATH
$prepend_path="${prependPath}"
` : ''}\
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
${(nodePath || prependPath) ? ' $pathsep=";"\n' : ''}\
}`
if (shNodePath || shPrependPath) {
pwsh += `\
else {
${shNodePath ? ` $new_node_path="${shNodePath}"\n` : ''}\
${shPrependPath ? ` $prepend_path="${shPrependPath}"\n` : ''}\
}
`
}
if (shNodePath) {
pwsh += `\
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
`
}
if (opts.prependToPath) {
pwsh += `
$env:PATH="$prepend_path$pathsep$env:PATH"
`
}
if (pwshLongProg) {
pwsh += `
$ret=0
if (Test-Path ${pwshLongProg}) {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & ${pwshLongProg} ${args} ${shTarget} ${progArgs}$args
} else {
& ${pwshLongProg} ${args} ${shTarget} ${progArgs}$args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & ${pwshProg} ${args} ${shTarget} ${progArgs}$args
} else {
& ${pwshProg} ${args} ${shTarget} ${progArgs}$args
}
$ret=$LASTEXITCODE
}
${nodePath ? '$env:NODE_PATH=$env_node_path\n' : ''}\
${prependPath ? '$env:PATH=$env_path\n' : ''}\
exit $ret
`
} else {
pwsh += `
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & ${pwshProg} ${args} ${shTarget} ${progArgs}$args
} else {
& ${pwshProg} ${args} ${shTarget} ${progArgs}$args
}
${nodePath ? '$env:NODE_PATH=$env_node_path\n' : ''}\
${prependPath ? '$env:PATH=$env_path\n' : ''}\
exit $LASTEXITCODE
`
}
return pwsh
}
function chmodShim (to: string, opts: InternalOptions) {
return opts.fs_.chmod(to, 0o755)
}
interface NormalizedPathEnvVar {
win32: string
posix: string
[index: number]: {win32: string, posix: string}
}
function normalizePathEnvVar (nodePath: undefined | string | string[]): NormalizedPathEnvVar {
if (!nodePath || !nodePath.length) {
return {
win32: '',
posix: '',
}
}
let split = (typeof nodePath === 'string' ? nodePath.split(path.delimiter) : Array.from(nodePath))
let result = {} as NormalizedPathEnvVar
for (let i = 0; i < split.length; i++) {
const win32 = split[i].split('/').join('\\')
const posix = isWindows ? split[i].split('\\').join('/').replace(/^([^:\\/]*):/, (_, $1) => `${isCygwin() ? '/proc/cygdrive' : '/mnt'}/${$1.toLowerCase()}`) : split[i]
result.win32 = result.win32 ? `${result.win32};${win32}` : win32
result.posix = result.posix ? `${result.posix}:${posix}` : posix
result[i] = {win32, posix}
}
return result
}
function shimTarget (src: string): string {
return `cmd-shim-target=${src.split('\\').join('/')}`
}
+284
View File
@@ -0,0 +1,284 @@
import { spawn, spawnSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { describe, test, snapshot } from 'node:test'
import assert from 'node:assert/strict'
snapshot.setDefaultSnapshotSerializers([
(value) => typeof value === 'string' ? `\n${value.replaceAll('\r', '')}` : JSON.stringify(value),
])
import { temporaryDirectory } from 'tempy'
import { cmdShim } from '@pnpm/bins.cmd-shim'
const describeOnWindows = process.platform === 'win32' ? describe : describe.skip
const describeOnPosix = process.platform === 'win32' ? describe.skip : describe
describeOnWindows('create a command shim for a .exe file', () => {
test('shim files', async (t) => {
const tempDir = temporaryDirectory()
fs.writeFileSync(path.join(tempDir, 'foo.exe'), '', 'utf8')
await cmdShim(path.join(tempDir, 'foo'), path.join(tempDir, 'dest'))
const stripMarker = (s) => s.replace(/# cmd-shim-target=.*\n/, '')
t.assert.snapshot(stripMarker(fs.readFileSync(path.join(tempDir, 'dest'), 'utf-8')))
t.assert.snapshot(fs.readFileSync(path.join(tempDir, 'dest.cmd'), 'utf-8'))
t.assert.snapshot(fs.readFileSync(path.join(tempDir, 'dest.ps1'), 'utf-8'))
})
})
describeOnWindows('sh shim wrapping a .cmd target invoked from Git Bash', () => {
// Regression for the MSYS path-translation bug: Git Bash rewrites a bare
// `/C` argument to `C:\` before launching cmd.exe, dropping the switch
// and leaving cmd.exe interactive. The fix escapes it as `//C` so MSYS
// passes it through.
test('runs the wrapped batch script and captures its output', async () => {
const tempDir = temporaryDirectory()
const target = path.join(tempDir, 'src.cmd')
fs.writeFileSync(target, '@echo HELLO_FROM_CMD\r\n', 'utf8')
const shim = path.join(tempDir, 'shim')
await cmdShim(target, shim)
// Invoke the sh shim via Git Bash. If `/C` survives MSYS translation,
// cmd.exe runs src.cmd and prints HELLO_FROM_CMD; if it gets rewritten
// to a drive path, cmd.exe starts interactively and dumps its banner
// (`Microsoft Windows [Version ...]`) instead.
const bash = process.env.PROGRAMFILES
? path.join(process.env.PROGRAMFILES, 'Git', 'bin', 'bash.exe')
: 'bash'
const r = spawnSync(bash, ['--noprofile', '--norc', shim], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})
assert.equal(r.status, 0, `bash exited ${r.status}\nstdout: ${r.stdout}\nstderr: ${r.stderr}`)
assert.match(r.stdout, /HELLO_FROM_CMD/, `expected script output, got:\n${r.stdout}`)
assert.doesNotMatch(
r.stdout,
/Microsoft Windows \[Version/,
'cmd.exe started interactively — the /C switch was dropped'
)
})
})
describeOnPosix('sh shim binstub uses exec', () => {
// Regression for the binstub bug: without `exec`, the shell process
// wraps the wrapped binary, so signals sent to the shim do not reach
// the wrapped process and the binary's PID differs from the shim's
// spawn PID. With `exec`, the shell process is replaced in place and
// the wrapped binary inherits the shim's PID.
test('wrapped binary inherits the shim\'s PID (proves exec replaced the shell)', async () => {
const tempDir = temporaryDirectory()
// process.execPath is a no-shebang native binary, so cmdShim hits the
// non-shLongProg branch of generateShShim — the one that needs `exec`.
const shim = path.join(tempDir, 'shim')
await cmdShim(process.execPath, shim)
const proc = spawn('/bin/sh', [shim, '-e', 'process.stdout.write(String(process.pid))'], {
stdio: ['ignore', 'pipe', 'pipe'],
})
const shimPid = proc.pid
let stdout = ''
proc.stdout.on('data', (chunk) => { stdout += chunk })
// `exit` may fire before the stdio streams have been drained. Wait for
// `close` so stdout contains the complete PID before asserting on it.
const exitCode = await new Promise((resolve) => proc.on('close', resolve))
assert.equal(exitCode, 0, `shim exited ${exitCode}; stdout=${stdout}`)
const reportedPid = Number(stdout.trim())
assert.equal(
reportedPid,
shimPid,
`expected child to inherit shim PID via exec — got reported=${reportedPid}, shim=${shimPid}`
)
})
})
describeOnPosix('sh shim uses POSIX runtime from PATH', () => {
test('does not fall through to node.exe when node is only on PATH', async () => {
const tempDir = temporaryDirectory()
const target = path.join(tempDir, 'tool.js')
fs.writeFileSync(target, '#!/usr/bin/env node\nconsole.log("SHIM_OK")\n', 'utf8')
const shim = path.join(tempDir, 'tool')
await cmdShim(target, shim, { createCmdFile: false })
const r = spawnSync('/bin/sh', [shim], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})
assert.equal(r.status, 0, `shim exited ${r.status}\nstdout: ${r.stdout}\nstderr: ${r.stderr}`)
assert.equal(r.stdout.trim(), 'SHIM_OK')
assert.doesNotMatch(r.stderr, /node\.exe/)
})
})
describeOnPosix('sh shim invoked through a chain of external symlinks', () => {
// POSIX shells set $0 to the invoked symlink, not the shim it points at,
// so the shim must follow the chain before deriving basedir
// (https://github.com/pnpm/pnpm/issues/13405).
const makeShimmedTool = async (tempDir) => {
const binDir = path.join(tempDir, 'node_modules', '.bin')
const targetDir = path.join(tempDir, 'node_modules', 'typescript', 'bin')
fs.mkdirSync(binDir, { recursive: true })
fs.mkdirSync(targetDir, { recursive: true })
const target = path.join(targetDir, 'tsc')
fs.writeFileSync(target, '#!/bin/sh\necho "tsc-output"\n', 'utf8')
fs.chmodSync(target, 0o755)
const shim = path.join(binDir, 'tsc')
await cmdShim(target, shim)
return shim
}
const runShim = (cmd) => {
const r = spawnSync(cmd, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})
assert.equal(r.status, 0, `shim exited ${r.status}\nstdout: ${r.stdout}\nstderr: ${r.stderr}`)
return r.stdout.trim()
}
test('follows a chain of absolute symlinks', async () => {
const tempDir = temporaryDirectory()
const shim = await makeShimmedTool(tempDir)
const hop1 = path.join(tempDir, 'symlink_hop_1')
const hop2 = path.join(tempDir, 'symlink_hop_2')
fs.symlinkSync(shim, hop1)
fs.symlinkSync(hop1, hop2)
assert.equal(runShim(hop2), 'tsc-output')
})
test('follows relative symlinks from another directory', async () => {
const tempDir = temporaryDirectory()
await makeShimmedTool(tempDir)
// Both hops use relative targets, exercising the shim's
// dirname-composition branch rather than the absolute one.
const localBin = path.join(tempDir, 'usr', 'local', 'bin')
fs.mkdirSync(localBin, { recursive: true })
const hop1 = path.join(tempDir, 'usr', 'tsc')
fs.symlinkSync(path.join('..', 'node_modules', '.bin', 'tsc'), hop1)
const hop2 = path.join(localBin, 'tsc')
fs.symlinkSync(path.join('..', '..', 'tsc'), hop2)
assert.equal(runShim(hop2), 'tsc-output')
})
test('resolves symlinks when directories contain spaces', async () => {
const tempDir = path.join(temporaryDirectory(), 'dir with spaces')
fs.mkdirSync(tempDir, { recursive: true })
await makeShimmedTool(tempDir)
const hopDir = path.join(tempDir, 'link dir')
fs.mkdirSync(hopDir)
const hop = path.join(hopDir, 'tsc')
fs.symlinkSync(path.join('..', 'node_modules', '.bin', 'tsc'), hop)
assert.equal(runShim(hop), 'tsc-output')
})
test('runs a node-shebang bin through a symlink chain', async () => {
const tempDir = temporaryDirectory()
const binDir = path.join(tempDir, 'node_modules', '.bin')
const pkgDir = path.join(tempDir, 'node_modules', 'tool')
fs.mkdirSync(binDir, { recursive: true })
fs.mkdirSync(pkgDir, { recursive: true })
const target = path.join(pkgDir, 'cli.js')
fs.writeFileSync(target, '#!/usr/bin/env node\nconsole.log("NODE_BIN_OK")\n', 'utf8')
const shim = path.join(binDir, 'tool')
await cmdShim(target, shim, { createCmdFile: false })
const hop = path.join(tempDir, 'tool')
fs.symlinkSync(path.join('node_modules', '.bin', 'tool'), hop)
assert.equal(runShim(hop), 'NODE_BIN_OK')
})
})
describeOnPosix('sh shim resolves its helpers off the caller\'s PATH', () => {
// A shim runs with node_modules/.bin at the front of PATH, which is where a
// dependency's own bins live, so a helper taken from there could report any
// directory it liked and redirect what the shim finally execs
// (https://github.com/pnpm/pnpm/issues/14837).
const writeExecutable = (file, body) => {
fs.writeFileSync(file, body, 'utf8')
fs.chmodSync(file, 0o755)
}
// A shimmed tool plus a relative symlink to it in the same directory, so the
// walk composes a directory with the link target instead of taking one
// straight from readlink.
const makeShimmedTool = async (tempDir) => {
const binDir = path.join(tempDir, 'node_modules', '.bin')
const target = path.join(tempDir, 'node_modules', 'typescript', 'bin', 'tsc.js')
fs.mkdirSync(binDir, { recursive: true })
fs.mkdirSync(path.dirname(target), { recursive: true })
fs.writeFileSync(target, 'console.log("tsc-output")\n', 'utf8')
// A dependency can declare a bin named node.exe, and the shim's basedir is
// the directory those bins land in. Only a lying uname reaches it.
writeExecutable(path.join(binDir, 'node.exe'), '#!/bin/sh\necho hijacked\n')
const shim = path.join(binDir, 'tsc')
await cmdShim(target, shim, { createCmdFile: false })
fs.symlinkSync('tsc', path.join(binDir, 'tsc-link'))
return binDir
}
// Write the tree the decoys point at, and the decoys, returning the directory
// to put at the front of PATH. Each decoy answers with what its real
// counterpart would be asked for, so any one of them alone is enough to
// redirect the shim.
const plantHijackTreeAndDecoys = (tempDir) => {
const hijack = path.join(tempDir, 'hijack', 'node_modules')
const hijackBin = path.join(hijack, '.bin')
const hijackTarget = path.join(hijack, 'typescript', 'bin', 'tsc.js')
fs.mkdirSync(hijackBin, { recursive: true })
fs.mkdirSync(path.dirname(hijackTarget), { recursive: true })
fs.writeFileSync(hijackTarget, 'console.log("hijacked")\n', 'utf8')
const decoyDir = path.join(tempDir, 'decoy')
fs.mkdirSync(decoyDir)
const answer = (p) => `#!/bin/sh\necho '${p}'\n`
for (const helper of ['readlink', 'sed']) {
writeExecutable(path.join(decoyDir, helper), answer(path.join(hijackBin, 'tsc')))
}
writeExecutable(path.join(decoyDir, 'dirname'), answer(hijackBin))
writeExecutable(path.join(decoyDir, 'uname'), '#!/bin/sh\necho MINGW64_NT-10.0\n')
return decoyDir
}
const runWithDecoys = (tempDir, cmd, args, cwd) => {
const decoyDir = plantHijackTreeAndDecoys(tempDir)
const r = spawnSync(cmd, args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
PATH: [decoyDir, path.dirname(process.execPath), process.env.PATH].join(path.delimiter),
},
})
assert.equal(r.status, 0, `shim exited ${r.status}\nstdout: ${r.stdout}\nstderr: ${r.stderr}`)
assert.equal(r.stdout.trim(), 'tsc-output', 'the shim took a helper from the caller\'s PATH')
}
test('reaches its target with decoy readlink, dirname, sed, and uname first on PATH', async () => {
const tempDir = temporaryDirectory()
const binDir = await makeShimmedTool(tempDir)
runWithDecoys(tempDir, path.join(binDir, 'tsc-link'), [])
})
// The kernel and execvp hand the interpreter the path they resolved, so $0 is
// bare only when a shell is given the name itself.
test('reaches its target when sh receives a bare name', async () => {
const tempDir = temporaryDirectory()
const binDir = await makeShimmedTool(tempDir)
runWithDecoys(tempDir, 'sh', ['tsc-link'], binDir)
})
})
@@ -0,0 +1,85 @@
exports[`create a command shim for a .exe file > shim files 1`] = `
#!/bin/sh
# Resolve $0 through symlinks so basedir is the shim's real directory.
# Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim.
#
# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and
# uname go through \`command -p\`, which searches the system default path instead.
# A dependency's bin cannot stand in for one of them and take over the shim
# before it reaches its target. Directories come from \`\${link%/*}\`, which needs
# no helper at all.
link="$0"
# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup
# and stands for a file in the current directory.
case "$link" in
*/*|*\\\\*) ;;
*) link="./$link" ;;
esac
hops=0
while [ -L "$link" ] && [ "$hops" -lt 40 ]; do
hops=$((hops+1))
target=$(command -p readlink "$link")
case "$target" in
/*) link="$target" ;;
*) link="\${link%/*}/$target" ;;
esac
done
basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g')
basedir="\${basedir%/*}"
basedir_win="$basedir"
exe=""
msys=""
case \`command -p uname -a\` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir_win=\`cygpath -w "$basedir"\`
fi
exe=".exe"
msys="true"
;;
*WSL2*)
if command -v wslpath > /dev/null 2>&1; then
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
basedir_win="$basedir"
else
exe=".exe"
fi
fi
;;
esac
exec "$basedir/foo" "$@"
exit $?
`;
exports[`create a command shim for a .exe file > shim files 2`] = `
@SETLOCAL
@"%~dp0\\foo" %*
`;
exports[`create a command shim for a .exe file > shim files 3`] = `
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/foo" $args
} else {
& "$basedir/foo" $args
}
exit $LASTEXITCODE
`;
+42
View File
@@ -0,0 +1,42 @@
import { fs as memfs } from 'memfs'
import path from 'node:path'
const { fixtures, fixtures2 } = process.platform === 'win32' ? {
fixtures: 'I:\\cmd-shim\\fixtures',
fixtures2: 'J:\\cmd-shim\\fixtures'
} : {
fixtures: '/foo/cmd-shim/fixtures',
fixtures2: '/bar/cmd-shim/fixtures'
}
export { fixtures, fixtures2, memfs as fs }
/** @type {{ [dir: string]: { [filename: string]: string } }} */
const fixtureFiles = {
[fixtures]: {
'src.exe': 'exe',
'src.bat': 'bat',
'src.env': '#!/usr/bin/env node\nconsole.log(/hi/)\n',
'src.env.args': '#!/usr/bin/env node --expose_gc\ngc()\n',
'src.sh': '#!/usr/bin/sh\necho hi\n',
'src.sh.args': '#!/usr/bin/sh -x\necho hi\n',
'from.env.S': '#!/usr/bin/env -S node --expose_gc\ngc()\n'
},
[fixtures2]: {
'src.sh.args': '#!/usr/bin/sh -x\necho hi\n'
}
}
export async function setupFixtures () {
const fsPromises = memfs.promises
await Promise.all(
Object.entries(fixtureFiles).map(async ([dir, files]) => {
await fsPromises.mkdir(dir, { recursive: true })
return Promise.all(
Object.entries(files).map(([filename, contents]) => {
return fsPromises.writeFile(path.join(dir, filename), contents)
})
)
})
)
}
+284
View File
@@ -0,0 +1,284 @@
import { describe, test, before, snapshot } from 'node:test'
import assert from 'node:assert/strict'
snapshot.setDefaultSnapshotSerializers([
(value) => typeof value === 'string' ? `\n${value.replaceAll('\r', '')}` : JSON.stringify(value),
])
import path from 'node:path'
import { cmdExtension } from 'cmd-extension'
import { fixtures, fixtures2, fs, setupFixtures } from './setup.js'
import { cmdShim, isShimPointingAt } from '@pnpm/bins.cmd-shim'
/**
* @param {import('node:test').TestContext} t
* @param {string} fileName
* @param {'\n' | '\r\n'} lineEnding
*/
async function testFile (t, fileName, lineEnding = '\n') {
await t.test(path.basename(fileName).toLowerCase(), async (t) => {
const invalidLineEnding = lineEnding === '\r\n' ? /$(?<!\r)\n/ugm : /$\r\n/ugm
let content = await fs.promises.readFile(fileName, 'utf8')
assert.equal(invalidLineEnding.test(content), false, 'unexpected line ending')
// Normalize cmd extension casing for cross-platform snapshot consistency
if (cmdExtension !== '.cmd') {
content = content.replaceAll(cmdExtension, '.cmd')
}
// Strip the target marker line — it contains a platform-dependent absolute path.
// The marker is tested directly by the isShimPointingAt tests.
content = content.replace(/# cmd-shim-target=.*\n/, '')
t.assert.snapshot(content)
})
}
describe('isShimPointingAt', () => {
const src = path.resolve(fixtures, 'src.exe')
const to = path.resolve(fixtures, 'exe.shim')
before(async () => {
await setupFixtures()
return cmdShim(src, to, { createCmdFile: false, fs })
})
test('returns true for the correct target', async () => {
const content = await fs.promises.readFile(to, 'utf8')
assert.equal(isShimPointingAt(content, src), true)
})
test('returns false for a different target', async () => {
const content = await fs.promises.readFile(to, 'utf8')
assert.equal(isShimPointingAt(content, '/wrong/path.exe'), false)
})
test('returns false for a subdirectory prefix of the target', async () => {
const content = await fs.promises.readFile(to, 'utf8')
// src without the last path segment — must not match
assert.equal(isShimPointingAt(content, path.dirname(src)), false)
})
})
describe('no cmd file', () => {
const src = path.resolve(fixtures, 'src.exe')
const to = path.resolve(fixtures, 'exe.shim')
before(async () => {
await setupFixtures()
return cmdShim(src, to, { createCmdFile: false, fs })
})
test('shim files', async (t) => {
await testFile(t, to)
assert.throws(() => fs.readFileSync(`${to}.cmd`, 'utf8'), /no such file or directory/)
await testFile(t, `${to}.ps1`)
})
})
describe('no shebang', () => {
const src = path.resolve(fixtures, 'src.exe')
const to = path.resolve(fixtures, 'exe.shim')
before(async () => {
await setupFixtures()
return cmdShim(src, to, { createCmdFile: true, fs })
})
test('shim files', async (t) => {
await testFile(t, to)
await testFile(t, `${to}${cmdExtension}`, '\r\n')
await testFile(t, `${to}.ps1`)
})
})
describe('env shebang', () => {
const src = path.resolve(fixtures, 'src.env')
const to = path.resolve(fixtures, 'env.shim')
before(async () => {
await setupFixtures()
return cmdShim(src, to, { createCmdFile: true, fs })
})
test('shim files', async (t) => {
await testFile(t, to)
await testFile(t, `${to}${cmdExtension}`, '\r\n')
await testFile(t, `${to}.ps1`)
})
})
describe('env shebang with PATH extending', () => {
const src = path.resolve(fixtures, 'src.env')
const to = path.resolve(fixtures, 'env.shim')
before(async () => {
await setupFixtures()
return cmdShim(src, to, { prependToPath: '/add-to-path', createCmdFile: true, fs })
})
test('shim files', async (t) => {
await testFile(t, to)
await testFile(t, `${to}${cmdExtension}`, '\r\n')
await testFile(t, `${to}.ps1`)
})
})
describe('env shebang with NODE_PATH', () => {
const src = path.resolve(fixtures, 'src.env')
const to = path.resolve(fixtures, 'env.shim')
before(async () => {
await setupFixtures()
return cmdShim(src, to, { nodePath: ['/john/src/node_modules', '/bin/node/node_modules'], createCmdFile: true, fs })
})
test('shim files', async (t) => {
await testFile(t, to)
await testFile(t, `${to}${cmdExtension}`, '\r\n')
await testFile(t, `${to}.ps1`)
})
})
describe('env shebang with no NODE_PATH', () => {
const src = path.resolve(fixtures, 'src.env')
const to = path.resolve(fixtures, 'env.shim')
before(async () => {
await setupFixtures()
return cmdShim(src, to, { nodePath: [], createCmdFile: true, fs })
})
test('shim files', async (t) => {
await testFile(t, to)
await testFile(t, `${to}${cmdExtension}`, '\r\n')
await testFile(t, `${to}.ps1`)
})
})
describe('env shebang with default args', () => {
const src = path.resolve(fixtures, 'src.env')
const to = path.resolve(fixtures, 'env.shim')
before(async () => {
await setupFixtures()
return cmdShim(src, to, { preserveSymlinks: true, createCmdFile: true, fs })
})
test('shim files', async (t) => {
await testFile(t, to)
await testFile(t, `${to}${cmdExtension}`, '\r\n')
await testFile(t, `${to}.ps1`)
})
})
describe('env shebang with args', () => {
const src = path.resolve(fixtures, 'src.env.args')
const to = path.resolve(fixtures, 'env.args.shim')
before(async () => {
await setupFixtures()
return cmdShim(src, to, { createCmdFile: true, fs })
})
test('shim files', async (t) => {
await testFile(t, to)
await testFile(t, `${to}${cmdExtension}`, '\r\n')
await testFile(t, `${to}.ps1`)
})
})
describe('explicit shebang', () => {
const src = path.resolve(fixtures, 'src.sh')
const to = path.resolve(fixtures, 'sh.shim')
before(async () => {
await setupFixtures()
return cmdShim(src, to, { createCmdFile: true, fs })
})
test('shim files', async (t) => {
await testFile(t, to)
await testFile(t, `${to}${cmdExtension}`, '\r\n')
await testFile(t, `${to}.ps1`)
})
})
describe('explicit shebang with args', () => {
const src = path.resolve(fixtures, 'src.sh.args')
const to = path.resolve(fixtures, 'sh.args.shim')
before(async () => {
await setupFixtures()
return cmdShim(src, to, { createCmdFile: true, fs })
})
test('shim files', async (t) => {
await testFile(t, to)
await testFile(t, `${to}${cmdExtension}`, '\r\n')
await testFile(t, `${to}.ps1`)
})
})
describe('explicit shebang with prog args', () => {
const src = path.resolve(fixtures, 'src.sh.args')
const to = path.resolve(fixtures, 'sh.args.shim')
before(async () => {
await setupFixtures()
return cmdShim(src, to, { createCmdFile: true, progArgs: ['hello'], fs })
})
test('shim files', async (t) => {
await testFile(t, to)
await testFile(t, `${to}${cmdExtension}`, '\r\n')
await testFile(t, `${to}.ps1`)
})
})
describe('custom node executable', () => {
const src = path.resolve(fixtures, 'src.env')
const to = path.resolve(fixtures, 'env.shim')
before(async () => {
await setupFixtures()
return cmdShim(src, to, { createCmdFile: true, nodeExecPath: '/.pnpm/nodejs/16.0.0/node', fs })
})
test('shim files', async (t) => {
await testFile(t, to)
await testFile(t, `${to}${cmdExtension}`, '\r\n')
await testFile(t, `${to}.ps1`)
})
})
const describeOnWindows = process.platform === 'win32' ? describe : describe.skip
describeOnWindows('explicit shebang with args, linking to another drive on Windows', () => {
const src = path.resolve(fixtures2, 'src.sh.args')
const to = path.resolve(fixtures, 'sh.args.shim')
before(async () => {
await setupFixtures()
return cmdShim(src, to, { createCmdFile: true, fs })
})
test('shim files', async (t) => {
await testFile(t, to)
await testFile(t, `${to}${cmdExtension}`, '\r\n')
await testFile(t, `${to}.ps1`)
})
})
describe('shebang with -S', () => {
const src = path.resolve(fixtures, 'from.env.S')
const to = path.resolve(fixtures, 'from.env.S.shim')
before(async () => {
await setupFixtures()
return cmdShim(src, to, { createCmdFile: true, fs })
})
test('shim files', async (t) => {
await testFile(t, to)
await testFile(t, `${to}${cmdExtension}`, '\r\n')
await testFile(t, `${to}.ps1`)
})
})
describe('batch script', () => {
const src = path.resolve(fixtures, 'src.bat')
const to = path.resolve(fixtures, 'bat.shim')
before(async () => {
await setupFixtures()
return cmdShim(src, to, { createCmdFile: true, fs })
})
test('shim files', async (t) => {
await testFile(t, to)
await testFile(t, `${to}${cmdExtension}`, '\r\n')
await testFile(t, `${to}.ps1`)
})
})
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "../node_modules/.test.lib",
"rootDir": "..",
"isolatedModules": true
},
"include": [
"**/*.ts",
"../../../__typings__/**/*.d.ts"
],
"references": [
{
"path": ".."
}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "@pnpm/tsconfig",
"compilerOptions": {
"outDir": "lib",
"rootDir": "src"
},
"include": [
"src/**/*.ts",
"../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../fs/graceful-fs"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*.ts",
"test/**/*.ts",
"../../__typings__/**/*.d.ts"
]
}
+1 -1
View File
@@ -33,6 +33,7 @@
".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest"
},
"dependencies": {
"@pnpm/bins.cmd-shim": "workspace:*",
"@pnpm/bins.resolver": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/fs.read-modules-dir": "workspace:*",
@@ -40,7 +41,6 @@
"@pnpm/pkg-manifest.utils": "workspace:*",
"@pnpm/types": "workspace:*",
"@pnpm/workspace.project-manifest-reader": "workspace:*",
"@zkochan/cmd-shim": "catalog:",
"@zkochan/rimraf": "catalog:",
"bin-links": "catalog:",
"is-subdir": "catalog:",
+1 -13
View File
@@ -2,6 +2,7 @@ import { existsSync, promises as fs } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import { cmdShim, getExeExtension, isShimPointingAt } from '@pnpm/bins.cmd-shim'
import { type Command, getBinsFromPackageManifest, pkgOwnsBin } from '@pnpm/bins.resolver'
import { PnpmError } from '@pnpm/error'
import { readModulesDir } from '@pnpm/fs.read-modules-dir'
@@ -10,7 +11,6 @@ import { readPackageJsonFromDir } from '@pnpm/pkg-manifest.reader'
import { getAllDependenciesFromManifest } from '@pnpm/pkg-manifest.utils'
import type { DependencyManifest, EngineDependency, ProjectManifest } from '@pnpm/types'
import { safeReadProjectManifestOnly } from '@pnpm/workspace.project-manifest-reader'
import { cmdShim, isShimPointingAt } from '@zkochan/cmd-shim'
import { rimraf } from '@zkochan/rimraf'
import fixBin from 'bin-links/lib/fix-bin.js'
import { isSubdir } from 'is-subdir'
@@ -511,18 +511,6 @@ async function hasWindowsShebang (file: string): Promise<boolean> {
}
}
function getExeExtension (): string {
let cmdExtension
if (process.env.PATHEXT) {
cmdExtension = process.env.PATHEXT
.split(path.delimiter)
.find(ext => ext.toUpperCase() === '.EXE')
}
return cmdExtension ?? '.exe'
}
async function safeReadPkgJson (pkgDir: string): Promise<DependencyManifest | null> {
try {
return await readPackageJsonFromDir(pkgDir) as DependencyManifest
+1 -1
View File
@@ -4,8 +4,8 @@ import fs from 'node:fs'
import path from 'node:path'
import { beforeEach, describe, expect, jest, test } from '@jest/globals'
import { cmdShim } from '@pnpm/bins.cmd-shim'
import { fixtures } from '@pnpm/test-fixtures'
import { cmdShim } from '@zkochan/cmd-shim'
import { cmdExtension as CMD_EXTENSION } from 'cmd-extension'
import isWindows from 'is-windows'
import normalizePath from 'normalize-path'
+3
View File
@@ -33,6 +33,9 @@
{
"path": "../../workspace/project-manifest-reader"
},
{
"path": "../cmd-shim"
},
{
"path": "../resolver"
}
@@ -1516,7 +1516,7 @@ describe('linkExePlatformBinary', () => {
// 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
// and @pnpm/bins.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
@@ -84,13 +84,15 @@ test('saveCatalogName works with different protocols', async () => {
.intercept({ path: '/kevva/is-positive', method: 'HEAD' })
.reply(200)
const options = createOptions()
// Override the TLS and socket defaults to keep requests on the global mock dispatcher.
const options = { ...createOptions(), strictSsl: true, maxSockets: 0 }
options.registriesByScope['@jsr'] = 'https://npm.jsr.io/'
await add.handler(options, [
'@pnpm.e2e/foo@100.1.0',
'jsr:@rus/greet@0.0.3',
'github:kevva/is-positive#97edff6',
])
getMockAgent().assertNoPendingInterceptors()
expect(loadJsonFileSync('package.json')).toHaveProperty(['dependencies'], {
'@pnpm.e2e/foo': 'catalog:',
+1 -1
View File
@@ -49,9 +49,9 @@
},
"devDependencies": {
"@jest/globals": "catalog:",
"@pnpm/bins.cmd-shim": "workspace:*",
"@pnpm/exe": "workspace:*",
"@pnpm/jest-config": "workspace:*",
"@zkochan/cmd-shim": "catalog:",
"execa": "catalog:"
},
"jest": {
+2 -2
View File
@@ -4,7 +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 { cmdShim } from '@pnpm/bins.cmd-shim'
import { familySync } from 'detect-libc'
// @ts-expect-error — JS helper without type declarations
@@ -252,7 +252,7 @@ winBashTest('aliases run from Bash (Git Bash / MSYS2) without dropping into inte
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
// entry into @pnpm/bins.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.