diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0a8612a9a1..e9f02c72c9 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7044,12 +7044,66 @@ def _run_with_idle_timeout( return subprocess.CompletedProcess(cmd, rc, stdout=combined, stderr="") +def _nixos_build_env() -> dict[str, str] | None: + """Return extra env vars for native module builds on NixOS. + + On NixOS, python3 is typically not on the system PATH (it lives in + the Nix store and only enters PATH inside a nix-shell or when + explicitly installed as a system package). node-gyp uses Python to + compile native addons like ``node-pty`` and its ``find-python.js`` + does a bare ``PATH`` lookup — which fails on NixOS. + + Two-tier resolution: + 1. Fast path — the hermes venv's python3 (present in managed installs) + 2. Fallback — resolves the absolute python3 path via ``nix-shell`` + + Returns an env dict suitable for ``subprocess.run(env=...)`` or + ``None`` when we are not on NixOS or python3 is already on PATH. + """ + import re + + try: + os_release = Path("/etc/os-release").read_text(encoding="utf-8") + except OSError: + return None + if not re.search(r"^ID=nixos$", os_release, re.M): + return None + + # python3 already on PATH — nothing to do + if shutil.which("python3"): + return None + + # Tier 1: fast path — hermes venv python3, no nix-shell overhead + for venv_name in ("venv", ".venv"): + venv_python = PROJECT_ROOT / venv_name / "bin" / "python3" + if venv_python.exists(): + return {**os.environ, "PYTHON": str(venv_python)} + + # Tier 2: nix-shell fallback — resolves the absolute python3 path once. + # Slower (~2–5 s for the nix-shell eval) but always works, even without + # a hermes venv (pip / non-managed / bare-git installs). The resolved + # path is a self-contained Nix store binary (all deps via RPATH) so it + # stays valid even after the nix-shell exits. + try: + result = subprocess.run( + ["nix-shell", "-p", "python3", "--run", "which python3"], + capture_output=True, text=True, check=False, timeout=15, + ) + if result.returncode == 0: + python3_path = result.stdout.strip() + if python3_path and Path(python3_path).exists(): + return {**os.environ, "PYTHON": python3_path} + except Exception: + pass # nix-shell not available — caller will get None + + return None def _run_npm_install_deterministic( npm: str, cwd: Path, *, extra_args: tuple[str, ...] = (), capture_output: bool = True, + env: dict[str, str] | None = None, ) -> subprocess.CompletedProcess: """Run a deterministic npm install that does not mutate ``package-lock.json``. @@ -7066,6 +7120,7 @@ def _run_npm_install_deterministic( ci_result = subprocess.run( ci_cmd, cwd=cwd, + env=env, capture_output=capture_output, text=True, encoding="utf-8", @@ -7080,6 +7135,7 @@ def _run_npm_install_deterministic( return subprocess.run( install_cmd, cwd=cwd, + env=env, capture_output=capture_output, text=True, encoding="utf-8", @@ -7620,7 +7676,8 @@ def cmd_gui(args: argparse.Namespace): print(f"✓ Desktop {build_label} is up to date (content stamp matches)") else: print("→ Installing desktop workspace dependencies...") - install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False) + nixos_env = _nixos_build_env() + install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False, env=nixos_env) if install_result.returncode != 0: print("✗ Desktop dependency install failed") print(f" Run manually: cd {PROJECT_ROOT} && npm ci") @@ -9619,6 +9676,8 @@ def _update_node_dependencies() -> None: print("→ Updating Node.js dependencies...") extra_args = ["--no-fund", "--no-audit", "--progress=false"] + nixos_env = _nixos_build_env() + # Step 1: root install (no workspace recursion). root_args = [*extra_args, "--workspaces=false"] root_result = _run_npm_install_deterministic( @@ -9626,6 +9685,7 @@ def _update_node_dependencies() -> None: PROJECT_ROOT, extra_args=tuple(root_args), capture_output=False, + env=nixos_env, ) if root_result.returncode != 0: print(" ⚠ npm install failed in repo root") @@ -9642,6 +9702,7 @@ def _update_node_dependencies() -> None: PROJECT_ROOT, extra_args=tuple(ws_args), capture_output=False, + env=nixos_env, ) if ws_result.returncode == 0: print(" ✓ repo root + ui-tui, web workspaces (desktop skipped)") diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py index 0b96e99018..04d10018e2 100644 --- a/tests/hermes_cli/test_gui_command.py +++ b/tests/hermes_cli/test_gui_command.py @@ -70,7 +70,7 @@ def test_gui_installs_packages_and_launches_desktop_app(tmp_path, monkeypatch): cli_main.cmd_gui(_ns()) assert exc.value.code == 0 - mock_install.assert_called_once_with("/usr/bin/npm", root, capture_output=False) + mock_install.assert_called_once_with("/usr/bin/npm", root, capture_output=False, env=None) assert mock_run.call_args_list[0].args[0] == ["/usr/bin/npm", "run", "pack"] assert mock_run.call_args_list[0].kwargs["cwd"] == desktop_dir assert mock_run.call_args_list[1].args[0] == [str(packaged_exe)]