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

Cherry-picked from main (e9318ce974).
This commit is contained in:
Zoltan Kochan
2026-03-24 15:57:06 +01:00
parent f3613f19c4
commit 7df00bc3db
2 changed files with 18 additions and 11 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/plugin-commands-script-runners": patch
"pnpm": patch
---
Fixed false "Command not found" errors on Windows when a command exists in PATH but exits with a non-zero code. Also fixed path resolution for `--filter` contexts where the command runs in a different package directory.

View File

@@ -304,7 +304,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,
@@ -398,19 +398,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