|
| 1 | +""" |
| 2 | +brew_tools.py |
| 3 | +============= |
| 4 | +Homebrew package-management helpers for macOS (and Linuxbrew, if ever needed). |
| 5 | +
|
| 6 | +Design notes |
| 7 | +------------ |
| 8 | +* Homebrew explicitly refuses to run as root. All functions accept a |
| 9 | + ``brew_user`` argument and execute brew as that user via the Executor's |
| 10 | + ``user=`` mechanism. |
| 11 | +* Use ``get_brew_user()`` from ``lib.platform_utils`` to obtain the correct |
| 12 | + user before calling anything here. |
| 13 | +* ``find_brew()`` checks PATH first, then the canonical Apple-Silicon and |
| 14 | + Intel install locations, so it works correctly even when called as root |
| 15 | + (where PATH may not include Homebrew's prefix). |
| 16 | +""" |
| 17 | + |
| 18 | +import os |
| 19 | +import shutil |
| 20 | +from typing import Optional |
| 21 | + |
| 22 | +from ..executor import Executor |
| 23 | +from ..logger import log |
| 24 | + |
| 25 | +# Ordered by likelihood: Apple Silicon first, then Intel, then Linuxbrew. |
| 26 | +_BREW_CANDIDATE_PATHS: list[str] = [ |
| 27 | + "/opt/homebrew/bin/brew", # Apple Silicon (M-series) |
| 28 | + "/usr/local/bin/brew", # Intel Mac |
| 29 | + "/home/linuxbrew/.linuxbrew/bin/brew", # Linuxbrew (rarely used here) |
| 30 | +] |
| 31 | + |
| 32 | + |
| 33 | +def find_brew() -> Optional[str]: |
| 34 | + """ |
| 35 | + Return the absolute path to the ``brew`` binary, or ``None`` if Homebrew |
| 36 | + is not installed. Checks ``PATH`` first, then known install locations. |
| 37 | + """ |
| 38 | + in_path = shutil.which("brew") |
| 39 | + if in_path: |
| 40 | + return in_path |
| 41 | + for candidate in _BREW_CANDIDATE_PATHS: |
| 42 | + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): |
| 43 | + return candidate |
| 44 | + return None |
| 45 | + |
| 46 | + |
| 47 | +def ensure_brew_installed(exec_obj: Executor, brew_user: str) -> str: |
| 48 | + """ |
| 49 | + Ensure Homebrew is installed. If not found, runs the official install |
| 50 | + script interactively as *brew_user*. |
| 51 | +
|
| 52 | + Returns the path to the ``brew`` binary. |
| 53 | + Raises ``RuntimeError`` if installation fails. |
| 54 | + """ |
| 55 | + existing = find_brew() |
| 56 | + if existing: |
| 57 | + log.success(f"Homebrew found at {existing}.") |
| 58 | + return existing |
| 59 | + |
| 60 | + log.info("Homebrew not found — installing via official script…") |
| 61 | + exec_obj.run( |
| 62 | + 'curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh | bash', |
| 63 | + user=brew_user, |
| 64 | + interactive=True, |
| 65 | + ) |
| 66 | + |
| 67 | + installed = find_brew() |
| 68 | + if not installed: |
| 69 | + raise RuntimeError( |
| 70 | + "Homebrew installation completed but 'brew' binary still not found. " |
| 71 | + "You may need to add Homebrew to PATH manually." |
| 72 | + ) |
| 73 | + log.success(f"Homebrew installed at {installed}.") |
| 74 | + return installed |
| 75 | + |
| 76 | + |
| 77 | +def brew_install(exec_obj: Executor, brew_user: str, *packages: str) -> None: |
| 78 | + """ |
| 79 | + Install one or more Homebrew formulae (idempotent). |
| 80 | +
|
| 81 | + Checks ``brew list --formula`` for each package before attempting to |
| 82 | + install, so re-running is safe and fast. |
| 83 | + """ |
| 84 | + brew = find_brew() |
| 85 | + if not brew: |
| 86 | + raise FileNotFoundError( |
| 87 | + "Homebrew not found. Run ensure_brew_installed() first." |
| 88 | + ) |
| 89 | + |
| 90 | + to_install: list[str] = [] |
| 91 | + for pkg in packages: |
| 92 | + try: |
| 93 | + result = exec_obj.run( |
| 94 | + [brew, "list", "--formula", pkg], |
| 95 | + user=brew_user, |
| 96 | + check=False, |
| 97 | + run_quiet=True, |
| 98 | + ) |
| 99 | + if result.returncode == 0: |
| 100 | + log.success(f"Brew formula already installed: {pkg}") |
| 101 | + else: |
| 102 | + to_install.append(pkg) |
| 103 | + except Exception: |
| 104 | + to_install.append(pkg) |
| 105 | + |
| 106 | + if not to_install: |
| 107 | + return |
| 108 | + |
| 109 | + log.info(f"Installing brew formulae: {', '.join(to_install)} …") |
| 110 | + exec_obj.run([brew, "install"] + to_install, user=brew_user) |
| 111 | + log.success(f"Installed via brew: {', '.join(to_install)}") |
| 112 | + |
| 113 | + |
| 114 | +def brew_service_start(exec_obj: Executor, brew_user: str, service: str) -> None: |
| 115 | + """ |
| 116 | + Start *service* via ``brew services start`` (registers with launchd). |
| 117 | + Idempotent — if already running, brew will report that and exit 0. |
| 118 | + """ |
| 119 | + brew = find_brew() |
| 120 | + if not brew: |
| 121 | + raise FileNotFoundError("Homebrew not found.") |
| 122 | + |
| 123 | + log.info(f"Starting brew service: {service} …") |
| 124 | + exec_obj.run([brew, "services", "start", service], user=brew_user) |
| 125 | + log.success(f"Brew service '{service}' started (launchd registered).") |
| 126 | + |
| 127 | + |
| 128 | +def is_brew_service_running(exec_obj: Executor, brew_user: str, service: str) -> bool: |
| 129 | + """ |
| 130 | + Return ``True`` if *service* is currently in the 'started' state according |
| 131 | + to ``brew services info``. |
| 132 | + """ |
| 133 | + brew = find_brew() |
| 134 | + if not brew: |
| 135 | + return False |
| 136 | + try: |
| 137 | + result = exec_obj.run( |
| 138 | + [brew, "services", "info", service, "--json"], |
| 139 | + user=brew_user, |
| 140 | + check=False, |
| 141 | + run_quiet=True, |
| 142 | + ) |
| 143 | + return result.returncode == 0 and '"started"' in result.stdout |
| 144 | + except Exception: |
| 145 | + return False |
0 commit comments