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