fix(doctor): honor selected PM runtimes and launcher ownership
This commit is contained in:
@@ -18,8 +18,17 @@ from hermes_cli.doctor_report import (
|
|||||||
from hermes_constants import is_termux as _is_termux
|
from hermes_constants import is_termux as _is_termux
|
||||||
|
|
||||||
|
|
||||||
def _python_install_cmd() -> str:
|
def _python_repair_hint() -> str:
|
||||||
return "python -m pip install" if _is_termux() else "uv pip install"
|
from hermes_cli.config import detect_install_method
|
||||||
|
from hermes_cli.doctor import PROJECT_ROOT
|
||||||
|
|
||||||
|
method = detect_install_method(PROJECT_ROOT)
|
||||||
|
if is_nix_install_method(method):
|
||||||
|
return recommended_update_command_for_method(method)
|
||||||
|
if method in ("docker", "apt"):
|
||||||
|
command = recommended_update_command_for_method(method)
|
||||||
|
return f"Run `{command}`" + (", then recreate the Hermes container" if method == "docker" else "")
|
||||||
|
return "Run `hermes pm repair`, then restart Hermes"
|
||||||
|
|
||||||
|
|
||||||
def _system_package_install_cmd(pkg: str) -> str:
|
def _system_package_install_cmd(pkg: str) -> str:
|
||||||
@@ -198,7 +207,7 @@ def check_certificates(should_fix: bool = False, issues: "list | None" = None) -
|
|||||||
ssl.create_default_context()
|
ssl.create_default_context()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_fail_and_issue("TLS default SSL context cannot be constructed", str(e),
|
_fail_and_issue("TLS default SSL context cannot be constructed", str(e),
|
||||||
"Recreate the venv or reinstall Hermes — the TLS stack is broken.", issues)
|
_python_repair_hint() + "; if TLS still fails, repair Python through the installation owner.", issues)
|
||||||
return
|
return
|
||||||
if platform_store:
|
if platform_store:
|
||||||
check_ok("TLS platform trust store configured; default SSL context available")
|
check_ok("TLS platform trust store configured; default SSL context available")
|
||||||
@@ -354,10 +363,10 @@ def _staged_venv_dir() -> "Path | None":
|
|||||||
"""pm's provisioned runtime venv, or None when nothing is staged.
|
"""pm's provisioned runtime venv, or None when nothing is staged.
|
||||||
|
|
||||||
``pm.packages.Venv().venv_dir()`` is pm's public authority for where
|
``pm.packages.Venv().venv_dir()`` is pm's public authority for where
|
||||||
the runtime venv lives (sealed installs: the mutable venv in the
|
the runtime venv lives, including an external selected generation or
|
||||||
writable hermes root, seeded from the payload; dev installs: the repo
|
the original source/payload environment before first sync. A resolved
|
||||||
venv). A resolved path without a venv marker is not a provisioned
|
path without a venv marker is not a provisioned venv — pm also returns
|
||||||
venv — pm also returns the intended location before first sync, and
|
the intended location before first sync, and
|
||||||
doctor must not read an empty directory as staged dependencies.
|
doctor must not read an empty directory as staged dependencies.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@@ -392,16 +401,14 @@ def _check_python_environment(should_fix: bool, f: Finding) -> None:
|
|||||||
if src:
|
if src:
|
||||||
check_info(f"SQLite source id: {(src[:48] + '…') if len(src) > 48 else src}")
|
check_info(f"SQLite source id: {(src[:48] + '…') if len(src) > 48 else src}")
|
||||||
_report_database_journal_modes()
|
_report_database_journal_modes()
|
||||||
# Staged dependencies vs the running interpreter, reported as two
|
# PM launchers run base Python with the selected dependency tree on
|
||||||
# distinct facts. When pm has provisioned the runtime venv, its
|
# sys.path. Neither sys.prefix nor a stale PYTHONPATH proves activation.
|
||||||
# resolved location IS the answer ("dependencies staged"); whether
|
|
||||||
# THIS process runs inside THAT venv is a separate comparison of
|
|
||||||
# resolved prefixes (sys.prefix != base_prefix alone would also be
|
|
||||||
# true for an unrelated venv). Only when nothing is staged does the
|
|
||||||
# legacy interpreter probe stand alone.
|
|
||||||
staged = _staged_venv_dir()
|
staged = _staged_venv_dir()
|
||||||
if staged is not None:
|
if staged is not None:
|
||||||
running_here = Path(sys.prefix).resolve() == staged.resolve()
|
from hermes_cli.runtime_paths import site_packages
|
||||||
|
|
||||||
|
selected_site = site_packages(staged).resolve()
|
||||||
|
running_here = selected_site.is_dir() and any(Path(entry).resolve() == selected_site for entry in sys.path)
|
||||||
check_ok(f"Runtime venv staged ({staged})",
|
check_ok(f"Runtime venv staged ({staged})",
|
||||||
"(active in this process)" if running_here else "(this process runs outside it)")
|
"(active in this process)" if running_here else "(this process runs outside it)")
|
||||||
else:
|
else:
|
||||||
@@ -443,7 +450,7 @@ def _check_required_packages(should_fix: bool, f: Finding) -> None:
|
|||||||
if optional:
|
if optional:
|
||||||
check_warn(name, "(optional, not installed)")
|
check_warn(name, "(optional, not installed)")
|
||||||
else:
|
else:
|
||||||
_fail_and_issue(name, "(missing)", f"Install {name}: {_python_install_cmd()} {module}", f.issues)
|
_fail_and_issue(name, "(missing)", f"Repair {name}: {_python_repair_hint()}", f.issues)
|
||||||
|
|
||||||
|
|
||||||
@doctor_check()
|
@doctor_check()
|
||||||
@@ -454,16 +461,36 @@ def _check_gateway_supervision(should_fix: bool, f: Finding) -> None:
|
|||||||
|
|
||||||
@doctor_check()
|
@doctor_check()
|
||||||
def _check_command_installation(should_fix: bool, f: Finding) -> None:
|
def _check_command_installation(should_fix: bool, f: Finding) -> None:
|
||||||
"""Venv entry point and the ~/.local/bin (or $PREFIX/bin) symlink; skipped on Windows."""
|
"""Check the install-owned launch contract without replacing custom commands."""
|
||||||
from hermes_cli.doctor import PROJECT_ROOT
|
from hermes_cli.doctor import PROJECT_ROOT
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
return
|
return
|
||||||
_section("Command Installation")
|
_section("Command Installation")
|
||||||
venv_bin = next((c for c in (PROJECT_ROOT / n / "bin" / "hermes" for n in ("venv", ".venv")) if c.exists()), None)
|
from hermes_cli.config import detect_install_method
|
||||||
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]')")
|
method = detect_install_method(PROJECT_ROOT)
|
||||||
return f.manual_issues.append(f"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'")
|
if is_nix_install_method(method) or method in ("docker", "apt"):
|
||||||
check_ok(f"Venv entry point exists ({venv_bin.relative_to(PROJECT_ROOT)})")
|
command = shutil.which("hermes")
|
||||||
|
if command:
|
||||||
|
check_ok(f"Hermes command managed by {method} ({command})")
|
||||||
|
else:
|
||||||
|
check_warn(f"Hermes command not on PATH ({method}-managed)")
|
||||||
|
f.manual_issues.append(_python_repair_hint())
|
||||||
|
return
|
||||||
|
from hermes_cli._launchers import resolve_store_python
|
||||||
|
from hermes_cli.runtime_paths import base_venv, selected_venv
|
||||||
|
|
||||||
|
try:
|
||||||
|
selected = selected_venv(PROJECT_ROOT)
|
||||||
|
except (OSError, ValueError, RuntimeError) as exc:
|
||||||
|
check_fail("Cannot resolve selected dependencies", str(exc))
|
||||||
|
return f.manual_issues.append(_python_repair_hint())
|
||||||
|
pm_launcher = selected != base_venv(PROJECT_ROOT) or resolve_store_python(PROJECT_ROOT) is not None
|
||||||
|
venv_bin = PROJECT_ROOT / "hermes" if pm_launcher else selected / "bin" / "hermes"
|
||||||
|
if not venv_bin.is_file():
|
||||||
|
check_warn("Hermes entry point not found", f"({venv_bin})")
|
||||||
|
return f.manual_issues.append("Repair or reinstall the Hermes launcher through the installation owner")
|
||||||
|
check_ok(f"Hermes entry point exists ({venv_bin})")
|
||||||
# Expected command link directory (mirrors install.sh logic).
|
# Expected command link directory (mirrors install.sh logic).
|
||||||
prefix = os.environ.get("PREFIX", "")
|
prefix = os.environ.get("PREFIX", "")
|
||||||
termux = prefix and (os.environ.get("TERMUX_VERSION") or "com.termux/files/usr" in prefix)
|
termux = prefix and (os.environ.get("TERMUX_VERSION") or "com.termux/files/usr" in prefix)
|
||||||
@@ -474,9 +501,11 @@ def _check_command_installation(should_fix: bool, f: Finding) -> None:
|
|||||||
if target == expected:
|
if target == expected:
|
||||||
return check_ok(f"{display}/hermes → correct target")
|
return check_ok(f"{display}/hermes → correct target")
|
||||||
check_warn(f"{display}/hermes points to wrong target", f"(→ {target}, expected → {expected})")
|
check_warn(f"{display}/hermes points to wrong target", f"(→ {target}, expected → {expected})")
|
||||||
|
owned_targets = {(PROJECT_ROOT / name / "bin" / "hermes").resolve() for name in ("venv", ".venv")}
|
||||||
|
if target not in owned_targets:
|
||||||
|
return f.manual_issues.append(f"Review {display}/hermes manually; its target is user-managed and was not changed")
|
||||||
if not should_fix:
|
if not should_fix:
|
||||||
return f.issues.append(f"Broken symlink at {display}/hermes — run 'hermes doctor --fix'")
|
return f.issues.append(f"Broken symlink at {display}/hermes — run 'hermes doctor --fix'")
|
||||||
link.unlink()
|
|
||||||
verb = "Fixed"
|
verb = "Fixed"
|
||||||
elif link.exists(): # regular file (wrapper script), not a symlink
|
elif link.exists(): # regular file (wrapper script), not a symlink
|
||||||
return check_ok(f"{display}/hermes exists (non-symlink)")
|
return check_ok(f"{display}/hermes exists (non-symlink)")
|
||||||
@@ -486,8 +515,18 @@ def _check_command_installation(should_fix: bool, f: Finding) -> None:
|
|||||||
return f.issues.append(f"Missing {display}/hermes symlink — run 'hermes doctor --fix'")
|
return f.issues.append(f"Missing {display}/hermes symlink — run 'hermes doctor --fix'")
|
||||||
link_dir.mkdir(parents=True, exist_ok=True)
|
link_dir.mkdir(parents=True, exist_ok=True)
|
||||||
verb = "Created"
|
verb = "Created"
|
||||||
link.symlink_to(venv_bin)
|
if pm_launcher:
|
||||||
check_ok(f"{verb} symlink: {display}/hermes → {venv_bin}")
|
from hermes_cli._launchers import stage_launcher
|
||||||
|
|
||||||
|
if stage_launcher("hermes", PROJECT_ROOT, link_dir) is None:
|
||||||
|
check_fail("Could not publish Hermes launcher")
|
||||||
|
return f.manual_issues.append("Repair the PM store interpreter through the installation owner, then rerun 'hermes doctor --fix'")
|
||||||
|
check_ok(f"{verb} PM launcher: {display}/hermes")
|
||||||
|
else:
|
||||||
|
if link.is_symlink():
|
||||||
|
link.unlink()
|
||||||
|
link.symlink_to(venv_bin)
|
||||||
|
check_ok(f"{verb} symlink: {display}/hermes → {venv_bin}")
|
||||||
f.fixed += 1
|
f.fixed += 1
|
||||||
if verb == "Created" and str(link_dir) not in os.environ.get("PATH", "").split(os.pathsep):
|
if verb == "Created" and str(link_dir) not in os.environ.get("PATH", "").split(os.pathsep):
|
||||||
check_warn(f"{display} is not on your PATH", "(add it to your shell config: export PATH=\"$HOME/.local/bin:$PATH\")")
|
check_warn(f"{display} is not on your PATH", "(add it to your shell config: export PATH=\"$HOME/.local/bin:$PATH\")")
|
||||||
|
|||||||
@@ -1763,11 +1763,13 @@ class TestStagedRuntimeVenv:
|
|||||||
assert "runs outside it" in out
|
assert "runs outside it" in out
|
||||||
assert "active in this process" not in out
|
assert "active in this process" not in out
|
||||||
|
|
||||||
def test_staged_and_active_names_both_facts(self, monkeypatch, capsys):
|
def test_staged_and_active_names_both_facts(self, monkeypatch, capsys, tmp_path):
|
||||||
staged = Path("/payload/venv")
|
from hermes_cli.runtime_paths import site_packages
|
||||||
|
|
||||||
|
staged = tmp_path / "venv"
|
||||||
|
site_packages(staged).mkdir(parents=True)
|
||||||
monkeypatch.setattr(doctor_platform, "_staged_venv_dir", lambda: staged)
|
monkeypatch.setattr(doctor_platform, "_staged_venv_dir", lambda: staged)
|
||||||
monkeypatch.setattr(doctor_platform.sys, "prefix", str(staged))
|
monkeypatch.syspath_prepend(str(site_packages(staged)))
|
||||||
monkeypatch.setattr(doctor_platform.sys, "base_prefix", "/usr")
|
|
||||||
|
|
||||||
doctor_platform._check_python_environment(False)
|
doctor_platform._check_python_environment(False)
|
||||||
|
|
||||||
|
|||||||
@@ -1,132 +1,193 @@
|
|||||||
"""Tests for the Command Installation check in hermes doctor."""
|
"""Command diagnostics use the same selection and launch contract as setup."""
|
||||||
|
|
||||||
import sys
|
import json
|
||||||
import types
|
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import hermes_cli.doctor as doctor_mod
|
from hermes_cli import _launchers, doctor, doctor_platform
|
||||||
|
from hermes_cli.runtime_paths import install_state_dir, site_packages
|
||||||
|
|
||||||
|
|
||||||
def _setup_doctor_env(monkeypatch, tmp_path, venv_name="venv"):
|
def _tree(tmp_path, monkeypatch):
|
||||||
"""Create a minimal HERMES_HOME + PROJECT_ROOT for doctor tests."""
|
home = tmp_path / "data"
|
||||||
home = tmp_path / ".hermes"
|
home.mkdir()
|
||||||
home.mkdir(parents=True, exist_ok=True)
|
project = tmp_path / "source"
|
||||||
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
|
project.mkdir()
|
||||||
|
(project / ".install_method").write_text("git", encoding="utf-8")
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||||
|
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(home / "tools"))
|
||||||
|
monkeypatch.delenv("PREFIX", raising=False)
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||||
|
monkeypatch.setattr(doctor, "PROJECT_ROOT", project)
|
||||||
|
monkeypatch.setattr(doctor, "HERMES_HOME", home)
|
||||||
|
monkeypatch.setattr(doctor, "DOCTOR_CHECKS", ((None, doctor_platform._check_command_installation),))
|
||||||
|
command = tmp_path / ".local" / "bin" / "hermes"
|
||||||
|
command.parent.mkdir(parents=True)
|
||||||
|
monkeypatch.setenv("PATH", str(command.parent))
|
||||||
|
return project, home, command
|
||||||
|
|
||||||
project = tmp_path / "project"
|
|
||||||
project.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
# Create a fake venv entry point
|
def _generation(project):
|
||||||
venv_bin_dir = project / venv_name / "bin"
|
selected = install_state_dir(project) / "environments" / "selected" / "venv"
|
||||||
venv_bin_dir.mkdir(parents=True, exist_ok=True)
|
site_packages(selected).mkdir(parents=True)
|
||||||
hermes_bin = venv_bin_dir / "hermes"
|
(selected / "pyvenv.cfg").write_text("home = fixture\n", encoding="utf-8")
|
||||||
hermes_bin.write_text("#!/usr/bin/env python\n# entry point\n")
|
(install_state_dir(project) / "facts.json").write_text(
|
||||||
hermes_bin.chmod(0o755)
|
json.dumps({"packages": {"venv": {"environment": str(selected)}}}), encoding="utf-8"
|
||||||
|
|
||||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
|
||||||
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
|
|
||||||
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
|
||||||
|
|
||||||
# Stub model_tools so doctor doesn't fail on import
|
|
||||||
fake_model_tools = types.SimpleNamespace(
|
|
||||||
check_tool_availability=lambda *a, **kw: ([], []),
|
|
||||||
TOOLSET_REQUIREMENTS={},
|
|
||||||
)
|
)
|
||||||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
return selected
|
||||||
|
|
||||||
# Stub auth checks
|
|
||||||
try:
|
|
||||||
from hermes_cli import auth as _auth_mod
|
|
||||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
|
||||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Stub httpx.get to avoid network calls
|
|
||||||
try:
|
|
||||||
import httpx
|
|
||||||
monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return home, project, hermes_bin
|
|
||||||
|
|
||||||
|
|
||||||
def _run_doctor(fix=False):
|
def _pm_source(project, home):
|
||||||
"""Run doctor and capture stdout."""
|
root = Path(__file__).resolve().parents[2]
|
||||||
import io
|
for relative in (
|
||||||
import contextlib
|
"hermes", "hermes_bootstrap.py", "hermes_constants.py", "hermes_cli/__init__.py",
|
||||||
|
"hermes_cli/runtime_paths.py", "hermes_cli/runtime_state.py",
|
||||||
buf = io.StringIO()
|
"hermes_cli/_early_recovery.py", "hermes_cli/_parser.py",
|
||||||
with contextlib.redirect_stdout(buf):
|
):
|
||||||
doctor_mod.run_doctor(Namespace(fix=fix))
|
target = project / relative
|
||||||
return buf.getvalue()
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(root / relative, target)
|
||||||
|
(project / "hermes_cli/main.py").write_text(
|
||||||
|
"def main():\n import selected_probe\n print(selected_probe.VALUE)\n return 0\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
interpreter = home / "tools" / "python-fixture" / "bin" / "python3"
|
||||||
|
interpreter.parent.mkdir(parents=True)
|
||||||
|
interpreter.symlink_to(Path(sys._base_executable).resolve())
|
||||||
|
(home / "tools" / "facts.json").write_text(
|
||||||
|
json.dumps({"packages": {"python": {"entry": "python-fixture"}}}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
return interpreter
|
||||||
|
|
||||||
|
|
||||||
class TestDoctorCommandInstallation:
|
@pytest.mark.platforms("posix")
|
||||||
"""Tests for the ◆ Command Installation section."""
|
def test_pm_generation_does_not_require_a_legacy_console_script(tmp_path, monkeypatch, capsys):
|
||||||
|
project, home, command = _tree(tmp_path, monkeypatch)
|
||||||
|
selected = _generation(project)
|
||||||
|
_pm_source(project, home)
|
||||||
|
assert _launchers.stage_launcher("hermes", project, command.parent) == command
|
||||||
|
|
||||||
|
doctor.run_doctor(Namespace(fix=True))
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "All checks passed" in out
|
||||||
|
assert not (project / "venv").exists()
|
||||||
|
assert not (selected / "bin" / "hermes").exists()
|
||||||
|
assert command.is_file() and not command.is_symlink()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.platforms("posix")
|
||||||
|
@pytest.mark.parametrize("prior", ["missing", "legacy"])
|
||||||
|
def test_pm_fix_publishes_a_generation_aware_launcher(tmp_path, monkeypatch, capsys, prior):
|
||||||
|
project, home, command = _tree(tmp_path, monkeypatch)
|
||||||
|
_pm_source(project, home)
|
||||||
|
selected = _generation(project)
|
||||||
|
(site_packages(selected) / "selected_probe.py").write_text("VALUE = 'selected'\n", encoding="utf-8")
|
||||||
|
stale = project / "venv" / "bin" / "hermes"
|
||||||
|
stale.parent.mkdir(parents=True)
|
||||||
|
stale.write_text("#!/bin/sh\nexit 99\n", encoding="utf-8")
|
||||||
|
stale.chmod(0o755)
|
||||||
|
if prior == "legacy":
|
||||||
|
command.symlink_to(stale)
|
||||||
|
|
||||||
|
doctor.run_doctor(Namespace(fix=True))
|
||||||
|
|
||||||
|
assert "Fixed 1 issue" in capsys.readouterr().out
|
||||||
|
assert not command.is_symlink()
|
||||||
|
result = subprocess.run([str(command)], cwd=tmp_path, capture_output=True, text=True, timeout=30)
|
||||||
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
|
assert result.stdout.strip() == "selected"
|
||||||
|
assert stale.is_file()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.platforms("posix")
|
||||||
|
@pytest.mark.parametrize("kind", ["wrapper", "symlink"])
|
||||||
|
def test_fix_preserves_user_managed_commands(tmp_path, monkeypatch, capsys, kind):
|
||||||
|
project, home, command = _tree(tmp_path, monkeypatch)
|
||||||
|
_pm_source(project, home)
|
||||||
|
_generation(project)
|
||||||
|
wrapper = tmp_path / "custom-wrapper"
|
||||||
|
body = "#!/bin/sh\nexit 42\n"
|
||||||
|
wrapper.write_text(body, encoding="utf-8")
|
||||||
|
wrapper.chmod(0o755)
|
||||||
|
if kind == "symlink":
|
||||||
|
command.symlink_to(wrapper)
|
||||||
|
else:
|
||||||
|
shutil.copy2(wrapper, command)
|
||||||
|
|
||||||
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
doctor.run_doctor(Namespace(fix=True))
|
||||||
def test_fix_repairs_wrong_symlink(self, monkeypatch, tmp_path):
|
|
||||||
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
|
|
||||||
|
|
||||||
# Create a symlink pointing to wrong target
|
out = capsys.readouterr().out
|
||||||
cmd_link_dir = tmp_path / ".local" / "bin"
|
assert "Fixed 1 issue" not in out
|
||||||
cmd_link_dir.mkdir(parents=True)
|
assert command.read_text(encoding="utf-8") == body
|
||||||
cmd_link = cmd_link_dir / "hermes"
|
if kind == "symlink":
|
||||||
wrong_target = tmp_path / "wrong_hermes"
|
assert command.is_symlink() and command.resolve() == wrapper
|
||||||
wrong_target.write_text("#!/usr/bin/env python\n")
|
assert "manual" in out.lower()
|
||||||
cmd_link.symlink_to(wrong_target)
|
|
||||||
|
|
||||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
||||||
|
|
||||||
out = _run_doctor(fix=True)
|
@pytest.mark.parametrize("layout", ["legacy", "generation"])
|
||||||
assert "Fixed symlink" in out
|
@pytest.mark.parametrize("active", [True, False])
|
||||||
|
def test_doctor_reports_selected_import_tree_not_interpreter_prefix(tmp_path, monkeypatch, capsys, layout, active):
|
||||||
|
import importlib
|
||||||
|
import pm.paths
|
||||||
|
|
||||||
# Verify the symlink now points to the correct target
|
project, _home, _command = _tree(tmp_path, monkeypatch)
|
||||||
assert cmd_link.is_symlink()
|
if layout == "generation":
|
||||||
assert cmd_link.resolve() == hermes_bin.resolve()
|
selected = _generation(project)
|
||||||
|
else:
|
||||||
|
selected = project / "venv"
|
||||||
|
site_packages(selected).mkdir(parents=True)
|
||||||
|
(selected / "pyvenv.cfg").write_text("home = fixture\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(pm.paths, "repo_root", lambda: project)
|
||||||
|
monkeypatch.setattr(doctor, "DOCTOR_CHECKS", ((None, doctor_platform._check_python_environment),))
|
||||||
|
# A venv prefix with no selected imports is not evidence of activation;
|
||||||
|
# conversely PM's base interpreter can import the selected tree directly.
|
||||||
|
monkeypatch.setattr(sys, "prefix", sys.base_prefix if active else str(selected))
|
||||||
|
if active:
|
||||||
|
module = site_packages(selected) / "doctor_selected_probe.py"
|
||||||
|
module.write_text("VALUE = 'selected'\n", encoding="utf-8")
|
||||||
|
monkeypatch.syspath_prepend(str(site_packages(selected)))
|
||||||
|
monkeypatch.delitem(sys.modules, "doctor_selected_probe", raising=False)
|
||||||
|
loaded = importlib.import_module("doctor_selected_probe")
|
||||||
|
assert Path(loaded.__file__) == module
|
||||||
|
monkeypatch.delitem(sys.modules, "doctor_selected_probe")
|
||||||
|
|
||||||
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
doctor.run_doctor(Namespace(fix=False))
|
||||||
def test_missing_venv_entry_point_shows_warn(self, monkeypatch, tmp_path):
|
|
||||||
home = tmp_path / ".hermes"
|
|
||||||
home.mkdir(parents=True, exist_ok=True)
|
|
||||||
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
|
|
||||||
|
|
||||||
project = tmp_path / "project"
|
out = capsys.readouterr().out
|
||||||
project.mkdir(exist_ok=True)
|
assert str(selected) in out
|
||||||
# Do NOT create any venv entry point
|
assert ("active in this process" in out) is active
|
||||||
|
assert ("runs outside it" in out) is not active
|
||||||
|
|
||||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
|
||||||
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
|
|
||||||
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
|
||||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
||||||
|
|
||||||
fake_model_tools = types.SimpleNamespace(
|
@pytest.mark.platforms("posix")
|
||||||
check_tool_availability=lambda *a, **kw: ([], []),
|
@pytest.mark.parametrize("method, remedy", [
|
||||||
TOOLSET_REQUIREMENTS={},
|
("git", "hermes pm repair"), ("nix", "Nix"), ("docker", "docker pull"), ("apt", "pkg upgrade"),
|
||||||
)
|
])
|
||||||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
def test_remedies_and_launcher_repairs_respect_install_owner(tmp_path, monkeypatch, capsys, method, remedy):
|
||||||
try:
|
project, _home, command = _tree(tmp_path, monkeypatch)
|
||||||
from hermes_cli import auth as _auth_mod
|
(project / ".install_method").write_text(method, encoding="utf-8")
|
||||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
entry = project / "venv" / "bin" / "hermes"
|
||||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
entry.parent.mkdir(parents=True)
|
||||||
except Exception:
|
entry.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||||
pass
|
entry.chmod(0o755)
|
||||||
try:
|
monkeypatch.setattr(doctor_platform, "_PACKAGES", (("doctor_missing_dependency_probe", "Required dependency", False),))
|
||||||
import httpx
|
monkeypatch.setattr(doctor, "DOCTOR_CHECKS", (
|
||||||
monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200))
|
(None, doctor_platform._check_required_packages), (None, doctor_platform._check_command_installation),
|
||||||
except Exception:
|
))
|
||||||
pass
|
|
||||||
|
|
||||||
out = _run_doctor(fix=False)
|
doctor.run_doctor(Namespace(fix=True))
|
||||||
assert "Command Installation" in out
|
|
||||||
assert "Venv entry point not found" in out
|
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert remedy in out
|
||||||
|
assert "pip install" not in out
|
||||||
|
if method == "git":
|
||||||
|
assert command.is_symlink() and command.resolve() == entry
|
||||||
|
else:
|
||||||
|
assert not command.exists()
|
||||||
|
assert "hermes pm repair" not in out
|
||||||
|
|||||||
Reference in New Issue
Block a user