diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 18ea422f3e24..437a54e2c2ee 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -6,6 +6,7 @@ import os import sys +import sysconfig import subprocess import shutil import importlib.util @@ -1623,16 +1624,86 @@ def run_doctor(args): _check_gateway_service_linger(issues) _check_s6_supervision(issues) - if sys.platform != "win32": - _section("Command Installation") - # Determine the venv entry point location - _venv_bin = None - for _venv_name in ("venv", ".venv"): - _candidate = PROJECT_ROOT / _venv_name / "bin" / "hermes" + # Entry-point detection runs on ALL platforms. The environment's scripts + # directory is platform-aware via sysconfig: on Windows it is + # venv\Scripts (console scripts installed as hermes.exe/.cmd/.bat), on + # POSIX venv/bin (bare "hermes"). Only the *symlink* management below is + # POSIX-specific, so that part stays guarded. + _section("Command Installation") + # Determine the venv entry point location + _venv_bin = None + _uses_environment_entry_point = False + for _venv_name in ("venv", ".venv"): + _candidate = PROJECT_ROOT / _venv_name / "bin" / "hermes" + if _candidate.exists(): + _venv_bin = _candidate + break + + # A wheel-installed virtualenv keeps the console script under the + # environment's scripts directory while PROJECT_ROOT points inside + # site-packages. The source-checkout candidates above cannot describe + # that layout, so consult Python's platform-aware install scheme before + # reporting a missing entry point (#49529). + _active_venv = sys.prefix != getattr(sys, "base_prefix", sys.prefix) + _environment_scripts_dir = None + if _active_venv: + try: + _scripts_path = sysconfig.get_path("scripts") + if _scripts_path: + _environment_scripts_dir = Path(_scripts_path) + except (KeyError, TypeError, ValueError): + pass + if _venv_bin is None and _environment_scripts_dir is not None: + # Windows console scripts are installed as .exe (plus .cmd/.bat + # shims); POSIX installs a bare . Check all platform variants so + # an environment-installed entry point is not reported missing on + # Windows (verified on Win11: venv\Scripts\hermes.exe, bare "hermes" + # absent) or in cross-layout venvs. + for _script_name in ("hermes", "hermes.exe", "hermes.cmd", "hermes.bat"): + _candidate = _environment_scripts_dir / _script_name if _candidate.exists(): _venv_bin = _candidate + _uses_environment_entry_point = True break + _source_checkout = (PROJECT_ROOT / "pyproject.toml").is_file() + + if _venv_bin is None: + if _active_venv and not _source_checkout: + _expected_dir = _environment_scripts_dir or Path(sys.prefix) / "bin" + _reinstall_cmd = ( + f"{sys.executable} -m pip install --force-reinstall hermes-agent" + ) + check_warn( + "Venv entry point not found", + f"(hermes not in {_expected_dir} — reinstall with {_reinstall_cmd})", + ) + manual_issues.append(f"Reinstall entry point: {_reinstall_cmd}") + else: + check_warn( + "Venv entry point not found", + "(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')" + ) + manual_issues.append( + f"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'" + ) + else: + try: + _venv_bin_display = _venv_bin.relative_to(PROJECT_ROOT) + except ValueError: + _venv_bin_display = _venv_bin + check_ok(f"Venv entry point exists ({_venv_bin_display})") + + # A wheel's console script is already installed in the active + # environment. Requiring a second global link would turn a + # healthy isolated venv into a false failure and make --fix leak + # that environment into the user's global command path. + if _uses_environment_entry_point and not _source_checkout: + check_ok("Active environment entry point needs no global symlink") + + # POSIX-only: the ~/.local/bin symlink management does not apply on + # Windows (command resolution goes through the console script + PATH). + if sys.platform != "win32": # Determine the expected command link directory (mirrors install.sh logic) _prefix = os.environ.get("PREFIX", "") _is_termux_env = bool(os.environ.get("TERMUX_VERSION")) or "com.termux/files/usr" in _prefix @@ -1644,18 +1715,7 @@ def run_doctor(args): _cmd_link_display = "~/.local/bin" _cmd_link = _cmd_link_dir / "hermes" - if _venv_bin is None: - check_warn( - "Venv entry point not found", - "(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')" - ) - manual_issues.append( - f"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'" - ) - else: - check_ok(f"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})") - - # Check the symlink at the command link location + if _venv_bin is not None and not (_uses_environment_entry_point and not _source_checkout): if _cmd_link.is_symlink(): _target = _cmd_link.resolve() _expected = _venv_bin.resolve() diff --git a/tests/hermes_cli/test_doctor_command_install.py b/tests/hermes_cli/test_doctor_command_install.py index c6b2da7d1503..fc213235a2c8 100644 --- a/tests/hermes_cli/test_doctor_command_install.py +++ b/tests/hermes_cli/test_doctor_command_install.py @@ -1,6 +1,7 @@ """Tests for the Command Installation check in hermes doctor.""" import sys +import sysconfig import types from argparse import Namespace from pathlib import Path @@ -66,6 +67,26 @@ def _run_doctor(fix=False): return buf.getvalue() +def _stub_posix_stdlib(monkeypatch): + """Provide minimal POSIX-only stdlib stubs (fcntl) so a win32 host can + exercise the POSIX-guarded doctor section after sys.platform is mocked.""" + import types as _types + try: + import fcntl # noqa: F401 + except ImportError: + monkeypatch.setitem( + sys.modules, + "fcntl", + _types.SimpleNamespace( + flock=lambda *a, **k: None, + LOCK_EX=2, + LOCK_UN=8, + LOCK_SH=1, + LOCK_NB=4, + ), + ) + + class TestDoctorCommandInstallation: """Tests for the ◆ Command Installation section.""" @@ -108,6 +129,8 @@ def test_missing_venv_entry_point_shows_warn(self, monkeypatch, tmp_path): monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) monkeypatch.setattr(doctor_mod, "_DHH", str(home)) monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setattr(sys, "prefix", str(tmp_path / "system-python")) + monkeypatch.setattr(sys, "base_prefix", str(tmp_path / "system-python")) fake_model_tools = types.SimpleNamespace( check_tool_availability=lambda *a, **kw: ([], []), @@ -130,7 +153,88 @@ def test_missing_venv_entry_point_shows_warn(self, monkeypatch, tmp_path): assert "Command Installation" in out assert "Venv entry point not found" in out + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only") + @pytest.mark.parametrize("fix", [False, True]) + def test_wheel_virtualenv_entry_point_needs_no_global_symlink( + self, monkeypatch, tmp_path, fix + ): + """A wheel venv is healthy without a global command symlink.""" + _setup_doctor_env(monkeypatch, tmp_path) + venv = tmp_path / "wheel-venv" + scripts = venv / "bin" + scripts.mkdir(parents=True) + hermes_bin = scripts / "hermes" + hermes_bin.write_text("#!/usr/bin/env python\n# wheel entry point\n") + hermes_bin.chmod(0o755) + + site_packages = venv / "lib" / "python3.11" / "site-packages" + site_packages.mkdir(parents=True) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", site_packages) + monkeypatch.setattr(sys, "prefix", str(venv)) + monkeypatch.setattr(sys, "base_prefix", str(tmp_path / "base-python")) + real_get_path = sysconfig.get_path + monkeypatch.setattr( + sysconfig, + "get_path", + lambda name, *args, **kwargs: ( + str(scripts) + if name == "scripts" + else real_get_path(name, *args, **kwargs) + ), + ) + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + out = _run_doctor(fix=fix) + + assert "Venv entry point exists" in out + assert str(hermes_bin) in out + assert "Venv entry point not found" not in out + assert "Active environment entry point needs no global symlink" in out + assert "~/.local/bin/hermes not found" not in out + assert "Missing ~/.local/bin/hermes symlink" not in out + command_link = tmp_path / ".local" / "bin" / "hermes" + assert not command_link.exists() + assert not command_link.is_symlink() + + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only") + def test_wheel_virtualenv_missing_entry_point_avoids_editable_fix( + self, monkeypatch, tmp_path + ): + """Wheel installs must not be repaired as source checkouts.""" + _setup_doctor_env(monkeypatch, tmp_path) + venv = tmp_path / "wheel-venv" + scripts = venv / "bin" + scripts.mkdir(parents=True) + site_packages = venv / "lib" / "python3.11" / "site-packages" + site_packages.mkdir(parents=True) + + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", site_packages) + monkeypatch.setattr(sys, "prefix", str(venv)) + monkeypatch.setattr(sys, "base_prefix", str(tmp_path / "base-python")) + monkeypatch.setattr(sys, "executable", str(scripts / "python")) + real_get_path = sysconfig.get_path + monkeypatch.setattr( + sysconfig, + "get_path", + lambda name, *args, **kwargs: ( + str(scripts) + if name == "scripts" + else real_get_path(name, *args, **kwargs) + ), + ) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + out = _run_doctor(fix=True) + + assert "Venv entry point not found" in out + assert "pip install -e" not in out + assert "-m pip install --force-reinstall hermes-agent" in out + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only") + def test_dot_venv_dir_is_found(self, monkeypatch, tmp_path): + """The check finds entry points in .venv/ as well as venv/.""" + home, project, _ = _setup_doctor_env(monkeypatch, tmp_path, venv_name=".venv") @pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only") def test_termux_uses_prefix_bin(self, monkeypatch, tmp_path): @@ -149,3 +253,77 @@ def test_termux_uses_prefix_bin(self, monkeypatch, tmp_path): assert "Command Installation" in out assert "$PREFIX/bin" in out + @pytest.mark.skipif(sys.platform != "win32", reason="Tests the native Windows console-script layout") + def test_wheel_virtualenv_windows_console_script_entry_point( + self, monkeypatch, tmp_path + ): + """Windows console-script layout (hermes.exe in the env Scripts dir) + must be detected on a real win32 host — no platform fake. The + entry-point detection runs on all platforms; only symlink + management is POSIX-guarded (#49529, verified on Win11: + venv\\Scripts\\hermes.exe, bare \"hermes\" absent).""" + _setup_doctor_env(monkeypatch, tmp_path) + venv = tmp_path / "wheel-venv" + scripts = venv / "Scripts" + scripts.mkdir(parents=True) + hermes_exe = scripts / "hermes.exe" + hermes_exe.write_bytes(b"MZ\x90\x00") # minimal PE header stub + + site_packages = venv / "Lib" / "site-packages" + site_packages.mkdir(parents=True) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", site_packages) + monkeypatch.setattr(sys, "prefix", str(venv)) + monkeypatch.setattr(sys, "base_prefix", str(tmp_path / "base-python")) + real_get_path = sysconfig.get_path + monkeypatch.setattr( + sysconfig, + "get_path", + lambda name, *args, **kwargs: ( + str(scripts) + if name == "scripts" + else real_get_path(name, *args, **kwargs) + ), + ) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + out = _run_doctor(fix=False) + + assert "Command Installation" in out + assert "Venv entry point exists" in out + assert "hermes.exe" in out + assert "Venv entry point not found" not in out + assert "Active environment entry point needs no global symlink" in out + + @pytest.mark.skipif(sys.platform != "win32", reason="Tests the native Windows console-script layout") + def test_wheel_virtualenv_windows_console_script_missing_still_warns( + self, monkeypatch, tmp_path + ): + """Windows layout with no console script at all must still warn (no + false all-clear from the .exe/.cmd/.bat name scan).""" + _setup_doctor_env(monkeypatch, tmp_path) + venv = tmp_path / "wheel-venv" + scripts = venv / "Scripts" + scripts.mkdir(parents=True) + + site_packages = venv / "Lib" / "site-packages" + site_packages.mkdir(parents=True) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", site_packages) + monkeypatch.setattr(sys, "prefix", str(venv)) + monkeypatch.setattr(sys, "base_prefix", str(tmp_path / "base-python")) + real_get_path = sysconfig.get_path + monkeypatch.setattr( + sysconfig, + "get_path", + lambda name, *args, **kwargs: ( + str(scripts) + if name == "scripts" + else real_get_path(name, *args, **kwargs) + ), + ) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + out = _run_doctor(fix=False) + + assert "Venv entry point not found" in out + assert "Active environment entry point needs no global symlink" not in out +