"""Installer manager -- dispatch-table-driven tool installation. Handles pacman, dnf, apt, uv, pipx, pip, ollama, npm, git, git_node, script, and custom installer types. Every ``subprocess`` / ``os.makedirs`` call is confined here. Key capabilities --------------- 1. **Step-down containment**: Each Python tool tries the most isolated install method first (ollama -> uv -> pipx -> pip). If the preferred method fails, it steps down to the next one automatically. 2. **Working directory enforcement**: All tool artifacts are installed under ``tools_root//`` (or ``tools_root/npm_globals/`` for npm). This keeps the host system clean and makes tools portable. 3. **``~/.local`` remap**: Environment variables are set so that ``uv``, ``pip``, and ``pipx`` install into ``tools_root`` instead of the user's home directory. 4. **Per-tool env overrides**: Tools like vLLM, huggingface tools, etc. can declare ``env_overrides`` in the registry to redirect HF_HOME, TRANSFORMERS_CACHE, and other upstream paths into ``/mnt/AI/cache/`` or ``/mnt/AI/data/``. 5. **Post-install hooks**: Git-cloned tools can declare ``post_install`` commands (e.g. ``pip install -r requirements.txt``, ``make``) that run automatically after clone. 6. **Preflight detection**: ``preflight()`` checks whether a tool is already installed (via ``which``, directory existence, or pacman query) and returns a ``PreflightResult`` so the UI can offer "update to latest" instead of blindly reinstalling. 7. **Installation verification**: ``verify()`` runs a compliance checklist against a single tool and returns a ``VerificationResult`` with a quality score (0-100%). 8. **Version detection**: Attempts to extract the installed version for comparison with the latest available version. """ from __future__ import annotations import os import re import shlex import shutil import subprocess from pathlib import Path from typing import Any from urllib.parse import urlparse from ai_lsc.utils.logging import get_logger from ai_lsc.utils.process import enriched_env logger = get_logger(__name__) # Registry tool_id / package name validation patterns. Applied at every # subprocess boundary to prevent path-traversal / command-injection from a # malicious or malformed registry entry. # NOTE: tool_id is used as a path component (tools_root//) so it # must NOT allow `/` or `..`. Package names (PyPI / npm) DO allow `/` # (e.g. `@scope/pkg`) and `@`, so they use a separate, looser regex. _TOOL_ID_RE = re.compile(r"^[A-Za-z0-9_.:\-]+$") _PKG_NAME_RE = re.compile(r"^[A-Za-z0-9_.@/\-+]+$") def _validate_tool_id(tool_id: str) -> None: # Reject empty / regex-mismatch first. if not tool_id or not _TOOL_ID_RE.fullmatch(tool_id): raise ValueError(f"invalid tool_id: {tool_id!r}") # Reject path-traversal attempts that pass the char-set regex but # escape tools_root when joined: `.`, `..`, `...` (normpath leaves # these unchanged, so we check them explicitly), plus anything where # normpath DOES change the value (e.g. `foo/..` — though `/` is # already rejected by the regex above, this is defense-in-depth). if tool_id in {".", ".."} or os.path.normpath(tool_id) != tool_id: raise ValueError(f"tool_id contains path-traversal segments: {tool_id!r}") def _validate_pkg(pkg: str) -> None: if not pkg or not _PKG_NAME_RE.fullmatch(pkg): raise ValueError(f"invalid package name: {pkg!r}") def _validate_url(url: str, *, allow_schemes: tuple[str, ...] = ("http", "https")) -> str: """Validate URL scheme and return the URL unchanged if safe.""" parsed = urlparse(url) if parsed.scheme not in allow_schemes or not parsed.netloc: raise ValueError(f"unsafe URL rejected: {url!r}") return url # Step-down containment order (most isolated first) STEP_DOWN_ORDER: list[str] = [ "ollama", "uv", "pipx", "pip", "git", "git_node", "npm", "pacman", "dnf", "apt", "script", "custom", ] # Version extraction commands per installer type _VERSION_CMDS: dict[str, str] = { "pacman": "pacman -Qi {pkg} 2>/dev/null | grep Version", "dnf": "dnf info {pkg} 2>/dev/null | grep Version", "apt": "dpkg -s {pkg} 2>/dev/null | grep Version", "uv": "{cmd} --version 2>/dev/null", "npm": "npm list -g {pkg} --depth=0 2>/dev/null", "pip": "pip show {pkg} 2>/dev/null | grep Version", "pipx": "pipx list 2>/dev/null | grep {pkg}", } # Known upstream env vars that tools commonly use for data/cache. # Format: env_var -> (human_label, default_subdir_under_base) _UPSTREAM_ENV_VARS: dict[str, tuple[str, str]] = { "HF_HOME": ("HuggingFace cache", "cache/huggingface"), "TRANSFORMERS_CACHE": ("Transformers cache", "cache/huggingface"), "DIFFUSERS_CACHE": ("Diffusers cache", "cache/huggingface"), "RUST_BACKTRACE": ("Rust backtrace", None), "NODE_PATH": ("Node modules", None), "npm_config_prefix": ("npm prefix", None), } class InstallerManager: """Install or sync tools via the appropriate package manager. Parameters ---------- tools_root : Base directory for tool installations (default ``/mnt/AI/tools``). base_dir : Top-level AI-LSC directory (``/mnt/AI``). Used to expand per-tool filesystem paths. base_bin_dir : Colon-separated PATH string to prepend to all commands. """ def __init__( self, tools_root: str, base_dir: str = "", base_bin_dir: str = "", license_gate: Any = None, ) -> None: from ai_lsc.constants import BASE_DIR self.tools_root = tools_root self.base_dir = base_dir or BASE_DIR self.base_bin_dir = base_bin_dir # License gate — if provided, every install_with_preflight / # run call checks the tool's license before proceeding. If # None, the gate is skipped (license checks happen elsewhere, # e.g. in the UI layer). self.license_gate = license_gate # ── Environment construction ───────────────────────────────────── def _env( self, tool_id: str = "", env_overrides: dict[str, str] | None = None, ) -> dict[str, str]: """Build an enriched environment with ``~/.local`` remapped. For Python tools, we redirect uv/pip/pipx directories into ``tools_root`` so that artifacts do not leak into the user's home directory. Per-tool ``env_overrides`` (from the registry) are applied last so they take precedence. """ env = enriched_env(self.base_bin_dir) # ── Global XDG remap: ~/.local -> tools_root/.local ──────────── env["LOCAL_BIN"] = os.path.join(self.tools_root, ".local", "bin") env["XDG_DATA_HOME"] = os.path.join(self.tools_root, ".local", "share") env["XDG_CONFIG_HOME"] = os.path.join(self.tools_root, ".local", "config") env["XDG_CACHE_HOME"] = os.path.join(self.tools_root, ".local", "cache") # ── uv-specific: force tool installs into tools_root ─────────── if tool_id: uv_tool_dir = os.path.join(self.tools_root, tool_id, ".uv", "tools") uv_bin_dir = os.path.join(self.tools_root, tool_id, ".uv", "bin") else: uv_tool_dir = os.path.join(self.tools_root, ".uv", "tools") uv_bin_dir = os.path.join(self.tools_root, ".uv", "bin") env["UV_TOOL_DIR"] = uv_tool_dir env["UV_TOOL_BIN_DIR"] = uv_bin_dir env["UV_CACHE_DIR"] = os.path.join(self.tools_root, ".uv", "cache") # ── pipx-specific: force installs into tools_root ──────────────── if tool_id: env["PIPX_BIN_DIR"] = os.path.join( self.tools_root, tool_id, ".pipx", "bin", ) env["PIPX_HOME"] = os.path.join( self.tools_root, tool_id, ".pipx", ) else: env["PIPX_BIN_DIR"] = os.path.join(self.tools_root, ".pipx", "bin") env["PIPX_HOME"] = os.path.join(self.tools_root, ".pipx") # ── Per-tool env overrides from registry ──────────────────────── # Keys may contain {tools_root}, {base_dir} placeholders. if env_overrides: for key, raw_val in env_overrides.items(): expanded = raw_val.replace( "{tools_root}", self.tools_root, ).replace( "{base_dir}", self.base_dir, ) env[key] = expanded logger.debug( "env override: %s=%s (tool %s)", key, expanded, tool_id, ) # ── Prepend managed bin dirs to PATH ─────────────────────────── managed_bins = [ env.get("PIPX_BIN_DIR", ""), env.get("UV_TOOL_BIN_DIR", ""), os.path.join(self.tools_root, "bin"), os.path.join(self.tools_root, ".local", "bin"), ] extra = ":".join(d for d in managed_bins if d) env["PATH"] = f"{extra}:{env.get('PATH', '')}" return env # ── Preflight detection ───────────────────────────────────────── def preflight( self, tool_id: str, inst_type: str, pkg: str, cmd: str = "", ) -> dict[str, Any]: """Check whether a tool is already installed before installing. Returns a dict matching ``PreflightResult`` fields. """ result: dict[str, Any] = { "tool_id": tool_id, "found": False, "install_type": inst_type, "location": "", "version": "", "is_update_available": False, "suggested_action": "install", } location, version = self._detect_installation( tool_id, inst_type, pkg, cmd, ) if location: result["found"] = True result["location"] = location result["version"] = version or "" result["suggested_action"] = "update" return result def _detect_installation( self, tool_id: str, inst_type: str, pkg: str, cmd: str = "", ) -> tuple[str, str]: """Detect existing installation. Returns (location, version).""" # 1. Check tools_root/ directory existence tool_dir = os.path.join(self.tools_root, tool_id) if os.path.isdir(tool_dir): ver = self._detect_version(inst_type, pkg, cmd, tool_dir) return tool_dir, ver # 2. Check tools_root/.pipx, tools_root/.uv, tools_root/.local for subdir in [".pipx", ".uv", ".local"]: check = os.path.join(self.tools_root, subdir, "bin", pkg) if os.path.exists(check): return os.path.dirname(check), "" # 3. Check tools_root/bin bin_check = os.path.join(self.tools_root, "bin", pkg) if os.path.exists(bin_check): return os.path.dirname(bin_check), "" # 4. Check system PATH via shutil.which binary_name = self._binary_name(pkg, inst_type) system_path = shutil.which(binary_name) if system_path: ver = self._detect_version(inst_type, pkg, cmd) return system_path, ver # 5. OS package manager query (pacman / dnf / apt) — list-form # subprocess calls, no shell, no interpolation. _PKG_MGR_QUERIES: dict[str, list[str]] = { "pacman": ["pacman", "-Qi", pkg], "dnf": ["dnf", "info", pkg], "apt": ["dpkg", "-s", pkg], } if inst_type in _PKG_MGR_QUERIES: try: proc = subprocess.run( _PKG_MGR_QUERIES[inst_type], capture_output=True, text=True, timeout=10, ) if proc.returncode == 0: for line in proc.stdout.splitlines(): if line.strip().startswith("Version"): ver = line.split(":", 1)[-1].strip() return f"{inst_type}:{pkg}", ver except (OSError, subprocess.SubprocessError): pass return "", "" def _binary_name(self, pkg: str, inst_type: str) -> str: """Map a package name to its likely binary name.""" if inst_type == "npm": return pkg if "/" not in pkg else pkg.split("/")[-1] if inst_type in ("uv", "pip"): return pkg.replace("-", "_").replace(".", "_") return pkg def _detect_version( self, inst_type: str, pkg: str, cmd: str, cwd: str = "", ) -> str: """Try to extract the installed version.""" if inst_type == "git": git_dir = os.path.join(self.tools_root, pkg.split("/")[-1] .replace(".git", "")) if os.path.isdir(os.path.join(git_dir, ".git")): for argv in ( ["git", "describe", "--tags", "--abbrev=0"], ["git", "rev-parse", "--short", "HEAD"], ): try: proc = subprocess.run( argv, capture_output=True, text=True, timeout=10, cwd=git_dir, ) if proc.returncode == 0 and proc.stdout.strip(): return proc.stdout.strip() except (OSError, subprocess.SubprocessError): continue return "" # Try the launcher command for version ver_argv: list[str] = [] if cmd: ver_argv = shlex.split(cmd) + ["--version"] else: tmpl = _VERSION_CMDS.get(inst_type, "") if tmpl: ver_argv = shlex.split(tmpl.format(pkg=pkg, cmd=pkg)) if not ver_argv: return "" try: proc = subprocess.run( ver_argv, capture_output=True, text=True, timeout=10, cwd=cwd or None, ) if proc.returncode == 0: return proc.stdout.strip().split("\n")[0] except (OSError, subprocess.SubprocessError): pass return "" # ── Post-install hooks ────────────────────────────────────────── def _run_post_install( self, tool_id: str, post_install_cmd: str, ) -> str: """Run a post-install hook inside ``tools_root/``.""" if not post_install_cmd: return "" dest = os.path.join(self.tools_root, tool_id) env = self._env(tool_id) # Replace {tools_root} in the command cmd = post_install_cmd.replace("{tools_root}", self.tools_root) logger.info("Running post-install for %s: %s", tool_id, cmd) try: # Post-install commands are arbitrary shell snippets supplied by # the registry; we still need a shell here, but we run them under # `bash -c` with an explicit argv (no shell=True) so the registry # string is passed verbatim as a single argument and cannot # break out of the subprocess call itself. subprocess.run( ["bash", "-c", cmd], check=True, env=env, timeout=300, cwd=dest, ) return f"Post-install completed for {tool_id}." except (subprocess.CalledProcessError, OSError) as exc: logger.warning( "Post-install failed for %s: %s", tool_id, exc, ) return f"Post-install FAILED for {tool_id}: {exc}" # ── Strategy methods ──────────────────────────────────────────── def install_ollama(self, pkg: str, tool_id: str) -> str: """Pull an Ollama model or install the ollama binary.""" if tool_id == "ollama": dest = os.path.join(self.tools_root, "ollama") os.makedirs(dest, exist_ok=True) import tempfile # SE-01: download-then-execute pattern avoids shell=True tmp = tempfile.NamedTemporaryFile( suffix=".sh", prefix="ollama-install-", delete=False, ) tmp_path = tmp.name tmp.close() try: subprocess.run( ["curl", "-fsSL", "https://ollama.com/install.sh", "-o", tmp_path], check=True, env=self._env("ollama"), ) os.chmod(tmp_path, 0o755) subprocess.run( ["bash", tmp_path], check=True, env=self._env("ollama"), timeout=600, ) finally: try: os.unlink(tmp_path) except OSError: pass return "Ollama binary installed to system (managed by ollama)." return f"Ollama model '{pkg}' queued for pull." def install_uv(self, pkg: str, tool_id: str, env_overrides: dict[str, str] | None = None) -> str: """Install a Python tool via ``uv tool install`` pinned to tools_root.""" dest = os.path.join(self.tools_root, tool_id) os.makedirs(dest, exist_ok=True) env = self._env(tool_id, env_overrides) try: _validate_pkg(pkg) subprocess.run( ["uv", "tool", "install", pkg], check=True, env=env, timeout=300, ) return f"UV tool '{pkg}' installed to {env['UV_TOOL_DIR']}." except subprocess.CalledProcessError: logger.info("uv install failed for %s, stepping down to pipx", pkg) return self.install_pipx(pkg, tool_id, env_overrides) def install_pipx(self, pkg: str, tool_id: str, env_overrides: dict[str, str] | None = None) -> str: """Install a Python CLI tool via ``pipx`` pinned to tools_root.""" dest = os.path.join(self.tools_root, tool_id) os.makedirs(dest, exist_ok=True) env = self._env(tool_id, env_overrides) try: _validate_pkg(pkg) subprocess.run( ["pipx", "install", pkg], check=True, env=env, timeout=300, ) return f"pipx '{pkg}' installed to {env['PIPX_HOME']}." except subprocess.CalledProcessError: logger.info("pipx install failed for %s, stepping down to pip", pkg) return self.install_pip(pkg, tool_id, env_overrides) def install_pip(self, pkg: str, tool_id: str, env_overrides: dict[str, str] | None = None) -> str: """Install a Python tool via ``pip`` into a per-tool venv.""" dest = os.path.join(self.tools_root, tool_id) venv_dir = os.path.join(dest, ".venv") os.makedirs(dest, exist_ok=True) env = self._env(tool_id, env_overrides) if not os.path.isdir(venv_dir): subprocess.run( ["python3", "-m", "venv", venv_dir], check=True, env=env, timeout=60, ) pip_bin = os.path.join(venv_dir, "bin", "pip") try: _validate_pkg(pkg) subprocess.run( [pip_bin, "install", pkg], check=True, env=env, timeout=300, ) except subprocess.CalledProcessError as exc: logger.warning("pip install failed for %s: %s", pkg, exc) raise self._symlink_venv_bin(tool_id, venv_dir, pkg) return f"pip '{pkg}' installed to {venv_dir}." def _symlink_venv_bin( self, tool_id: str, venv_dir: str, pkg: str, ) -> None: """Create symlinks from the venv bin to tools_root/bin.""" bin_dir = os.path.join(self.tools_root, "bin") os.makedirs(bin_dir, exist_ok=True) venv_bin = os.path.join(venv_dir, "bin") if os.path.isdir(venv_bin): for entry in os.listdir(venv_bin): src = os.path.join(venv_bin, entry) dst = os.path.join(bin_dir, entry) if not os.path.isfile(src): continue # L-03: TOCTOU-safe symlink — create then handle # FileExistsError, instead of check-then-create. try: os.symlink(src, dst) except FileExistsError: pass def install_pacman(self, pkg: str) -> str: """Open a terminal for ``pacman -S`` (Arch system package).""" _validate_pkg(pkg) subprocess.Popen([ "x-terminal-emulator", "-e", "bash", "-c", f"sudo pacman -S --noconfirm {shlex.quote(pkg)}; sleep 2", ]) return f"Dispatched pacman for {pkg}." def install_dnf(self, pkg: str) -> str: """Open a terminal for ``dnf install`` (Fedora / RHEL).""" _validate_pkg(pkg) subprocess.Popen([ "x-terminal-emulator", "-e", "bash", "-c", f"sudo dnf install -y {shlex.quote(pkg)}; sleep 2", ]) return f"Dispatched dnf for {pkg}." def install_apt(self, pkg: str) -> str: """Open a terminal for ``apt install`` (Debian / Ubuntu).""" _validate_pkg(pkg) subprocess.Popen([ "x-terminal-emulator", "-e", "bash", "-c", f"sudo apt-get install -y {shlex.quote(pkg)}; sleep 2", ]) return f"Dispatched apt for {pkg}." def install_npm(self, pkg: str, tool_id: str, env_overrides: dict[str, str] | None = None) -> str: """Install an npm package to an isolated prefix under tools_root.""" dest = os.path.join(self.tools_root, tool_id) os.makedirs(dest, exist_ok=True) env = self._env(tool_id, env_overrides) _validate_pkg(pkg) subprocess.run( ["npm", "install", "--prefix", dest, pkg], check=True, env=env, timeout=300, ) return f"NPM '{pkg}' installed to {dest}." def install_git( self, pkg: str, tool_id: str, post_install: str | None = None, env_overrides: dict[str, str] | None = None, ) -> str: """Clone a git repository into ``tools_root/``.""" dest = os.path.join(self.tools_root, tool_id) if os.path.exists(dest): subprocess.run( ["git", "-C", dest, "pull", "--ff-only"], check=True, timeout=600, ) msg = f"Git source updated: {dest}" else: os.makedirs(dest, exist_ok=True) subprocess.run( ["git", "clone", pkg, dest], check=True, timeout=600, ) msg = f"Git source cloned: {dest}" if post_install: self._run_post_install(tool_id, post_install) return msg def install_git_node( self, pkg: str, tool_id: str, post_install: str | None = None, ) -> str: """Clone a git repo and run ``yarn setup``.""" dest = os.path.join(self.tools_root, tool_id) if os.path.exists(dest): subprocess.run( ["git", "-C", dest, "pull", "--ff-only"], check=True, timeout=600, ) subprocess.run( ["yarn", "install"], cwd=dest, check=True, timeout=300, ) msg = f"Git+Node source updated: {dest}" else: os.makedirs(dest, exist_ok=True) subprocess.run( ["git", "clone", pkg, dest], check=True, timeout=600, ) subprocess.run( ["yarn", "install"], cwd=dest, check=True, timeout=300, ) msg = f"Git+Node source synchronized: {dest}" if post_install: self._run_post_install(tool_id, post_install) return msg def install_script( self, cmd: str, ctx: dict[str, str], tool_id: str = "", env_overrides: dict[str, str] | None = None, ) -> str: """Execute an arbitrary shell script (installer type ``"script"``). The ``{tools_root}`` placeholder is resolved so scripts can direct output to the correct directory. """ if "tools_root" not in ctx: ctx["tools_root"] = self.tools_root env = self._env(tool_id, env_overrides) # Registry 'script' installers are arbitrary shell snippets (e.g. # `uv pip install ... && python -m compileall .`). We pass the # fully-formatted command to bash as a single argv element so the # subprocess call itself is shell-free. rendered = cmd.format(**ctx) subprocess.run( ["bash", "-c", rendered], check=True, env=env, ) return "Shell script deployment completed." def install_custom(self, pkg: str, tool_id: str) -> str: """Open the install URL in the browser for manual installation.""" import webbrowser url = pkg if not url.startswith("http"): url = f"https://{url}" # H-20 / H-22: reject non-http(s) schemes (file://, javascript:, …) _validate_url(url) webbrowser.open(url) return ( f"Opened {url} in browser for manual installation " f"of {tool_id}. Follow the instructions on the page." ) # ── Dispatcher ───────────────────────────────────────────────── def _check_license(self, tool_id: str, spdx: str | None) -> None: """Check the tool's license against the gate before install. Raises ``LicenseBlocked`` if the tool_id is on the SaaS blocklist, or ``LicenseAcceptanceRequired`` if the license has not been accepted yet. No-op if ``self.license_gate`` is None or *spdx* is falsy. """ if self.license_gate is None or not spdx: return result = self.license_gate.check(tool_id, spdx) if result.status == "blocked": from ai_lsc.registry.license_gate import LicenseBlocked raise LicenseBlocked(tool_id=tool_id, reason=result.reason) if result.status == "needs_acceptance": from ai_lsc.registry.license_gate import LicenseAcceptanceRequired raise LicenseAcceptanceRequired( tool_id=tool_id, license_info=result.license_info, ) def run( self, inst_type: str, pkg: str, cmd: str = "", ctx: dict[str, str] | None = None, tool_id: str = "", post_install: str | None = None, env_overrides: dict[str, str] | None = None, license_spdx: str | None = None, ) -> str: """Dispatch to the correct installer strategy. Returns a human-readable description of what happened. Parameters ---------- license_spdx : SPDX ID for the tool's license. If provided AND a ``license_gate`` was passed to the InstallerManager constructor, the gate checks the license before dispatch. If the gate returns ``blocked`` or ``needs_acceptance``, the appropriate exception is raised before any subprocess call. Raises ------ ValueError If *inst_type* is not recognized. subprocess.CalledProcessError If the underlying command fails. LicenseBlocked If the tool_id is on the SaaS blocklist. LicenseAcceptanceRequired If the tool's license has not been accepted yet. """ ctx = ctx or {} if not tool_id: if "github.com" in pkg: tool_id = (pkg.rstrip("/").rsplit("/", 1)[-1] .replace(".git", "")) else: tool_id = pkg.split("/")[-1].split(":")[0] # License gate — check before any subprocess call. self._check_license(tool_id, license_spdx) strategies: dict[str, Any] = { "ollama": lambda: self.install_ollama(pkg, tool_id), "uv": lambda: self.install_uv(pkg, tool_id, env_overrides), "pipx": lambda: self.install_pipx(pkg, tool_id, env_overrides), "pip": lambda: self.install_pip(pkg, tool_id, env_overrides), "script": lambda: self.install_script( cmd, ctx, tool_id, env_overrides, ), "pacman": lambda: self.install_pacman(pkg), "dnf": lambda: self.install_dnf(pkg), "apt": lambda: self.install_apt(pkg), "npm": lambda: self.install_npm(pkg, tool_id, env_overrides), "git": lambda: self.install_git( pkg, tool_id, post_install, env_overrides, ), "git_node": lambda: self.install_git_node( pkg, tool_id, post_install, ), "custom": lambda: self.install_custom(pkg, tool_id), } handler = strategies.get(inst_type) if handler is None: raise ValueError(f"Unknown installer type '{inst_type}'") return handler() # ── Batch operations ──────────────────────────────────────────── def preflight_batch( self, tools: dict[str, dict[str, Any]], ) -> dict[str, dict[str, Any]]: """Run preflight checks for multiple tools at once. Parameters ---------- tools : Dict of ``{tool_id: registry_entry}`` from the registry. Returns ------- Dict of ``{tool_id: preflight_result_dict}``. """ return { tid: self.preflight( tool_id=tid, inst_type=meta.get("installer", {}).get("type", "pacman"), pkg=meta.get("installer", {}).get("pkg", ""), cmd=meta.get("installer", {}).get("cmd", ""), ) for tid, meta in tools.items() } def install_with_preflight( self, tool_id: str, inst_type: str, pkg: str, cmd: str = "", ctx: dict[str, str] | None = None, force: bool = False, post_install: str | None = None, env_overrides: dict[str, str] | None = None, license_spdx: str | None = None, ) -> str: """Install a tool with preflight detection. If the tool is already installed and *force* is False, returns a message saying the tool exists and suggesting an update. If *force* is True, proceeds with installation regardless. Parameters ---------- license_spdx : SPDX ID for the tool's license. Forwarded to ``run()`` for gate checking. """ # License gate — check before preflight so we don't waste a # subprocess call on a blocked tool. self._check_license(tool_id, license_spdx) check = self.preflight(tool_id, inst_type, pkg, cmd) if check["found"] and not force: return ( f"Tool '{tool_id}' already installed at {check['location']}. " f"Version: {check['version'] or 'unknown'}. " f"Use force=True to update." ) return self.run( inst_type, pkg, cmd, ctx, tool_id, post_install, env_overrides, ) # ── Installation verification ─────────────────────────────────── def verify( self, tool_id: str, inst_type: str, pkg: str, cmd: str = "", filesystem: dict[str, str] | None = None, ) -> dict[str, Any]: """Run a compliance checklist against a single tool installation. Checks: 1. Native install detected 2. Installed entirely under /mnt/AI (no ~/.local leak) 3. Config redirected from $HOME 4. Cache redirected 5. Logs redirected 6. Launcher binary accessible 7. Update command available 8. Version detection works 9. Health check (binary --version or --help) Returns a dict matching ``VerificationResult`` fields. """ from ai_lsc.types import VerifyCheck checks: list[VerifyCheck] = [] fs = filesystem or {} tool_dir = os.path.join(self.tools_root, tool_id) # 1. Native install detected location, version = self._detect_installation( tool_id, inst_type, pkg, cmd, ) checks.append(VerifyCheck( name="Native Install", passed=bool(location), detail=location or "not found", )) # 2. Installed under /mnt/AI (no system leak) is_managed = ( location and location.startswith(self.base_dir) ) or inst_type == "pacman" or inst_type in ("dnf", "apt") checks.append(VerifyCheck( name="Filesystem Compliance", passed=is_managed, detail=location or "N/A", )) # 3. Config path (if declared in filesystem spec) config_path = fs.get("config", "") if config_path: full = os.path.join(self.base_dir, config_path) exists = os.path.isdir(full) checks.append(VerifyCheck( name="Config Redirect", passed=exists or not location, detail=full, )) # 4. Cache path cache_path = fs.get("cache", "") if cache_path: full = os.path.join(self.base_dir, cache_path) checks.append(VerifyCheck( name="Cache Redirect", passed=os.path.isdir(full) or not location, detail=full, )) # 5. Logs path logs_path = fs.get("logs", "") if logs_path: full = os.path.join(self.base_dir, logs_path) checks.append(VerifyCheck( name="Logs Redirect", passed=os.path.isdir(full) or not location, detail=full, )) # 6. Launcher binary accessible binary = self._binary_name(pkg, inst_type) bin_path = shutil.which(binary) checks.append(VerifyCheck( name="Launcher Accessible", passed=bool(bin_path), detail=bin_path or f"{binary} not in PATH", )) # 7. Version detection checks.append(VerifyCheck( name="Version Detection", passed=bool(version), detail=version or "unknown", )) # 8. Health check (try --version or --help) healthy = False if bin_path: for flag in ("--version", "--help"): try: proc = subprocess.run( [bin_path, flag], capture_output=True, text=True, timeout=5, ) if proc.returncode == 0: healthy = True break except (OSError, subprocess.SubprocessError): continue checks.append(VerifyCheck( name="Health Check", passed=healthy, detail="responds to --version/--help" if healthy else "no response", )) return { "tool_id": tool_id, "checks": [ {"name": c.name, "passed": c.passed, "detail": c.detail} for c in checks ], "install_method": inst_type, "install_location": location or "", "score": ( int(sum(1 for c in checks if c.passed) / len(checks) * 100) if checks else 0 ), }