From 7502d38bf9ce6eeb86a17a0906a4fadf439c39d4 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:06:39 -0600 Subject: [PATCH] fix(windows): prefer cmd npm shim on PATH fallback --- hermes_constants.py | 30 +++++++++++++++++++++++++++++- tests/test_hermes_constants.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/hermes_constants.py b/hermes_constants.py index 738d4c224cc..9f131f30489 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -290,13 +290,41 @@ def find_hermes_node_executable(command: str) -> str | None: return None +def find_node_executable_on_path(command: str) -> str | None: + """Return a Node/npm executable from PATH with Windows shim ordering. + + ``shutil.which("npm")`` can resolve an extensionless npm shim before the + ``.cmd`` shim on Windows. Python's CreateProcess cannot execute that shim + directly, so prefer the launchable variants explicitly for Hermes-owned + subprocesses. + """ + if sys.platform != "win32": + return shutil.which(command) + + command_str = str(command) + has_path_separator = any( + sep and sep in command_str for sep in (os.sep, os.altsep, "/", "\\") + ) + if has_path_separator: + return command_str if Path(command_str).is_file() else None + + for name in _candidate_node_command_names(command_str): + for directory in os.environ.get("PATH", "").split(os.pathsep): + if not directory: + continue + candidate = Path(directory) / name + if candidate.is_file(): + return str(candidate) + return None + + def find_node_executable(command: str) -> str | None: """Resolve a Node.js command, preferring Hermes-managed installs. This is for Hermes-owned subprocesses that should not be broken by a bad, missing, or elevation-triggering system Node/npm on PATH. """ - return find_hermes_node_executable(command) or shutil.which(command) + return find_hermes_node_executable(command) or find_node_executable_on_path(command) def with_hermes_node_path(env: dict[str, str] | None = None) -> dict[str, str]: diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index a3c2a03a304..d6b67cd3348 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -9,6 +9,8 @@ import hermes_constants from hermes_constants import ( VALID_REASONING_EFFORTS, find_hermes_node_executable, + find_node_executable, + find_node_executable_on_path, get_default_hermes_root, get_hermes_home, iter_hermes_node_dirs, @@ -131,6 +133,35 @@ class TestHermesManagedNode: assert find_hermes_node_executable("npm") == str(npm_cmd) + def test_windows_path_fallback_prefers_npm_cmd(self, tmp_path, monkeypatch): + bin_dir = tmp_path / "nodejs" + bin_dir.mkdir() + extensionless = bin_dir / "npm" + powershell = bin_dir / "npm.ps1" + npm_cmd = bin_dir / "npm.cmd" + extensionless.write_text("#!/usr/bin/env node\n") + powershell.write_text("Write-Output npm\n") + npm_cmd.write_text("@echo off\n") + monkeypatch.setattr(hermes_constants.sys, "platform", "win32") + monkeypatch.setenv("PATH", str(bin_dir)) + + assert find_node_executable_on_path("npm") == str(npm_cmd) + + def test_windows_node_executable_falls_back_to_safe_path_shim(self, tmp_path, monkeypatch): + home = tmp_path / "hermes" + home.mkdir() + bin_dir = tmp_path / "nodejs" + bin_dir.mkdir() + extensionless = bin_dir / "npm" + npm_cmd = bin_dir / "npm.cmd" + extensionless.write_text("#!/usr/bin/env node\n") + npm_cmd.write_text("@echo off\n") + monkeypatch.setattr(hermes_constants.sys, "platform", "win32") + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("PATH", str(bin_dir)) + + assert find_node_executable("npm") == str(npm_cmd) + def test_with_hermes_node_path_prepends_existing_managed_dirs(self, tmp_path, monkeypatch): home = tmp_path / "hermes" node_dir = home / "node"