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

* fix: use ENOENT check instead of which.sync for command-not-found on 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 #11000

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve prependPaths against exec prefix for correct Windows command 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 #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>

---------

Co-authored-by: zubeyralmaho <zubeyralmaho@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zybo
2026-03-22 15:28:54 +03:00
committed by GitHub
parent f7bb668100
commit e9318ce974
2 changed files with 18 additions and 11 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/exec.commands": patch
"pnpm": patch
---
Fixed false "Command not found" error on Windows when the command exists but exits with a non-zero exit code [#11000](https://github.com/pnpm/pnpm/issues/11000).

View File

@@ -296,7 +296,7 @@ export async function handler (
result[prefix].status = 'passed'
result[prefix].duration = getExecutionDuration(startTime)
} catch (err: any) { // eslint-disable-line
if (isErrorCommandNotFound(params[0], err, prependPaths)) {
if (isErrorCommandNotFound(params[0], err, prefix, prependPaths)) {
err.message = `Command "${params[0]}" not found`
err.hint = await createExecCommandNotFoundHint(params[0], {
implicitlyFellbackFromRun: opts.implicitlyFellbackFromRun ?? false,
@@ -394,19 +394,20 @@ interface CommandError extends Error {
shortMessage: string
}
function isErrorCommandNotFound (command: string, error: CommandError, prependPaths: string[]): boolean {
// Mac/Linux
if (process.platform === 'linux' || process.platform === 'darwin') {
return error.originalMessage === `spawn ${command} ENOENT`
function isErrorCommandNotFound (command: string, error: CommandError, prefix: string, prependPaths: string[]): boolean {
if (error.originalMessage === `spawn ${command} ENOENT`) {
return true
}
// Windows
// On Windows, execa 9.x uses cross-spawn only for command parsing (not spawning),
// so cross-spawn's ENOENT hook never fires. Non-existent commands get wrapped as
// `cmd.exe /c <command>` which exits with code 1 instead of emitting ENOENT.
// Fall back to checking if the command exists in PATH, resolving relative paths
// against the exec prefix to correctly handle --filter contexts.
if (process.platform === 'win32') {
const { value: path } = prependDirsToPath(prependPaths)
return !which.sync(command, {
nothrow: true,
path,
})
const absolutePrependPaths = prependPaths.map(p => path.resolve(prefix, p))
const { value: searchPath } = prependDirsToPath(absolutePrependPaths)
return !which.sync(command, { nothrow: true, path: searchPath })
}
return false