diff --git a/raven/cli/tui_commands.py b/raven/cli/tui_commands.py index 34126da..c72d30c 100644 --- a/raven/cli/tui_commands.py +++ b/raven/cli/tui_commands.py @@ -68,6 +68,13 @@ def _stdout_isatty() -> bool: return sys.stdout.isatty() +def _is_windows() -> bool: + """Whether the host is Windows. A seam so tests can exercise the Windows + runtime-layout branch by patching this, instead of patching os.name — the + latter makes pathlib instantiate an unusable WindowsPath on POSIX hosts.""" + return os.name == "nt" + + def find_node() -> Tuple[Optional[str], Optional[Tuple[int, int, int]]]: """Find a usable node executable (>= 22). @@ -83,14 +90,25 @@ def find_node() -> Tuple[Optional[str], Optional[Tuple[int, int, int]]]: else: # Priority 2: active venv if venv := os.environ.get("VIRTUAL_ENV"): - if os.name == "nt": + if _is_windows(): candidates.append(str(Path(venv) / "Scripts" / "node.exe")) else: candidates.append(str(Path(venv) / "bin" / "node")) - # Priority 3: PATH - if path_node := shutil.which("node"): - candidates.append(path_node) + # Priority 3: PATH — enumerate EVERY node on PATH, not just the first. + # shutil.which returns only the first hit, so a stale < 22 node earlier + # on PATH (e.g. an old /usr/local/bin/node or a version-manager shim) + # would otherwise shadow a newer one later on PATH (e.g. a Homebrew + # node 26). The version filter below then picks the first usable one. + exe = "node.exe" if _is_windows() else "node" + seen_path: set[str] = set() + for path_dir in os.environ.get("PATH", "").split(os.pathsep): + if not path_dir: + continue + cand = os.path.join(path_dir, exe) + if cand not in seen_path and os.path.isfile(cand): + seen_path.add(cand) + candidates.append(cand) # Priority 4: Raven-managed private runtime installed by the one-line # installer into ~/.raven/runtime/. This is the zero-config fallback so @@ -102,7 +120,7 @@ def find_node() -> Tuple[Optional[str], Optional[Tuple[int, int, int]]]: # (node-v22.x.y-win-x64/node.exe) — install.ps1 provisions the latter. runtime_root = Path(os.environ.get("RAVEN_HOME", Path.home() / ".raven")) / "runtime" if runtime_root.is_dir(): - if os.name == "nt": + if _is_windows(): direct = runtime_root / "node" / "node.exe" if direct.exists(): candidates.append(str(direct)) @@ -113,6 +131,11 @@ def find_node() -> Tuple[Optional[str], Optional[Tuple[int, int, int]]]: candidates.append(str(direct)) candidates.extend(str(p) for p in sorted(runtime_root.glob("node-*/bin/node"))) + # Return the first candidate that meets the minimum, in priority order. + # Track the highest below-minimum candidate seen so that when nothing + # qualifies the caller can still report the real version ("found 20.20.1, + # need >= 22") instead of a bare "not found". + best_below_min: Optional[Tuple[str, Tuple[int, int, int]]] = None for node_path in candidates: if not Path(node_path).exists(): continue @@ -128,11 +151,14 @@ def find_node() -> Tuple[Optional[str], Optional[Tuple[int, int, int]]]: if not match: continue version = (int(match.group(1)), int(match.group(2)), int(match.group(3))) - return (node_path, version) except (subprocess.SubprocessError, FileNotFoundError, OSError): continue + if version >= _MIN_NODE_VERSION: + return (node_path, version) + if best_below_min is None or version > best_below_min[1]: + best_below_min = (node_path, version) - return (None, None) + return best_below_min if best_below_min is not None else (None, None) def run_subprocess( diff --git a/tests/test_cli_tui_bootstrap.py b/tests/test_cli_tui_bootstrap.py index 427d0f6..5aab259 100644 --- a/tests/test_cli_tui_bootstrap.py +++ b/tests/test_cli_tui_bootstrap.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import stat import sys from pathlib import Path @@ -175,7 +176,7 @@ def test_find_node_discovers_windows_private_runtime(monkeypatch, tmp_path): node.write_text("#!/bin/sh\necho v22.20.0\n", encoding="utf-8") node.chmod(node.stat().st_mode | stat.S_IXUSR) - monkeypatch.setattr(tui_commands.sys, "platform", "win32") + monkeypatch.setattr(tui_commands, "_is_windows", lambda: True) monkeypatch.setenv("RAVEN_HOME", str(tmp_path)) monkeypatch.delenv("RAVEN_NODE", raising=False) monkeypatch.delenv("VIRTUAL_ENV", raising=False) @@ -187,6 +188,62 @@ def test_find_node_discovers_windows_private_runtime(monkeypatch, tmp_path): assert version == (22, 20, 0) +def test_find_node_skips_below_min_and_scans_all_path(monkeypatch, tmp_path): + """A stale < 22 node earlier on PATH must not shadow a newer node later on + PATH: find_node enumerates every PATH entry and returns the first that + meets the minimum, rather than shutil.which's single first hit.""" + if sys.platform == "win32": + pytest.skip("uses POSIX fake node executables") + + from raven.cli import tui_commands + + def make_node(d: Path, ver: str) -> Path: + d.mkdir(parents=True) + p = d / "node" + p.write_text(f"#!/bin/sh\necho v{ver}\n", encoding="utf-8") + p.chmod(p.stat().st_mode | stat.S_IXUSR) + return p + + old = make_node(tmp_path / "old", "20.20.1") # earlier on PATH + new = make_node(tmp_path / "new", "26.5.0") # later on PATH + + monkeypatch.delenv("RAVEN_NODE", raising=False) + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + monkeypatch.setenv("RAVEN_HOME", str(tmp_path / "no-runtime")) + monkeypatch.setenv("PATH", f"{old.parent}{os.pathsep}{new.parent}") + + node_path, version = tui_commands.find_node() + + assert node_path == str(new) + assert version == (26, 5, 0) + + +def test_find_node_reports_below_min_when_nothing_qualifies(monkeypatch, tmp_path): + """When only a < 22 node exists, find_node returns it (not None) so the + caller can report the real version ('found 20.20.1, need >= 22') instead + of a bare 'not found'.""" + if sys.platform == "win32": + pytest.skip("uses a POSIX fake node executable") + + from raven.cli import tui_commands + + old_dir = tmp_path / "old" + old_dir.mkdir() + old = old_dir / "node" + old.write_text("#!/bin/sh\necho v20.20.1\n", encoding="utf-8") + old.chmod(old.stat().st_mode | stat.S_IXUSR) + + monkeypatch.delenv("RAVEN_NODE", raising=False) + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + monkeypatch.setenv("RAVEN_HOME", str(tmp_path / "no-runtime")) + monkeypatch.setenv("PATH", str(old_dir)) + + node_path, version = tui_commands.find_node() + + assert node_path == str(old) + assert version == (20, 20, 1) + + def test_dev_npx_derived_from_node_path(monkeypatch, tmp_path): """`--dev` mode must derive npx from the validated node_path, not from PATH — so RAVEN_NODE's version-pin semantics are honored."""