Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions apps/desktop/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down
26 changes: 14 additions & 12 deletions gateway/platforms/whatsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,18 @@
import logging
import os
import platform
import shutil
import signal
import subprocess

_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__)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -404,10 +406,9 @@ async def connect(self) -> bool:
_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
Expand All @@ -418,6 +419,7 @@ async def connect(self) -> bool:
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}")
Expand Down Expand Up @@ -490,7 +492,7 @@ async def connect(self) -> bool:
# 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
Expand All @@ -508,7 +510,7 @@ async def connect(self) -> bool:

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),
Expand Down
43 changes: 32 additions & 11 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -4760,12 +4771,15 @@ def _say(text: str) -> None:
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:
Expand Down Expand Up @@ -4797,6 +4811,7 @@ def _relay(result: "subprocess.CompletedProcess") -> None:
npm,
npm_cwd,
extra_args=(*npm_workspace_args, "--silent"),
env=build_env,
)
if r1.returncode != 0:
_say(
Expand All @@ -4812,13 +4827,13 @@ def _relay(result: "subprocess.CompletedProcess") -> None:
# 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
Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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):
Expand All @@ -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")
Expand Down Expand Up @@ -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

Expand All @@ -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"]
Expand Down
66 changes: 66 additions & 0 deletions hermes_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import os
import shutil
import sys
import sysconfig
from contextvars import ContextVar, Token
Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading