fix(windows): prefer managed node for whatsapp and desktop

This commit is contained in:
helix4u 2026-06-19 13:55:15 -06:00 committed by kshitijk4poor
parent 38f1a923af
commit 7a7b56d498
5 changed files with 173 additions and 31 deletions

View file

@ -268,6 +268,20 @@ function resolveHermesHome() {
}
const HERMES_HOME = resolveHermesHome()
function hermesManagedNodePathEntries() {
const root = path.join(HERMES_HOME, 'node')
const bin = path.join(root, 'bin')
const entries = IS_WINDOWS ? [root, bin] : [bin, root]
return entries.filter(directoryExists)
}
function pathWithHermesManagedNode(...entries) {
return [...hermesManagedNodePathEntries(), ...entries, process.env.PATH]
.filter(Boolean)
.join(path.delimiter)
}
// ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path
// install.ps1 / install.sh use, so a desktop-only user and a CLI-only user end
// up with identical layouts and can share one install.
@ -1827,7 +1841,7 @@ async function applyUpdates(opts = {}) {
env: {
...process.env,
HERMES_HOME,
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter)
PATH: pathWithHermesManagedNode(venvBin)
},
detached: true,
stdio: 'ignore',
@ -1871,7 +1885,7 @@ async function handOffWindowsBootstrapRecovery(reason) {
env: {
...process.env,
HERMES_HOME,
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter)
PATH: pathWithHermesManagedNode(venvBin)
},
detached: true,
stdio: 'ignore',
@ -1952,13 +1966,11 @@ async function applyUpdatesPosixInApp() {
}
// Put the Hermes-managed Node and the venv on PATH so `hermes desktop`'s
// npm build can find them on a machine with no system Node.
const extraPath = [path.join(HERMES_HOME, 'node', 'bin'), path.join(updateRoot, 'venv', 'bin')]
.filter(Boolean)
.join(path.delimiter)
// npm build can find them on a machine with no system Node. Windows portable
// Node lives directly under %LOCALAPPDATA%\hermes\node, not node\bin.
const env = {
HERMES_HOME,
PATH: [extraPath, process.env.PATH].filter(Boolean).join(path.delimiter)
PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin'))
}
// `hermes update` reaps stale `hermes dashboard` backends (a code update

View file

