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
This commit is contained in:
ethernet
2026-06-02 20:29:54 -04:00
parent a51a7b9b92
commit 4df280d511
7 changed files with 578 additions and 231 deletions
+17 -138
View File
@@ -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