fix(cli): set PYTHON env for node-gyp native builds on NixOS (#40690)

* fix(cli): set PYTHON env for node-gyp native builds on NixOS

node-gyp (triggered by node-pty during npm ci) looks for python3 on
PATH, which fails on NixOS because python3 lives in the nix store and
is not on the system PATH.

Add _nixos_build_env() — a two-tier helper that detects NixOS and:
1. Fast path: hermes venv python3 (~0s)
2. Fallback: nix-shell which python3 (~2-5s)

Wire it into _run_npm_install_deterministic() via a new env= parameter,
then pass it through cmd_gui() and _update_node_dependencies().

Non-NixOS systems: _nixos_build_env() returns None, behavior unchanged.

* fix(cli): merge _nixos_build_env() with os.environ, fix NixOS detection, add explicit return None

- Critical fix: both Tier 1 (venv) and Tier 2 (nix-shell) now return
  {**os.environ, "PYTHON": ...} instead of {"PYTHON": ...} — subprocess.run
  with env= replaces the entire environment, so the old code wiped PATH
  and broke npm/node on NixOS entirely.
- Uses re.search(r"^ID=nixos$", ...) for anchored NixOS detection instead
  of unanchored substring match (could match ID_LIKE=...nixos).
- Removes redundant Path.exists() guard before read_text(); just catches
  OSError (one filesystem read instead of two).
- Adds explicit return None at end of function for type-hint consistency.
This commit is contained in:
floory 2026-06-08 09:27:37 +01:00 committed by GitHub
parent 7a5827c8b0
commit 15c99b437f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 63 additions and 2 deletions

View file

@ -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 (~25 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)")

View file

@ -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)]