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",