Files
hermes-agent/agent/lsp/install.py
T
Teknium b6e1037730 refactor(agent/lsp): table-driven server registry, shared client/manager helpers, compact docs
- servers.py: per-language _root_*/_spawn_* functions collapsed into declarative
  ServerDef entries built by _make_spec/_simple_spawn/_markers_root (-707 LOC)
- client.py: _write/_send_reply/_cancel_task helpers replace repeated
  try/except blocks; handler dispatch tables for server->client requests
- manager.py: _broken_key unifies the (server_id, root) key derivation
- install.py/cli.py/eventlog.py/workspace.py/protocol.py/range_shift.py/
  reporter.py: dedupe helpers (_find_binary, _link_into_bin, _run_installer,
  _emit_once, _walk_up), drop dead log_no_server_configured, compact docs
2026-09-02 13:53:27 -07:00

314 lines
11 KiB
Python

"""Auto-installation of LSP server binaries.
Installs go to a Hermes-owned staging dir, ``<HERMES_HOME>/lsp/bin/``, so the
user's global toolchain stays untouched. Strategies: ``auto`` (install with
the best available package manager), ``manual`` / ``off`` (probe only; a
missing binary skips the server and ``hermes lsp status`` reports it).
Installs run synchronously the first time a server is needed; concurrent
:func:`try_install` calls for the same package are serialized per-package.
Every failure path returns ``None`` so the tool layer falls back to its
in-process syntax checker.
"""
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")
def _recipe(strategy: str, pkg: str, bin_name: str, **extra: Any) -> Dict[str, Any]:
return {"strategy": strategy, "pkg": pkg, "bin": bin_name, **extra}
# Recipe key → {strategy, pkg, bin[, extra_pkgs]}. After install we look for
# ``bin`` in ``<HERMES_HOME>/lsp/bin/`` first, then on PATH. ``extra_pkgs``
# are sibling npm packages a server needs in the same node_modules tree.
INSTALL_RECIPES: Dict[str, Dict[str, Any]] = {
"pyright": _recipe("npm", "pyright", "pyright-langserver"),
# tsserver must be importable from the same node_modules tree or
# initialize() fails with "Could not find a valid TypeScript installation".
"typescript-language-server": _recipe("npm", "typescript-language-server", "typescript-language-server", extra_pkgs=["typescript"]),
"@vue/language-server": _recipe("npm", "@vue/language-server", "vue-language-server"),
"svelte-language-server": _recipe("npm", "svelte-language-server", "svelteserver"),
"@astrojs/language-server": _recipe("npm", "@astrojs/language-server", "astro-ls"),
"yaml-language-server": _recipe("npm", "yaml-language-server", "yaml-language-server"),
"bash-language-server": _recipe("npm", "bash-language-server", "bash-language-server"),
"intelephense": _recipe("npm", "intelephense", "intelephense"),
"dockerfile-language-server-nodejs": _recipe("npm", "dockerfile-language-server-nodejs", "docker-langserver"),
"gopls": _recipe("go", "golang.org/x/tools/gopls@latest", "gopls"),
# Manual: rust-analyzer (via rustup) and clangd (ships with LLVM) are far too
# heavy to bootstrap; LuaLS is platform-specific GitHub release binaries.
"rust-analyzer": _recipe("manual", "", "rust-analyzer"),
"clangd": _recipe("manual", "", "clangd"),
"lua-language-server": _recipe("manual", "", "lua-language-server"),
# PowerShellEditorServices is a release-zip bundle driven by pwsh; we probe
# the host so `hermes lsp status` reports its presence.
"powershell": _recipe("manual", "", "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``; return the binary path or ``None``.
Only ``"auto"`` installs; ``"manual"``/``"off"`` just probe for an
existing binary. Results are cached per package and concurrent calls
are serialized.
"""
if strategy not in {"auto",}:
recipe = INSTALL_RECIPES.get(pkg, {})
return _existing_binary(recipe.get("bin", pkg))
if pkg in _install_results:
return _install_results[pkg]
with _get_lock(pkg):
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)
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 _run_installer(tool: str, pkg: str, cmd: list, *, timeout: int, env: Optional[dict] = None) -> bool:
"""Run one install subprocess; log and return False on non-zero exit or error."""
try:
proc = subprocess.run(
cmd,
check=False,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
timeout=timeout,
env=env,
stdin=subprocess.DEVNULL,
creationflags=windows_hide_flags(),
)
if proc.returncode != 0:
logger.warning(
"[install] %s install failed for %s: %s", tool, pkg, proc.stderr.strip()[:500]
)
return False
except (subprocess.TimeoutExpired, OSError) as e:
logger.warning("[install] %s install errored for %s: %s", tool, pkg, e)
return False
return True
def _link_into_bin(target: Path) -> str:
"""Symlink (or copy, where symlinks fail) ``target`` into ``lsp/bin/`` and return the path to use."""
link = hermes_lsp_bin_dir() / target.name
if not link.exists():
try:
link.symlink_to(target)
except (OSError, NotImplementedError):
# Symlinks fail on some Windows setups — copy instead.
try:
shutil.copy2(target, link)
except OSError:
return str(target)
return str(link if link.exists() else target)
def _install_npm(
pkg: str,
bin_name: str,
extra_pkgs: Optional[list] = None,
) -> Optional[str]:
"""``npm install --prefix <staging>`` then link ``node_modules/.bin/<bin_name>`` into ``lsp/bin/``."""
# Managed npm first: $HERMES_HOME/node isn't on an arbitrary process's
# PATH, so a bare which() would miss the Node that Hermes installed.
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 [])
logger.info(
"[install] npm install --prefix %s %s",
staging,
" ".join(install_targets),
)
if not _run_installer(
"npm", pkg,
[npm, "install", "--prefix", str(staging), "--silent", "--no-fund", "--no-audit", *install_targets],
timeout=300,
):
return None
nm_bin = staging / "node_modules" / ".bin" / bin_name
for c in _native_binary_candidates(nm_bin):
if c.exists():
return _link_into_bin(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)
logger.info("[install] go install %s (GOBIN=%s)", pkg, staging)
if not _run_installer("go", pkg, [go, "install", pkg], timeout=600, env=env):
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]:
"""``pip install --target <staging>/python-packages`` then link the console script into ``lsp/bin/``."""
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
# POSIX wheels write console scripts to bin/, native Windows to 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():
return _link_into_bin(bin_path)
return None
def detect_status(pkg: str) -> str:
"""Return ``installed``, ``missing``, or ``manual-only`` (for ``hermes lsp status``; spawns nothing)."""
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",
]