Skip to content

fix: use ENOENT check instead of which.sync for command-not-found on Windows#11004

Merged
zkochan merged 2 commits into
pnpm:mainfrom
zubeyralmaho:fix/windows-exec-command-not-found-11000
Mar 22, 2026
Merged

fix: use ENOENT check instead of which.sync for command-not-found on Windows#11004
zkochan merged 2 commits into
pnpm:mainfrom
zubeyralmaho:fix/windows-exec-command-not-found-11000

Conversation

@zubeyralmaho

@zubeyralmaho zubeyralmaho commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #11000

On Windows, pnpm exec (with --filter) falsely reports "Command not found" when the command actually exists but exits with a non-zero exit code. For example, running pnpm --filter sub-package exec prettier --check test.js where Prettier finds formatting issues reports Command "prettier" not found instead of the actual exit code error.

Root Cause

The isErrorCommandNotFound() function in exec/commands/src/exec.ts had a flawed Windows implementation:

  • Linux/macOS: Correctly checked error.originalMessage === 'spawn ${command} ENOENT' — the standard Node.js error when a binary cannot be found.
  • Windows: Used which.sync() to check if the command exists in PATH, but resolved relative paths (like ./node_modules/.bin) against process.cwd() instead of the exec prefix directory. In --filter contexts where the command runs in a different package directory, this caused which.sync to look in the wrong node_modules/.bin, failing to find the command and incorrectly reporting "Command not found".

Fix

Two changes to isErrorCommandNotFound():

  1. Unified ENOENT check first (all platforms): error.originalMessage === 'spawn ${command} ENOENT' — this handles the common case on Linux/macOS where Node.js spawn directly emits ENOENT.

  2. Fixed Windows fallback: which.sync is still needed on Windows because execa 9.x uses cross-spawn only for command parsing (crossSpawn._parse()), not for spawning — it calls node:child_process.spawn() directly. This means cross-spawn's hookChildProcess ENOENT detection never fires, and non-existent commands wrapped as cmd.exe /c <command> exit with code 1 instead of emitting ENOENT. The fix resolves relative prependPaths against the exec prefix directory (using path.resolve(prefix, p)) so which.sync correctly searches in the right node_modules/.bin.

Test plan

  • Compile passes (pnpm --filter @pnpm/exec.commands run compile)
  • Lint passes (pre-push hook)
  • CI: Verify Windows exec.e2e.ts command-not-found tests pass
  • CI: Verify Linux/macOS tests still pass

…Windows

On Windows, `which.sync()` only checks if a command exists in PATH,
not whether it actually executed successfully. This caused false
"Command not found" errors when a command exists but exits with a
non-zero code. Use the same `spawn ENOENT` check across all platforms,
which is reliable thanks to cross-spawn used by execa.

Closes pnpm#11000

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@zubeyralmaho zubeyralmaho requested a review from zkochan as a code owner March 18, 2026 04:41
Copilot AI review requested due to automatic review settings March 18, 2026 04:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a Windows-specific misclassification in pnpm exec (notably when used with filtering/recursive contexts) where non-zero exit codes from an existing command could be incorrectly reported as “Command not found”, by unifying “command not found” detection across platforms.

Changes:

  • Remove Windows-specific which.sync()-based existence checks and rely on ENOENT spawn failure detection for all platforms.
  • Drop the which dependency from @pnpm/exec.commands (and lockfile), plus related unused imports.
  • Add a changeset to release the fix for both @pnpm/exec.commands and pnpm.

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 3 comments.

File Description
exec/commands/src/exec.ts Simplifies command-not-found detection and removes which/PATH-prepend logic from error classification.
exec/commands/package.json Removes the which runtime dependency from @pnpm/exec.commands.
pnpm-lock.yaml Updates the lockfile to reflect removal of which from this importer.
.changeset/fix-windows-exec-command-not-found.md Adds a patch changeset documenting the Windows fix.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment thread exec/commands/src/exec.ts Outdated
Comment on lines 395 to 397
function isErrorCommandNotFound (command: string, error: CommandError): boolean {
return error.originalMessage === `spawn ${command} ENOENT`
}
Comment on lines 68 to 71
"realpath-missing": "catalog:",
"render-help": "catalog:",
"symlink-dir": "catalog:",
"which": "catalog:",
"write-json-file": "catalog:"
Comment thread exec/commands/src/exec.ts
Comment on lines 296 to 299
} catch (err: any) { // eslint-disable-line
if (isErrorCommandNotFound(params[0], err, prependPaths)) {
if (isErrorCommandNotFound(params[0], err)) {
err.message = `Command "${params[0]}" not found`
err.hint = await createExecCommandNotFoundHint(params[0], {
@zubeyralmaho

Copy link
Copy Markdown
Contributor Author

CI Failure Analysis

The failing tests in this CI run (@pnpm/deps.inspection.commands and @pnpm/deps.compliance.commands) are not related to the changes in this PR.

Evidence

Failing tests

  • @pnpm/deps.inspection.commandstest/outdated/index.tsMETA_FETCH_FAIL on multiple pnpm outdated tests
  • @pnpm/deps.compliance.commands → similar registry fetch failures

Same issue as #11003 — these are pre-existing flaky tests unrelated to this fix.

…mand lookup

The previous ENOENT-only approach doesn't work on Windows because execa 9.x
uses cross-spawn only for command parsing, not spawning. This means cross-spawn's
ENOENT hook (hookChildProcess) never fires, and non-existent commands wrapped as
`cmd.exe /c <command>` exit with code 1 instead of emitting ENOENT.

Restore the which.sync fallback for Windows, but fix the original pnpm#11000 bug by
resolving relative prependPaths (like ./node_modules/.bin) against the exec prefix
instead of relying on process.cwd(). This ensures correct path resolution in
--filter contexts where the command runs in a different package directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@zkochan zkochan merged commit e9318ce into pnpm:main Mar 22, 2026
8 checks passed
zkochan added a commit that referenced this pull request Mar 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PNPM falsely reports "Command not found" on Windows

3 participants