Skip to content
Merged
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
40 changes: 33 additions & 7 deletions raven/cli/tui_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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
Expand All @@ -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))
Expand All @@ -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
Expand All @@ -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(
Expand Down
59 changes: 58 additions & 1 deletion tests/test_cli_tui_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import os
import stat
import sys
from pathlib import Path
Expand Down Expand Up @@ -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)
Expand All @@ -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."""
Expand Down
Loading