ai-lsc/src/ai_lsc/runtime/installer.py

947 lines
36 KiB
Python
Executable File

"""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/<tool_id>/`` (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/<tool>`` or ``/mnt/AI/data/<tool>``.
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/<tool_id>/) 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/<tool_id> 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/<tool_id>``."""
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/<tool_id>``."""
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
),
}