diff --git a/.changeset/fix-windows-node-path-too-long.md b/.changeset/fix-windows-node-path-too-long.md new file mode 100644 index 0000000000..8f89a878a4 --- /dev/null +++ b/.changeset/fix-windows-node-path-too-long.md @@ -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). diff --git a/pkg-manager/link-bins/src/getBinNodePaths.ts b/pkg-manager/link-bins/src/getBinNodePaths.ts new file mode 100644 index 0000000000..469f963e81 --- /dev/null +++ b/pkg-manager/link-bins/src/getBinNodePaths.ts @@ -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 { + 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 [] +} diff --git a/pkg-manager/link-bins/src/index.ts b/pkg-manager/link-bins/src/index.ts index 8929c8cb5a..3b3198b755 100644 --- a/pkg-manager/link-bins/src/index.ts +++ b/pkg-manager/link-bins/src/index.ts @@ -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 { - 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 { try { return await readPackageJsonFromDir(pkgDir) as DependencyManifest diff --git a/pkg-manager/link-bins/test/getBinNodePaths.ts b/pkg-manager/link-bins/test/getBinNodePaths.ts new file mode 100644 index 0000000000..fce13047cf --- /dev/null +++ b/pkg-manager/link-bins/test/getBinNodePaths.ts @@ -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'), + ]) +})