Hermes installs runtimes for itself — `uv` at `$HERMES_HOME/bin/uv`, Node
at `$HERMES_HOME/node` — and neither directory is on an arbitrary
process's PATH. Every `shutil.which("node"/"npm"/"npx"/"uv")` in Hermes's
own code therefore has two failure modes: the managed runtime is invisible,
so the caller reports "not installed" or degrades to a slower tier on a
machine that has exactly what it needed; and when a system copy also
exists, the one Hermes does not own wins.
Routed the Hermes-owned call sites through managed-aware resolvers:
- `agent/lsp/install.py`, `hermes_cli/dep_ensure.py`, `hermes_cli/main.py`
(`_make_tui_argv`), `hermes_cli/tools_config.py` (`_run_post_setup`) now
use `find_node_executable()`.
- `hermes_cli/tools_config.py::_pip_install` and `hermes_cli/setup.py`'s
vercel install use `ensure_uv()` (installing uv is in scope during setup,
and the Windows installer's `uv venv` does not seed pip, so the fallback
tier is "No module named pip"). `tools/lazy_deps.py` uses `resolve_uv()`
— a lookup, not a bootstrap, because it runs mid-turn for an optional
dependency and downloading a runtime as a side effect exceeds what the
caller asked for.
- `hermes_cli/gateway.py`: extracted `_append_node_dir_for_service()`,
shared by the systemd unit and launchd plist generators, which appends
the managed dirs before the PATH-resolved one. A service definition is
written once and survives reboots, so resolving a system Node that
happens to lead the installing shell's PATH bakes the wrong interpreter
in permanently. Managed dirs are profile-scoped, so each profile's unit
still names its own Node; the existing symlink-parent rule (don't
`.resolve()`) is preserved verbatim.
- `tools/environments/local.py`: the terminal tool's subshell PATH gains
the managed dirs, appended alongside the sane entries rather than
prepended — a tool the user deliberately put on their own PATH still
wins, and the managed one only fills a gap. This is also what makes the
bare `which("uv")` in `tools/env_probe.py` correct: that probe reports
the environment the *model* sees, and the model can only run what is on
that subshell's PATH.
`scripts/install.ps1`: the persisted User PATH update becomes
`Set-ManagedNodeFirstOnUserPath`, a move-to-front rather than an
add-if-missing. Installs made by an older install.ps1 already have the
managed dir in User PATH — at the tail, behind a system Node — and an
add-if-missing check sees it present and leaves that ordering in place
forever, so the users the bug hurt would never be repaired. Unrelated
entries keep their relative order (empty segments included; a trailing
`;` is legal and the installer's other PATH code preserves them),
duplicates collapse, and it writes only when the string actually changes.
Tests:
- `tests/test_managed_runtime_resolution.py` — AST guard that fails any
new bare `which()` for a managed runtime, with a short justified
allow-list and a companion test that fails when an allow-list entry goes
stale. Reading source is banned by AGENTS.md and this is the documented
exception: the property is "no call site anywhere spells it this way",
which no runtime seam can observe.
- `scripts/ci/test_install_ps1_path_migration.ps1` — behavioral, not a
source regex: it lifts the real `Set-ManagedNodeFirstOnUserPath` out of
install.ps1's AST and rewrites only the two registry calls into an
in-memory store, so the shipped split/dedupe/prepend/change-detection
logic executes for real. Not in the default lane (Linux runners have no
PowerShell host); runs under `pwsh`. 13/13 assertions pass.
413 lines
15 KiB
Python
413 lines
15 KiB
Python
"""Auto-installation of LSP server binaries.
|
|
|
|
Tries to install missing servers using whatever package manager is
|
|
appropriate. All installs go to a Hermes-owned bin staging dir,
|
|
``<HERMES_HOME>/lsp/bin/``, so we don't pollute the user's global
|
|
toolchain.
|
|
|
|
Strategies:
|
|
|
|
- ``auto`` — attempt to install with the best available package
|
|
manager. This is the default.
|
|
- ``manual`` — never install; if a binary is missing, the server is
|
|
silently skipped and the user is told about it via ``hermes lsp
|
|
status``.
|
|
- ``off`` — same as ``manual`` for now (kept distinct so we can
|
|
evolve behavior later, e.g. logging differently).
|
|
|
|
The actual installs happen synchronously the first time a server is
|
|
needed and concurrent calls to :func:`try_install` for the same
|
|
package are deduplicated via a per-package lock.
|
|
|
|
Failure modes are non-fatal: every install path is wrapped in
|
|
try/except and returns ``None`` on failure. The tool layer then
|
|
falls back to its in-process syntax checker, exactly as if the user
|
|
hadn't enabled LSP at all.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
from hermes_cli._subprocess_compat import windows_hide_flags
|
|
from hermes_constants import find_node_executable
|
|
|
|
logger = logging.getLogger("agent.lsp.install")
|
|
|
|
# Package-name → install-strategy hint registry. Each entry is a
|
|
# tuple of strategy name + package name + executable name. When the
|
|
# install completes, we look for the executable in
|
|
# ``<HERMES_HOME>/lsp/bin/`` first, then on PATH.
|
|
#
|
|
# Optional fields:
|
|
# - ``extra_pkgs``: list of sibling packages to install alongside
|
|
# ``pkg`` in the same node_modules tree. Used when an LSP server
|
|
# has a runtime peer dependency that npm doesn't auto-pull (e.g.
|
|
# typescript-language-server needs ``typescript``).
|
|
INSTALL_RECIPES: Dict[str, Dict[str, Any]] = {
|
|
# Python
|
|
"pyright": {"strategy": "npm", "pkg": "pyright", "bin": "pyright-langserver"},
|
|
# JS/TS family
|
|
"typescript-language-server": {
|
|
"strategy": "npm",
|
|
"pkg": "typescript-language-server",
|
|
"bin": "typescript-language-server",
|
|
# typescript-language-server requires the `typescript` SDK
|
|
# (tsserver) to be importable from the same node_modules tree;
|
|
# otherwise initialize() fails with "Could not find a valid
|
|
# TypeScript installation". Install them together.
|
|
"extra_pkgs": ["typescript"],
|
|
},
|
|
"@vue/language-server": {
|
|
"strategy": "npm",
|
|
"pkg": "@vue/language-server",
|
|
"bin": "vue-language-server",
|
|
},
|
|
"svelte-language-server": {
|
|
"strategy": "npm",
|
|
"pkg": "svelte-language-server",
|
|
"bin": "svelteserver",
|
|
},
|
|
"@astrojs/language-server": {
|
|
"strategy": "npm",
|
|
"pkg": "@astrojs/language-server",
|
|
"bin": "astro-ls",
|
|
},
|
|
"yaml-language-server": {
|
|
"strategy": "npm",
|
|
"pkg": "yaml-language-server",
|
|
"bin": "yaml-language-server",
|
|
},
|
|
"bash-language-server": {
|
|
"strategy": "npm",
|
|
"pkg": "bash-language-server",
|
|
"bin": "bash-language-server",
|
|
},
|
|
"intelephense": {"strategy": "npm", "pkg": "intelephense", "bin": "intelephense"},
|
|
"dockerfile-language-server-nodejs": {
|
|
"strategy": "npm",
|
|
"pkg": "dockerfile-language-server-nodejs",
|
|
"bin": "docker-langserver",
|
|
},
|
|
# Go
|
|
"gopls": {"strategy": "go", "pkg": "golang.org/x/tools/gopls@latest", "bin": "gopls"},
|
|
# Rust — too heavy (hundreds of MB to bootstrap). We do NOT
|
|
# auto-install rust-analyzer; users install via rustup.
|
|
"rust-analyzer": {"strategy": "manual", "pkg": "", "bin": "rust-analyzer"},
|
|
# C/C++ — manual (clangd ships with LLVM, very heavy)
|
|
"clangd": {"strategy": "manual", "pkg": "", "bin": "clangd"},
|
|
# Lua — manual (LuaLS is platform-specific binaries from GitHub
|
|
# releases; complex enough that we punt to the user)
|
|
"lua-language-server": {"strategy": "manual", "pkg": "", "bin": "lua-language-server"},
|
|
# PowerShell — PowerShellEditorServices ships as a GitHub release
|
|
# zip driven by a pwsh bootstrap script, not a single binary. We
|
|
# require a manual bundle install and probe for the pwsh host so
|
|
# `hermes lsp status` reports the host's presence.
|
|
"powershell": {"strategy": "manual", "pkg": "", "bin": "pwsh"},
|
|
}
|
|
|
|
|
|
_install_locks: Dict[str, threading.Lock] = {}
|
|
_install_results: Dict[str, Optional[str]] = {}
|
|
_install_lock_meta = threading.Lock()
|
|
_WINDOWS_WRAPPER_SUFFIXES = (".cmd", ".exe", ".bat")
|
|
|
|
|
|
def _is_windows() -> bool:
|
|
return os.name == "nt"
|
|
|
|
|
|
def hermes_lsp_bin_dir() -> Path:
|
|
"""Return the Hermes-owned bin staging dir for LSP servers."""
|
|
from hermes_constants import get_hermes_home
|
|
|
|
p = get_hermes_home() / "lsp" / "bin"
|
|
p.mkdir(parents=True, exist_ok=True)
|
|
return p
|
|
|
|
|
|
def _native_binary_candidates(base: Path) -> list[Path]:
|
|
"""Return platform-native executable candidates for a staged binary."""
|
|
candidates = [base]
|
|
if _is_windows():
|
|
existing = {str(base).lower()}
|
|
for suffix in _WINDOWS_WRAPPER_SUFFIXES:
|
|
candidate = Path(str(base) + suffix)
|
|
key = str(candidate).lower()
|
|
if key not in existing:
|
|
candidates.append(candidate)
|
|
existing.add(key)
|
|
return candidates
|
|
|
|
|
|
def _existing_binary(name: str) -> Optional[str]:
|
|
"""Probe the staging dir + PATH for a binary named ``name``."""
|
|
for staged in _native_binary_candidates(hermes_lsp_bin_dir() / name):
|
|
if staged.exists() and os.access(staged, os.X_OK):
|
|
return str(staged)
|
|
on_path = shutil.which(name)
|
|
if on_path:
|
|
return on_path
|
|
if _is_windows():
|
|
for suffix in _WINDOWS_WRAPPER_SUFFIXES:
|
|
on_path = shutil.which(f"{name}{suffix}")
|
|
if on_path:
|
|
return on_path
|
|
return None
|
|
|
|
|
|
def _get_lock(pkg: str) -> threading.Lock:
|
|
with _install_lock_meta:
|
|
lock = _install_locks.get(pkg)
|
|
if lock is None:
|
|
lock = threading.Lock()
|
|
_install_locks[pkg] = lock
|
|
return lock
|
|
|
|
|
|
def try_install(pkg: str, strategy: str = "auto") -> Optional[str]:
|
|
"""Try to install ``pkg`` and return the binary path if successful.
|
|
|
|
``strategy`` is ``"auto"``, ``"manual"``, or ``"off"``. In
|
|
``manual``/``off`` mode, this function only probes for an
|
|
existing binary and returns ``None`` if not found.
|
|
|
|
The install is cached per-package — a second call returns the
|
|
same path (or ``None``) without reinstalling. Concurrent calls
|
|
are serialized.
|
|
"""
|
|
if strategy not in {"auto",}:
|
|
# Only ``auto`` triggers an actual install. In manual/off,
|
|
# we still check whether the binary already exists.
|
|
recipe = INSTALL_RECIPES.get(pkg, {})
|
|
bin_name = recipe.get("bin", pkg)
|
|
return _existing_binary(bin_name)
|
|
|
|
if pkg in _install_results:
|
|
return _install_results[pkg]
|
|
|
|
lock = _get_lock(pkg)
|
|
with lock:
|
|
# Double-check after acquiring lock.
|
|
if pkg in _install_results:
|
|
return _install_results[pkg]
|
|
result = _do_install(pkg)
|
|
_install_results[pkg] = result
|
|
return result
|
|
|
|
|
|
def _do_install(pkg: str) -> Optional[str]:
|
|
recipe = INSTALL_RECIPES.get(pkg)
|
|
if recipe is None:
|
|
# Not in our registry — best-effort: just probe PATH.
|
|
return shutil.which(pkg)
|
|
|
|
strategy = recipe.get("strategy", "manual")
|
|
bin_name = recipe.get("bin", pkg)
|
|
|
|
# Check if already present (shutil.which or staging dir)
|
|
existing = _existing_binary(bin_name)
|
|
if existing:
|
|
return existing
|
|
|
|
if strategy == "manual":
|
|
logger.debug("[install] %s requires manual install (recipe=%s)", pkg, recipe)
|
|
return None
|
|
|
|
if strategy == "npm":
|
|
return _install_npm(
|
|
recipe.get("pkg", pkg),
|
|
bin_name,
|
|
extra_pkgs=recipe.get("extra_pkgs") or [],
|
|
)
|
|
if strategy == "go":
|
|
return _install_go(recipe.get("pkg", pkg), bin_name)
|
|
if strategy == "pip":
|
|
return _install_pip(recipe.get("pkg", pkg), bin_name)
|
|
|
|
logger.warning("[install] unknown strategy %r for %s", strategy, pkg)
|
|
return None
|
|
|
|
|
|
def _install_npm(
|
|
pkg: str,
|
|
bin_name: str,
|
|
extra_pkgs: Optional[list] = None,
|
|
) -> Optional[str]:
|
|
"""Install an npm package into our staging dir.
|
|
|
|
Uses ``npm install --prefix`` so the binaries land in
|
|
``<staging>/node_modules/.bin/<bin_name>`` and we symlink them up
|
|
one level for direct PATH-style access.
|
|
|
|
``extra_pkgs`` is a list of sibling packages to install in the
|
|
same ``node_modules`` tree. Used for LSP servers with runtime
|
|
peer deps that npm doesn't auto-pull (typescript-language-server
|
|
needs ``typescript`` next to it; intelephense ships standalone).
|
|
"""
|
|
# Managed npm first: $HERMES_HOME/node is not on an arbitrary process's
|
|
# PATH, so a bare which() misses the Node that Hermes installed and
|
|
# reports "npm not on PATH" on a machine that has a perfectly good one.
|
|
npm = find_node_executable("npm")
|
|
if npm is None:
|
|
logger.info("[install] cannot install %s: no usable npm found", pkg)
|
|
return None
|
|
staging = hermes_lsp_bin_dir().parent # <HERMES_HOME>/lsp/
|
|
install_targets = [pkg] + list(extra_pkgs or [])
|
|
try:
|
|
logger.info(
|
|
"[install] npm install --prefix %s %s",
|
|
staging,
|
|
" ".join(install_targets),
|
|
)
|
|
proc = subprocess.run(
|
|
[npm, "install", "--prefix", str(staging), "--silent", "--no-fund", "--no-audit", *install_targets],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True, encoding="utf-8", errors="replace",
|
|
timeout=300,
|
|
stdin=subprocess.DEVNULL,
|
|
creationflags=windows_hide_flags(),
|
|
)
|
|
if proc.returncode != 0:
|
|
logger.warning(
|
|
"[install] npm install failed for %s: %s", pkg, proc.stderr.strip()[:500]
|
|
)
|
|
return None
|
|
except (subprocess.TimeoutExpired, OSError) as e:
|
|
logger.warning("[install] npm install errored for %s: %s", pkg, e)
|
|
return None
|
|
|
|
# Find the bin
|
|
nm_bin = staging / "node_modules" / ".bin" / bin_name
|
|
for c in _native_binary_candidates(nm_bin):
|
|
if c.exists():
|
|
# Symlink into our `lsp/bin/` for stable PATH access.
|
|
link = hermes_lsp_bin_dir() / c.name
|
|
if not link.exists():
|
|
try:
|
|
link.symlink_to(c)
|
|
except (OSError, NotImplementedError):
|
|
# Symlinks fail on some Windows setups — copy instead.
|
|
try:
|
|
shutil.copy2(c, link)
|
|
except OSError:
|
|
return str(c)
|
|
return str(link if link.exists() else c)
|
|
logger.warning("[install] npm install for %s succeeded but bin %s not found", pkg, bin_name)
|
|
return None
|
|
|
|
|
|
def _install_go(pkg: str, bin_name: str) -> Optional[str]:
|
|
"""Install a Go module to GOBIN=<staging>."""
|
|
go = shutil.which("go")
|
|
if go is None:
|
|
logger.info("[install] cannot install %s: go not on PATH", pkg)
|
|
return None
|
|
staging = hermes_lsp_bin_dir()
|
|
env = dict(os.environ)
|
|
env["GOBIN"] = str(staging)
|
|
try:
|
|
logger.info("[install] go install %s (GOBIN=%s)", pkg, staging)
|
|
proc = subprocess.run(
|
|
[go, "install", pkg],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True, encoding="utf-8", errors="replace",
|
|
timeout=600,
|
|
env=env,
|
|
stdin=subprocess.DEVNULL,
|
|
creationflags=windows_hide_flags(),
|
|
)
|
|
if proc.returncode != 0:
|
|
logger.warning(
|
|
"[install] go install failed for %s: %s", pkg, proc.stderr.strip()[:500]
|
|
)
|
|
return None
|
|
except (subprocess.TimeoutExpired, OSError) as e:
|
|
logger.warning("[install] go install errored for %s: %s", pkg, e)
|
|
return None
|
|
bin_path = staging / bin_name
|
|
if _is_windows():
|
|
bin_path = bin_path.with_suffix(".exe")
|
|
if bin_path.exists():
|
|
return str(bin_path)
|
|
logger.warning("[install] go install for %s succeeded but bin %s not found", pkg, bin_name)
|
|
return None
|
|
|
|
|
|
def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
|
|
"""Install a Python package into a hermes-owned target dir.
|
|
|
|
We avoid polluting the user's site-packages by using
|
|
``pip install --target``. Bins go into
|
|
``<staging>/python-packages/bin/`` which we symlink into
|
|
``<staging>/bin``. Note: this only works for packages that ship a
|
|
console script.
|
|
"""
|
|
pip_target = hermes_lsp_bin_dir().parent / "python-packages"
|
|
pip_target.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
logger.info("[install] pip install --target %s %s", pip_target, pkg)
|
|
from hermes_cli.tools_config import _pip_install
|
|
|
|
proc = _pip_install(
|
|
["--target", str(pip_target), "--quiet", pkg],
|
|
timeout=300,
|
|
)
|
|
if proc.returncode != 0:
|
|
logger.warning(
|
|
"[install] pip install failed for %s: %s", pkg, (proc.stderr or "").strip()[:500]
|
|
)
|
|
return None
|
|
except (subprocess.TimeoutExpired, OSError) as e:
|
|
logger.warning("[install] pip install errored for %s: %s", pkg, e)
|
|
return None
|
|
# Look for the console script. POSIX wheels generally write to bin/,
|
|
# while native Windows installs use Scripts/.
|
|
script_dirs = [pip_target / "bin"]
|
|
if _is_windows():
|
|
script_dirs.append(pip_target / "Scripts")
|
|
for script_dir in script_dirs:
|
|
for bin_path in _native_binary_candidates(script_dir / bin_name):
|
|
if bin_path.exists():
|
|
link = hermes_lsp_bin_dir() / bin_path.name
|
|
if not link.exists():
|
|
try:
|
|
link.symlink_to(bin_path)
|
|
except (OSError, NotImplementedError):
|
|
try:
|
|
shutil.copy2(bin_path, link)
|
|
except OSError:
|
|
return str(bin_path)
|
|
return str(link if link.exists() else bin_path)
|
|
return None
|
|
|
|
|
|
def detect_status(pkg: str) -> str:
|
|
"""Return ``installed``, ``missing``, or ``manual-only`` for a package.
|
|
|
|
Used by the ``hermes lsp status`` CLI to give users a quick
|
|
overview of what's available without spawning anything.
|
|
"""
|
|
recipe = INSTALL_RECIPES.get(pkg)
|
|
bin_name = recipe.get("bin", pkg) if recipe else pkg
|
|
if _existing_binary(bin_name):
|
|
return "installed"
|
|
if recipe and recipe.get("strategy") == "manual":
|
|
return "manual-only"
|
|
return "missing"
|
|
|
|
|
|
__all__ = [
|
|
"INSTALL_RECIPES",
|
|
"try_install",
|
|
"detect_status",
|
|
"hermes_lsp_bin_dir",
|
|
]
|