"""Auto-installation of LSP server binaries. Installs go to a Hermes-owned staging dir, ``/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 ``/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 `` then link ``node_modules/.bin/`` 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 # /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=.""" 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 /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", ]