From 4df280d5119ab3c96588be1290514ba78faec61b Mon Sep 17 00:00:00 2001 From: ethernet Date: Tue, 2 Jun 2026 17:34:40 -0400 Subject: [PATCH] refactor(uv): single managed-uv path, delete fts5 installer escalation Replace the multi-path UV resolution chain (PATH probing, conda guards, 5-location trust ordering, temp-dir fallback installs) with a single managed uv binary at $HERMES_HOME/bin/uv. Every code path that needs uv resolves it from that one location; if missing, ensure_uv() bootstraps it via the official standalone installer. Key changes: - New hermes_cli/managed_uv.py: managed_uv_path(), resolve_uv(), ensure_uv() (returns (path, freshly_bootstrapped) tuple), update_managed_uv(), rebuild_venv(), installer internals. - hermes_cli/main.py: replace all shutil.which('uv') with ensure_uv(), add venv rebuild on first-time managed uv bootstrap, update_managed_uv before dep install on all 3 update paths. - scripts/install.sh: install_uv() always installs to $HERMES_HOME/bin/uv; delete ensure_fts5, _python_has_fts5, _reinstall_python_with_fts5, _warn_no_fts5 (61 lines). Managed uv always installs current Python with FTS5. - scripts/install.ps1: Install-Uv always installs to $HermesHome\bin\uv.exe; Resolve-UvCmd checks managed location first. - hermes_state.py: simplified FTS5 warning now suggests 'hermes update' as the fix instead of blaming install method. - tests: 15 tests in test_managed_uv.py, autouse _patch_managed_uv fixture in test_cmd_update.py. Closes #37605, Closes #37622 --- hermes_cli/main.py | 61 ++++++-- hermes_cli/managed_uv.py | 228 ++++++++++++++++++++++++++++ hermes_state.py | 9 +- scripts/install.ps1 | 115 +++++--------- scripts/install.sh | 155 +++---------------- tests/hermes_cli/test_cmd_update.py | 36 +++++ tests/hermes_cli/test_managed_uv.py | 205 +++++++++++++++++++++++++ 7 files changed, 578 insertions(+), 231 deletions(-) create mode 100644 hermes_cli/managed_uv.py create mode 100644 tests/hermes_cli/test_managed_uv.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3a709f6aa..e752dff57 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7679,8 +7679,21 @@ def _update_via_zip(args): # individually so update does not silently strip working capabilities. print("→ Updating Python dependencies...") + from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv + + # Keep managed uv current — runs `uv self update` if we already have one. + update_managed_uv() + + uv_bin, fresh_bootstrap = ensure_uv() + # First-time managed uv install on an existing checkout: the old venv + # may point to a Python without FTS5. Rebuild it so the new managed + # uv provides a fresh interpreter with FTS5 guaranteed. + if fresh_bootstrap and uv_bin: + rebuild_venv(uv_bin, PROJECT_ROOT / "venv") + pip_cmd = [sys.executable, "-m", "pip"] - uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd) + if not uv_bin: + uv_bin = _ensure_uv_for_termux(pip_cmd) if uv_bin: uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")} if _is_termux_env(uv_env): @@ -8780,16 +8793,27 @@ def _install_psutil_android_compat( def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None: - """Best-effort uv bootstrap on Termux for faster update installs.""" - uv_bin = shutil.which("uv") - if uv_bin or not _is_termux_env(): - return uv_bin + """Best-effort uv bootstrap on Termux for faster update installs. + + The normal path (``ensure_uv()`` in managed_uv) installs the managed + standalone uv into ``$HERMES_HOME/bin/uv``, but on Termux the official + installer may not work (glibc vs bionic). Fall back to ``pip install uv`` + which gets a Termux-compatible binary. + """ + from hermes_cli.managed_uv import resolve_uv + + existing = resolve_uv() + if existing: + return existing + if not _is_termux_env(): + return None try: print(" → Termux detected: trying to install uv for faster dependency updates...") subprocess.run(pip_cmd + ["install", "uv"], cwd=PROJECT_ROOT, check=False) except Exception: pass - return shutil.which("uv") + # After pip install, check managed path first, then PATH + return resolve_uv() or shutil.which("uv") def _update_node_dependencies() -> None: @@ -9440,7 +9464,12 @@ def _cmd_update_pip(args): print(f"→ Current version: {__version__}") print("→ Checking PyPI for updates...") - uv = shutil.which("uv") + from hermes_cli.managed_uv import ensure_uv, update_managed_uv + + # Keep managed uv current before using it. + update_managed_uv() + + uv, _fresh_bootstrap = ensure_uv() in_venv = sys.prefix != sys.base_prefix # pipx-managed installs live under .../pipx/venvs//... pipx_managed = "pipx" in sys.prefix.split(os.sep) @@ -9455,7 +9484,8 @@ def _cmd_update_pip(args): if is_uv_tool_install(): if not uv: - print("✗ Detected a uv-tool install but `uv` is not on PATH; install uv and retry.") + print("✗ Detected a uv-tool install but managed uv install failed.") + print(" Install uv manually: https://docs.astral.sh/uv/getting-started/installation/") sys.exit(1) cmd = [uv, "tool", "upgrade", "hermes-agent"] elif pipx_managed and pipx: @@ -9851,8 +9881,21 @@ def _cmd_update_impl(args, gateway_mode: bool): # breaks on this machine, keep base deps and reinstall the remaining extras # individually so update does not silently strip working capabilities. print("→ Updating Python dependencies...") + from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv + + # Keep managed uv current — runs `uv self update` if we already have one. + update_managed_uv() + + uv_bin, fresh_bootstrap = ensure_uv() + # First-time managed uv install on an existing checkout: the old venv + # may point to a Python without FTS5. Rebuild it so the new managed + # uv provides a fresh interpreter with FTS5 guaranteed. + if fresh_bootstrap and uv_bin: + rebuild_venv(uv_bin, PROJECT_ROOT / "venv") + pip_cmd = [sys.executable, "-m", "pip"] - uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd) + if not uv_bin: + uv_bin = _ensure_uv_for_termux(pip_cmd) install_group = "all" if uv_bin: diff --git a/hermes_cli/managed_uv.py b/hermes_cli/managed_uv.py new file mode 100644 index 000000000..b38893f71 --- /dev/null +++ b/hermes_cli/managed_uv.py @@ -0,0 +1,228 @@ +"""Managed uv — one path, no guessing. + +Hermes owns its own uv binary at ``$HERMES_HOME/bin/uv`` (or ``uv.exe`` on +Windows). Every code path that needs uv resolves it from that single location. +If the binary is missing, ``ensure_uv()`` bootstraps it via the official +standalone installer with ``UV_UNMANAGED_INSTALL`` / ``UV_INSTALL_DIR`` pointed +at ``$HERMES_HOME/bin`` so the installer writes directly there — no PATH +probing, no conda guards, no multi-location resolution chains. + +When ``ensure_uv()`` bootstraps uv for the first time (i.e. there was no +managed uv before), it returns ``(path, True)`` instead of just ``path``. +Callers in the update path use that signal to nuke and recreate the venv +with the now-current managed uv, guaranteeing a Python with FTS5. +""" + +from __future__ import annotations + +import logging +import os +import platform +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Optional, Tuple + +from hermes_constants import get_hermes_home + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Public helpers +# --------------------------------------------------------------------------- + +def managed_uv_path() -> Path: + """Return the path where Hermes keeps *its* uv binary. + + ``$HERMES_HOME/bin/uv`` on POSIX, ``$HERMES_HOME\\bin\\uv.exe`` on + Windows. The directory may not exist yet — callers should use + ``ensure_uv()`` to bootstrap it. + """ + home = get_hermes_home() + if platform.system() == "Windows": + return home / "bin" / "uv.exe" + return home / "bin" / "uv" + + +def resolve_uv() -> Optional[str]: + """Return the managed uv path if it exists, else ``None``. + + No side effects — pure lookup. + """ + p = managed_uv_path() + if p.is_file() and os.access(p, os.X_OK): + return str(p) + return None + + +def ensure_uv() -> Tuple[Optional[str], bool]: + """Return the managed uv path, installing it first if necessary. + + Returns ``(path, freshly_bootstrapped)`` where *freshly_bootstrapped* is + ``True`` when we just installed managed uv for the first time (there was + no managed uv before this call). Callers can use that signal to rebuild + the venv so Python is guaranteed to have FTS5. + + On failure returns ``(None, False)`` (never raises) so callers can fall + back to pip gracefully. + """ + existing = resolve_uv() + if existing: + return (existing, False) + + target = managed_uv_path() + target.parent.mkdir(parents=True, exist_ok=True) + + print(f" → Installing managed uv into {target.parent} ...") + + try: + _install_uv(target) + except Exception as exc: + logger.warning("Managed uv install failed: %s", exc) + print(f" ✗ Failed to install managed uv: {exc}") + return (None, False) + + # Verify + result = resolve_uv() + if result: + version = subprocess.run( + [result, "--version"], + capture_output=True, + text=True, + check=False, + ).stdout.strip() + print(f" ✓ Managed uv installed ({version})") + else: + print(" ✗ Managed uv install appeared to succeed but binary not found") + return (result, result is not None) + + +def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool: + """Nuke and recreate the venv with managed uv. + + Called when managed uv is first bootstrapped on an existing install — the + old venv may point to a Python without FTS5, so we rebuild it with a + fresh interpreter from the current managed uv. Returns ``True`` on + success. + """ + if venv_dir.exists(): + print(f" → Rebuilding venv (old Python may lack FTS5)...") + shutil.rmtree(venv_dir, ignore_errors=True) + + result = subprocess.run( + [uv_bin, "venv", str(venv_dir), "--python", python_version], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + venv_python = venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") / "python" + py_ver = subprocess.run( + [str(venv_python), "--version"], + capture_output=True, + text=True, + check=False, + ).stdout.strip() + print(f" ✓ venv rebuilt ({py_ver})") + return True + else: + logger.warning("venv rebuild failed: %s", result.stderr) + print(f" ✗ venv rebuild failed: {result.stderr.strip()}") + return False + + +def update_managed_uv() -> Optional[str]: + """Run ``uv self update`` on the managed uv binary. + + Call this during ``hermes update`` so the managed copy stays current. + Returns the managed path on success, ``None`` if uv isn't available or + the self-update fails (non-fatal — the old version still works). + """ + existing = resolve_uv() + if not existing: + # Not installed yet — ensure_uv() will handle that elsewhere. + return None + + result = subprocess.run( + [existing, "self", "update"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + version = subprocess.run( + [existing, "--version"], + capture_output=True, + text=True, + check=False, + ).stdout.strip() + print(f" ✓ Managed uv updated ({version})") + else: + # Non-fatal — old uv still works fine. + logger.debug("uv self update failed (rc=%d): %s", result.returncode, result.stderr) + return existing + + +# --------------------------------------------------------------------------- +# Installer internals +# --------------------------------------------------------------------------- + +def _install_uv(target: Path) -> None: + """Bootstrap uv into *target* using the official standalone installer. + + Uses ``UV_UNMANAGED_INSTALL`` (POSIX) or ``UV_INSTALL_DIR`` (Windows) + so the astral installer writes the binary directly into + ``$HERMES_HOME/bin/`` instead of ``~/.local/bin/``. + """ + system = platform.system() + env = { + **os.environ, + # Tell the astral installer to drop the binary in our dir, not + # ~/.local/bin. UV_UNMANAGED_INSTALL is the POSIX env var; Windows + # uses UV_INSTALL_DIR. + "UV_UNMANAGED_INSTALL": str(target.parent), + "UV_INSTALL_DIR": str(target.parent), + } + + if system == "Windows": + _install_uv_windows(env) + else: + _install_uv_posix(env) + + +def _install_uv_posix(env: dict[str, str]) -> None: + """Download + sh the POSIX installer (two-stage to avoid curl|sh pitfalls).""" + with tempfile.NamedTemporaryFile(suffix=".sh", delete=False) as f: + installer_path = f.name + + try: + subprocess.run( + ["curl", "-LsSf", "https://astral.sh/uv/install.sh", "-o", installer_path], + check=True, + capture_output=True, + ) + subprocess.run( + ["sh", installer_path], + env=env, + check=True, + capture_output=True, + ) + finally: + try: + os.unlink(installer_path) + except OSError: + pass + + +def _install_uv_windows(env: dict[str, str]) -> None: + """Invoke the PowerShell installer.""" + cmd = ( + 'irm https://astral.sh/uv/install.ps1 | iex' + ) + subprocess.run( + ["powershell", "-ExecutionPolicy", "Bypass", "-c", cmd], + env=env, + check=True, + capture_output=True, + ) diff --git a/hermes_state.py b/hermes_state.py index 268086b6b..f08acdce2 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -452,12 +452,9 @@ class SessionDB: self._fts_unavailable_warned = True logger.warning( "SQLite FTS5 unavailable for %s; full-text session search " - "disabled. This usually means Hermes is running on an " - "unsupported install (e.g. a pip-installed or pip-managed " - "Python whose bundled SQLite lacks FTS5) rather than a " - "mainline install. Some features may be missing or behave " - "differently. Install the supported way: " - "https://hermes-agent.nousresearch.com (underlying error: %s)", + "disabled. Run `hermes update` to rebuild the venv with a " + "current Python (managed uv guarantees FTS5). " + "(underlying error: %s)", self.db_path, exc, ) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index bed44ef13..cfb58f28f 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -289,78 +289,42 @@ function Install-AgentBrowser { # ============================================================================ function Install-Uv { - Write-Info "Checking for uv package manager..." - - # Check if uv is already available - if (Get-Command uv -ErrorAction SilentlyContinue) { - $version = uv --version - $script:UvCmd = "uv" - Write-Success "uv found ($version)" + # Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there — + # no PATH probing, no conda guards, no multi-location resolution chains. + # The runtime update path (hermes_cli/managed_uv.py) looks in the same + # place, so install.ps1 and `hermes update` stay in sync. + $managedUv = Join-Path $HermesHome "bin\uv.exe" + + if (Test-Path $managedUv) { + $script:UvCmd = $managedUv + $version = & $managedUv --version + Write-Success "Managed uv found ($version)" return $true } - - # Check common install locations - $uvPaths = @( - "$env:USERPROFILE\.local\bin\uv.exe", - "$env:USERPROFILE\.cargo\bin\uv.exe" - ) - foreach ($uvPath in $uvPaths) { - if (Test-Path $uvPath) { - $script:UvCmd = $uvPath - $version = & $uvPath --version - Write-Success "uv found at $uvPath ($version)" - return $true - } - } - - # Install uv - Write-Info "Installing uv (fast Python package manager)..." - # Capture EAP outside the try block so the catch's restore call always - # has a meaningful value -- if the assignment lived inside try and the - # try body threw before reaching it, the catch would see $prevEAP - # unset and leave EAP at whatever the previous protected call set. + + Write-Info "Installing managed uv into $HermesHome\bin ..." + New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null + + # UV_INSTALL_DIR tells the astral installer to place the binary + # directly into $HermesHome\bin instead of ~/.local/bin. $prevEAP = $ErrorActionPreference try { - # Relax ErrorActionPreference around the nested astral installer. - # The astral installer (a separate `powershell -c "irm ... | iex"`) - # writes download progress to stderr. With $ErrorActionPreference - # = "Stop" set at the top of this script, PowerShell wraps stderr - # lines from native commands (which `powershell -c` is, from our - # perspective) as ErrorRecord objects when captured via 2>&1, then - # throws a terminating exception on the first one -- even though - # uv installs successfully and the child exits 0. Same fix - # pattern Test-Python uses for `uv python install`; verify success - # via Test-Path on the expected binary afterwards, which is more - # reliable than exit-code/stderr signal anyway. $ErrorActionPreference = "Continue" + $env:UV_INSTALL_DIR = Join-Path $HermesHome "bin" powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null $ErrorActionPreference = $prevEAP - # Find the installed binary - $uvExe = "$env:USERPROFILE\.local\bin\uv.exe" - if (-not (Test-Path $uvExe)) { - $uvExe = "$env:USERPROFILE\.cargo\bin\uv.exe" - } - if (-not (Test-Path $uvExe)) { - # Refresh PATH and try again - $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") - if (Get-Command uv -ErrorAction SilentlyContinue) { - $uvExe = (Get-Command uv).Source - } - } - - if (Test-Path $uvExe) { - $script:UvCmd = $uvExe - $version = & $uvExe --version - Write-Success "uv installed ($version)" + if (Test-Path $managedUv) { + $script:UvCmd = $managedUv + $version = & $managedUv --version + Write-Success "Managed uv installed ($version)" return $true } - - Write-Err "uv installed but not found on PATH" - Write-Info "Try restarting your terminal and re-running" + + Write-Err "uv installed but not found at $managedUv" + Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" return $false } catch { - # Restore EAP in case the try block threw before the assignment if ($prevEAP) { $ErrorActionPreference = $prevEAP } Write-Err "Failed to install uv: $_" Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" @@ -385,11 +349,9 @@ function Sync-EnvPath { # in a fresh powershell process, so $script:UvCmd set by Install-Uv in a # prior process is not visible here. Later stages (Test-Python, # Install-Venv, Install-Dependencies, Install-PlatformSdks) call this -# at the top to populate $script:UvCmd from PATH or known install paths. -# Throws if uv is not findable -- the caller's stage then surfaces a -# clean error via the stage-driver's try/catch. Fast path is a single -# Get-Command call when uv is on PATH (the common case after Stage-Uv -# ran path-modifying installs in a sibling process). +# at the top to populate $script:UvCmd from the managed location. +# Throws if uv is not findable — the caller's stage then surfaces a +# clean error via the stage-driver's try/catch. function Resolve-UvCmd { # Already resolved (default invocation path: Install-Uv ran earlier # in the same process and set $script:UvCmd). @@ -404,9 +366,15 @@ function Resolve-UvCmd { # Stale; fall through to re-discover. } - # Try PATH first (covers `winget install astral.uv`, manual installs, - # and the post-Install-Uv state where uv.exe lives in - # %USERPROFILE%\.local\bin which the installer added to PATH). + # Check the managed location first — this is where Install-Uv puts it. + $managedUv = Join-Path $HermesHome "bin\uv.exe" + if (Test-Path $managedUv) { + $script:UvCmd = $managedUv + return + } + + # Fall back to PATH (covers edge cases where the installer ran in a + # sibling process and HERMES_HOME wasn't propagated). if (Get-Command uv -ErrorAction SilentlyContinue) { $script:UvCmd = "uv" return @@ -420,16 +388,7 @@ function Resolve-UvCmd { return } - # Check the well-known install locations the astral.sh installer drops - # uv into. Mirrors the probe order Install-Uv uses. - foreach ($uvPath in @("$env:USERPROFILE\.local\bin\uv.exe", "$env:USERPROFILE\.cargo\bin\uv.exe")) { - if (Test-Path $uvPath) { - $script:UvCmd = $uvPath - return - } - } - - throw "uv is not installed or not on PATH. Run install.ps1 -Stage uv first." + throw "uv is not installed. Run install.ps1 -Stage uv first." } function Test-Python { diff --git a/scripts/install.sh b/scripts/install.sh index 24321c9b8..df9b259d0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -475,39 +475,22 @@ install_uv() { return 0 fi - log_info "Checking for uv package manager..." + # Hermes owns its own uv at $HERMES_HOME/bin/uv. Always install there — + # no PATH probing, no conda guards, no multi-location resolution chains. + # The runtime update path (hermes_cli/managed_uv.py) looks in the same + # place, so install.sh and `hermes update` stay in sync. + local _managed_uv="$HERMES_HOME/bin/uv" - # Check common locations for uv - if command -v uv &> /dev/null; then - UV_CMD="uv" + if [ -x "$_managed_uv" ]; then + UV_CMD="$_managed_uv" UV_VERSION=$($UV_CMD --version 2>/dev/null) - log_success "uv found ($UV_VERSION)" + log_success "Managed uv found ($UV_VERSION)" return 0 fi - # Check ~/.local/bin (default uv install location) even if not on PATH yet - if [ -x "$HOME/.local/bin/uv" ]; then - UV_CMD="$HOME/.local/bin/uv" - UV_VERSION=$($UV_CMD --version 2>/dev/null) - log_success "uv found at ~/.local/bin ($UV_VERSION)" - return 0 - fi + log_info "Installing managed uv into $HERMES_HOME/bin ..." + mkdir -p "$HERMES_HOME/bin" - # Check ~/.cargo/bin (alternative uv install location) - if [ -x "$HOME/.cargo/bin/uv" ]; then - UV_CMD="$HOME/.cargo/bin/uv" - UV_VERSION=$($UV_CMD --version 2>/dev/null) - log_success "uv found at ~/.cargo/bin ($UV_VERSION)" - return 0 - fi - - # Install uv - log_info "Installing uv (fast Python package manager)..." - # Capture installer output so a failure shows the user WHY (network, - # glibc mismatch on old distros, missing curl, ~/.local/bin not - # writable, disk full, corp proxy / TLS interception, etc.) instead - # of the previous "✗ Failed to install uv" with zero diagnostic. - # # Two-stage: download the installer, then run it. Piping # `curl | sh` masks curl failures (sh exits 0 on empty stdin) # and conflates network errors with installer errors. @@ -522,26 +505,22 @@ install_uv() { rm -f "$_uv_install_log" "$_uv_installer" exit 1 fi - if sh "$_uv_installer" >>"$_uv_install_log" 2>&1; then + # UV_UNMANAGED_INSTALL tells the astral installer to place the binary + # directly into $HERMES_HOME/bin instead of ~/.local/bin. + if UV_UNMANAGED_INSTALL="$HERMES_HOME/bin" sh "$_uv_installer" >>"$_uv_install_log" 2>&1; then rm -f "$_uv_installer" - # uv installs to ~/.local/bin by default - if [ -x "$HOME/.local/bin/uv" ]; then - UV_CMD="$HOME/.local/bin/uv" - elif [ -x "$HOME/.cargo/bin/uv" ]; then - UV_CMD="$HOME/.cargo/bin/uv" - elif command -v uv &> /dev/null; then - UV_CMD="uv" + if [ -x "$_managed_uv" ]; then + UV_CMD="$_managed_uv" else - log_error "uv installer reported success but binary not found on PATH" + log_error "uv installer reported success but binary not found at $_managed_uv" log_info "Installer output:" sed 's/^/ /' "$_uv_install_log" >&2 - log_info "Try adding ~/.local/bin to your PATH and re-running" rm -f "$_uv_install_log" exit 1 fi rm -f "$_uv_install_log" UV_VERSION=$($UV_CMD --version 2>/dev/null) - log_success "uv installed ($UV_VERSION)" + log_success "Managed uv installed ($UV_VERSION)" else log_error "Failed to install uv" log_info "Installer output:" @@ -579,7 +558,6 @@ check_python() { if PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION" 2>/dev/null)"; then PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)" log_success "Python found: $PYTHON_FOUND_VERSION" - ensure_fts5 return 0 fi @@ -589,7 +567,6 @@ check_python() { PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION")" PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)" log_success "Python installed: $PYTHON_FOUND_VERSION" - ensure_fts5 else log_error "Failed to install Python $PYTHON_VERSION" log_info "Install Python $PYTHON_VERSION manually, then re-run this script" @@ -597,104 +574,6 @@ check_python() { fi } -# Probe whether $1 (a python executable) links a SQLite with the FTS5 -# module compiled in. Hermes' session store (hermes_state.py) creates FTS5 -# virtual tables for full-text session search; a SQLite without FTS5 makes -# the bundled-python path unusable for that feature. Returns 0 if FTS5 works. -_python_has_fts5() { - "$1" - <<'PY' 2>/dev/null -import sqlite3, sys -try: - sqlite3.connect(":memory:").execute("CREATE VIRTUAL TABLE t USING fts5(x)") -except Exception: - sys.exit(1) -PY -} - -# Reinstall $PYTHON_VERSION with the current uv and re-resolve PYTHON_PATH. -# Returns 0 if the resulting interpreter ships FTS5. -_reinstall_python_with_fts5() { - local uv_bin="$1" - "$uv_bin" python install "$PYTHON_VERSION" --reinstall >/dev/null 2>&1 || return 1 - PYTHON_PATH="$("$uv_bin" python find "$PYTHON_VERSION" 2>/dev/null)" - PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)" - [ -n "${PYTHON_PATH:-}" ] && _python_has_fts5 "$PYTHON_PATH" -} - -_warn_no_fts5() { - # Could not obtain an FTS5-capable interpreter (offline, pinned env, etc.). - # Install proceeds — Hermes degrades gracefully and disables only full-text - # session search — but warn so it isn't a silent gap. - log_warn "Could not obtain an FTS5-capable Python. Hermes will run, but" - log_warn "full-text session search will be disabled until FTS5 is present." -} - -# Guarantee the resolved uv-managed interpreter ships FTS5. uv's Python -# distributions only gained FTS5 in mid-2025 (python-build-standalone #694), -# but WHICH builds a given uv can install is baked into the uv binary's -# download manifest — so a stale uv (e.g. `pip install uv==0.7.20`) only knows -# about pre-FTS5 builds, and even `uv python install --reinstall` just pulls the -# same FTS5-less interpreter. A plain reinstall with an old uv is therefore a -# no-op for FTS5. To actually fix everyone's install, we escalate uv itself: -# -# 1. reinstall with the current $UV_CMD (handles a stale *interpreter* under -# an already-current uv) -# 2. if still no FTS5, bring uv up to date (`uv self update`) and reinstall — -# this is what fixes a stale standalone uv -# 3. if uv can't self-update (pip/apt/brew-managed uv refuses), install a -# fresh standalone uv via the official installer into a temp dir and use -# THAT to reinstall — this fixes package-manager-managed stale uv -# -# Pythons live in uv's shared store, so a fresh uv's --reinstall overwrites the -# stale interpreter in place and the installer's later `uv python find` resolves -# to it. Keeps session search working without bundling a second SQLite or asking -# the user to do anything. -ensure_fts5() { - [ -n "${PYTHON_PATH:-}" ] || return 0 - if _python_has_fts5 "$PYTHON_PATH"; then - return 0 - fi - # Termux / non-uv installs have nothing to escalate. - [ -n "${UV_CMD:-}" ] || { _warn_no_fts5; return 0; } - - log_warn "Resolved Python's SQLite lacks the FTS5 module (session search needs it)." - log_info "Reinstalling a current Python $PYTHON_VERSION with FTS5 via uv..." - if _reinstall_python_with_fts5 "$UV_CMD"; then - log_success "FTS5 available ($PYTHON_FOUND_VERSION)" - return 0 - fi - - # Still no FTS5 — the uv binary itself is too old to know about FTS5-capable - # Python builds. Try to update uv in place. - log_info "uv is too old to provide an FTS5-capable Python — updating uv..." - if "$UV_CMD" self update >/dev/null 2>&1; then - if _reinstall_python_with_fts5 "$UV_CMD"; then - log_success "FTS5 available ($PYTHON_FOUND_VERSION)" - return 0 - fi - fi - - # `uv self update` is unavailable on externally-managed uv (pip/apt/brew), - # which is exactly the case the user hit (`pip install uv==0.7.20`). Install - # a fresh standalone uv into a temp dir and use it just for the reinstall. - log_info "Installing an up-to-date standalone uv to obtain an FTS5 Python..." - local _tmp_uv_dir _fresh_uv - _tmp_uv_dir="$(mktemp -d 2>/dev/null || echo "/tmp/hermes-fresh-uv.$$")" - mkdir -p "$_tmp_uv_dir" - if curl -LsSf https://astral.sh/uv/install.sh 2>/dev/null \ - | env UV_INSTALL_DIR="$_tmp_uv_dir" UV_UNMANAGED_INSTALL="$_tmp_uv_dir" sh >/dev/null 2>&1; then - _fresh_uv="$_tmp_uv_dir/uv" - if [ -x "$_fresh_uv" ] && _reinstall_python_with_fts5 "$_fresh_uv"; then - log_success "FTS5 available ($PYTHON_FOUND_VERSION)" - rm -rf "$_tmp_uv_dir" - return 0 - fi - fi - rm -rf "$_tmp_uv_dir" - - _warn_no_fts5 -} - # Best-effort automatic git provisioning, mirroring install.ps1's Install-Git # (which downloads PortableGit on Windows). git is required to clone the repo, # and a fresh "normie" machine with no developer tools won't have it. Returns 0 diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index 098842711..bcf92c5e7 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -39,6 +39,42 @@ def mock_args(): return SimpleNamespace() +# --------------------------------------------------------------------------- +# Managed-uv compatibility for tests that patch shutil.which +# --------------------------------------------------------------------------- +# The production code now uses ``ensure_uv()`` / ``update_managed_uv()`` +# instead of ``shutil.which("uv")``. Many tests in this file patch +# ``shutil.which`` to control whether uv is "available" — these autouse +# fixtures make the managed_uv functions delegate to the patched +# ``shutil.which`` so the existing test setup keeps working without +# per-test changes. +@pytest.fixture(autouse=True) +def _patch_managed_uv(request): + """Make managed_uv helpers follow shutil.which mocking in tests.""" + import shutil + + # resolve_uv delegates to shutil.which("uv") so that test patches + # on shutil.which flow through naturally. + def _fake_resolve_uv(): + return shutil.which("uv") + + def _fake_ensure_uv(): + path = shutil.which("uv") + return (path, False) # never freshly bootstrapped in tests + + def _fake_update_managed_uv(): + return None # never actually self-update in tests + + def _fake_rebuild_venv(*args, **kwargs): + return True # no-op in tests + + with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \ + patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \ + patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \ + patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv): + yield + + class TestCmdUpdatePip: """Regression tests for pip-install update flows.""" diff --git a/tests/hermes_cli/test_managed_uv.py b/tests/hermes_cli/test_managed_uv.py new file mode 100644 index 000000000..89f3d8bad --- /dev/null +++ b/tests/hermes_cli/test_managed_uv.py @@ -0,0 +1,205 @@ +"""Tests for hermes_cli.managed_uv — one path, no guessing.""" + +from __future__ import annotations + +import os +import stat +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_executable(path: Path) -> None: + """Create a minimal fake uv binary at *path*.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("#!/bin/sh\necho uv 0.1.2\n") + path.chmod(path.stat().st_mode | stat.S_IEXEC) + + +# --------------------------------------------------------------------------- +# managed_uv_path +# --------------------------------------------------------------------------- + +class TestManagedUvPath: + def test_posix(self, tmp_path): + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ + patch("hermes_cli.managed_uv.platform.system", return_value="Linux"): + from hermes_cli.managed_uv import managed_uv_path + assert managed_uv_path() == tmp_path / "bin" / "uv" + + def test_windows(self, tmp_path): + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ + patch("hermes_cli.managed_uv.platform.system", return_value="Windows"): + from hermes_cli.managed_uv import managed_uv_path + assert managed_uv_path() == tmp_path / "bin" / "uv.exe" + + +# --------------------------------------------------------------------------- +# resolve_uv +# --------------------------------------------------------------------------- + +class TestResolveUv: + def test_missing_returns_none(self, tmp_path): + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path): + from hermes_cli.managed_uv import resolve_uv + assert resolve_uv() is None + + def test_existing_executable(self, tmp_path): + _make_executable(tmp_path / "bin" / "uv") + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path): + from hermes_cli.managed_uv import resolve_uv + result = resolve_uv() + assert result == str(tmp_path / "bin" / "uv") + + def test_non_executable_file_returns_none(self, tmp_path): + uv = tmp_path / "bin" / "uv" + uv.parent.mkdir(parents=True) + uv.write_text("not a binary") + # Ensure no execute bit + uv.chmod(0o644) + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path): + from hermes_cli.managed_uv import resolve_uv + assert resolve_uv() is None + + +# --------------------------------------------------------------------------- +# ensure_uv +# --------------------------------------------------------------------------- + +class TestEnsureUv: + def test_already_installed_no_bootstrap(self, tmp_path): + _make_executable(tmp_path / "bin" / "uv") + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path): + from hermes_cli.managed_uv import ensure_uv + path, fresh = ensure_uv() + assert path == str(tmp_path / "bin" / "uv") + assert fresh is False + + def test_installs_if_missing_sets_bootstrap_flag(self, tmp_path): + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ + patch("hermes_cli.managed_uv._install_uv") as mock_install: + # Simulate the installer creating the binary + def fake_install(target): + _make_executable(target) + mock_install.side_effect = fake_install + + from hermes_cli.managed_uv import ensure_uv + path, fresh = ensure_uv() + assert path == str(tmp_path / "bin" / "uv") + assert fresh is True + mock_install.assert_called_once() + + def test_install_failure_returns_none_false(self, tmp_path): + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ + patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")): + from hermes_cli.managed_uv import ensure_uv + path, fresh = ensure_uv() + assert path is None + assert fresh is False + + +# --------------------------------------------------------------------------- +# rebuild_venv +# --------------------------------------------------------------------------- + +class TestRebuildVenv: + def test_removes_old_venv_and_creates_new(self, tmp_path): + venv_dir = tmp_path / "venv" + venv_dir.mkdir() + (venv_dir / "old_file").write_text("stale") + + uv_bin = str(tmp_path / "bin" / "uv") + + def fake_run(cmd, **kwargs): + m = MagicMock(returncode=0) + if cmd[1] == "venv": + # Simulate uv creating the venv dir + venv_dir.mkdir(exist_ok=True) + bin_dir = venv_dir / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + (bin_dir / "python").write_text("#!/bin/sh\necho Python 3.11.0") + elif "--version" in cmd: + m.stdout = "Python 3.11.0" + return m + + with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \ + patch("hermes_cli.managed_uv.shutil.rmtree") as mock_rmtree: + from hermes_cli.managed_uv import rebuild_venv + result = rebuild_venv(uv_bin, venv_dir) + assert result is True + mock_rmtree.assert_called_once_with(venv_dir, ignore_errors=True) + + def test_rebuild_failure_returns_false(self, tmp_path): + venv_dir = tmp_path / "venv" + uv_bin = str(tmp_path / "bin" / "uv") + + with patch("hermes_cli.managed_uv.subprocess.run") as mock_run, \ + patch("hermes_cli.managed_uv.shutil.rmtree"): + mock_run.return_value = MagicMock(returncode=1, stderr="nope") + from hermes_cli.managed_uv import rebuild_venv + result = rebuild_venv(uv_bin, venv_dir) + assert result is False + + +# --------------------------------------------------------------------------- +# update_managed_uv +# --------------------------------------------------------------------------- + +class TestUpdateManagedUv: + def test_no_uv_returns_none(self, tmp_path): + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path): + from hermes_cli.managed_uv import update_managed_uv + assert update_managed_uv() is None + + def test_self_update_success(self, tmp_path): + _make_executable(tmp_path / "bin" / "uv") + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ + patch("hermes_cli.managed_uv.subprocess.run") as mock_run: + # uv self update succeeds + mock_run.return_value = MagicMock(returncode=0, stdout="uv 0.2.0") + from hermes_cli.managed_uv import update_managed_uv + result = update_managed_uv() + assert result == str(tmp_path / "bin" / "uv") + # First call is self update, second is --version + assert mock_run.call_count == 2 + assert mock_run.call_args_list[0][0][0] == [str(tmp_path / "bin" / "uv"), "self", "update"] + + def test_self_update_failure_non_fatal(self, tmp_path): + _make_executable(tmp_path / "bin" / "uv") + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ + patch("hermes_cli.managed_uv.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=1, stderr="nope") + from hermes_cli.managed_uv import update_managed_uv + result = update_managed_uv() + # Still returns the path — failure is non-fatal + assert result == str(tmp_path / "bin" / "uv") + + +# --------------------------------------------------------------------------- +# _install_uv internals +# --------------------------------------------------------------------------- + +class TestInstallUvInternals: + def test_posix_sets_uv_unmanaged_install(self, tmp_path): + target = tmp_path / "bin" / "uv" + with patch("hermes_cli.managed_uv._install_uv_posix") as mock_posix: + from hermes_cli.managed_uv import _install_uv + _install_uv(target) + mock_posix.assert_called_once() + call_env = mock_posix.call_args[0][0] + assert call_env["UV_UNMANAGED_INSTALL"] == str(tmp_path / "bin") + + def test_windows_sets_uv_install_dir(self, tmp_path): + target = tmp_path / "bin" / "uv.exe" + with patch("hermes_cli.managed_uv.platform.system", return_value="Windows"), \ + patch("hermes_cli.managed_uv._install_uv_windows") as mock_windows: + from hermes_cli.managed_uv import _install_uv + _install_uv(target) + mock_windows.assert_called_once() + call_env = mock_windows.call_args[0][0] + assert call_env["UV_INSTALL_DIR"] == str(tmp_path / "bin")