fix(link-bins): stop prepending redundant paths to NODE_PATH in command shims (#10673)

Fixed "input line too long" error on Windows when running lifecycle scripts with the global virtual store enabled. The `NODE_PATH` in command shims no longer includes all paths from `Module._nodeModulePaths()`. Instead, it includes only the package's bundled dependencies directory (e.g., `.pnpm/pkg@version/node_modules/pkg/node_modules`), the package's sibling dependencies directory (e.g., `.pnpm/pkg@version/node_modules`), and the hoisted `node_modules` directory. These paths are needed so that tools like `import-local` (used by jest, eslint, etc.) which resolve from CWD can find the correct dependency versions.
This commit is contained in:
Zoltan Kochan
2026-02-23 04:19:32 +01:00
committed by GitHub
parent 1549743b36
commit cb228c900c
4 changed files with 189 additions and 21 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/link-bins": patch
"pnpm": patch
---
Fixed "input line too long" error on Windows when running lifecycle scripts with the global virtual store enabled. The `NODE_PATH` in command shims no longer includes all paths from `Module._nodeModulePaths()`. Instead, it includes only the package's bundled dependencies directory (e.g., `.pnpm/pkg@version/node_modules/pkg/node_modules`), the package's sibling dependencies directory (e.g., `.pnpm/pkg@version/node_modules`), and the hoisted `node_modules` directory. These paths are needed so that tools like `import-local` (used by jest, eslint, etc.) which resolve from CWD can find the correct dependency versions [#10673](https://github.com/pnpm/pnpm/pull/10673).

View File

@@ -0,0 +1,55 @@
import { promises as fs } from 'fs'
import path from 'path'
/**
* Returns the node_modules paths relevant to a binary in the virtual store layout.
* For a binary at `.pnpm/pkg@version/node_modules/pkg/bin/cli.js`, this returns:
* 1. `.pnpm/pkg@version/node_modules/pkg/node_modules` (bundled dependencies)
* 2. `.pnpm/pkg@version/node_modules` (sibling/regular dependencies)
*
* These directories must be in NODE_PATH so that tools like `import-local`
* (used by jest, eslint, etc.) which resolve from CWD can find the correct
* dependency versions.
*/
export async function getBinNodePaths (target: string): Promise<string[]> {
const targetDir = path.dirname(target)
let dir: string
try {
dir = await fs.realpath(targetDir)
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err
}
dir = targetDir
}
// Walk up from the resolved directory to find the first non-nested node_modules
let currentDir = dir
while (true) {
if (path.basename(currentDir) === 'node_modules') {
// Skip nested node_modules (e.g., node_modules/node_modules)
if (path.basename(path.dirname(currentDir)) !== 'node_modules') {
const nodeModulesDir = currentDir
const result: string[] = []
// Determine the package directory from the relative path between
// node_modules and the resolved binary directory
const rel = path.relative(nodeModulesDir, dir)
if (rel) {
const relSegments = rel.split(path.sep)
// For scoped packages, the package dir is two levels deep: @scope/pkg
const pkgDir = relSegments[0].startsWith('@')
? path.join(nodeModulesDir, relSegments[0], relSegments[1])
: path.join(nodeModulesDir, relSegments[0])
result.push(path.join(pkgDir, 'node_modules'))
}
result.push(nodeModulesDir)
return result
}
}
const parent = path.dirname(currentDir)
if (parent === currentDir) break
currentDir = parent
}
return []
}

View File

@@ -1,5 +1,5 @@
import { promises as fs, existsSync } from 'fs'
import Module, { createRequire } from 'module'
import { createRequire } from 'module'
import path from 'path'
import { getNodeBinLocationForCurrentOS, getDenoBinLocationForCurrentOS, getBunBinLocationForCurrentOS } from '@pnpm/constants'
import { PnpmError } from '@pnpm/error'
@@ -19,6 +19,7 @@ import { isEmpty, unnest, groupBy, partition } from 'ramda'
import semver from 'semver'
import symlinkDir from 'symlink-dir'
import fixBin from 'bin-links/lib/fix-bin.js'
import { getBinNodePaths } from './getBinNodePaths.js'
const binsConflictLogger = logger('bins-conflict')
const IS_WINDOWS = isWindows()
@@ -306,12 +307,17 @@ async function linkBin (cmd: CommandInfo, binsDir: string, opts?: LinkBinOptions
try {
let nodePath: string[] | undefined
if (opts?.extraNodePaths?.length) {
nodePath = []
for (const modulesPath of await getBinNodePaths(cmd.path)) {
if (opts.extraNodePaths.includes(modulesPath)) break
nodePath.push(modulesPath)
const binNodePaths = await getBinNodePaths(cmd.path)
if (binNodePaths.length === 0) {
nodePath = opts.extraNodePaths
} else {
nodePath = [...binNodePaths]
for (const p of opts.extraNodePaths) {
if (!binNodePaths.includes(p)) {
nodePath.push(p)
}
}
}
nodePath.push(...opts.extraNodePaths)
}
await cmdShim(cmd.path, externalBinPath, {
createPwshFile: cmd.makePowerShellShim,
@@ -344,21 +350,6 @@ function getExeExtension (): string {
return cmdExtension ?? '.exe'
}
async function getBinNodePaths (target: string): Promise<string[]> {
const targetDir = path.dirname(target)
try {
const targetRealPath = await fs.realpath(targetDir)
// @ts-expect-error
return Module['_nodeModulePaths'](targetRealPath)
} catch (err: any) { // eslint-disable-line
if (err.code !== 'ENOENT') {
throw err
}
// @ts-expect-error
return Module['_nodeModulePaths'](targetDir)
}
}
async function safeReadPkgJson (pkgDir: string): Promise<DependencyManifest | null> {
try {
return await readPackageJsonFromDir(pkgDir) as DependencyManifest

View File

@@ -0,0 +1,116 @@
import fs from 'fs'
import path from 'path'
import { getBinNodePaths } from '../src/getBinNodePaths.js'
import { temporaryDirectory } from 'tempy'
test('returns package node_modules and sibling node_modules for virtual store layout', async () => {
const tmp = temporaryDirectory()
// Simulate: .pnpm/pkg@1.0.0/node_modules/pkg/bin/cli.js
const binPath = path.join(tmp, '.pnpm', 'pkg@1.0.0', 'node_modules', 'pkg', 'bin', 'cli.js')
fs.mkdirSync(path.dirname(binPath), { recursive: true })
fs.writeFileSync(binPath, '')
const result = await getBinNodePaths(binPath)
expect(result).toEqual([
path.join(tmp, '.pnpm', 'pkg@1.0.0', 'node_modules', 'pkg', 'node_modules'),
path.join(tmp, '.pnpm', 'pkg@1.0.0', 'node_modules'),
])
})
test('returns only the node_modules dir when binary is directly inside node_modules/pkg', async () => {
const tmp = temporaryDirectory()
// Simulate: node_modules/pkg/bin/cli.js
const binPath = path.join(tmp, 'node_modules', 'pkg', 'bin', 'cli.js')
fs.mkdirSync(path.dirname(binPath), { recursive: true })
fs.writeFileSync(binPath, '')
const result = await getBinNodePaths(binPath)
expect(result).toEqual([
path.join(tmp, 'node_modules', 'pkg', 'node_modules'),
path.join(tmp, 'node_modules'),
])
})
test('returns empty array when there is no node_modules ancestor', async () => {
const tmp = temporaryDirectory()
// Simulate: some/path/bin/cli.js (no node_modules)
const binPath = path.join(tmp, 'some', 'path', 'bin', 'cli.js')
fs.mkdirSync(path.dirname(binPath), { recursive: true })
fs.writeFileSync(binPath, '')
const result = await getBinNodePaths(binPath)
expect(result).toEqual([])
})
test('resolves symlinks to find the real path', async () => {
const tmp = temporaryDirectory()
// Real location: .pnpm/pkg@1.0.0/node_modules/pkg/bin/cli.js
const realBinDir = path.join(tmp, '.pnpm', 'pkg@1.0.0', 'node_modules', 'pkg', 'bin')
fs.mkdirSync(realBinDir, { recursive: true })
fs.writeFileSync(path.join(realBinDir, 'cli.js'), '')
// Symlink: node_modules/pkg -> .pnpm/pkg@1.0.0/node_modules/pkg
const symlinkTarget = path.join(tmp, 'node_modules', 'pkg')
fs.mkdirSync(path.join(tmp, 'node_modules'), { recursive: true })
fs.symlinkSync(
path.join(tmp, '.pnpm', 'pkg@1.0.0', 'node_modules', 'pkg'),
symlinkTarget,
'junction'
)
// Pass the symlinked path
const binPath = path.join(symlinkTarget, 'bin', 'cli.js')
const result = await getBinNodePaths(binPath)
// Should resolve through the symlink and return paths based on the real location
expect(result).toEqual([
path.join(tmp, '.pnpm', 'pkg@1.0.0', 'node_modules', 'pkg', 'node_modules'),
path.join(tmp, '.pnpm', 'pkg@1.0.0', 'node_modules'),
])
})
test('falls back to original path when target directory does not exist', async () => {
const tmp = temporaryDirectory()
// Path that does not exist on disk
const binPath = path.join(tmp, 'node_modules', 'pkg', 'bin', 'cli.js')
const result = await getBinNodePaths(binPath)
expect(result).toEqual([
path.join(tmp, 'node_modules', 'pkg', 'node_modules'),
path.join(tmp, 'node_modules'),
])
})
test('handles scoped packages in virtual store layout', async () => {
const tmp = temporaryDirectory()
// Simulate: .pnpm/@scope+pkg@1.0.0/node_modules/@scope/pkg/bin/cli.js
const binPath = path.join(tmp, '.pnpm', '@scope+pkg@1.0.0', 'node_modules', '@scope', 'pkg', 'bin', 'cli.js')
fs.mkdirSync(path.dirname(binPath), { recursive: true })
fs.writeFileSync(binPath, '')
const result = await getBinNodePaths(binPath)
expect(result).toEqual([
path.join(tmp, '.pnpm', '@scope+pkg@1.0.0', 'node_modules', '@scope', 'pkg', 'node_modules'),
path.join(tmp, '.pnpm', '@scope+pkg@1.0.0', 'node_modules'),
])
})
test('binary at root of package (no subdirectory)', async () => {
const tmp = temporaryDirectory()
// Simulate: .pnpm/pkg@1.0.0/node_modules/pkg/cli.js (binary at package root)
const binPath = path.join(tmp, '.pnpm', 'pkg@1.0.0', 'node_modules', 'pkg', 'cli.js')
fs.mkdirSync(path.dirname(binPath), { recursive: true })
fs.writeFileSync(binPath, '')
const result = await getBinNodePaths(binPath)
expect(result).toEqual([
path.join(tmp, '.pnpm', 'pkg@1.0.0', 'node_modules', 'pkg', 'node_modules'),
path.join(tmp, '.pnpm', 'pkg@1.0.0', 'node_modules'),
])
})