From 5560d6b7934c209d434341abac865615987ddb05 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 14:27:01 +0000 Subject: [PATCH] Add macOS support with Homebrew + Xcode CLT bootstrap Detect Darwin automatically and, on macOS, install the Xcode Command Line Tools and Homebrew as the first actions before any package work. Route system packages through brew (formulae and casks), skip the Flatpak section entirely, refuse to run as root (brew won't), and adjust custom-package URL/install-path templates with new {os}, {os_go}, {os_zig}, {os_nvim} substitutions. Architecture detection remains dynamic on both Apple Silicon and Intel. https://claude.ai/code/session_01R2CnCeEBYT5QoHiwxczDNq --- bootstrap_environment.py | 265 ++++++++++++++++++++++++++++++++++----- formatted_packages.py | 10 +- 2 files changed, 238 insertions(+), 37 deletions(-) diff --git a/bootstrap_environment.py b/bootstrap_environment.py index 684cf74..6f4ffdb 100755 --- a/bootstrap_environment.py +++ b/bootstrap_environment.py @@ -3,12 +3,19 @@ Bootstrap packages declared in formatted_packages.py. Sections handled: - System Packages — installed via dnf or apt-get - Flatpak Packages — installed via flatpak from Flathub (skipped with --no-gui) + System Packages — installed via dnf, apt-get, or brew (macOS) + Flatpak Packages — installed via flatpak from Flathub (Linux only; + skipped with --no-gui and skipped entirely on macOS) Custom Packages — downloaded, verified, extracted +OS detection is automatic. On macOS the first actions are to install the +Xcode Command Line Tools and Homebrew, which is then used as the system +package manager. + Usage: - sudo python3 bootstrap_environment.py [--only system|flatpak|custom] [--no-gui] + Linux: sudo python3 bootstrap_environment.py [--only system|flatpak|custom] [--no-gui] + macOS: python3 bootstrap_environment.py [--only system|custom] [--no-gui] + (do NOT use sudo on macOS — Homebrew refuses to run as root) """ import argparse @@ -97,6 +104,25 @@ def shell( def has_cmd(name: str) -> bool: return shutil.which(name) is not None +# ── OS detection ────────────────────────────────────────────────────────────── + +def detect_os() -> str: + s = platform.system() + if s == "Linux": + return "linux" + if s == "Darwin": + return "macos" + sys.exit(f"Unsupported OS: {s} (supports Linux, Darwin)") + +OS = detect_os() +IS_MACOS = OS == "macos" + +# Per-OS substitutions used by download URL construction (vendors disagree +# on the canonical OS token — Go uses "darwin", Zig/Neovim use "macos"). +_OS_GO = {"linux": "linux", "macos": "darwin"} +_OS_ZIG = {"linux": "linux", "macos": "macos"} +_OS_NVIM = {"linux": "linux", "macos": "macos"} + # ── architecture detection ──────────────────────────────────────────────────── # Tokens commonly seen in download URLs / asset names per architecture. @@ -122,8 +148,16 @@ _ARCH_DEB = {"x86_64": "amd64", "aarch64": "arm64"} _ARCH_NVIM = {"x86_64": "x86_64", "aarch64": "arm64"} def _url_format(template: str, version: Optional[str]) -> str: - """Interpolate {version}, {arch}, {arch_go} into a URL template.""" - return template.format(version=version or "", arch=ARCH, arch_go=_ARCH_GO[ARCH]) + """Interpolate {version}, {arch}, {arch_go}, {os}, {os_go}, {os_zig}, {os_nvim}.""" + return template.format( + version=version or "", + arch=ARCH, + arch_go=_ARCH_GO[ARCH], + os=OS, + os_go=_OS_GO[OS], + os_zig=_OS_ZIG[OS], + os_nvim=_OS_NVIM[OS], + ) def _arch_matches(name: str, arch: str = ARCH) -> bool: n = name.lower() @@ -140,6 +174,12 @@ def _has_other_arch_token(name: str) -> bool: def check_sudo() -> None: if os.geteuid() == 0: + if IS_MACOS: + sys.exit( + "Do not run this script with sudo on macOS — Homebrew refuses " + "to run as root. Re-run as your regular user; the script will " + "request sudo for the specific operations that need it." + ) return if not has_cmd("sudo"): sys.exit("sudo is required but not installed.") @@ -148,13 +188,70 @@ def check_sudo() -> None: if result.returncode != 0: sys.exit("sudo authentication failed.") +# ── macOS prerequisites: Xcode CLT + Homebrew ───────────────────────────────── + +def ensure_xcode_clt() -> None: + """Install the Xcode Command Line Tools if missing. macOS only.""" + if not IS_MACOS: + return + result = subprocess.run(["xcode-select", "-p"], capture_output=True, check=False) + if result.returncode == 0: + print(f"[Xcode CLT] Already installed at {result.stdout.decode().strip()}") + return + print("[Xcode CLT] Installing Xcode Command Line Tools ...") + print(" A GUI dialog will appear — click 'Install' to proceed.") + subprocess.run(["xcode-select", "--install"], check=False) + print(" Waiting for installation to complete ...") + while subprocess.run(["xcode-select", "-p"], capture_output=True).returncode != 0: + time.sleep(5) + print("[Xcode CLT] Installation complete.") + + +def _brew_prefix() -> str: + """Standard Homebrew prefix for the current architecture.""" + return "/opt/homebrew" if ARCH == "aarch64" else "/usr/local" + + +def ensure_homebrew() -> None: + """Install Homebrew if missing and prime PATH for this process. macOS only.""" + if not IS_MACOS: + return + if has_cmd("brew"): + print(f"[Homebrew] Already installed at {shutil.which('brew')}") + return + print("[Homebrew] Installing Homebrew ...") + installer = ( + 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL ' + 'https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' + ) + if shell(installer, check=False).returncode != 0: + sys.exit("Homebrew installation failed") + + brew_bin_dir = Path(_brew_prefix()) / "bin" + brew_path = brew_bin_dir / "brew" + if not brew_path.exists(): + sys.exit(f"Homebrew installed but brew not found at {brew_path}") + + os.environ["PATH"] = f"{brew_bin_dir}:{os.environ.get('PATH', '')}" + + shellenv_line = f'eval "$({brew_path} shellenv)"' + run(["bash", "-c", + f"grep -qxF {shellenv_line!r} /etc/zprofile 2>/dev/null || " + f"echo {shellenv_line!r} >> /etc/zprofile"], + as_sudo=True, check=False) + print(f"[Homebrew] Installed at {_brew_prefix()}; added shellenv to /etc/zprofile") + # ── package manager detection ───────────────────────────────────────────────── def detect_pkg_mgr() -> str: + if IS_MACOS: + # brew may not be installed yet — ensure_homebrew() runs before any + # call that actually invokes brew. + return "brew" for mgr in ("dnf", "apt-get"): if has_cmd(mgr): return mgr - sys.exit("No supported package manager found (expected dnf or apt-get).") + sys.exit("No supported package manager found (expected dnf, apt-get, or brew on macOS).") PKG_MGR = detect_pkg_mgr() @@ -215,6 +312,16 @@ def is_system_pkg_installed(pkg: str) -> bool: capture_output=True, text=True, check=False, ) return "install ok installed" in result.stdout + elif PKG_MGR == "brew": + if not has_cmd("brew"): + return False + if subprocess.run(["brew", "list", "--formula", pkg], + capture_output=True, check=False).returncode == 0: + return True + if subprocess.run(["brew", "list", "--cask", pkg], + capture_output=True, check=False).returncode == 0: + return True + return False return False def is_flatpak_installed(app_id: str) -> bool: @@ -250,6 +357,63 @@ _OVERRIDES: dict[str, dict[str, Optional[list[str]]]] = { "ffmpeg-free": ["ffmpeg"], # Fedora-specific name "rg": ["ripgrep"], }, + "brew": { + # Provided by Xcode CLT or the OS — no-op on macOS. + "build-essential": None, + "gcc": None, # `gcc` from brew is real GCC; clang from CLT suffices + "make": None, + "patch": None, + "zsh": None, # built-in + "ansible-core": None, # bundled with `ansible` + # Docker on macOS ships as Docker Desktop (cask); the Linux package + # split into containerd/buildx/cli/etc. doesn't apply. + "containerd.io": None, + "docker-buildx-plugin": None, + "docker-ce-cli": None, + "docker-ce-rootless-extras": None, + "docker-ce": ["docker"], + "docker-compose-plugin": ["docker-compose"], + # Name fixups. + "dotnet-sdk-10.0": ["dotnet"], + "ffmpeg-free": ["ffmpeg"], + "github-desktop": ["github"], + "google-chrome-stable": ["google-chrome"], + "obs-studio": ["obs"], + "rg": ["ripgrep"], + "temurin-25-jdk": ["temurin"], + "vivaldi-stable": ["vivaldi"], + # Linux-only apps. + "shutter": None, + "virt-manager": None, + "webcamoid": None, + # Python build deps — macOS SDK / brew formulas already bundle headers. + "bzip2-devel": None, + "gdbm-libs": ["gdbm"], + "libffi-devel": ["libffi"], + "libnsl2": None, + "libuuid-devel": None, + "libzstd-devel": ["zstd"], + "openssl-devel": ["openssl@3"], + "readline-devel": ["readline"], + "sqlite-devel": None, + "tk-devel": ["tcl-tk"], + "xz-devel": None, + "zlib-devel": None, + }, +} + +# Brew packages that must be installed via `brew install --cask` rather than +# as formulae. After _OVERRIDES are applied, these are the resolved names. +_BREW_CASKS: set[str] = { + "docker", + "github", + "google-chrome", + "obs", + "obsidian", + "temurin", + "vagrant", + "vivaldi", + "zoom", } def resolve_system_pkgs(names: list[str]) -> tuple[list[str], list[str]]: @@ -421,7 +585,10 @@ _REPO_GROUPS: list[tuple[set[str], callable]] = [ # ── special package installers ──────────────────────────────────────────────── -_SPECIAL_PKGS = {"github-desktop", "zoom", "obsidian", "minikube", "bashtop", "pipx", "poetry"} +_SPECIAL_PKGS: set[str] = ( + set() if IS_MACOS + else {"github-desktop", "zoom", "obsidian", "minikube", "bashtop", "pipx", "poetry"} +) # GUI apps — skipped when --no-gui is passed (headless environments). _GUI_SYSTEM_PKGS = { @@ -556,12 +723,7 @@ def _install_bashtop(_tmp: Path) -> None: err("bashtop 'make install' failed") return # Also expose the clone dir on PATH so `bashtop` from source works. - profile_line = f"export PATH=$PATH:{clone_dir}" - profile_script = "/etc/profile.d/bashtop.sh" - run(["bash", "-c", - f"grep -qxF {profile_line!r} {profile_script} 2>/dev/null || " - f"echo {profile_line!r} >> {profile_script}"], - as_sudo=True, check=False) + _append_profile_line("bashtop", f"export PATH=$PATH:{clone_dir}") print(f" bashtop installed. Clone at {clone_dir}, binary at /usr/local/bin/bashtop") @@ -604,6 +766,18 @@ def install_special_pkg(pkg: str, tmp: Path) -> None: def install_system_packages(to_install_regular: list[str], to_install_special: list[str]) -> None: print("\n=== System Packages ===") + if PKG_MGR == "brew": + for pkg in to_install_regular: + if pkg in _BREW_CASKS: + cmd = ["brew", "install", "--cask", pkg] + else: + cmd = ["brew", "install", pkg] + result = run(cmd, check=False) + if result.returncode != 0: + err(f"System package failed to install: {pkg}") + # No special packages on macOS — brew covers all of them. + return + seen_repos: set[int] = set() for pkg in to_install_regular: for idx, (members, setup_fn) in enumerate(_REPO_GROUPS): @@ -685,10 +859,23 @@ _DEFAULT_INSTALL_PATHS: dict[str, Path] = { "zig": Path("/usr/local/bin/zig"), "nvm": Path("~/.nvm"), "pyenv": Path("~/.pyenv"), - "neovim": Path(f"/opt/nvim-linux-{_ARCH_NVIM[ARCH]}"), + "neovim": Path(f"/opt/nvim-{_OS_NVIM[OS]}-{_ARCH_NVIM[ARCH]}"), "oh-my-zsh": Path("~/.oh-my-zsh"), } + +def _append_profile_line(script_name: str, line: str) -> None: + """Append a PATH/env line to a system-wide login-shell profile, idempotently. + + On Linux we drop a dedicated file under /etc/profile.d/; macOS has no such + directory, so we append to /etc/zprofile (sourced by every zsh login shell). + """ + target = "/etc/zprofile" if IS_MACOS else f"/etc/profile.d/{script_name}.sh" + run(["bash", "-c", + f"grep -qxF {line!r} {target} 2>/dev/null || " + f"echo {line!r} >> {target}"], + as_sudo=True, check=False) + def _default_install_path(pkg: CustomPackage) -> Optional[Path]: return _DEFAULT_INSTALL_PATHS.get(pkg.name.lower()) @@ -773,12 +960,7 @@ def _install_go(archive: Path) -> None: print(f" Removing existing Go at {go_root} ...") run(["rm", "-rf", str(go_root)], as_sudo=True) run(["tar", "-C", "/usr/local", "-xzf", str(archive)], as_sudo=True) - profile_line = "export PATH=$PATH:/usr/local/go/bin" - profile_script = "/etc/profile.d/local_go.sh" - run(["bash", "-c", - f"grep -qxF {profile_line!r} {profile_script} 2>/dev/null || " - f"echo {profile_line!r} >> {profile_script}"], - as_sudo=True, check=False) + _append_profile_line("local_go", "export PATH=$PATH:/usr/local/go/bin") print(f" Go installed to {go_root}") @@ -805,7 +987,7 @@ def _install_zig(pkg: CustomPackage, archive: Path, tmp: Path) -> None: if zig_dir.exists(): run(["rm", "-rf", str(zig_dir)], as_sudo=True) run(["tar", "-C", str(parent), "-xJf", str(archive)], as_sudo=True) - extracted = next(parent.glob(f"zig-{ARCH}-linux*"), None) + extracted = next(parent.glob(f"zig-{ARCH}-{_OS_ZIG[OS]}*"), None) if extracted and extracted != zig_dir: run(["mv", str(extracted), str(zig_dir)], as_sudo=True) symlink = Path("/usr/local/bin/zig") @@ -906,7 +1088,8 @@ def _install_neovim(pkg: CustomPackage, tmp: Path) -> None: return arch_token = _ARCH_NVIM[ARCH] - asset_name = f"nvim-linux-{arch_token}.tar.gz" + os_token = _OS_NVIM[OS] + asset_name = f"nvim-{os_token}-{arch_token}.tar.gz" asset = next((a for a in data["assets"] if a["name"] == asset_name), None) if asset is None: err(f"Neovim asset {asset_name} not found") @@ -928,17 +1111,13 @@ def _install_neovim(pkg: CustomPackage, tmp: Path) -> None: return print(" SHA256 OK") - install_dir = f"/opt/nvim-linux-{arch_token}" + install_dir = f"/opt/nvim-{os_token}-{arch_token}" print(f" Extracting Neovim to /opt ...") + run(["mkdir", "-p", "/opt"], as_sudo=True, check=False) run(["rm", "-rf", install_dir], as_sudo=True) run(["tar", "-C", "/opt", "-xzf", str(dest)], as_sudo=True) - profile_line = f'export PATH="$PATH:{install_dir}/bin"' - profile_script = "/etc/profile.d/neovim.sh" - run(["bash", "-c", - f"grep -qxF {profile_line!r} {profile_script} 2>/dev/null || " - f"echo {profile_line!r} >> {profile_script}"], - as_sudo=True, check=False) + _append_profile_line("neovim", f'export PATH="$PATH:{install_dir}/bin"') print(f" Neovim installed to {install_dir}") @@ -1147,7 +1326,7 @@ def _resolve_latest_go(pkg: CustomPackage) -> Optional[tuple[str, str]]: version = raw_version[2:] if raw_version.startswith("go") else raw_version if not version: return None - archive_name = f"go{version}.linux-{_ARCH_GO[ARCH]}.tar.gz" + archive_name = f"go{version}.{_OS_GO[OS]}-{_ARCH_GO[ARCH]}.tar.gz" entry = next( (f for f in latest.get("files", []) if f.get("filename") == archive_name and f.get("kind") == "archive"), @@ -1159,6 +1338,8 @@ def _resolve_latest_go(pkg: CustomPackage) -> Optional[tuple[str, str]]: def _resolve_latest_firecracker(pkg: CustomPackage) -> Optional[tuple[str, str]]: + if IS_MACOS: + return None # firecracker is Linux-only; install_custom_packages skips it data = _fetch_json( "https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest" ) @@ -1189,7 +1370,7 @@ def _resolve_latest_zig(_pkg: CustomPackage) -> Optional[tuple[str, str]]: return None stable.sort(key=lambda v: tuple(int(x) for x in v.split("."))) version = stable[-1] - entry = data[version].get(f"{ARCH}-linux") + entry = data[version].get(f"{ARCH}-{_OS_ZIG[OS]}") if not entry or "shasum" not in entry: return None return version, entry["shasum"] @@ -1237,6 +1418,9 @@ def install_custom_packages(to_install: list[CustomPackage]) -> None: print("\n=== Custom Packages ===") for pkg in to_install: name_lower = pkg.name.lower() + if name_lower == "firecracker" and IS_MACOS: + warn("firecracker is Linux-only — skipping on macOS") + continue _, check_path = is_custom_pkg_installed(pkg) print(f"\n Installing {pkg.display_name} ..." + (f" (install path: {check_path})" if check_path else "")) @@ -1456,10 +1640,18 @@ def main() -> None: system_pkgs, flatpak_pkgs, custom_pkgs = load_packages() + print(f"OS: {OS}") print(f"Architecture: {ARCH}") print(f"Package manager: {PKG_MGR}") if args.no_gui: print("Mode: headless (--no-gui) — skipping GUI apps and Flatpak") + + if IS_MACOS: + # Refuse to run as root before doing anything (brew won't run as root). + check_sudo() + ensure_xcode_clt() + ensure_homebrew() + print("Checking installed packages ...") if args.no_gui: @@ -1469,9 +1661,14 @@ def main() -> None: print(f" [NO-GUI] Skipping GUI system packages: {_fmt(skipped_gui)}") flatpak_pkgs = [] - # --no-gui suppresses the Flatpak section entirely (both `flatpak` itself - # and the Flathub apps), even when --only=flatpak is requested. - do_flatpak = args.only in (None, "flatpak") and not args.no_gui + # Flatpak is Linux-only — macOS has no Flatpak section regardless of flags. + # --no-gui also suppresses the Flatpak section entirely (both `flatpak` + # itself and the Flathub apps), even when --only=flatpak is requested. + do_flatpak = ( + args.only in (None, "flatpak") + and not args.no_gui + and not IS_MACOS + ) sys_c = check_system_packages(system_pkgs) if args.only in (None, "system") else {} flat_c = check_flatpak_packages(flatpak_pkgs) if do_flatpak else {} diff --git a/formatted_packages.py b/formatted_packages.py index 3d47dec..5864718 100644 --- a/formatted_packages.py +++ b/formatted_packages.py @@ -10,6 +10,10 @@ URL templates use ``str.format`` with the following substitutions: {version} pkg.version (or the latest resolved version) {arch} "x86_64" or "aarch64" {arch_go} Go-style: "amd64" or "arm64" + {os} "linux" or "macos" + {os_go} Go-style: "linux" or "darwin" + {os_zig} Zig-style: "linux" or "macos" + {os_nvim} Neovim-style: "linux" or "macos" """ SYSTEM_PACKAGES: list[str] = [ @@ -84,7 +88,7 @@ CUSTOM_PACKAGES: list[dict] = [ { "name": "go", "version": "1.26.3", - "url_template": "https://go.dev/dl/go{version}.linux-{arch_go}.tar.gz", + "url_template": "https://go.dev/dl/go{version}.{os_go}-{arch_go}.tar.gz", "sha256": "2b2cfc7148493da5e73981bffbf3353af381d5f93e789c82c79aff64962eb556", "fetch_latest": "go", }, @@ -102,9 +106,9 @@ CUSTOM_PACKAGES: list[dict] = [ { "name": "zig", "version": "0.16.0", - "url_template": "https://ziglang.org/download/{version}/zig-{arch}-linux-{version}.tar.xz", + "url_template": "https://ziglang.org/download/{version}/zig-{arch}-{os_zig}-{version}.tar.xz", "sha256_url_template": ( - "https://ziglang.org/download/{version}/zig-{arch}-linux-{version}.tar.xz.minisig" + "https://ziglang.org/download/{version}/zig-{arch}-{os_zig}-{version}.tar.xz.minisig" ), "minisign_key": "RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U", "fetch_latest": "zig",