From 6c7d2100b9f55fa82e7ce8a067ae9b42dc2873b3 Mon Sep 17 00:00:00 2001 From: b84330808 Date: Tue, 27 Oct 2026 17:32:00 +0900 Subject: [PATCH] fix(exec): run the .cmd shim of a bin with a dot in its name on Windows cmd.exe looks up a command that already has an extension by that exact name before it tries PATHEXT. For a bin like `npm.windows-bin-issue` that finds the extensionless sh shim in node_modules/.bin, and Windows opens it with whatever program is registered for the "extension" instead of running it, so `npx` prints nothing. When the bin name has a dot and a .cmd shim for it exists in binPaths or in a node_modules/.bin above runPath, run the .cmd shim directly. Fixes #8518 Co-Authored-By: Claude Opus 5.5 --- workspaces/libnpmexec/lib/run-script.js | 20 ++++++++++++++++ workspaces/libnpmexec/test/run-script.js | 30 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/workspaces/libnpmexec/lib/run-script.js b/workspaces/libnpmexec/lib/run-script.js index 2de40454f9dcf..16a25910577ef 100644 --- a/workspaces/libnpmexec/lib/run-script.js +++ b/workspaces/libnpmexec/lib/run-script.js @@ -1,9 +1,26 @@ +const { extname, resolve } = require('node:path') const ciInfo = require('ci-info') const runScript = require('@npmcli/run-script') const pkgJson = require('@npmcli/package-json') const { log, output } = require('proc-log') const noTTY = require('./no-tty.js') const isWindowsShell = require('./is-windows.js') +const { fileExists, localFileExists } = require('./file-exists.js') + +// cmd.exe looks up a command that already has an extension by its exact name +// before it tries PATHEXT. For a bin with a dot in its name that finds the +// extensionless sh shim, and Windows opens it with whatever program is +// registered for that "extension" instead of running it. Point cmd.exe at the +// .cmd shim next to it instead. +const hasCmdShim = async (bin, binPaths, runPath) => { + const shim = `${bin}.cmd` + for (const binPath of binPaths) { + if (await fileExists(resolve(binPath, shim))) { + return true + } + } + return !!(await localFileExists(resolve(runPath), shim, '/')) +} const run = async ({ args, @@ -23,6 +40,9 @@ const run = async ({ // literally; double quotes still expand $(), backticks, $var and " args[0] = `'${args[0].replace(/'/g, `'\\''`)}'` } + } else if (args.length > 0 && extname(args[0]) && + await hasCmdShim(args[0], binPaths, runPath)) { + args[0] = `${args[0]}.cmd` } // turn list of args into command string diff --git a/workspaces/libnpmexec/test/run-script.js b/workspaces/libnpmexec/test/run-script.js index 5c92ce19397f6..1a7774c8ef74a 100644 --- a/workspaces/libnpmexec/test/run-script.js +++ b/workspaces/libnpmexec/test/run-script.js @@ -1,3 +1,4 @@ +const { resolve } = require('node:path') const t = require('tap') const mockRunScript = async (t, mocks, { level = 0 } = {}) => { @@ -130,6 +131,35 @@ t.test('isWindows', async t => { await runScript() }) +t.test('isWindows runs the .cmd shim of a bin with a dot in its name', async t => { + const dir = t.testdir({ + global: { 'dot.bin.cmd': '' }, + project: { + node_modules: { '.bin': { 'local.bin.cmd': '' } }, + nested: {}, + }, + }) + let pkg + const { runScript } = await mockRunScript(t, { + 'ci-info': { isCI: true }, + '@npmcli/run-script': async (opts) => { + pkg = opts.pkg + }, + '../lib/is-windows.js': true, + }) + const binPaths = [resolve(dir, 'global')] + const runPath = resolve(dir, 'project', 'nested') + + await runScript({ args: ['dot.bin', 'arg'], binPaths, runPath }) + t.equal(pkg.scripts.npx, 'dot.bin.cmd', 'shim found in binPaths') + + await runScript({ args: ['local.bin'], binPaths, runPath }) + t.equal(pkg.scripts.npx, 'local.bin.cmd', 'shim found in a node_modules/.bin above runPath') + + await runScript({ args: ['no.shim'], binPaths, runPath }) + t.equal(pkg.scripts.npx, 'no.shim', 'left alone without a .cmd shim') +}) + t.test('escapes executable name to neutralize shell metacharacters', async t => { let pkg const { runScript } = await mockRunScript(t, {