314 lines
9.0 KiB
Python
314 lines
9.0 KiB
Python
"""System dependency management.
|
|
|
|
Handles detection, checking, and installation of system-level package
|
|
dependencies (apt, brew, pacman, dnf) for CmdForge tools.
|
|
"""
|
|
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from typing import Optional, List, Tuple, TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from .tool import SystemDependency
|
|
|
|
# Supported package managers in precedence order
|
|
SUPPORTED_MANAGERS = ["apt", "brew", "pacman", "dnf"]
|
|
|
|
|
|
def detect_os() -> str:
|
|
"""Detect operating system.
|
|
|
|
Returns:
|
|
'linux', 'darwin', or 'windows'
|
|
"""
|
|
system = platform.system().lower()
|
|
if system == "darwin":
|
|
return "darwin"
|
|
elif system == "windows":
|
|
return "windows"
|
|
return "linux"
|
|
|
|
|
|
def detect_package_manager() -> Optional[str]:
|
|
"""Detect the system's package manager.
|
|
|
|
Uses shutil.which for fast detection without subprocess overhead.
|
|
|
|
Returns:
|
|
Package manager name ('apt', 'brew', 'pacman', 'dnf') or None
|
|
"""
|
|
os_type = detect_os()
|
|
|
|
if os_type == "windows":
|
|
return None # Windows not supported for auto-install
|
|
|
|
# Manager binary names (apt uses apt-get for scripted installs)
|
|
manager_binaries = {
|
|
"apt": "apt-get",
|
|
"brew": "brew",
|
|
"pacman": "pacman",
|
|
"dnf": "dnf",
|
|
}
|
|
|
|
# On macOS, check brew first
|
|
if os_type == "darwin":
|
|
if shutil.which("brew"):
|
|
return "brew"
|
|
return None
|
|
|
|
# On Linux, check in order of preference
|
|
for manager, binary in manager_binaries.items():
|
|
if shutil.which(binary):
|
|
return manager
|
|
|
|
return None
|
|
|
|
|
|
def check_system_dep(dep: "SystemDependency") -> bool:
|
|
"""Check if a system dependency is installed.
|
|
|
|
A dependency is considered satisfied if ANY of its binaries exist.
|
|
|
|
Args:
|
|
dep: SystemDependency to check
|
|
|
|
Returns:
|
|
True if dependency is satisfied (at least one binary found)
|
|
"""
|
|
binaries = dep.get_binaries_to_check()
|
|
return any(shutil.which(b) is not None for b in binaries)
|
|
|
|
|
|
def get_binary_path(dep: "SystemDependency") -> Optional[str]:
|
|
"""Get the path to the first found binary for a dependency.
|
|
|
|
Args:
|
|
dep: SystemDependency to check
|
|
|
|
Returns:
|
|
Path to binary, or None if not found
|
|
"""
|
|
binaries = dep.get_binaries_to_check()
|
|
for b in binaries:
|
|
path = shutil.which(b)
|
|
if path:
|
|
return path
|
|
return None
|
|
|
|
|
|
def get_install_command(dep: "SystemDependency", pkg_manager: str) -> Optional[List[str]]:
|
|
"""Get the install command as a list (safe, no shell injection).
|
|
|
|
Args:
|
|
dep: SystemDependency to install
|
|
pkg_manager: Package manager to use ('apt', 'brew', 'pacman', 'dnf')
|
|
|
|
Returns:
|
|
Command as list of strings, or None if unsupported manager
|
|
"""
|
|
pkg_name = dep.get_package_name(pkg_manager)
|
|
|
|
commands = {
|
|
"apt": ["sudo", "apt-get", "install", "-y", pkg_name],
|
|
"brew": ["brew", "install", pkg_name],
|
|
"pacman": ["sudo", "pacman", "-S", "--noconfirm", pkg_name],
|
|
"dnf": ["sudo", "dnf", "install", "-y", pkg_name],
|
|
}
|
|
return commands.get(pkg_manager)
|
|
|
|
|
|
def get_install_command_string(dep: "SystemDependency", pkg_manager: str) -> str:
|
|
"""Get human-readable install command for display.
|
|
|
|
Args:
|
|
dep: SystemDependency to install
|
|
pkg_manager: Package manager to use
|
|
|
|
Returns:
|
|
Command as string for display to user
|
|
"""
|
|
cmd = get_install_command(dep, pkg_manager)
|
|
return " ".join(cmd) if cmd else f"# No install command for {pkg_manager}"
|
|
|
|
|
|
def install_system_dep(dep: "SystemDependency", pkg_manager: str) -> Tuple[bool, str]:
|
|
"""Install a system dependency.
|
|
|
|
Args:
|
|
dep: SystemDependency to install
|
|
pkg_manager: Package manager to use
|
|
|
|
Returns:
|
|
Tuple of (success, message)
|
|
"""
|
|
cmd = get_install_command(dep, pkg_manager)
|
|
if not cmd:
|
|
return False, f"No install command for package manager: {pkg_manager}"
|
|
|
|
try:
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
# Verify installation worked
|
|
if check_system_dep(dep):
|
|
return True, f"Installed {dep.name}"
|
|
else:
|
|
return False, f"Installed but binaries not found"
|
|
else:
|
|
error = result.stderr.strip() or result.stdout.strip()
|
|
return False, f"Failed: {error[:200]}"
|
|
except FileNotFoundError:
|
|
return False, f"Package manager '{pkg_manager}' not found"
|
|
except PermissionError:
|
|
return False, "Permission denied (try running with sudo)"
|
|
except Exception as e:
|
|
return False, str(e)
|
|
|
|
|
|
def check_and_report(deps: List["SystemDependency"]) -> Tuple[List["SystemDependency"], List["SystemDependency"]]:
|
|
"""Check dependencies and return categorized lists.
|
|
|
|
Args:
|
|
deps: List of SystemDependency objects to check
|
|
|
|
Returns:
|
|
Tuple of (installed_deps, missing_deps)
|
|
"""
|
|
installed = []
|
|
missing = []
|
|
for dep in deps:
|
|
if check_system_dep(dep):
|
|
installed.append(dep)
|
|
else:
|
|
missing.append(dep)
|
|
return installed, missing
|
|
|
|
|
|
def format_dep_status(dep: "SystemDependency", pkg_manager: Optional[str] = None) -> str:
|
|
"""Format dependency status for display.
|
|
|
|
Args:
|
|
dep: SystemDependency to format
|
|
pkg_manager: Optional package manager for install hint
|
|
|
|
Returns:
|
|
Formatted status string
|
|
"""
|
|
if check_system_dep(dep):
|
|
path = get_binary_path(dep)
|
|
return f" \u2713 {dep.name} (found: {path})"
|
|
else:
|
|
binaries = dep.get_binaries_to_check()
|
|
line = f" \u2717 {dep.name} (not found"
|
|
if len(binaries) > 1:
|
|
line += f", provides: {', '.join(binaries)}"
|
|
line += ")"
|
|
if pkg_manager:
|
|
install_cmd = get_install_command_string(dep, pkg_manager)
|
|
line += f"\n Install: {install_cmd}"
|
|
return line
|
|
|
|
|
|
def install_missing_deps(
|
|
deps: List["SystemDependency"],
|
|
pkg_manager: str,
|
|
callback=None
|
|
) -> List[Tuple["SystemDependency", bool, str]]:
|
|
"""Install multiple missing dependencies.
|
|
|
|
Args:
|
|
deps: List of SystemDependency objects to install
|
|
pkg_manager: Package manager to use
|
|
callback: Optional callback(dep, success, message) called after each install
|
|
|
|
Returns:
|
|
List of tuples: (dep, success, message)
|
|
"""
|
|
results = []
|
|
for dep in deps:
|
|
success, msg = install_system_dep(dep, pkg_manager)
|
|
results.append((dep, success, msg))
|
|
if callback:
|
|
callback(dep, success, msg)
|
|
return results
|
|
|
|
|
|
def prompt_install_missing(
|
|
deps: List["SystemDependency"],
|
|
tool_ref: str,
|
|
auto_yes: bool = False
|
|
) -> None:
|
|
"""Prompt to install missing system dependencies for a tool.
|
|
|
|
Args:
|
|
deps: List of system dependencies.
|
|
tool_ref: Tool reference string for messages (owner/name or name).
|
|
auto_yes: If True, install without prompting.
|
|
"""
|
|
installed, missing = check_and_report(deps)
|
|
if not missing:
|
|
return
|
|
|
|
print(f"Tool '{tool_ref}' requires system packages:")
|
|
pkg_manager = detect_package_manager()
|
|
|
|
for dep in missing:
|
|
desc = f" - {dep.description}" if dep.description else ""
|
|
print(f" \u2717 {dep.name}{desc}")
|
|
if pkg_manager:
|
|
print(f" Install: {get_install_command_string(dep, pkg_manager)}")
|
|
|
|
print()
|
|
|
|
os_type = detect_os()
|
|
if os_type == "windows":
|
|
print("Note: Automatic installation not supported on Windows.")
|
|
print("Please install the packages manually.")
|
|
return
|
|
|
|
if not pkg_manager:
|
|
print("Warning: No supported package manager found.")
|
|
print("Please install the packages manually.")
|
|
return
|
|
|
|
if not auto_yes and not sys.stdin.isatty():
|
|
print("Skipping system package install (non-interactive).")
|
|
print(f"Install later with: cmdforge system-deps {tool_ref} install")
|
|
return
|
|
|
|
if auto_yes:
|
|
do_install = True
|
|
else:
|
|
try:
|
|
confirm = input(f"Install {len(missing)} missing package(s)? [Y/n] ")
|
|
do_install = confirm.lower() in ('', 'y', 'yes')
|
|
except (EOFError, KeyboardInterrupt):
|
|
print("\nSkipping system package installation.")
|
|
return
|
|
|
|
if not do_install:
|
|
print(f"Skipped. Install later with: cmdforge system-deps {tool_ref} install")
|
|
return
|
|
|
|
print()
|
|
succeeded = 0
|
|
failed = 0
|
|
|
|
for dep in missing:
|
|
print(f"Installing {dep.name}...")
|
|
success, msg = install_system_dep(dep, pkg_manager)
|
|
if success:
|
|
print(f" \u2713 {msg}")
|
|
succeeded += 1
|
|
else:
|
|
print(f" \u2717 {msg}")
|
|
failed += 1
|
|
|
|
print()
|
|
if failed:
|
|
print(f"Installed {succeeded}/{len(missing)} packages.")
|
|
print("Tool installed but may not work correctly without missing packages.")
|
|
else:
|
|
print(f"All {succeeded} system package(s) installed.")
|