Merge pull request #54457 from NousResearch/bb/windows-console-launcher-repair

fix(windows): repair missing console script launchers
This commit is contained in:
brooklyn! 2026-06-28 17:15:56 -05:00 committed by GitHub
commit 16ff1a3b93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 256 additions and 4 deletions

View file

@ -7182,10 +7182,12 @@ def _hermes_exe_shims(scripts_dir: Path) -> list[Path]:
"""
if not _is_windows():
return []
return [
scripts_dir / "hermes.exe",
scripts_dir / "hermes-gateway.exe",
]
names = set(_load_console_script_names()) or {"hermes", "hermes-agent", "hermes-acp"}
# The gateway shim is not a [project.scripts] entry point, but older
# update/install paths still rewrite and quarantine it.
names.add("hermes-gateway")
return [scripts_dir / f"{name}.exe" for name in sorted(names)]
def _detect_concurrent_hermes_instances(
@ -7629,6 +7631,7 @@ def _install_python_dependencies_with_optional_fallback(
try:
_install(["install", "-e", f".[{group}]"])
_verify_console_scripts_installed(install_cmd_prefix, env=env)
return
except subprocess.CalledProcessError:
print(
@ -7666,6 +7669,97 @@ def _install_python_dependencies_with_optional_fallback(
# missing, then re-verify so the failure surfaces here instead of
# downstream.
_verify_core_dependencies_installed(install_cmd_prefix, env=env, group=group)
_verify_console_scripts_installed(install_cmd_prefix, env=env)
def _load_console_script_names() -> list[str]:
"""Return ``[project.scripts]`` entry-point names from pyproject.toml."""
try:
import tomllib # Python 3.11+
except ImportError: # pragma: no cover
return []
pyproject = PROJECT_ROOT / "pyproject.toml"
if not pyproject.is_file():
return []
try:
with open(pyproject, "rb") as f:
data = tomllib.load(f)
scripts = data.get("project", {}).get("scripts", {}) or {}
return [str(name) for name in scripts if name]
except Exception as e:
logger.debug("console script verification: failed to read pyproject.toml: %s", e)
return []
def _verify_console_scripts_installed(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
) -> None:
"""Ensure every declared console_script shim exists on disk after install.
On Windows, ``uv pip install -e .`` can register ``hermes.exe`` in the
wheel RECORD while the file never lands on disk typically when the live
``hermes.exe`` shim is locked during ``hermes update``, or when uv/distlib
skips a launcher write. The symptom is ``hermes-agent.exe`` and
``hermes-acp.exe`` present but ``hermes.exe`` missing, so ``hermes`` drops
off PATH even though the install reported success (issue #52931).
If any shim is missing we reinstall with ``--reinstall -e .`` under the
same quarantine dance as the primary install path, then re-check.
"""
if not _is_windows():
return
scripts_dir = _venv_scripts_dir()
if scripts_dir is None:
return
names = _load_console_script_names()
if not names:
return
def _missing() -> list[str]:
return [
name
for name in names
if not (scripts_dir / f"{name}.exe").is_file()
]
missing = _missing()
if not missing:
return
print(
f" ⚠ Verification: {len(missing)} console script(s) missing on disk: "
f"{', '.join(missing)}"
)
print(" → Reinstalling entry points with --reinstall...")
try:
_run_quarantined_install(
install_cmd_prefix + ["install", "--reinstall", "-e", "."],
env=env,
scripts_dir=scripts_dir,
)
except subprocess.CalledProcessError as e:
logger.warning("console script verification: repair install failed: %s", e)
print(
" ⚠ Entry point repair failed; try `hermes update --force` after "
"closing other hermes processes."
)
return
still_missing = _missing()
if still_missing:
print(
f" ⚠ Still missing after repair: {', '.join(still_missing)}. "
"Workaround: python -m hermes_cli.main <command>"
)
else:
print(" ✓ All console entry points restored")
def _verify_core_dependencies_installed(

View file

@ -1854,6 +1854,48 @@ except Exception:
Write-Success "Baseline imports verified in venv"
}
if (-not $NoVenv) {
# uv on Windows can register hermes.exe in dist-info/RECORD but fail to
# materialise the .exe (file lock during self-update, distlib edge case).
# Catch it here so a fresh install/update does not finish with a broken
# `hermes` command while hermes-agent.exe / hermes-acp.exe exist
$scriptsDir = Join-Path $InstallDir "venv\Scripts"
$pythonExe = Join-Path $scriptsDir "python.exe"
if ((Test-Path $scriptsDir) -and (Test-Path $pythonExe)) {
$scriptNames = & $pythonExe -c @"
import tomllib
with open('pyproject.toml', 'rb') as fh:
scripts = tomllib.load(fh).get('project', {}).get('scripts', {}) or {}
print(','.join(scripts))
"@ 2>$null
if ($LASTEXITCODE -eq 0 -and $scriptNames) {
$expected = @($scriptNames.Trim().Split(',') | Where-Object { $_ })
$missing = @()
foreach ($name in $expected) {
$exe = Join-Path $scriptsDir "$name.exe"
if (-not (Test-Path $exe)) { $missing += "$name.exe" }
}
if ($missing.Count -gt 0) {
Write-Warn "Console entry point(s) missing: $($missing -join ', ')"
Write-Info "Reinstalling entry points..."
$env:UV_PROJECT_ENVIRONMENT = "$InstallDir\venv"
Invoke-NativeWithRelaxedErrorAction { & $UvCmd pip install --reinstall -e . }
$stillMissing = @()
foreach ($name in $expected) {
$exe = Join-Path $scriptsDir "$name.exe"
if (-not (Test-Path $exe)) { $stillMissing += "$name.exe" }
}
if ($stillMissing.Count -gt 0) {
Write-Warn "Entry points still missing after repair: $($stillMissing -join ', ')"
Write-Info "Workaround: `"$pythonExe`" -m hermes_cli.main <command>"
} else {
Write-Success "Console entry points restored"
}
}
}
}
}
# Verify the dashboard deps specifically -- they're the most common thing
# users hit and lazy-import errors from `hermes dashboard` are confusing.
# If tier 1 failed (the common case), [web] was still picked up by tiers

View file

@ -0,0 +1,116 @@
"""Tests for _verify_console_scripts_installed (issue #52931)."""
from __future__ import annotations
import textwrap
from pathlib import Path
from unittest.mock import patch
import pytest
@pytest.fixture
def temp_pyproject(tmp_path, monkeypatch):
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
textwrap.dedent(
"""\
[project]
name = "fake"
version = "0.0.0"
[project.scripts]
hermes = "hermes_cli.main:main"
hermes-agent = "run_agent:main"
hermes-acp = "acp_adapter.entry:main"
"""
)
)
import hermes_cli.main as main_mod
monkeypatch.setattr(main_mod, "PROJECT_ROOT", tmp_path)
return tmp_path
@pytest.fixture
def fake_scripts_dir(tmp_path):
scripts = tmp_path / "venv" / "Scripts"
scripts.mkdir(parents=True)
return scripts
class TestVerifyConsoleScriptsInstalled:
def test_no_action_when_all_shims_present(self, temp_pyproject, fake_scripts_dir):
for name in ("hermes", "hermes-agent", "hermes-acp"):
(fake_scripts_dir / f"{name}.exe").write_bytes(b"fake")
with patch("hermes_cli.main._is_windows", return_value=True), \
patch("hermes_cli.main._venv_scripts_dir", return_value=fake_scripts_dir), \
patch("hermes_cli.main._run_quarantined_install") as mock_install:
from hermes_cli.main import _verify_console_scripts_installed
_verify_console_scripts_installed(["uv", "pip"], env={})
mock_install.assert_not_called()
def test_triggers_reinstall_when_hermes_exe_missing(
self, temp_pyproject, fake_scripts_dir
):
(fake_scripts_dir / "hermes-agent.exe").write_bytes(b"fake")
(fake_scripts_dir / "hermes-acp.exe").write_bytes(b"fake")
with patch("hermes_cli.main._is_windows", return_value=True), \
patch("hermes_cli.main._venv_scripts_dir", return_value=fake_scripts_dir), \
patch("hermes_cli.main._run_quarantined_install") as mock_install:
from hermes_cli.main import _verify_console_scripts_installed
_verify_console_scripts_installed(["uv", "pip"], env={})
mock_install.assert_called_once()
args = mock_install.call_args[0][0]
assert "--reinstall" in args
assert "-e" in args and "." in args
assert mock_install.call_args[1]["scripts_dir"] == fake_scripts_dir
def test_skips_off_windows(self, temp_pyproject, fake_scripts_dir):
with patch("hermes_cli.main._is_windows", return_value=False), \
patch("hermes_cli.main._run_quarantined_install") as mock_install:
from hermes_cli.main import _verify_console_scripts_installed
_verify_console_scripts_installed(["uv", "pip"], env={})
mock_install.assert_not_called()
def test_load_console_script_names_reads_pyproject(self, temp_pyproject):
from hermes_cli.main import _load_console_script_names
names = _load_console_script_names()
assert names == ["hermes", "hermes-agent", "hermes-acp"]
def test_primary_install_success_still_verifies_scripts(self):
import hermes_cli.main as main_mod
with patch("hermes_cli.main._is_windows", return_value=False), \
patch("hermes_cli.main._run_quarantined_install") as mock_install, \
patch("hermes_cli.main._verify_console_scripts_installed") as mock_verify:
main_mod._install_python_dependencies_with_optional_fallback(
["uv", "pip"], env={"VIRTUAL_ENV": "x"}
)
mock_install.assert_called_once_with(
["uv", "pip", "install", "-e", ".[all]"],
env={"VIRTUAL_ENV": "x"},
scripts_dir=None,
)
mock_verify.assert_called_once_with(["uv", "pip"], env={"VIRTUAL_ENV": "x"})
def test_quarantine_shims_include_declared_console_scripts(
self, temp_pyproject, fake_scripts_dir
):
import hermes_cli.main as main_mod
with patch("hermes_cli.main._is_windows", return_value=True):
names = {path.name for path in main_mod._hermes_exe_shims(fake_scripts_dir)}
assert {"hermes.exe", "hermes-agent.exe", "hermes-acp.exe"} <= names
assert "hermes-gateway.exe" in names