diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 74b74f2725bc9..512e469ae05a8 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6700,6 +6700,49 @@ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None: pass +def _rename_active_windows_exe() -> "Path | None": + """On Windows, rename hermes.exe to hermes.exe.old before uv/pip install. + + Windows holds a file lock on any running .exe, causing uv pip install -e . + to fail with "Access is denied (os error 5)" when it tries to overwrite + the active hermes.exe in venv/Scripts/. + + Renaming releases the write lock while keeping the process alive — Windows + allows renaming an open executable in place. The installer then writes a + fresh hermes.exe, and the stale .old file is cleaned up afterwards. + + Returns the Path of the renamed .old file, or None if not applicable. + """ + if sys.platform != "win32": + return None + exe_path = PROJECT_ROOT / "venv" / "Scripts" / "hermes.exe" + if not exe_path.exists(): + return None + old_path = exe_path.with_suffix(".exe.old") + try: + if old_path.exists(): + old_path.unlink(missing_ok=True) + exe_path.rename(old_path) + return old_path + except OSError as exc: + logger.debug("Windows exe rename skipped: %s", exc) + return None + + +def _cleanup_windows_exe_old(old_path: "Path | None") -> None: + """Remove the .old backup created by _rename_active_windows_exe().""" + if old_path is None: + return + try: + old_path.unlink(missing_ok=True) + except OSError as exc: + logger.debug( + "Could not remove %s (will be cleaned up on next update): %s", + old_path, + exc, + ) + + def _install_python_dependencies_with_optional_fallback( install_cmd_prefix: list[str], *, @@ -7578,49 +7621,56 @@ 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...") - pip_cmd = [sys.executable, "-m", "pip"] - uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd) - install_group = "all" - - if uv_bin: - uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")} - if _is_termux_env(uv_env): - uv_env.pop("PYTHONPATH", None) - uv_env.pop("PYTHONHOME", None) - install_group = "termux-all" - print(" → Termux detected: using uv + curated termux-all optional profile...") - if _is_termux_env(uv_env) and _is_android_python(): - print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...") - _install_psutil_android_compat([uv_bin, "pip"], env=uv_env) - _install_python_dependencies_with_optional_fallback( - [uv_bin, "pip"], env=uv_env, group=install_group - ) - else: - # Use sys.executable to explicitly call the venv's pip module, - # avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu. - # Some environments lose pip inside the venv; bootstrap it back with - # ensurepip before trying the editable install. + # On Windows, rename hermes.exe before install to release the + # file lock that would cause "Access is denied (os error 5)". + _windows_exe_old = _rename_active_windows_exe() + try: pip_cmd = [sys.executable, "-m", "pip"] - try: - subprocess.run( - pip_cmd + ["--version"], - cwd=PROJECT_ROOT, - check=True, - capture_output=True, - ) - except subprocess.CalledProcessError: - subprocess.run( - [sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], - cwd=PROJECT_ROOT, - check=True, + uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd) + install_group = "all" + + if uv_bin: + uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")} + if _is_termux_env(uv_env): + uv_env.pop("PYTHONPATH", None) + uv_env.pop("PYTHONHOME", None) + install_group = "termux-all" + print(" → Termux detected: using uv + curated termux-all optional profile...") + if _is_termux_env(uv_env) and _is_android_python(): + print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...") + _install_psutil_android_compat([uv_bin, "pip"], env=uv_env) + _install_python_dependencies_with_optional_fallback( + [uv_bin, "pip"], env=uv_env, group=install_group ) - if _is_termux_env(): - install_group = "termux-all" - print(" → Termux detected: using curated termux-all optional profile...") - if _is_termux_env() and _is_android_python(): - print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...") - _install_psutil_android_compat(pip_cmd) - _install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group) + else: + # Use sys.executable to explicitly call the venv's pip module, + # avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu. + # Some environments lose pip inside the venv; bootstrap it back with + # ensurepip before trying the editable install. + pip_cmd = [sys.executable, "-m", "pip"] + try: + subprocess.run( + pip_cmd + ["--version"], + cwd=PROJECT_ROOT, + check=True, + capture_output=True, + ) + except subprocess.CalledProcessError: + subprocess.run( + [sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], + cwd=PROJECT_ROOT, + check=True, + ) + if _is_termux_env(): + install_group = "termux-all" + print(" → Termux detected: using curated termux-all optional profile...") + if _is_termux_env() and _is_android_python(): + print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...") + _install_psutil_android_compat(pip_cmd) + _install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group) + + finally: + _cleanup_windows_exe_old(_windows_exe_old) _update_node_dependencies() _build_web_ui(PROJECT_ROOT / "web")