From 78951f2adbda6ccfe47fffd1ee606db86c176c9b Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Wed, 22 Apr 2026 14:41:12 +0200 Subject: [PATCH] fix: global bin shim invokes pnpm instead of Node when installed via @pnpm/exe (#11335) * fix(installation): skip pnpm exe when no Node.js is on PATH When pnpm is installed as @pnpm/exe (a Single Executable Application that bundles Node.js into the pnpm binary) and the user has no separate Node.js on PATH, `which('node')` fails and `getNodeExecPath` used to fall back to `process.execPath` - which in @pnpm/exe is the pnpm binary itself, not a Node binary. That path got baked into generated global bin shims via nodeExecPath, so running any globally-installed CLI invoked pnpm with the target script as its first positional arg, producing `ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND` from the current working directory. Detect the @pnpm/exe case via `detectIfCurrentPkgIsExecutable` and return undefined from the fallback so the shim emits a plain `exec node ` instead. Closes #11291 Refs #4645 * Update pkg-manager/plugin-commands-installation/src/nodeExecPath.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .changeset/fix-global-shim-pnpm-exe.md | 6 +++ .../plugin-commands-installation/package.json | 1 + .../src/installDeps.ts | 2 +- .../src/nodeExecPath.ts | 9 ++++- .../test/global.ts | 1 + .../test/nodeExecPath.ts | 37 +++++++++++++++++++ .../tsconfig.json | 3 ++ pnpm-lock.yaml | 3 ++ 8 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-global-shim-pnpm-exe.md create mode 100644 pkg-manager/plugin-commands-installation/test/nodeExecPath.ts diff --git a/.changeset/fix-global-shim-pnpm-exe.md b/.changeset/fix-global-shim-pnpm-exe.md new file mode 100644 index 0000000000..9b3ce68340 --- /dev/null +++ b/.changeset/fix-global-shim-pnpm-exe.md @@ -0,0 +1,6 @@ +--- +"@pnpm/plugin-commands-installation": patch +"pnpm": patch +--- + +Globally-installed bins no longer fail with `ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND` when pnpm was installed via the standalone `@pnpm/exe` binary (e.g. `curl -fsSL https://get.pnpm.io/install.sh | sh -`) on a system without a separate Node.js installation. Previously, when `which('node')` failed during `pnpm add --global`, pnpm fell back to `process.execPath`, which in `@pnpm/exe` is the pnpm binary itself — and that path was baked into the generated bin shim, causing the shim to invoke pnpm instead of Node [#11291](https://github.com/pnpm/pnpm/issues/11291), [#4645](https://github.com/pnpm/pnpm/issues/4645). diff --git a/pkg-manager/plugin-commands-installation/package.json b/pkg-manager/plugin-commands-installation/package.json index 5b183996ff..14478d35b8 100644 --- a/pkg-manager/plugin-commands-installation/package.json +++ b/pkg-manager/plugin-commands-installation/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@pnpm/catalogs.types": "workspace:*", + "@pnpm/cli-meta": "workspace:*", "@pnpm/cli-utils": "workspace:*", "@pnpm/colorize-semver-diff": "catalog:", "@pnpm/command": "workspace:*", diff --git a/pkg-manager/plugin-commands-installation/src/installDeps.ts b/pkg-manager/plugin-commands-installation/src/installDeps.ts index a79011e3b2..e55fcdc864 100644 --- a/pkg-manager/plugin-commands-installation/src/installDeps.ts +++ b/pkg-manager/plugin-commands-installation/src/installDeps.ts @@ -268,7 +268,7 @@ when running add/update with the --workspace option') } if (opts.global && opts.pnpmHomeDir != null) { const nodeExecPath = await getNodeExecPath() - if (isSubdir(opts.pnpmHomeDir, nodeExecPath)) { + if (nodeExecPath != null && isSubdir(opts.pnpmHomeDir, nodeExecPath)) { installOpts['nodeExecPath'] = nodeExecPath } } diff --git a/pkg-manager/plugin-commands-installation/src/nodeExecPath.ts b/pkg-manager/plugin-commands-installation/src/nodeExecPath.ts index f344aa0b89..34b8851b15 100644 --- a/pkg-manager/plugin-commands-installation/src/nodeExecPath.ts +++ b/pkg-manager/plugin-commands-installation/src/nodeExecPath.ts @@ -1,7 +1,8 @@ import { promises as fs } from 'fs' +import { detectIfCurrentPkgIsExecutable } from '@pnpm/cli-meta' import which from 'which' -export async function getNodeExecPath (): Promise { +export async function getNodeExecPath (): Promise { try { // The system default Node.js executable is preferred // not the one used to run the pnpm CLI. @@ -9,6 +10,12 @@ export async function getNodeExecPath (): Promise { return fs.realpath(nodeExecPath) } catch (err: any) { // eslint-disable-line if (err['code'] !== 'ENOENT') throw err + // When pnpm runs as @pnpm/exe (a packaged executable that bundles + // Node.js into the pnpm binary), process.execPath points at the pnpm + // binary itself rather than a standalone Node binary. Using it as the + // nodeExecPath of a bin shim makes the shim invoke pnpm instead of + // Node, which breaks globally-installed CLIs. See #11291 and #4645. + if (detectIfCurrentPkgIsExecutable()) return undefined return process.env.NODE ?? process.execPath } } diff --git a/pkg-manager/plugin-commands-installation/test/global.ts b/pkg-manager/plugin-commands-installation/test/global.ts index da03cf814c..0d777e31ae 100644 --- a/pkg-manager/plugin-commands-installation/test/global.ts +++ b/pkg-manager/plugin-commands-installation/test/global.ts @@ -43,6 +43,7 @@ const DEFAULT_OPTIONS = { test('globally installed package is linked with active version of Node.js', async () => { const nodeExecPath = await getNodeExecPath() + if (nodeExecPath == null) throw new Error('Node.js executable was not found') prepare() await add.handler({ ...DEFAULT_OPTIONS, diff --git a/pkg-manager/plugin-commands-installation/test/nodeExecPath.ts b/pkg-manager/plugin-commands-installation/test/nodeExecPath.ts new file mode 100644 index 0000000000..92e82f81d8 --- /dev/null +++ b/pkg-manager/plugin-commands-installation/test/nodeExecPath.ts @@ -0,0 +1,37 @@ +import { detectIfCurrentPkgIsExecutable } from '@pnpm/cli-meta' +import which from 'which' +import { getNodeExecPath } from '../lib/nodeExecPath.js' + +jest.mock('which', () => jest.fn()) +jest.mock('@pnpm/cli-meta', () => ({ + detectIfCurrentPkgIsExecutable: jest.fn(), +})) + +const whichMock = jest.mocked(which) +const detectMock = jest.mocked(detectIfCurrentPkgIsExecutable) + +afterEach(() => { + whichMock.mockReset() + detectMock.mockReset() +}) + +test('returns undefined when node is not on PATH and pnpm is running as @pnpm/exe', async () => { + const enoent: NodeJS.ErrnoException = Object.assign(new Error('not found: node'), { code: 'ENOENT' }) + whichMock.mockRejectedValue(enoent) + detectMock.mockReturnValue(true) + + await expect(getNodeExecPath()).resolves.toBeUndefined() +}) + +test('falls back to process.execPath when node is not on PATH and pnpm is running under a real Node.js', async () => { + const enoent: NodeJS.ErrnoException = Object.assign(new Error('not found: node'), { code: 'ENOENT' }) + whichMock.mockRejectedValue(enoent) + detectMock.mockReturnValue(false) + const savedNode = process.env.NODE + delete process.env.NODE + try { + await expect(getNodeExecPath()).resolves.toBe(process.execPath) + } finally { + if (savedNode != null) process.env.NODE = savedNode + } +}) diff --git a/pkg-manager/plugin-commands-installation/tsconfig.json b/pkg-manager/plugin-commands-installation/tsconfig.json index 52e5e55bc9..274b675de0 100644 --- a/pkg-manager/plugin-commands-installation/tsconfig.json +++ b/pkg-manager/plugin-commands-installation/tsconfig.json @@ -24,6 +24,9 @@ { "path": "../../catalogs/types" }, + { + "path": "../../cli/cli-meta" + }, { "path": "../../cli/cli-utils" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f2f7de8e6..9b093c8032 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5754,6 +5754,9 @@ importers: '@pnpm/catalogs.types': specifier: workspace:* version: link:../../catalogs/types + '@pnpm/cli-meta': + specifier: workspace:* + version: link:../../cli/cli-meta '@pnpm/cli-utils': specifier: workspace:* version: link:../../cli/cli-utils