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:
+17
-138
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user