fix(windows): unbreak install + update on Windows (#23394)

Three issues hit during a fresh Windows install + first `hermes update`:

1. `pyproject.toml` re-introduced the invalid `exclude-newer = "7 days"`
   under [tool.uv]. uv requires an RFC 3339 / ISO date — relative-duration
   strings parse-fail. The line was removed in PR #21221 on May 7 and
   accidentally added back in the v0.13.0 release commit (498bfc7bc1)
   the same day. Every uv invocation throughout install logged a TOML
   parse error, confusing users into thinking the install was broken.
   Fix: remove the line (and the now-empty [tool.uv] section).

2. `hermes update` failed on Windows with
   `Access is denied. (os error 5)` when uv tried to overwrite
   `venv\\Scripts\\hermes.exe` — the running entry-point shim. Windows
   blocks REPLACE on a mapped/loaded executable but allows RENAME (kernel
   tracks the file by handle, not path; same trick Chrome/Firefox use for
   self-update). Pre-rename live shims to `hermes.exe.old.<unix-ms>`
   before each `uv pip install -e .`; uv writes a fresh shim at the
   original path; the .old files are swept on the next hermes invocation.
   Wraps every install attempt (primary, base-only fallback, and
   per-extra retries). Restores shims if uv fails before writing
   replacements.

3. Tools post-setup hooks (ddgs, piper-tts, kittentts, langfuse,
   tinker-atropos) shelled out to `[sys.executable, '-m', 'pip', ...]`
   and died with `No module named pip` on every fresh Windows install.
   install.ps1 creates the venv via `uv venv` which doesn't seed pip;
   install.ps1 bootstraps pip later, but only inside the platform-SDK
   verify block — by then the wizard's post-setup hooks have already
   run and failed.

   New `_pip_install` helper tries uv pip first (works in pip-less
   venvs), then python -m pip, then ensurepip-bootstrap-then-pip. All
   five post-setup sites now route through it.

E2E:
- uv pip compile pyproject.toml — no parse warning
- quarantine + cleanup with simulated Windows scripts dir; rollback
  works when uv install fails before writing replacement shim
- _pip_install in a real `uv venv`-created (pip-less) venv: bootstraps
  pip via ensurepip and completes the install

Tests: tests/hermes_cli/ — 4135 pass, 8 pre-existing failures on main
unrelated to this PR (kanban_boards, openclaw_migration,
update_gateway_restart, web_server PluginAPIAuth).
This commit is contained in:
Teknium 2026-05-10 13:07:08 -07:00 committed by GitHub
parent 00ce5f04d9
commit 4d9dcbc47a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 213 additions and 57 deletions

View file

@ -13,6 +13,7 @@ import json as _json
import logging
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Optional, Set
@ -521,6 +522,75 @@ TOOLSET_ENV_REQUIREMENTS = {
# ─── Post-Setup Hooks ─────────────────────────────────────────────────────────
def _pip_install(
args: List[str],
*,
timeout: int = 300,
capture_output: bool = True,
):
"""Install Python packages from a post-setup hook.
Strategy (in order):
1. ``uv pip install`` if uv is on PATH fast, doesn't need pip in the venv.
2. ``python -m pip install`` works on stdlib venvs.
3. ``python -m ensurepip --upgrade`` then retry pip covers ``uv venv``
which creates a venv WITHOUT pip.
Why this exists: the Windows installer creates the venv via ``uv venv``,
which doesn't seed pip. Post-setup hooks that shelled out to
``[sys.executable, '-m', 'pip', 'install', ...]`` failed with
``No module named pip`` on every fresh install. uv-first sidesteps that.
Returns the ``subprocess.CompletedProcess`` from whichever tier succeeded
(or the last failure for the caller to inspect).
"""
venv_root = Path(sys.executable).parent.parent
uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)}
uv_bin = shutil.which("uv")
if uv_bin:
try:
result = subprocess.run(
[uv_bin, "pip", "install", *args],
capture_output=capture_output, text=True, timeout=timeout,
env=uv_env,
)
if result.returncode == 0:
return result
# Fall through to pip — uv may have failed for an unrelated reason
# (resolution conflict, network), and pip might handle it.
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
pip_cmd = [sys.executable, "-m", "pip"]
try:
# Probe for pip; bootstrap via ensurepip if missing (uv venv lacks it).
probe = subprocess.run(
pip_cmd + ["--version"],
capture_output=True, text=True, timeout=15,
)
if probe.returncode != 0:
raise FileNotFoundError("pip not in venv")
except (subprocess.TimeoutExpired, FileNotFoundError):
try:
subprocess.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
capture_output=True, text=True, timeout=120, check=True,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
# Synthesize a result so callers see a clean failure path.
return subprocess.CompletedProcess(
pip_cmd, returncode=1, stdout="",
stderr=f"pip not available and ensurepip failed: {e}",
)
return subprocess.run(
pip_cmd + ["install", *args],
capture_output=capture_output, text=True, timeout=timeout,
)
def _run_post_setup(post_setup_key: str):
"""Run post-setup hooks for tools that need extra installation steps."""
import shutil
@ -712,51 +782,43 @@ def _run_post_setup(post_setup_key: str):
return
except ImportError:
pass
import subprocess
_print_info(" Installing kittentts (~25-80MB model, CPU-only)...")
wheel_url = (
"https://github.com/KittenML/KittenTTS/releases/download/"
"0.8.1/kittentts-0.8.1-py3-none-any.whl"
)
try:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "-U", wheel_url, "soundfile", "--quiet"],
capture_output=True, text=True, timeout=300,
)
result = _pip_install(["-U", wheel_url, "soundfile", "--quiet"], timeout=300)
if result.returncode == 0:
_print_success(" kittentts installed")
_print_info(" Voices: Jasper, Bella, Luna, Bruno, Rosie, Hugo, Kiki, Leo")
_print_info(" Models: KittenML/kitten-tts-nano-0.8-int8 (25MB), micro (41MB), mini (80MB)")
else:
_print_warning(" kittentts install failed:")
_print_info(f" {result.stderr.strip()[:300]}")
_print_info(f" Run manually: python -m pip install -U '{wheel_url}' soundfile")
_print_info(f" {(result.stderr or '').strip()[:300]}")
_print_info(f" Run manually: uv pip install -U '{wheel_url}' soundfile")
except subprocess.TimeoutExpired:
_print_warning(" kittentts install timed out (>5min)")
_print_info(f" Run manually: python -m pip install -U '{wheel_url}' soundfile")
_print_info(f" Run manually: uv pip install -U '{wheel_url}' soundfile")
elif post_setup_key == "piper":
try:
__import__("piper")
_print_success(" piper-tts is already installed")
except ImportError:
import subprocess
_print_info(" Installing piper-tts (~14MB wheel, voices downloaded on first use)...")
try:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "-U", "piper-tts", "--quiet"],
capture_output=True, text=True, timeout=300,
)
result = _pip_install(["-U", "piper-tts", "--quiet"], timeout=300)
if result.returncode == 0:
_print_success(" piper-tts installed")
else:
_print_warning(" piper-tts install failed:")
_print_info(f" {result.stderr.strip()[:300]}")
_print_info(" Run manually: python -m pip install -U piper-tts")
_print_info(f" {(result.stderr or '').strip()[:300]}")
_print_info(" Run manually: uv pip install -U piper-tts")
return
except subprocess.TimeoutExpired:
_print_warning(" piper-tts install timed out (>5min)")
_print_info(" Run manually: python -m pip install -U piper-tts")
_print_info(" Run manually: uv pip install -U piper-tts")
return
_print_info(" Default voice: en_US-lessac-medium (downloaded on first TTS call)")
_print_info(" Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md")
@ -767,23 +829,19 @@ def _run_post_setup(post_setup_key: str):
__import__("ddgs")
_print_success(" ddgs is already installed")
except ImportError:
import subprocess
_print_info(" Installing ddgs (DuckDuckGo search package)...")
try:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "-U", "ddgs", "--quiet"],
capture_output=True, text=True, timeout=300,
)
result = _pip_install(["-U", "ddgs", "--quiet"], timeout=300)
if result.returncode == 0:
_print_success(" ddgs installed")
else:
_print_warning(" ddgs install failed:")
_print_info(f" {result.stderr.strip()[:300]}")
_print_info(" Run manually: python -m pip install -U ddgs")
_print_info(f" {(result.stderr or '').strip()[:300]}")
_print_info(" Run manually: uv pip install -U ddgs")
return
except subprocess.TimeoutExpired:
_print_warning(" ddgs install timed out (>5min)")
_print_info(" Run manually: python -m pip install -U ddgs")
_print_info(" Run manually: uv pip install -U ddgs")
return
_print_info(" No API key required. DuckDuckGo enforces server-side rate limits.")
_print_info(" Pair with an extract provider if you also need web_extract.")
@ -824,18 +882,7 @@ def _run_post_setup(post_setup_key: str):
tinker_dir = PROJECT_ROOT / "tinker-atropos"
if tinker_dir.exists() and (tinker_dir / "pyproject.toml").exists():
_print_info(" Installing tinker-atropos submodule...")
import subprocess
uv_bin = shutil.which("uv")
if uv_bin:
result = subprocess.run(
[uv_bin, "pip", "install", "--python", sys.executable, "-e", str(tinker_dir)],
capture_output=True, text=True
)
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "-e", str(tinker_dir)],
capture_output=True, text=True
)
result = _pip_install(["-e", str(tinker_dir)])
if result.returncode == 0:
_print_success(" tinker-atropos installed")
else:
@ -852,16 +899,12 @@ def _run_post_setup(post_setup_key: str):
__import__("langfuse")
_print_success(" langfuse SDK already installed")
except ImportError:
import subprocess
_print_info(" Installing langfuse SDK...")
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "langfuse", "--quiet"],
capture_output=True, text=True, timeout=120,
)
result = _pip_install(["langfuse", "--quiet"], timeout=120)
if result.returncode == 0:
_print_success(" langfuse SDK installed")
else:
_print_warning(" langfuse SDK install failed — run manually: pip install langfuse")
_print_warning(" langfuse SDK install failed — run manually: uv pip install langfuse")
# Opt the bundled observability/langfuse plugin into plugins.enabled.
# The plugin ships in the repo but doesn't load until the user enables
# it (standalone plugins are opt-in).