CmdForge/src/cmdforge/system_deps.py

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.")