fix: use ENOENT check instead of which.sync for command-not-found on Windows#11004
Conversation
…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>
There was a problem hiding this comment.
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
whichdependency from@pnpm/exec.commands(and lockfile), plus related unused imports. - Add a changeset to release the fix for both
@pnpm/exec.commandsandpnpm.
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.
| function isErrorCommandNotFound (command: string, error: CommandError): boolean { | ||
| return error.originalMessage === `spawn ${command} ENOENT` | ||
| } |
| "realpath-missing": "catalog:", | ||
| "render-help": "catalog:", | ||
| "symlink-dir": "catalog:", | ||
| "which": "catalog:", | ||
| "write-json-file": "catalog:" |
| } 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], { |
CI Failure AnalysisThe failing tests in this CI run ( Evidence
Failing tests
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>
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, runningpnpm --filter sub-package exec prettier --check test.jswhere Prettier finds formatting issues reportsCommand "prettier" not foundinstead of the actual exit code error.Root Cause
The
isErrorCommandNotFound()function inexec/commands/src/exec.tshad a flawed Windows implementation:error.originalMessage === 'spawn ${command} ENOENT'— the standard Node.js error when a binary cannot be found.which.sync()to check if the command exists in PATH, but resolved relative paths (like./node_modules/.bin) againstprocess.cwd()instead of the exec prefix directory. In--filtercontexts where the command runs in a different package directory, this causedwhich.syncto look in the wrongnode_modules/.bin, failing to find the command and incorrectly reporting "Command not found".Fix
Two changes to
isErrorCommandNotFound():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.Fixed Windows fallback:
which.syncis still needed on Windows because execa 9.x usescross-spawnonly for command parsing (crossSpawn._parse()), not for spawning — it callsnode:child_process.spawn()directly. This means cross-spawn'shookChildProcessENOENT detection never fires, and non-existent commands wrapped ascmd.exe /c <command>exit with code 1 instead of emitting ENOENT. The fix resolves relativeprependPathsagainst the execprefixdirectory (usingpath.resolve(prefix, p)) sowhich.synccorrectly searches in the rightnode_modules/.bin.Test plan
pnpm --filter @pnpm/exec.commands run compile)exec.e2e.tscommand-not-found tests pass