@ -19,7 +19,6 @@ import asyncio
import logging
import os
import platform
import shutil
import signal
import subprocess
@ -27,7 +26,11 @@ _IS_WINDOWS = platform.system() == "Windows"
from pathlib import Path
from typing import Dict, Optional, Any
from hermes_constants import get_hermes_dir
from hermes_constants import (
find_node_executable,
get_hermes_dir,
with_hermes_node_path,
)
logger = logging.getLogger(__name__)
@ -212,10 +215,9 @@ def check_whatsapp_requirements() -> bool:
WhatsApp requires a Node.js bridge for most implementations.
"""
# Check for Node.js. Resolve via shutil.which so we respect PATHEXT
# (node.exe vs node) and get a meaningful "not installed" signal
# instead of spawning a cmd flash on Windows.
_node = shutil.which("node")
# Prefer Hermes-managed Node/npm so Windows installs are not broken by a
# bad or elevation-triggering system Node on PATH.
_node = find_node_executable("node")
if not _node:
return False
try:
@ -404,10 +406,9 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
_deps_fresh = False
if not _deps_fresh:
print(f"[{self.name}] Installing WhatsApp bridge dependencies...")
# Resolve npm path so Windows can execute the .cmd shim.
# shutil.which honours PATHEXT; on POSIX it returns the
# plain executable path.
_npm_bin = shutil.which("npm") or "npm"
# Resolve npm path so Windows uses npm.cmd from the
# Hermes-managed portable Node before falling back to PATH.
_npm_bin = find_node_executable("npm") or "npm"
try:
# Read timeout from environment variable, default to 300 seconds (5 minutes)
# to accommodate slower systems like Unraid NAS
@ -418,6 +419,7 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
capture_output=True,
text=True,
timeout=npm_install_timeout,
env=with_hermes_node_path(),
)
if install_result.returncode != 0:
print(f"[{self.name}] npm install failed: {install_result.stderr}")
@ -490,7 +492,7 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
# Build bridge subprocess environment.
# Pass WHATSAPP_REPLY_PREFIX from config.yaml so the Node bridge
# can use it without the user needing to set a separate env var.
bridge_env = os.environ.copy()
bridge_env = with_hermes_node_path(os.environ.copy())
if self._reply_prefix is not None:
bridge_env["WHATSAPP_REPLY_PREFIX"] = self._reply_prefix
# Pass the profile-aware cache directories so the bridge writes
@ -508,7 +510,7 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
self._bridge_process = subprocess.Popen(
[
"node",
find_node_executable("node") or "node",
str(bridge_path),
"--port", str(self._bridge_port),
"--session", str(self._session_path),

View file

@ -2363,6 +2363,7 @@ def cmd_whatsapp(args):
"""Set up WhatsApp: choose mode, configure, install bridge, pair via QR."""
_require_tty("whatsapp")
from hermes_cli.config import get_env_value, save_env_value
from hermes_constants import find_node_executable, with_hermes_node_path
print()
print("⚕ WhatsApp Setup")
@ -2477,7 +2478,7 @@ def cmd_whatsapp(args):
print(
"\n→ Installing WhatsApp bridge dependencies (this can take a few minutes)..."
)
npm = shutil.which("npm")
npm = find_node_executable("npm")
if not npm:
print(" ✗ npm not found on PATH — install Node.js first")
return
@ -2490,6 +2491,7 @@ def cmd_whatsapp(args):
text=True,
encoding="utf-8",
errors="replace",
env=with_hermes_node_path(),
)
except KeyboardInterrupt:
print("\n ✗ Install cancelled")
@ -2546,8 +2548,15 @@ def cmd_whatsapp(args):
try:
subprocess.run(
["node", str(bridge_script), "--pair-only", "--session", str(session_dir)],
[
find_node_executable("node") or "node",
str(bridge_script),
"--pair-only",
"--session",
str(session_dir),
],
cwd=str(bridge_dir),
env=with_hermes_node_path(),
)
except KeyboardInterrupt:
pass
@ -4535,6 +4544,7 @@ def _run_with_idle_timeout(
*,
idle_timeout_seconds: int = 180,
indent: str = " ",
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess:
"""Run a subprocess that streams output, with an idle-output timeout.
@ -4569,6 +4579,7 @@ def _run_with_idle_timeout(
encoding="utf-8",
errors="replace",
bufsize=1,
env=env,
)
except OSError as exc:
# E.g. npm not on PATH between the which() check and now.
@ -4760,12 +4771,15 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
encoding = getattr(sys.stdout, "encoding", None) or "ascii"
print(text.encode(encoding, errors="replace").decode(encoding, errors="replace"))
npm = shutil.which("npm")
from hermes_constants import find_node_executable, with_hermes_node_path
npm = find_node_executable("npm")
if not npm:
if fatal:
_say("Web UI frontend not built and npm is not available.")
_say("Install Node.js, then run: cd web && npm install && npm run build")
return not fatal
build_env = with_hermes_node_path()
_say("→ Building web UI...")
def _relay(result: "subprocess.CompletedProcess") -> None:
@ -4797,6 +4811,7 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
npm,
npm_cwd,
extra_args=(*npm_workspace_args, "--silent"),
env=build_env,
)
if r1.returncode != 0:
_say(
@ -4812,13 +4827,13 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
# users react by rebooting, which leaves the editable install in a
# half-state. Streaming + idle-kill makes failures observable AND
# recoverable (the stale-dist fallback below handles the kill path).
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir)
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=build_env)
if r2.returncode != 0:
# Retry once after a short delay — covers boot-time races on Windows
# (antivirus scanning Node.js binaries, npm cache not ready, transient
# I/O when launched via Scheduled Task at logon). See issue #23817.
_time.sleep(3)
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir)
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=build_env)
if r2.returncode != 0:
# _run_with_idle_timeout merges stderr into stdout; older callers
@ -5197,7 +5212,9 @@ def _redownload_electron_dist(
installer = electron_dir / "install.js"
if not installer.is_file():
return False
node = shutil.which("node")
from hermes_constants import find_node_executable, with_hermes_node_path
node = find_node_executable("node")
if not node:
return False
@ -5208,7 +5225,7 @@ def _redownload_electron_dist(
except OSError:
pass
dl_env = dict(env)
dl_env = with_hermes_node_path(env)
if mirror:
dl_env["ELECTRON_MIRROR"] = mirror
try:
@ -5388,7 +5405,9 @@ def cmd_gui(args: argparse.Namespace):
except Exception:
pass
env = os.environ.copy()
from hermes_constants import find_node_executable, with_hermes_node_path
env = with_hermes_node_path(os.environ.copy())
if getattr(args, "fake_boot", False):
env["HERMES_DESKTOP_BOOT_FAKE"] = "1"
if getattr(args, "ignore_existing", False):
@ -5405,7 +5424,7 @@ def cmd_gui(args: argparse.Namespace):
packaged_executable = _desktop_packaged_executable(desktop_dir)
if source_mode or not skip_build:
npm = shutil.which("npm")
npm = find_node_executable("npm")
if not npm:
print("Desktop GUI requires Node.js/npm, but npm was not found on PATH.")
print("Install Node.js, then run: hermes gui")
@ -7637,7 +7656,9 @@ def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
def _update_node_dependencies() -> None:
npm = shutil.which("npm")
from hermes_constants import find_node_executable, with_hermes_node_path
npm = find_node_executable("npm")
if not npm:
return
@ -7654,7 +7675,7 @@ def _update_node_dependencies() -> None:
print("→ Updating Node.js dependencies...")
extra_args = ["--no-fund", "--no-audit", "--progress=false"]
nixos_env = _nixos_build_env()
nixos_env = with_hermes_node_path(_nixos_build_env())
# Step 1: root install (no workspace recursion).
root_args = [*extra_args, "--workspaces=false"]

View file

@ -5,6 +5,7 @@ without risk of circular imports.
"""
import os
import shutil
import sys
import sysconfig
from contextvars import ContextVar, Token
@ -242,6 +243,71 @@ def get_hermes_dir(new_subpath: str, old_name: str) -> Path:
return home / new_subpath
def iter_hermes_node_dirs(home: Path | None = None) -> list[Path]:
"""Return Hermes-managed Node.js directories in preferred lookup order.
Windows installs from ``scripts/install.ps1`` unpack portable Node directly
into ``%LOCALAPPDATA%\\hermes\\node``. POSIX installs use
``$HERMES_HOME/node/bin``. Include both shapes on every platform so mixed
or migrated installs still work.
"""
root = home or get_hermes_home()
dirs = [root / "node"]
bin_dir = root / "node" / "bin"
if sys.platform == "win32":
return dirs + [bin_dir]
return [bin_dir] + dirs
def _candidate_node_command_names(command: str) -> list[str]:
base = Path(command).name
if sys.platform != "win32" or "." in base:
return [base]
if base.lower() == "npm":
# Prefer npm.cmd. PowerShell may block npm.ps1 by execution policy, and
# CreateProcess cannot launch a bare .ps1 the way it can launch .cmd.
return ["npm.cmd", "npm.exe", "npm"]
if base.lower() == "npx":
return ["npx.cmd", "npx.exe", "npx"]
if base.lower() == "node":
return ["node.exe", "node"]
return [f"{base}.cmd", f"{base}.exe", base]
def find_hermes_node_executable(command: str) -> str | None:
"""Return a Hermes-managed Node/npm executable path, if installed."""
for directory in iter_hermes_node_dirs():
for name in _candidate_node_command_names(command):
candidate = directory / name
if candidate.is_file() and (
sys.platform == "win32" or os.access(candidate, os.X_OK)
):
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)
def with_hermes_node_path(env: dict[str, str] | None = None) -> dict[str, str]:
"""Return *env* with Hermes-managed Node directories prepended to PATH."""
merged = dict(os.environ if env is None else env)
existing = merged.get("PATH", "")
parts = [p for p in existing.split(os.pathsep) if p]
managed = [str(path) for path in iter_hermes_node_dirs() if path.is_dir()]
for entry in reversed(managed):
if entry not in parts:
parts.insert(0, entry)
merged["PATH"] = os.pathsep.join(parts)
return merged
def display_hermes_home() -> str:
"""Return a user-friendly display string for the current HERMES_HOME.

View file

@ -8,11 +8,14 @@ import pytest
import hermes_constants
from hermes_constants import (
VALID_REASONING_EFFORTS,
find_hermes_node_executable,
get_default_hermes_root,
get_hermes_home,
iter_hermes_node_dirs,
is_container,
parse_reasoning_effort,
secure_parent_dir,
with_hermes_node_path,
)
@ -105,6 +108,45 @@ class TestGetHermesHome:
assert get_hermes_home() == local_appdata / "hermes"
class TestHermesManagedNode:
def test_windows_node_dir_prefers_portable_root(self, tmp_path, monkeypatch):
home = tmp_path / "hermes"
node_dir = home / "node"
bin_dir = node_dir / "bin"
node_dir.mkdir(parents=True)
bin_dir.mkdir()
monkeypatch.setattr(hermes_constants.sys, "platform", "win32")
monkeypatch.setenv("HERMES_HOME", str(home))
assert iter_hermes_node_dirs() == [node_dir, bin_dir]
def test_windows_finds_npm_cmd_before_path(self, tmp_path, monkeypatch):
home = tmp_path / "hermes"
node_dir = home / "node"
node_dir.mkdir(parents=True)
npm_cmd = node_dir / "npm.cmd"
npm_cmd.write_text("@echo off\n")
monkeypatch.setattr(hermes_constants.sys, "platform", "win32")
monkeypatch.setenv("HERMES_HOME", str(home))
assert find_hermes_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"
bin_dir = node_dir / "bin"
node_dir.mkdir(parents=True)
bin_dir.mkdir()
monkeypatch.setattr(hermes_constants.sys, "platform", "win32")
monkeypatch.setenv("HERMES_HOME", str(home))
env = with_hermes_node_path({"PATH": "system-node"})
parts = env["PATH"].split(os.pathsep)
assert parts[:2] == [str(node_dir), str(bin_dir)]
assert parts[-1] == "system-node"
class TestIsContainer:
"""Tests for is_container() — Docker/Podman detection."""
@ -351,4 +393,3 @@ class TestSecureParentDir:
secure_parent_dir(link_target)
assert len(called_with) == 1
assert called_with[0] == (str(real_dir), 0o700)