From 658460fe4c93b26f322efffddc693708045fcdfc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 22 May 2026 21:48:57 +0000 Subject: [PATCH] port: rewrite bootstrap tool in Go Replace the Python bootstrap script with a Go implementation that cross-compiles to native binaries for Linux and macOS on x86_64 and aarch64. The Go port preserves all sections of the original (system packages, optional Flatpak GUI apps, custom downloads, and the macOS firecracker VM bridge) and adds first-class pacman support so the tool works on Arch-family distros alongside Debian, RHEL, and macOS. A Makefile produces a host binary via `make build` and the full four-target matrix under dist/ via `make build-all`. --- .gitignore | 5 +- Makefile | 44 + README.md | 48 +- bootstrap_environment.py | 2551 -------------------------------------- check.go | 177 +++ custom.go | 509 ++++++++ detect.go | 174 +++ exec.go | 181 +++ flatpak.go | 33 + formatted_packages.py | 135 -- go.mod | 3 + issues.go | 74 ++ macos.go | 659 ++++++++++ main.go | 187 +++ net.go | 116 ++ packages.go | 140 +++ pkgmgr.go | 233 ++++ post.go | 457 +++++++ repos.go | 234 ++++ system.go | 444 +++++++ 20 files changed, 3711 insertions(+), 2693 deletions(-) create mode 100644 Makefile delete mode 100755 bootstrap_environment.py create mode 100644 check.go create mode 100644 custom.go create mode 100644 detect.go create mode 100644 exec.go create mode 100644 flatpak.go delete mode 100644 formatted_packages.py create mode 100644 go.mod create mode 100644 issues.go create mode 100644 macos.go create mode 100644 main.go create mode 100644 net.go create mode 100644 packages.go create mode 100644 pkgmgr.go create mode 100644 post.go create mode 100644 repos.go create mode 100644 system.go diff --git a/.gitignore b/.gitignore index c18dd8d..b3f4cae 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -__pycache__/ +dist/ +bootstrap_dev_env +bootstrap_environment +bootstrap_run.log diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..93d0fbc --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +BINARY := bootstrap_environment +DIST := dist +PKG := . + +# Statically-linked, stripped binaries for distribution. +GOFLAGS := -trimpath -ldflags="-s -w" + +TARGETS := \ + linux/amd64 \ + linux/arm64 \ + darwin/amd64 \ + darwin/arm64 + +.PHONY: all build build-all test vet fmt clean + +all: build + +build: + go build $(GOFLAGS) -o $(BINARY) $(PKG) + +# Cross-compile native binaries for each supported (OS, arch) pair into dist/. +build-all: $(DIST) + @for t in $(TARGETS); do \ + os=$${t%/*}; arch=$${t#*/}; \ + out=$(DIST)/$(BINARY)-$$os-$$arch; \ + echo "==> $$os/$$arch -> $$out"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch \ + go build $(GOFLAGS) -o $$out $(PKG) || exit 1; \ + done + +$(DIST): + mkdir -p $(DIST) + +test: + go test ./... + +vet: + go vet ./... + +fmt: + gofmt -w . + +clean: + rm -rf $(DIST) $(BINARY) diff --git a/README.md b/README.md index d3e8d58..18cf7df 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,44 @@ -# Usage +# bootstrap_dev_env -``` shell -curl -L -o bootstrap.zip https://github.com/JMR-dev/bootstrap_dev_env/archive/refs/heads/main.zip && \ -unzip bootstrap.zip && \ -cd bootstrap_dev_env-main && \ -python3 bootstrap_environment.py +A Go-based bootstrap tool that installs a development environment across +macOS, Debian/Ubuntu, RHEL/Fedora, and Arch Linux on `x86_64` and `aarch64`. +It installs system packages, optional Flatpak GUI apps, and a set of custom +third-party tools (Go, Neovim, Zig, NVM, pyenv, oh-my-zsh, Firecracker on +Linux). + +## Install + +Download the native binary for your platform from a release, or build from +source: + +```shell +git clone https://github.com/JMR-dev/bootstrap_dev_env.git +cd bootstrap_dev_env +make build # builds ./bootstrap_environment for the host ``` + +To produce native binaries for all four supported targets at once: + +```shell +make build-all # writes dist/bootstrap_environment-{linux,darwin}-{amd64,arm64} +``` + +## Usage + +```shell +# Linux (do NOT use sudo on macOS — Homebrew refuses to run as root) +sudo ./bootstrap_environment [--only system|flatpak|custom] [--gui] + +# macOS +./bootstrap_environment [--only system|custom] [--gui] [--no-vm] +``` + +Flags: + +- `--only` — restrict to one section (`system`, `flatpak`, or `custom`). +- `--gui` — include GUI applications and the Flatpak section. Default is + headless: both are skipped. +- `--no-vm` — macOS only: skip provisioning the Fedora-on-QEMU/VirtualBox VM + that backs the `firecracker()` zsh wrapper. + +Package lists live in `packages.go`. Edit and rebuild. diff --git a/bootstrap_environment.py b/bootstrap_environment.py deleted file mode 100755 index ac89c8a..0000000 --- a/bootstrap_environment.py +++ /dev/null @@ -1,2551 +0,0 @@ -#!/usr/bin/env python3 -""" -Bootstrap packages declared in formatted_packages.py. - -Sections handled: - System Packages — installed via dnf, apt-get, or brew (macOS) - Flatpak Packages — installed via flatpak from Flathub (Linux only; - skipped by default and skipped entirely on macOS; use --gui to enable) - Custom Packages — downloaded, verified, extracted - macOS firecracker VM — provisions a Fedora cloud image under a - hypervisor that supports nested virtualization, - installs firecracker inside it, and adds a - `firecracker()` wrapper to ~/.zshrc that proxies - invocations via SSH. Backend is picked automatically: - • Apple Silicon M3+ / macOS 15+: QEMU/HVF (el2=on) - • Intel Mac: VirtualBox (nested VT-x) - • Apple Silicon M1/M2: skipped (no local - nested-virt option) - Suppress with --no-vm. - -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: - Linux: sudo python3 bootstrap_environment.py [--only system|flatpak|custom] [--gui] - macOS: python3 bootstrap_environment.py [--only system|custom] [--gui] [--no-vm] - (do NOT use sudo on macOS — Homebrew refuses to run as root) -""" - -import argparse -import datetime -import getpass -import hashlib -import json -import os -import platform -import re -import shutil -import subprocess -import sys -import tarfile -import tempfile -import threading -import time -import urllib.error -import urllib.request -from dataclasses import dataclass -from pathlib import Path -from typing import Optional, Union - -import formatted_packages - -SCRIPT_DIR = Path(__file__).parent -RUN_LOG = SCRIPT_DIR / "bootstrap_run.log" - -# ── issue log ───────────────────────────────────────────────────────────────── - -_issues: list[str] = [] -_issues_lock = threading.Lock() - -def _log_issue(level: str, msg: str) -> None: - with _issues_lock: - print(f" [{level}] {msg}") - _issues.append(f"[{level}] {msg}") - -def warn(msg: str) -> None: - _log_issue("WARN", msg) - -def err(msg: str) -> None: - _log_issue("ERROR", msg) - -def write_run_log() -> None: - if not _issues: - print("\nNo issues — log file not written.") - return - timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - lines = [f"# Bootstrap run — {timestamp}", ""] + _issues - RUN_LOG.write_text("\n".join(lines) + "\n") - print(f"\n{len(_issues)} issue(s) logged to: {RUN_LOG}") - -_notices: list[str] = [] - -def notice(msg: str) -> None: - _notices.append(msg) - -def print_notices() -> None: - if not _notices: - return - print("\nNotices:") - for n in _notices: - print(f" • {n}") - -# ── subprocess helpers ──────────────────────────────────────────────────────── - -# Default per-call cap for run()/shell(). Generous enough for heavy installs -# (apt, brew, large downloads) but bounded so a stuck command can't hang the -# bootstrap forever. Override per-call for genuinely longer operations -# (e.g. pyenv compiles). -DEFAULT_SUBPROCESS_TIMEOUT: float = 1800 - -def run( - cmd: list, - *, - as_sudo: bool = False, - check: bool = True, - input: Optional[bytes] = None, - capture_output: bool = False, - cwd: Optional[str] = None, - timeout: Optional[float] = DEFAULT_SUBPROCESS_TIMEOUT, -) -> subprocess.CompletedProcess: - if as_sudo and os.geteuid() != 0: - cmd = ["sudo"] + cmd - print(f" $ {' '.join(str(c) for c in cmd)}") - try: - return subprocess.run( - cmd, check=check, input=input, - capture_output=capture_output, cwd=cwd, timeout=timeout, - ) - except subprocess.TimeoutExpired as exc: - warn(f"{cmd[0]!r} timed out after {exc.timeout}s") - if check: - raise - return subprocess.CompletedProcess(cmd, returncode=124) - except OSError as exc: - if check: - raise - warn(f"OSError launching {cmd[0]!r}: {exc}") - return subprocess.CompletedProcess(cmd, returncode=1) - -def shell( - cmd: str, - *, - check: bool = True, - capture_output: bool = False, - text: bool = False, - timeout: Optional[float] = DEFAULT_SUBPROCESS_TIMEOUT, -) -> subprocess.CompletedProcess: - print(f" $ {cmd}") - try: - return subprocess.run( - cmd, shell=True, check=check, - capture_output=capture_output, text=text, timeout=timeout, - ) - except subprocess.TimeoutExpired as exc: - warn(f"shell command timed out after {exc.timeout}s") - if check: - raise - return subprocess.CompletedProcess(cmd, returncode=124) - except OSError as exc: - if check: - raise - warn(f"OSError in shell command: {exc}") - return subprocess.CompletedProcess(cmd, returncode=1) - -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. -_ARCH_TOKENS: dict[str, tuple[str, ...]] = { - "x86_64": ("x86_64", "amd64", "x64"), - "aarch64": ("aarch64", "arm64"), -} - -def detect_arch() -> str: - m = platform.machine().lower() - if m in ("x86_64", "amd64"): - return "x86_64" - if m in ("aarch64", "arm64"): - return "aarch64" - sys.exit(f"Unsupported architecture: {platform.machine()} (supports x86_64, aarch64)") - -ARCH = detect_arch() - -# Per-arch substitutions used by repo / download URL construction. -_ARCH_GO = {"x86_64": "amd64", "aarch64": "arm64"} -_ARCH_MINIKUBE = {"x86_64": "amd64", "aarch64": "arm64"} -_ARCH_DEB = {"x86_64": "amd64", "aarch64": "arm64"} -_ARCH_NVIM = {"x86_64": "x86_64", "aarch64": "arm64"} -_ARCH_PULUMI = {"x86_64": "x64", "aarch64": "arm64"} - -def _url_format(template: str, version: Optional[str]) -> str: - """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() - return any(tok in n for tok in _ARCH_TOKENS[arch]) - -def _other_arch() -> str: - return "aarch64" if ARCH == "x86_64" else "x86_64" - -def _has_other_arch_token(name: str) -> bool: - n = name.lower() - return any(tok in n for tok in _ARCH_TOKENS[_other_arch()]) - -# ── sudo prereq ─────────────────────────────────────────────────────────────── - -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.") - print("Validating sudo access ...") - try: - result = subprocess.run(["sudo", "-v"], check=False, timeout=120) - except subprocess.TimeoutExpired: - sys.exit("sudo authentication timed out.") - 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, timeout=10, - ) - 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, timeout=30) - print(" Waiting for installation to complete ...") - while subprocess.run( - ["xcode-select", "-p"], capture_output=True, timeout=10, - ).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, apt-get, or brew on macOS).") - -PKG_MGR = detect_pkg_mgr() - - -def _os_release_field(field: str) -> str: - """Return the value of a field from /etc/os-release (unquoted), or ''.""" - try: - data = Path("/etc/os-release").read_text() - except OSError: - return "" - for line in data.splitlines(): - if line.startswith(field + "="): - _, _, val = line.partition("=") - return val.strip().strip('"') - return "" - -def _is_rhel_family() -> bool: - """True for RHEL-derived distros (Fedora, RHEL, CentOS, Rocky, Alma, ...).""" - try: - data = Path("/etc/os-release").read_text() - except OSError: - return PKG_MGR == "dnf" - tokens: list[str] = [] - for line in data.splitlines(): - if line.startswith(("ID=", "ID_LIKE=")): - _, _, val = line.partition("=") - tokens.extend(val.strip().strip('"').split()) - return any(t in {"rhel", "fedora", "centos", "rocky", "almalinux"} for t in tokens) - -IS_RHEL_FAMILY = _is_rhel_family() - -# ── network helpers ─────────────────────────────────────────────────────────── - -def _download(url: str, dest: Path) -> bool: - """Stream URL → dest. Returns True on success, False on failure (logged).""" - print(f" Downloading {Path(url).name} ...") - try: - with urllib.request.urlopen(url) as resp, open(dest, "wb") as f: - shutil.copyfileobj(resp, f, length=1 << 20) - except (urllib.error.URLError, OSError) as e: - err(f"Download failed for {url}: {e}") - return False - return True - -def _fetch_json(url: str) -> Optional[dict]: - req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) - try: - with urllib.request.urlopen(req) as resp: - return json.load(resp) - except (urllib.error.URLError, OSError, json.JSONDecodeError) as e: - err(f"API request failed for {url}: {e}") - return None - -def _fetch_text(url: str) -> Optional[str]: - try: - with urllib.request.urlopen(url) as resp: - return resp.read().decode().strip() - except (urllib.error.URLError, OSError) as e: - err(f"Fetch failed for {url}: {e}") - return None - -# ── installation checks ─────────────────────────────────────────────────────── - -def _probe(cmd: list, *, timeout: float = 30) -> Optional[subprocess.CompletedProcess]: - """Run a short read-only probe. Returns None on timeout or launch failure.""" - try: - return subprocess.run( - cmd, capture_output=True, text=True, check=False, timeout=timeout, - ) - except (subprocess.TimeoutExpired, OSError) as exc: - warn(f"{cmd[0]!r} probe failed: {exc}") - return None - - -def is_system_pkg_installed(pkg: str) -> bool: - if PKG_MGR == "dnf": - r = _probe(["rpm", "-q", pkg]) - return r is not None and r.returncode == 0 - elif PKG_MGR == "apt-get": - r = _probe(["dpkg-query", "-W", "-f=${Status}", pkg]) - return r is not None and "install ok installed" in r.stdout - elif PKG_MGR == "brew": - if not has_cmd("brew"): - return False - for kind in ("--formula", "--cask"): - r = _probe(["brew", "list", kind, pkg], timeout=60) - if r is not None and r.returncode == 0: - return True - return False - return False - -def is_flatpak_installed(app_id: str) -> bool: - if not has_cmd("flatpak"): - return False - r = _probe(["flatpak", "info", app_id]) - return r is not None and r.returncode == 0 - -def is_special_pkg_installed(pkg: str) -> bool: - if pkg == "obsidian": - return Path("/usr/local/bin/obsidian").exists() - if pkg == "minikube": - return Path("/usr/local/bin/minikube").exists() or has_cmd("minikube") - if pkg == "bashtop": - return Path("/usr/local/bin/bashtop").exists() or (Path.home() / "bashtop").exists() - if pkg == "pulumi": - return Path("/opt/pulumi/pulumi").exists() or has_cmd("pulumi") - if pkg == "pipx": - return has_cmd("pipx") - if pkg == "poetry": - return has_cmd("poetry") - return is_system_pkg_installed(pkg) - -# ── package name overrides ──────────────────────────────────────────────────── -# Maps distro-specific or unavailable names to their real equivalents. -# None = skip with a warning. - -_OVERRIDES: dict[str, dict[str, Optional[list[str]]]] = { - "dnf": { - "build-essential": ["gcc", "gcc-c++", "make"], # Debian meta-package - "rg": ["ripgrep"], # binary name ≠ package name - "docker-compose": None, # conflicts with docker-compose-plugin from Docker CE; v2 covers this - "webcamoid": None, # not in Fedora repos; installed via Flatpak instead - }, - "apt-get": { - "ffmpeg-free": ["ffmpeg"], # Fedora-specific name - "lua": ["lua5.4"], # Debian ships versioned packages only - "qemu": ["qemu-system"], # Debian meta-package name - "rg": ["ripgrep"], - # Python build deps — Fedora/RHEL naming differs from Debian/Ubuntu - "bzip2-devel": ["libbz2-dev"], - "gdbm-libs": ["libgdbm-dev"], - "libffi-devel": ["libffi-dev"], - "libnsl2": ["libnsl-dev"], # Debian package name - "libuuid-devel": ["uuid-dev"], - "libxml2-devel": ["libxml2-dev"], - "libzstd-devel": ["libzstd-dev"], - "ncurses-devel": ["libncursesw5-dev"], - "openssl-devel": ["libssl-dev"], - "readline-devel": ["libreadline-dev"], - "sqlite": ["sqlite3"], - "sqlite-devel": ["libsqlite3-dev"], - "tk-devel": ["tk-dev"], - "xmlsec1-devel": ["libxmlsec1-dev"], - "xz": ["xz-utils"], - "xz-devel": ["liblzma-dev"], - "zlib-devel": ["zlib1g-dev"], - }, - "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, - "curl": None, # built-in on macOS - "gdbm-libs": ["gdbm"], - "libffi-devel": ["libffi"], - "libnsl2": None, - "libuuid-devel": None, - "libxml2-devel": ["libxml2"], - "libzstd-devel": ["zstd"], - "ncurses-devel": None, # provided by macOS SDK - "openssl-devel": ["openssl@3"], - "readline-devel": ["readline"], - "sqlite-devel": None, - "tk-devel": ["tcl-tk"], - "xmlsec1-devel": ["libxmlsec1"], - "xz": ["xz"], - "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]]: - """Return (resolved_names, skipped_names) after applying distro overrides.""" - overrides = _OVERRIDES.get(PKG_MGR, {}) - resolved, skipped = [], [] - for pkg in names: - if pkg in overrides: - replacement = overrides[pkg] - if replacement is None: - skipped.append(pkg) - else: - resolved.extend(replacement) - else: - resolved.append(pkg) - return resolved, skipped - -# ── repo setup ──────────────────────────────────────────────────────────────── - -def _repo_file_exists(*paths: str) -> bool: - return any(Path(p).exists() for p in paths) - -def _write_dnf_repo(name: str, display_name: str, baseurl: str, gpgkey: str) -> None: - content = ( - f"[{name}]\n" - f"name={display_name}\n" - f"baseurl={baseurl}\n" - f"enabled=1\ngpgcheck=1\n" - f"gpgkey={gpgkey}\n" - ) - path = f"/etc/yum.repos.d/{name}.repo" - run(["tee", path], as_sudo=True, input=content.encode(), - capture_output=True, check=True) - -def setup_docker_repo() -> None: - if PKG_MGR == "dnf": - if _repo_file_exists("/etc/yum.repos.d/docker-ce.repo"): - return - run(["dnf", "config-manager", "addrepo", "--from-repofile", - "https://download.docker.com/linux/fedora/docker-ce.repo"], as_sudo=True) - elif PKG_MGR == "apt-get": - if _repo_file_exists("/etc/apt/sources.list.d/docker.list"): - return - run(["apt-get", "update"], as_sudo=True) - run(["apt-get", "install", "-y", "ca-certificates", "curl", "gnupg"], as_sudo=True) - distro_id = _os_release_field("ID") - docker_distro = distro_id if distro_id in {"debian", "ubuntu"} else "ubuntu" - shell( - "install -m 0755 -d /etc/apt/keyrings && " - f"curl -fsSL https://download.docker.com/linux/{docker_distro}/gpg | " - "sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg && " - "sudo chmod a+r /etc/apt/keyrings/docker.gpg" - ) - codename = shell( - ". /etc/os-release && echo $VERSION_CODENAME", - capture_output=True, text=True, - ).stdout.strip() - deb_arch = _ARCH_DEB[ARCH] - run( - ["tee", "/etc/apt/sources.list.d/docker.list"], - as_sudo=True, - input=( - f"deb [arch={deb_arch} signed-by=/etc/apt/keyrings/docker.gpg] " - f"https://download.docker.com/linux/{docker_distro} {codename} stable\n" - ).encode(), - capture_output=True, check=True, - ) - run(["apt-get", "update"], as_sudo=True) - -def setup_gh_repo() -> None: - if PKG_MGR == "dnf": - if _repo_file_exists("/etc/yum.repos.d/gh-cli.repo"): - return - run(["dnf", "config-manager", "addrepo", "--from-repofile", - "https://cli.github.com/packages/rpm/gh-cli.repo"], as_sudo=True) - elif PKG_MGR == "apt-get": - if _repo_file_exists("/etc/apt/sources.list.d/github-cli.list"): - return - deb_arch = _ARCH_DEB[ARCH] - shell( - "curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | " - "sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && " - "sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg && " - f"echo 'deb [arch={deb_arch} signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] " - "https://cli.github.com/packages stable main' | " - "sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null" - ) - run(["apt-get", "update"], as_sudo=True) - -def setup_chrome_repo() -> None: - if ARCH != "x86_64": - warn("Google Chrome has no Linux build for this arch — skipping repo") - return - if PKG_MGR == "dnf": - if _repo_file_exists("/etc/yum.repos.d/google-chrome.repo"): - return - _write_dnf_repo( - "google-chrome", "Google Chrome", - "https://dl.google.com/linux/chrome/rpm/stable/x86_64", - "https://dl.google.com/linux/linux_signing_key.pub", - ) - elif PKG_MGR == "apt-get": - if _repo_file_exists("/etc/apt/sources.list.d/google-chrome.list"): - return - shell( - "curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | " - "sudo gpg --dearmor -o /etc/apt/keyrings/google-chrome.gpg && " - "echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.gpg] " - "https://dl.google.com/linux/chrome/deb/ stable main' | " - "sudo tee /etc/apt/sources.list.d/google-chrome.list > /dev/null && " - "sudo apt-get update" - ) - -def setup_vivaldi_repo() -> None: - if ARCH != "x86_64": - warn("Vivaldi repo on this arch is not supported by this script — skipping") - return - if PKG_MGR == "dnf": - if _repo_file_exists("/etc/yum.repos.d/vivaldi.repo"): - return - _write_dnf_repo( - "vivaldi", "Vivaldi", - "https://repo.vivaldi.com/archive/rpm/x86_64", - "https://repo.vivaldi.com/archive/linux_signing_key.pub", - ) - elif PKG_MGR == "apt-get": - if _repo_file_exists("/etc/apt/sources.list.d/vivaldi.list"): - return - shell( - "curl -fsSL https://repo.vivaldi.com/archive/linux_signing_key.pub | " - "sudo gpg --dearmor -o /etc/apt/keyrings/vivaldi.gpg && " - "echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/vivaldi.gpg] " - "https://repo.vivaldi.com/archive/deb/ stable main' | " - "sudo tee /etc/apt/sources.list.d/vivaldi.list > /dev/null && " - "sudo apt-get update" - ) - -def setup_temurin_repo() -> None: - # Adoptium uses $basearch in baseurl → multi-arch. - if PKG_MGR == "dnf": - if _repo_file_exists("/etc/yum.repos.d/adoptium.repo"): - return - _write_dnf_repo( - "Adoptium", "Adoptium", - "https://packages.adoptium.net/artifactory/rpm/fedora/$releasever/$basearch", - "https://packages.adoptium.net/artifactory/api/gpg/key/public", - ) - elif PKG_MGR == "apt-get": - if _repo_file_exists("/etc/apt/sources.list.d/adoptium.list"): - return - shell( - "wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | " - "sudo gpg --dearmor | sudo tee /etc/apt/keyrings/adoptium.gpg > /dev/null && " - "echo \"deb [signed-by=/etc/apt/keyrings/adoptium.gpg] " - "https://packages.adoptium.net/artifactory/deb/ " - "$(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main\" | " - "sudo tee /etc/apt/sources.list.d/adoptium.list > /dev/null && " - "sudo apt-get update" - ) - -def setup_dotnet_repo() -> None: - # .NET is in Fedora repos directly — no extra repo needed. - if PKG_MGR != "apt-get": - return - if _repo_file_exists( - "/etc/apt/sources.list.d/microsoft-prod.list", - "/etc/apt/sources.list.d/dotnet.list", - ): - return - distro_id = _os_release_field("ID").strip('"') - version_id = _os_release_field("VERSION_ID").strip('"') - deb_url = ( - f"https://packages.microsoft.com/config/{distro_id}/{version_id}" - "/packages-microsoft-prod.deb" - ) - shell( - f"curl -fsSL {deb_url} -o /tmp/packages-microsoft-prod.deb && " - "sudo dpkg -i /tmp/packages-microsoft-prod.deb && " - "sudo apt-get update" - ) - -_REPO_GROUPS: list[tuple[set[str], callable]] = [ - ( - {"containerd.io", "docker-buildx-plugin", "docker-ce-cli", - "docker-ce-rootless-extras", "docker-ce", "docker-compose-plugin"}, - setup_docker_repo, - ), - ({"gh"}, setup_gh_repo), - ({"google-chrome-stable"}, setup_chrome_repo), - ({"vivaldi-stable"}, setup_vivaldi_repo), - ({"temurin-25-jdk"}, setup_temurin_repo), - ({"dotnet-sdk-10.0"}, setup_dotnet_repo), -] - -# ── special package installers ──────────────────────────────────────────────── - -_SPECIAL_PKGS: set[str] = ( - set() if IS_MACOS - else {"github-desktop", "zoom", "obsidian", "minikube", "bashtop", "pipx", "poetry", "pulumi"} -) - -# GUI apps — skipped by default (headless); included only when --gui is passed. -_GUI_SYSTEM_PKGS = { - "github-desktop", - "google-chrome-stable", - "obs-studio", - "obsidian", - "shutter", - "virt-manager", - "vivaldi-stable", - "webcamoid", - "wireshark", - "zoom", -} - - -def _install_github_desktop(tmp: Path) -> None: - data = _fetch_json("https://api.github.com/repos/shiftkey/desktop/releases/latest") - if data is None: - return - suffix, host_tokens, exclude_tokens = ( - (".rpm", _ARCH_TOKENS[ARCH], _ARCH_TOKENS[_other_arch()]) - if PKG_MGR == "dnf" - else (".deb", _ARCH_TOKENS[ARCH], _ARCH_TOKENS[_other_arch()]) - ) - - def matches(name: str) -> bool: - n = name.lower() - if not n.endswith(suffix): - return False - if not any(t in n for t in host_tokens): - return False - if any(t in n for t in exclude_tokens if t not in host_tokens): - return False - return True - - asset = next((a for a in data["assets"] if matches(a["name"])), None) - if asset is None: - err(f"No GitHub Desktop {suffix} asset found for {ARCH}") - return - dest = tmp / asset["name"] - if not _download(asset["browser_download_url"], dest): - return - installer = "dnf" if PKG_MGR == "dnf" else "apt-get" - run([installer, "install", "-y", str(dest)], as_sudo=True, check=False) - - -def _install_zoom(tmp: Path) -> None: - if ARCH != "x86_64": - warn("Zoom has no aarch64 Linux client — skipping") - return - if PKG_MGR == "dnf": - dest = tmp / "zoom.rpm" - if not _download("https://zoom.us/client/latest/zoom_x86_64.rpm", dest): - return - run(["dnf", "install", "-y", str(dest)], as_sudo=True, check=False) - elif PKG_MGR == "apt-get": - dest = tmp / "zoom.deb" - if not _download("https://zoom.us/client/latest/zoom_amd64.deb", dest): - return - run(["apt-get", "install", "-y", str(dest)], as_sudo=True, check=False) - - -def _install_obsidian(tmp: Path) -> None: - data = _fetch_json("https://api.github.com/repos/obsidianmd/obsidian-releases/releases/latest") - if data is None: - return - host_tokens = _ARCH_TOKENS[ARCH] - other_tokens = _ARCH_TOKENS[_other_arch()] - - def matches(name: str) -> bool: - n = name.lower() - if not n.endswith(".appimage"): - return False - if not any(t in n for t in host_tokens): - return False - if any(t in n for t in other_tokens if t not in host_tokens): - return False - return True - - asset = next((a for a in data["assets"] if matches(a["name"])), None) - if asset is None: - err(f"No Obsidian AppImage found for {ARCH}") - return - dest = tmp / asset["name"] - if not _download(asset["browser_download_url"], dest): - return - install_path = Path("/usr/local/bin/obsidian") - run(["cp", str(dest), str(install_path)], as_sudo=True) - run(["chmod", "755", str(install_path)], as_sudo=True) - print(f" Obsidian AppImage installed at {install_path}") - - -def _install_minikube(tmp: Path) -> None: - arch_token = _ARCH_MINIKUBE[ARCH] - base_url = f"https://storage.googleapis.com/minikube/releases/latest/minikube-linux-{arch_token}" - dest = tmp / "minikube" - if not _download(base_url, dest): - return - print(" Fetching SHA256 ...") - try: - with urllib.request.urlopen(base_url + ".sha256") as resp: - expected = resp.read().decode().strip().split()[0] - except (urllib.error.URLError, OSError) as e: - err(f"minikube SHA256 fetch failed: {e}") - return - actual = _sha256_of(dest) - if actual != expected: - err(f"minikube SHA256 mismatch: expected {expected}, got {actual}") - return - print(" SHA256 OK") - install_path = Path("/usr/local/bin/minikube") - run(["cp", str(dest), str(install_path)], as_sudo=True) - run(["chmod", "755", str(install_path)], as_sudo=True) - print(f" minikube installed to {install_path}") - - -def _install_bashtop(_tmp: Path) -> None: - clone_dir = Path.home() / "bashtop" - if clone_dir.exists(): - print(f" Updating existing clone at {clone_dir} ...") - if run(["git", "-C", str(clone_dir), "pull"], check=False).returncode != 0: - err("bashtop git pull failed") - return - else: - print(f" Cloning bashtop to {clone_dir} ...") - if run(["git", "clone", "https://github.com/aristocratos/bashtop.git", - str(clone_dir)], check=False).returncode != 0: - err("bashtop git clone failed") - return - if run(["make", "install"], as_sudo=True, cwd=str(clone_dir), check=False).returncode != 0: - err("bashtop 'make install' failed") - return - # Also expose the clone dir on PATH so `bashtop` from source works. - _append_profile_line("bashtop", f"export PATH=$PATH:{clone_dir}") - print(f" bashtop installed. Clone at {clone_dir}, binary at /usr/local/bin/bashtop") - - -def _install_pulumi(tmp: Path) -> None: - version = _fetch_text("https://www.pulumi.com/latest-version") - if not version: - err("Could not determine latest Pulumi version") - return - os_token = _OS_GO[OS] - arch_token = _ARCH_PULUMI[ARCH] - tarball = f"pulumi-v{version}-{os_token}-{arch_token}.tar.gz" - base = f"https://github.com/pulumi/pulumi/releases/download/v{version}" - dest = tmp / tarball - if not _download(f"{base}/{tarball}", dest): - return - - checksums = _fetch_text(f"{base}/pulumi-{version}-checksums.txt") - if not checksums: - err("Could not fetch Pulumi checksums") - return - expected = next( - (line.split()[0] for line in checksums.splitlines() if line.endswith(tarball)), - None, - ) - if not expected: - err(f"No checksum entry for {tarball}") - return - actual = _sha256_of(dest) - if actual != expected: - err(f"Pulumi SHA256 mismatch: expected {expected}, got {actual}") - return - print(" SHA256 OK") - - install_dir = "/opt/pulumi" - print(f" Extracting Pulumi 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) - - _append_profile_line("pulumi", f'export PATH="$PATH:{install_dir}"') - print(f" Pulumi {version} installed to {install_dir}") - - -def _install_pipx(_tmp: Path) -> None: - if not has_cmd("python3"): - err("Python 3 is not installed — cannot install pipx") - return - run([PKG_MGR, "install", "-y", "pipx"], as_sudo=True, check=False) - if has_cmd("pipx"): - run(["pipx", "ensurepath"], check=False) - else: - err("pipx command not found after install") - - -def _install_poetry(_tmp: Path) -> None: - if not has_cmd("pipx"): - err("pipx is not installed — cannot install poetry") - return - run(["pipx", "install", "poetry"], check=False) - - -def install_special_pkg(pkg: str, tmp: Path) -> None: - if pkg == "github-desktop": - _install_github_desktop(tmp) - elif pkg == "zoom": - _install_zoom(tmp) - elif pkg == "obsidian": - _install_obsidian(tmp) - elif pkg == "minikube": - _install_minikube(tmp) - elif pkg == "bashtop": - _install_bashtop(tmp) - elif pkg == "pulumi": - _install_pulumi(tmp) - elif pkg == "pipx": - _install_pipx(tmp) - elif pkg == "poetry": - _install_poetry(tmp) - -# ── system package installation ─────────────────────────────────────────────── - -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): - if pkg in members and idx not in seen_repos: - print(f" [REPO] Setting up repository for {pkg} ...") - setup_fn() - seen_repos.add(idx) - - for pkg in to_install_regular: - result = run([PKG_MGR, "install", "-y", pkg], as_sudo=True, check=False) - if result.returncode != 0: - err(f"System package failed to install: {pkg}") - - if to_install_special: - with tempfile.TemporaryDirectory() as tmp: - for pkg in to_install_special: - print(f"\n [SPECIAL] Installing {pkg} ...") - install_special_pkg(pkg, Path(tmp)) - -# ── flatpak package installation ────────────────────────────────────────────── - -def install_flatpak_packages(to_install: list[str]) -> None: - print("\n=== Flatpak Packages ===") - - if not has_cmd("flatpak"): - print(" flatpak is not installed.") - if not _yn(" Install flatpak now? [y/N] "): - warn("flatpak not installed — skipping Flatpak section") - return - result = run([PKG_MGR, "install", "-y", "flatpak"], as_sudo=True, check=False) - if result.returncode != 0 or not has_cmd("flatpak"): - err("flatpak installation failed — skipping Flatpak section") - return - - run( - ["flatpak", "remote-add", "--if-not-exists", "flathub", - "https://dl.flathub.org/repo/flathub.flatpakrepo"], - as_sudo=True, check=False, - ) - - for pkg_id in to_install: - print(f"\n Installing {pkg_id} ...") - result = run(["flatpak", "install", "--noninteractive", "flathub", pkg_id], check=False) - if result.returncode != 0: - err(f"Flatpak failed to install: {pkg_id}") - -# ── custom package installation ─────────────────────────────────────────────── - -@dataclass -class CustomPackage: - name: str - version: Optional[str] = None # pinned fallback version - url_template: Optional[str] = None # uses {version}, {arch}, {arch_go} - sha256: Optional[str] = None # single-arch hex digest (set by _resolve_latest) - sha256_map: Optional[dict] = None # per-platform pinned digests: {"os-arch": hex} - sha256_url_template: Optional[str] = None # template for a .minisig URL - minisign_key: Optional[str] = None # base64 public key for minisign verification - fetch_latest: Optional[str] = None # latest-version resolver hint - install_path: Optional[str] = None # override the default install-check path - - @property - def url(self) -> Optional[str]: - return _url_format(self.url_template, self.version) if self.url_template else None - - @property - def sha256_url(self) -> Optional[str]: - return ( - _url_format(self.sha256_url_template, self.version) - if self.sha256_url_template else None - ) - - @property - def resolved_sha256(self) -> Optional[str]: - """Return the SHA256 hex for the current OS+arch, lowercased. - - sha256 (set dynamically by _resolve_latest) takes priority over sha256_map - so that a freshly fetched checksum always wins over the pinned fallback. - """ - if self.sha256: - return self.sha256.lower() - if self.sha256_map: - key = f"{OS}-{ARCH}" - val = self.sha256_map.get(key) - if val: - return val.lower() - return None - - @property - def display_name(self) -> str: - return f"{self.name}-{self.version}" if self.version else self.name - - -_DEFAULT_INSTALL_PATHS: dict[str, Path] = { - "go": Path("/usr/local/go"), - "firecracker": Path("/usr/local/bin/firecracker"), - "zig": Path("/usr/local/bin/zig"), - "nvm": Path("~/.nvm"), - "pyenv": Path("~/.pyenv"), - "neovim": Path("/usr/local/bin/nvim"), - "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()) - - -def _python3_decimal_ok() -> bool: - """Return True if Python 3's _decimal C extension loads without error.""" - if not has_cmd("python3"): - return False - try: - r = subprocess.run( - ["python3", "-c", "from decimal import Decimal"], - capture_output=True, check=False, timeout=10, - ) - return r.returncode == 0 - except subprocess.TimeoutExpired: - return False - - -def _fix_python3_decimal() -> bool: - """Install missing Python C-extension packages to fix a broken _decimal import. - - On Debian/Ubuntu, python3.X ships without its C extensions when only the - base package is installed; python3-full (or the versioned equivalent) pulls - in _decimal, _hashlib, etc. On RPM distros python3-libs covers this. - Returns True if the extension works after the attempt. - """ - if PKG_MGR == "apt-get": - # python3-full is the meta-package that pulls in all C extensions for - # the default python3 on Debian/Ubuntu (including _decimal via libmpdec). - run(["apt-get", "install", "-y", "python3-full"], as_sudo=True, check=False) - elif PKG_MGR == "dnf": - run(["dnf", "install", "-y", "python3-libs"], as_sudo=True, check=False) - return _python3_decimal_ok() - - -def _pip_installed() -> bool: - if not has_cmd("python3"): - return False - try: - return subprocess.run( - ["python3", "-m", "pip", "--version"], - capture_output=True, check=False, timeout=10, - ).returncode == 0 - except subprocess.TimeoutExpired: - warn("pip detection timed out — treating as not installed") - return False - - -def is_custom_pkg_installed(pkg: CustomPackage) -> tuple[bool, Optional[Path]]: - """Return (is_installed, check_path). - - For most custom packages the check is a filesystem path. ``pip`` is the - exception: it ships inside a Python distribution rather than at a known - path, so it's detected by running ``python3 -m pip --version``. - """ - if pkg.name.lower() == "pip": - return _pip_installed(), None - raw = Path(pkg.install_path) if pkg.install_path else _default_install_path(pkg) - if raw is None: - return False, None - check = raw.expanduser() - return check.exists(), check - - -def _sha256_of(path: Path) -> str: - h = hashlib.sha256() - with open(path, "rb") as f: - for chunk in iter(lambda: f.read(1 << 20), b""): - h.update(chunk) - return h.hexdigest() - - -def _verify(archive: Path, pkg: CustomPackage) -> bool: - """Returns True if verification passed (or nothing to verify), False on failure.""" - expected = pkg.resolved_sha256 - if expected: - actual = _sha256_of(archive) - if actual != expected: - err(f"SHA256 mismatch for {pkg.name}: expected {expected}, got {actual}") - return False - print(" SHA256 OK") - elif pkg.sha256_url: - sig_path = archive.parent / Path(pkg.sha256_url).name - if not _download(pkg.sha256_url, sig_path): - return False - if has_cmd("minisign"): - cmd = ["minisign", "-Vm", str(archive), "-x", str(sig_path)] - if pkg.minisign_key: - cmd += ["-P", pkg.minisign_key] - result = run(cmd, check=False) - if result.returncode != 0: - err(f"minisign verification failed for {pkg.name}") - return False - print(" minisign OK") - else: - warn(f"minisign not installed — skipping signature verification for {pkg.name}") - return True - - -def _url_arch_ok(pkg: CustomPackage) -> bool: - """If the URL clearly targets a different arch than the host, warn and return False.""" - if not pkg.url: - return True - if _arch_matches(pkg.url): - return True - if _has_other_arch_token(pkg.url): - warn( - f"{pkg.name}: URL targets {_other_arch()} but host is {ARCH}. " - f"Update formatted_packages.txt with a matching URL/SHA256." - ) - return False - return True # ambiguous — let it proceed - - -def _install_go(archive: Path) -> None: - go_root = Path("/usr/local/go") - if go_root.exists(): - 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) - _append_profile_line("local_go", "export PATH=$PATH:/usr/local/go/bin") - print(f" Go installed to {go_root}") - - -def _install_firecracker(archive: Path, tmp: Path) -> None: - with tarfile.open(archive) as tf: - tf.extractall(tmp, filter="data") - binary = next( - (p for p in tmp.rglob("firecracker*") - if p.is_file() and not p.suffix == ".debug" and "debug" not in p.name), - None, - ) - if binary is None: - err("firecracker binary not found in archive") - return - dest = Path("/usr/local/bin/firecracker") - run(["cp", str(binary), str(dest)], as_sudo=True) - run(["chmod", "755", str(dest)], as_sudo=True) - print(f" firecracker installed to {dest}") - - -def _install_zig(pkg: CustomPackage, archive: Path, tmp: Path) -> None: - parent = Path("/usr/local") - zig_dir = parent / f"zig-{pkg.version}" - 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}-{_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") - run(["ln", "-sf", str(zig_dir / "zig"), str(symlink)], as_sudo=True) - print(f" Zig installed to {zig_dir}, symlinked at {symlink}") - - -def _install_pyenv() -> None: - print(" Installing pyenv via curl ...") - if shell("curl https://pyenv.run | bash", check=False).returncode != 0: - err("pyenv installation failed") - return - print(" pyenv installed to ~/.pyenv") - - -def _install_pip() -> None: - """Install pip via Python's bundled ``ensurepip`` module, then self-upgrade. - - Unlike the other custom packages, pip ships inside CPython itself and is - bootstrapped from the wheel in the standard library rather than downloaded. - Debian intentionally disables ensurepip in the system Python package, so we - fall back to the distro's python3-pip package before giving up. - """ - if not has_cmd("python3"): - err("python3 is not installed — cannot install pip") - return - - # Guard against a broken _decimal C extension (e.g. Python 3.13 on Ubuntu/Debian - # when python3-full is not installed). Any pip invocation will immediately crash - # with RuntimeError if this module is missing, so fix it before proceeding. - if not _python3_decimal_ok(): - warn("Python 3 _decimal C extension failed to import — attempting fix ...") - if _fix_python3_decimal(): - print(" Python 3 _decimal extension restored.") - else: - err( - "Python 3 _decimal C extension could not be fixed. " - "Run: sudo apt-get install python3-full (Debian/Ubuntu) " - "or: sudo dnf install python3-libs (Fedora/RHEL)" - ) - return - - print(" Bootstrapping pip via 'python3 -m ensurepip --upgrade' ...") - bootstrap = run( - ["python3", "-m", "ensurepip", "--upgrade"], - as_sudo=True, check=False, - ) - if bootstrap.returncode != 0: - if PKG_MGR == "apt-get": - warn("ensurepip unavailable in system Python — installing python3-pip via apt-get") - apt_result = run(["apt-get", "install", "-y", "python3-pip"], as_sudo=True, check=False) - if apt_result.returncode != 0: - err("python3-pip failed to install via apt-get — skipping pip bootstrap") - return - else: - err("python3 -m ensurepip failed (system Python may need a distro 'python3-pip' package)") - return - print(" Upgrading pip to the latest version ...") - upgrade = run( - ["python3", "-m", "pip", "install", "--upgrade", "pip"], - as_sudo=True, check=False, - ) - if upgrade.returncode != 0: - warn("pip self-upgrade failed (likely PEP 668 externally-managed); " - "ensurepip-provided pip remains") - - -def _install_oh_my_zsh() -> None: - """Install oh-my-zsh via its official installer and force ZSH_THEME=gnzh.""" - if not has_cmd("zsh"): - err("zsh is not installed — required by oh-my-zsh") - return - if not has_cmd("git"): - err("git is not installed — required by oh-my-zsh") - return - - target = Path.home() / ".oh-my-zsh" - if target.exists(): - print(f" oh-my-zsh already present at {target}; updating theme only") - else: - print(" Installing oh-my-zsh via the official installer ...") - installer = ( - 'sh -c "$(curl -fsSL ' - 'https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" ' - '"" --unattended' - ) - if shell(installer, check=False).returncode != 0: - err("oh-my-zsh installer failed") - return - - zshrc = Path.home() / ".zshrc" - if not zshrc.exists(): - warn("~/.zshrc not present after oh-my-zsh install; cannot set theme") - return - - text = zshrc.read_text() - new_text, replaced = re.subn(r'^\s*ZSH_THEME=.*$', 'ZSH_THEME="gnzh"', text, flags=re.M) - if replaced == 0: - new_text = text.rstrip() + '\nZSH_THEME="gnzh"\n' - if new_text != text: - zshrc.write_text(new_text) - print(' Set ZSH_THEME="gnzh" in ~/.zshrc') - else: - print(' ~/.zshrc already has ZSH_THEME="gnzh"') - - -def _install_nvm() -> None: - data = _fetch_json("https://api.github.com/repos/nvm-sh/nvm/releases/latest") - if data is None: - return - version = data["tag_name"] - install_url = f"https://raw.githubusercontent.com/nvm-sh/nvm/{version}/install.sh" - print(f" Installing NVM {version} via curl ...") - if shell(f"curl -o- {install_url} | bash", check=False).returncode != 0: - err("NVM installation failed") - return - print(f" NVM {version} installed to ~/.nvm") - - -def _install_neovim(pkg: CustomPackage, tmp: Path) -> None: - data = _fetch_json("https://api.github.com/repos/neovim/neovim/releases/latest") - if data is None: - return - - arch_token = _ARCH_NVIM[ARCH] - 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") - return - - expected_digest = asset.get("digest") - if not expected_digest or not expected_digest.startswith("sha256:"): - err("Neovim asset digest missing or invalid") - return - expected_hash = expected_digest.split(":", 1)[1] - - dest = tmp / asset_name - if not _download(asset["browser_download_url"], dest): - return - - actual_hash = _sha256_of(dest) - if actual_hash != expected_hash: - err(f"Neovim SHA256 mismatch: expected {expected_hash}, got {actual_hash}") - return - print(" SHA256 OK") - - 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) - - # Symlink into /usr/local/bin so `nvim` is on PATH for every shell type - # (login, interactive, scripts) without relying on /etc/profile.d, which - # is only sourced by login shells — terminal emulators typically launch - # non-login interactive shells. - run(["mkdir", "-p", "/usr/local/bin"], as_sudo=True, check=False) - symlink = "/usr/local/bin/nvim" - run(["ln", "-sf", f"{install_dir}/bin/nvim", symlink], as_sudo=True) - print(f" Neovim installed to {install_dir}, symlinked at {symlink}") - - -def _clone_nvim_config() -> None: - config_dir = Path.home() / ".config" / "nvim" - repo_url = "git@github.com:JMR-dev/nvim-config.git" - - print(f"\n[Neovim] Setting up configuration from {repo_url} ...") - - if config_dir.exists(): - n = 1 - while (backup := config_dir.with_name(f"nvim-{n}")).exists(): - n += 1 - print(f" Renaming existing {config_dir} → {backup} ...") - config_dir.rename(backup) - notice(f"Previous Neovim config preserved at {backup}") - - config_dir.parent.mkdir(parents=True, exist_ok=True) - - repo_name = repo_url.split("/")[-1].removesuffix(".git") - temp_clone = config_dir.parent / repo_name - if temp_clone.exists(): - shutil.rmtree(temp_clone) - - print(f" Cloning to {config_dir} ...") - result = run(["git", "clone", repo_url, str(temp_clone)], check=False) - if result.returncode != 0: - err("Neovim configuration clone failed") - return - - if temp_clone != config_dir: - print(f" Renaming {temp_clone.name} to {config_dir.name} ...") - temp_clone.rename(config_dir) - print(f" Neovim configuration ready at {config_dir}") - - -def _invoking_user() -> str: - """User whose login shell / home we should target. - - When the script is run via sudo, SUDO_USER is the original invoker; - otherwise the current process user is correct. - """ - return os.environ.get("SUDO_USER") or getpass.getuser() - - -def ensure_zsh_default() -> None: - """Make zsh the default login shell for the invoking user. - - Uses ``usermod -s`` on RHEL-family distros and ``chsh -s`` elsewhere — - on Debian/Ubuntu ``chsh`` is the canonical (and PAM-permitted) path, - while on RHEL/Fedora ``chsh`` for another user often fails under the - default authselect config and ``usermod`` is the reliable alternative. - """ - if not has_cmd("zsh"): - warn("zsh not installed — skipping default-shell change") - return - - zsh_path = shutil.which("zsh") or "/bin/zsh" - user = _invoking_user() - - import pwd - try: - current = pwd.getpwnam(user).pw_shell - except KeyError: - warn(f"user {user} not found in passwd; skipping default-shell change") - return - - if current == zsh_path: - print(f"\n[zsh] {user}'s default shell is already {zsh_path}.") - return - - family = "RHEL-family" if IS_RHEL_FAMILY else "Debian-family" - print(f"\n[zsh] Setting default shell for {user} to {zsh_path} ({family}) ...") - - if IS_RHEL_FAMILY: - cmd = ["usermod", "-s", zsh_path, user] - else: - cmd = ["chsh", "-s", zsh_path, user] - - if run(cmd, as_sudo=True, check=False).returncode != 0: - err(f"Failed to set default shell to zsh for {user}") - else: - print(f"[zsh] Default shell updated. Log out and back in for it to take effect.") - - -def ensure_node_lts() -> None: - """If nvm is present, ensure Node LTS is installed and set as the default.""" - nvm_dir = Path.home() / ".nvm" - if not nvm_dir.exists(): - return - # nvm version lts/* prints the installed LTS version, or "N/A" if not installed - check = shell( - 'bash -c "source ~/.nvm/nvm.sh 2>/dev/null && nvm version lts/* 2>/dev/null"', - capture_output=True, text=True, check=False, - ) - installed = check.stdout.strip() - if installed and installed != "N/A": - print(f"\n[NVM] Node LTS ({installed}) already installed.") - else: - print("\n[NVM] Installing Node.js LTS ...") - result = shell('bash -c "source ~/.nvm/nvm.sh && nvm install --lts"', check=False) - if result.returncode != 0: - err("Node.js LTS install via nvm failed") - return - print(" Node.js LTS installed.") - - print("[NVM] Setting Node LTS as default ...") - result = shell( - "bash -c \"source ~/.nvm/nvm.sh && nvm alias default 'lts/*' && nvm use --lts\"", - check=False, - ) - if result.returncode != 0: - err("Setting nvm default to LTS failed") - - -def _latest_stable_python(pyenv_bin: Path) -> Optional[str]: - """Return the latest stable CPython 3.x version string from `pyenv install --list`.""" - try: - result = subprocess.run( - [str(pyenv_bin), "install", "--list"], - capture_output=True, text=True, check=False, timeout=120, - ) - except subprocess.TimeoutExpired: - err("pyenv install --list timed out") - return None - if result.returncode != 0: - err("pyenv install --list failed") - return None - # Match only pure X.Y.Z lines — excludes a1/b1/rc1/dev suffixes and PyPy/Anaconda/etc. - stable_re = re.compile(r"^\s*(\d+)\.(\d+)\.(\d+)\s*$") - versions: list[tuple[int, int, int]] = [] - for line in result.stdout.splitlines(): - m = stable_re.match(line) - if m: - major, minor, patch = int(m.group(1)), int(m.group(2)), int(m.group(3)) - if major >= 3: - versions.append((major, minor, patch)) - if not versions: - return None - versions.sort() - return ".".join(str(p) for p in versions[-1]) - - -def ensure_python_latest() -> Optional[threading.Thread]: - """If pyenv is present, ensure the latest stable Python is installed and set as global. - - When a compile is required, runs it in a background thread and returns the - thread handle. The caller must `join()` it before writing the run log so any - install/global failure is captured. Returns None if no compile was needed. - """ - pyenv_dir = Path.home() / ".pyenv" - if not pyenv_dir.exists(): - return None - pyenv_bin = pyenv_dir / "bin" / "pyenv" - if not pyenv_bin.exists(): - warn(f"pyenv binary not found at {pyenv_bin}") - return None - - latest = _latest_stable_python(pyenv_bin) - if latest is None: - err("Could not determine latest stable Python from pyenv") - return None - - try: - installed = subprocess.run( - [str(pyenv_bin), "versions", "--bare"], - capture_output=True, text=True, check=False, timeout=30, - ).stdout.split() - except subprocess.TimeoutExpired: - err("pyenv versions --bare timed out") - return None - - if latest in installed: - # Fast path — no compile needed, just set global synchronously. - print(f"\n[pyenv] Python {latest} already installed.") - print(f"[pyenv] Setting Python {latest} as global default ...") - if run([str(pyenv_bin), "global", latest], check=False).returncode != 0: - err(f"pyenv global {latest} failed") - return None - - print(f"\n[pyenv] Backgrounding install of Python {latest} " - f"(compile may take several minutes; output captured) ...") - start = time.monotonic() - - def _worker(): - install_cmd = [str(pyenv_bin), "install", "--skip-existing", latest] - try: - p1 = subprocess.run( - install_cmd, capture_output=True, text=True, check=False, - timeout=3600, - ) - except subprocess.TimeoutExpired: - elapsed = int(time.monotonic() - start) - err(f"pyenv install {latest} timed out after {elapsed}s") - return - elapsed = int(time.monotonic() - start) - if p1.returncode != 0: - err(f"pyenv install {latest} failed after {elapsed}s") - tail = "\n".join(p1.stderr.splitlines()[-20:]) if p1.stderr else "" - if tail: - print(f"\n[pyenv stderr tail]\n{tail}") - return - try: - p2 = subprocess.run( - [str(pyenv_bin), "global", latest], - capture_output=True, text=True, check=False, timeout=60, - ) - except subprocess.TimeoutExpired: - err(f"pyenv global {latest} timed out") - return - if p2.returncode != 0: - err(f"pyenv global {latest} failed") - return - print(f"\n[pyenv] Python {latest} installed and set as global default ({elapsed}s).") - - t = threading.Thread(target=_worker, daemon=True, name="pyenv-install") - t.start() - return t - - -def _resolve_latest_go(pkg: CustomPackage) -> Optional[tuple[str, str]]: - releases = _fetch_json("https://go.dev/dl/?mode=json") - if not releases: - return None - latest = releases[0] if isinstance(releases, list) else releases - raw_version = latest.get("version", "") - version = raw_version[2:] if raw_version.startswith("go") else raw_version - if not version: - return None - 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"), - None, - ) - if not entry or not entry.get("sha256"): - return None - return version, entry["sha256"] - - -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" - ) - if not data: - return None - version = data.get("tag_name", "").lstrip("v") - if not version: - return None - archive_name = f"firecracker-v{version}-{ARCH}.tgz" - sha_asset = next( - (a for a in data.get("assets", []) if a["name"] == f"{archive_name}.sha256.txt"), - None, - ) - if not sha_asset: - return None - sha = _fetch_text(sha_asset["browser_download_url"]) - if not sha: - return None - return version, sha.split()[0] - - -def _resolve_latest_zig(_pkg: CustomPackage) -> Optional[tuple[str, str]]: - data = _fetch_json("https://ziglang.org/download/index.json") - if not data: - return None - stable = [v for v in data.keys() if v != "master" and re.match(r"^\d+\.\d+\.\d+$", v)] - if not stable: - return None - stable.sort(key=lambda v: tuple(int(x) for x in v.split("."))) - version = stable[-1] - entry = data[version].get(f"{ARCH}-{_OS_ZIG[OS]}") - if not entry or "shasum" not in entry: - return None - return version, entry["shasum"] - - -_LATEST_RESOLVERS = { - "go": _resolve_latest_go, - "firecracker": _resolve_latest_firecracker, - "zig": _resolve_latest_zig, -} - - -def _resolve_latest(pkg: CustomPackage) -> None: - """Best-effort upgrade pkg.version/sha256 to the latest release. - - On any failure, logs a warning and leaves the pinned values in place. - Clears sha256_url_template when sha256 is overridden so the dynamic - digest is what gets verified. - """ - resolver = _LATEST_RESOLVERS.get(pkg.fetch_latest or "") - if resolver is None: - return - print(f" Checking latest version for {pkg.name} ...") - try: - result = resolver(pkg) - except Exception as e: # noqa: BLE001 — best-effort lookup, any failure is logged - warn(f"{pkg.name}: latest-version lookup raised {e!r}; " - f"falling back to pinned version {pkg.version}") - return - if result is None: - warn(f"{pkg.name}: could not resolve latest version; " - f"falling back to pinned version {pkg.version}") - return - latest_version, latest_sha = result - if latest_version == pkg.version: - print(f" Pinned version {pkg.version} is already the latest.") - return - print(f" Latest is {latest_version} (pinned was {pkg.version}); using latest.") - pkg.version = latest_version - pkg.sha256 = latest_sha.lower() - pkg.sha256_url_template = None # prefer the freshly resolved sha256 - - -def install_custom_packages(to_install: list[CustomPackage]) -> None: - print("\n=== Custom Packages ===") - for pkg in to_install: - name_lower = pkg.name.lower() - _, check_path = is_custom_pkg_installed(pkg) - print(f"\n Installing {pkg.display_name} ..." - + (f" (install path: {check_path})" if check_path else "")) - if check_path is None and name_lower != "pip": - warn(f"{pkg.name}: no known install path — script will not detect future installs") - - # Packages that are OS-specific - if name_lower == "firecracker" and IS_MACOS: - warn(f"{pkg.name}: Linux-only — skipping on macOS") - continue - - # Handlers that manage their own download/install - if name_lower == "nvm": - _install_nvm() - continue - if name_lower == "pyenv": - _install_pyenv() - continue - if name_lower == "pip": - _install_pip() - continue - if name_lower == "oh-my-zsh": - _install_oh_my_zsh() - continue - if name_lower == "neovim": - with tempfile.TemporaryDirectory() as tmp_str: - _install_neovim(pkg, Path(tmp_str)) - continue - - _resolve_latest(pkg) - - if not pkg.url: - warn(f"No URL or install handler for '{pkg.name}' — skipping") - continue - - if not _url_arch_ok(pkg): - continue - - with tempfile.TemporaryDirectory() as tmp_str: - tmp = Path(tmp_str) - archive = tmp / Path(pkg.url).name - if not _download(pkg.url, archive): - continue - if not _verify(archive, pkg): - continue - if name_lower == "go": - _install_go(archive) - elif name_lower == "firecracker": - _install_firecracker(archive, tmp) - elif name_lower == "zig": - _install_zig(pkg, archive, tmp) - else: - warn(f"No install handler for '{pkg.name}' — skipping") - -# ── pre-install checks ─────────────────────────────────────────────────────── - -def check_system_packages(names: list[str]) -> dict: - overrides = _OVERRIDES.get(PKG_MGR, {}) - resolved, skipped = resolve_system_pkgs(names) - # Packages remapped to different name(s) (not skipped entirely) - remapped = [(n, overrides[n]) for n in names if n in overrides and overrides[n] is not None] - - special = [p for p in resolved if p in _SPECIAL_PKGS] - regular = [p for p in resolved if p not in _SPECIAL_PKGS] - - to_install_r, already_r = [], [] - for p in regular: - (already_r if is_system_pkg_installed(p) else to_install_r).append(p) - - to_install_s, already_s = [], [] - for p in special: - (already_s if is_special_pkg_installed(p) else to_install_s).append(p) - - return { - "to_install_regular": to_install_r, - "to_install_special": to_install_s, - "already_installed": already_r + already_s, - "skipped": skipped, - "remapped": remapped, - } - - -def check_flatpak_packages(pkg_ids: list[str]) -> dict: - to_install, already = [], [] - for p in pkg_ids: - (already if is_flatpak_installed(p) else to_install).append(p) - return {"to_install": to_install, "already_installed": already} - - -def check_custom_packages(packages: list[CustomPackage]) -> dict: - to_install, already = [], [] - for pkg in packages: - installed, path = is_custom_pkg_installed(pkg) - (already if installed else to_install).append((pkg, path)) - return {"to_install": [p for p, _ in to_install], - "already_installed": already} - - -def _fmt(items: list, limit: int = 6) -> str: - names = [str(i) for i in items] - shown = " ".join(names[:limit]) - return shown + (f" … +{len(names)-limit} more" if len(names) > limit else "") - - -def print_check_summary(sys_c: dict, flat_c: dict, cust_c: dict, only: Optional[str]) -> int: - """Print pre-install summary. Returns total count to install.""" - total = 0 - - if only in (None, "system"): - to_r = sys_c["to_install_regular"] - to_s = sys_c["to_install_special"] - ok = sys_c["already_installed"] - skip = sys_c["skipped"] - remap = sys_c["remapped"] - print("\nSystem packages:") - if ok: - print(f" [OK] {len(ok):3d} already installed") - n = len(to_r) + len(to_s) - if n: - print(f" [INSTALL] {n:3d} to install: {_fmt(to_r + to_s)}") - if skip: - print(f" [SKIP] {len(skip):3d} overridden (→ skip): {_fmt(skip)}") - if remap: - pairs = " ".join(f"{a}→{','.join(b)}" for a, b in remap) - print(f" [REMAP] remapped: {pairs}") - total += n - - if only in (None, "flatpak") and flat_c: - to = flat_c["to_install"] - ok = flat_c["already_installed"] - print("\nFlatpak packages:") - if ok: - print(f" [OK] {len(ok):3d} already installed") - if to: - print(f" [INSTALL] {len(to):3d} to install: {_fmt(to)}") - total += len(to) - - if only in (None, "custom"): - to = cust_c["to_install"] - ok = cust_c["already_installed"] - print("\nCustom packages:") - for pkg, path in ok: - print(f" [OK] {pkg.display_name}" + (f" ({path})" if path else "")) - for pkg in to: - _, path = is_custom_pkg_installed(pkg) - print(f" [INSTALL] {pkg.display_name}" + (f" → {path}" if path else "")) - total += len(to) - - return total - -# ── ssh key + github auth ───────────────────────────────────────────────────── - -def _yn(prompt: str) -> bool: - """Ask a y/N question. Returns True only for 'y'.""" - try: - return input(prompt).strip().lower() == "y" - except (EOFError, KeyboardInterrupt): - print() - return False - - -def _gh_logged_in() -> bool: - try: - return subprocess.run( - ["gh", "auth", "status"], capture_output=True, check=False, timeout=30, - ).returncode == 0 - except subprocess.TimeoutExpired: - warn("gh auth status timed out — treating as not logged in") - return False - - -def _offer_github_upload(pub_keys: list[Path]) -> None: - if not pub_keys: - print(" No public key found to upload.") - return - - pub_key = max(pub_keys, key=lambda p: p.stat().st_mtime) - if not _yn(f"\n Upload {pub_key.name} to your GitHub profile? [y/N] "): - return - - default_title = f"{getpass.getuser()}@{os.uname().nodename}" - try: - title = input(f" Key title [{default_title}]: ").strip() or default_title - except (EOFError, KeyboardInterrupt): - title = default_title - - -def check_and_setup_ssh() -> None: - if not has_cmd("gh"): - print("\n[GitHub CLI] gh not installed — skipping authentication.") - return - - if _gh_logged_in(): - print("\n[GitHub CLI] Already authenticated.") - return - - if not _yn("\n[GitHub CLI] Would you like to authenticate the GitHub CLI? [y/N] "): - return - - try: - result = subprocess.run(["gh", "auth", "login"], check=False, timeout=900) - except subprocess.TimeoutExpired: - err("gh auth login timed out — skipping key upload.") - return - if result.returncode != 0: - err("gh auth login failed — skipping key upload.") - return - -# ── macOS: Fedora VM + firecracker bridge ──────────────────────────────────── -# -# Firecracker is Linux-only (needs KVM). On macOS we provision a Fedora cloud -# VM via QEMU/HVF, install firecracker inside it, and expose a `firecracker` -# zsh function on the host that proxies invocations over SSH into the VM. - -_VM_DIR = Path.home() / ".firecracker-vm" -_VM_SSH_PORT = 2222 -_VM_USER = "fc" -_VM_QCOW2_NAME = "fedora.qcow2" -_VM_SEED_ISO_NAME = "seed.iso" -_VM_PID_NAME = "vm.pid" -_VM_KEY_NAME = "id_ed25519" -_FIRECRACKER_FN_BEGIN = "# >>> firecracker-vm wrapper >>>" -_FIRECRACKER_FN_END = "# <<< firecracker-vm wrapper <<<" - - -def _latest_fedora_cloud_image() -> Optional[tuple[str, str, str]]: - """Return (filename, qcow2_url, checksum_url) for the latest Fedora cloud qcow2. - - Walks the Fedora mirror directory listing from newest release downward, - and returns the first arch-matching qcow2 it finds. - """ - base = "https://dl.fedoraproject.org/pub/fedora/linux/releases/" - listing = _fetch_text(base) - if not listing: - return None - versions = sorted( - {int(m.group(1)) for m in re.finditer(r'href="(\d+)/?"', listing)}, - reverse=True, - ) - for ver in versions: - images_url = f"{base}{ver}/Cloud/{ARCH}/images/" - idx = _fetch_text(images_url) - if not idx: - continue - qcow = re.search( - rf'href="(Fedora-Cloud-Base[A-Za-z0-9_-]*-{ver}-[\d.]+\.{ARCH}\.qcow2)"', - idx, - ) - ck = re.search(r'href="([^"]*CHECKSUM)"', idx) - if not qcow or not ck: - continue - return qcow.group(1), images_url + qcow.group(1), images_url + ck.group(1) - return None - - -def _verify_fedora_qcow2(qcow2: Path, checksum_url: str) -> bool: - text = _fetch_text(checksum_url) - if not text: - return False - expected: Optional[str] = None - for line in text.splitlines(): - m = re.match(rf"SHA256 \({re.escape(qcow2.name)}\) = ([0-9a-fA-F]+)", line) - if m: - expected = m.group(1).lower() - break - if expected is None: - err(f"No SHA256 entry for {qcow2.name} in checksum file") - return False - print(" Verifying SHA256 (this can take a minute) ...") - if _sha256_of(qcow2).lower() != expected: - err(f"Fedora image SHA256 mismatch (got {_sha256_of(qcow2)}, expected {expected})") - return False - print(" SHA256 OK") - return True - - -def _download_fedora_image(qcow2_url: str, dest: Path) -> bool: - """Stream the Fedora qcow2 via curl (progress bar, resumable).""" - if not has_cmd("curl"): - return _download(qcow2_url, dest) - print(f" Downloading {dest.name} ...") - result = run(["curl", "-L", "--fail", "-#", - "-o", str(dest), qcow2_url], check=False) - return result.returncode == 0 - - -_FIRECRACKER_USERDATA = """#cloud-config -hostname: firecracker-vm -users: - - name: {user} - sudo: ALL=(ALL) NOPASSWD:ALL - shell: /bin/bash - ssh_authorized_keys: - - {pubkey} -ssh_pwauth: false -packages: - - curl - - tar - - qemu-kvm -write_files: - - path: /usr/local/sbin/install-firecracker.sh - permissions: '0755' - content: | - #!/usr/bin/env bash - set -euo pipefail - ARCH=$(uname -m) - TAG=$(curl -fsSL https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest \\ - | grep -oE '"tag_name":[[:space:]]*"v[^"]+"' | head -1 \\ - | sed -E 's/.*"v([^"]+)"/\\1/') - cd /tmp - curl -fsSL -o fc.tgz \\ - "https://github.com/firecracker-microvm/firecracker/releases/download/v${{TAG}}/firecracker-v${{TAG}}-${{ARCH}}.tgz" - tar -xzf fc.tgz - BIN=$(find . -maxdepth 3 -type f -name "firecracker-v${{TAG}}-${{ARCH}}" ! -name '*.debug' | head -1) - install -m 0755 "$BIN" /usr/local/bin/firecracker - touch /var/lib/firecracker-ready -runcmd: - - /usr/local/sbin/install-firecracker.sh -""" - - -def _write_cloud_init_seed(seed_dir: Path, pubkey: str) -> None: - seed_dir.mkdir(parents=True, exist_ok=True) - (seed_dir / "user-data").write_text( - _FIRECRACKER_USERDATA.format(user=_VM_USER, pubkey=pubkey.strip()) - ) - (seed_dir / "meta-data").write_text( - "instance-id: firecracker-vm\nlocal-hostname: firecracker-vm\n" - ) - - -def _build_seed_iso(seed_dir: Path, iso_path: Path) -> bool: - if iso_path.exists(): - iso_path.unlink() - result = run( - ["hdiutil", "makehybrid", "-iso", "-joliet", - "-default-volume-name", "cidata", - "-o", str(iso_path), str(seed_dir)], - check=False, - ) - return result.returncode == 0 - - -def _write_qemu_start_script() -> Path: - """Write the QEMU launcher. Used only on Apple Silicon M3+ / macOS 15+, - where HVF exposes nested virtualization via `-cpu host,el2=on`.""" - brew_share = Path(_brew_prefix()) / "share" / "qemu" - script_path = _VM_DIR / "vm-start.sh" - - edk_code = brew_share / "edk2-aarch64-code.fd" - # The brew qemu package ships a generic arm vars template. - edk_vars_template = brew_share / "edk2-arm-vars.fd" - qemu_block = f"""\ -# Ensure a writable NVRAM file exists (UEFI vars persist here). -if [[ ! -f edk2-aarch64-vars.fd ]]; then - if [[ -f "{edk_vars_template}" ]]; then - cp "{edk_vars_template}" edk2-aarch64-vars.fd - else - truncate -s 64M edk2-aarch64-vars.fd - fi -fi - -exec qemu-system-aarch64 \\ - -machine virt,accel=hvf,highmem=on \\ - -cpu host,el2=on \\ - -smp 2 -m 2048 \\ - -drive if=pflash,format=raw,readonly=on,file="{edk_code}" \\ - -drive if=pflash,format=raw,file=edk2-aarch64-vars.fd \\ - -drive file={_VM_QCOW2_NAME},if=virtio,format=qcow2 \\ - -drive file={_VM_SEED_ISO_NAME},format=raw,if=virtio,readonly=on \\ - -display none -serial file:vm.log \\ - -netdev user,id=net0,hostfwd=tcp::{_VM_SSH_PORT}-:22 \\ - -device virtio-net-device,netdev=net0 \\ - -daemonize -pidfile {_VM_PID_NAME} -""" - - script = f"""#!/usr/bin/env bash -# Start the Fedora-on-QEMU VM that backs the host `firecracker` zsh function. -# Nested virt enabled via el2=on (requires Apple M3+ on macOS 15 Sequoia+). -set -euo pipefail -cd "{_VM_DIR}" -if [[ -f {_VM_PID_NAME} ]] && kill -0 "$(cat {_VM_PID_NAME})" 2>/dev/null; then - exit 0 -fi -rm -f {_VM_PID_NAME} -{qemu_block}""" - script_path.write_text(script) - script_path.chmod(0o755) - return script_path - - -def _ssh_to_vm(priv_key: Path, *remote: str, timeout: int = 3) -> subprocess.CompletedProcess: - try: - return subprocess.run( - ["ssh", "-q", - "-i", str(priv_key), - "-p", str(_VM_SSH_PORT), - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", f"ConnectTimeout={timeout}", - "-o", "LogLevel=ERROR", - f"{_VM_USER}@127.0.0.1", *remote], - capture_output=True, check=False, timeout=timeout + 30, - ) - except subprocess.TimeoutExpired: - return subprocess.CompletedProcess(["ssh"], returncode=124, stdout=b"", stderr=b"") - - -def _wait_for_vm_ssh(priv_key: Path, timeout_s: int = 300) -> bool: - print(f" Waiting for VM SSH on port {_VM_SSH_PORT} (up to {timeout_s}s) ...") - deadline = time.monotonic() + timeout_s - while time.monotonic() < deadline: - if _ssh_to_vm(priv_key, "true").returncode == 0: - print(" VM SSH ready.") - return True - time.sleep(5) - return False - - -def _wait_for_firecracker_in_vm(priv_key: Path, timeout_s: int = 900) -> bool: - print(f" Waiting for cloud-init to install firecracker inside the VM " - f"(up to {timeout_s}s) ...") - deadline = time.monotonic() + timeout_s - while time.monotonic() < deadline: - if _ssh_to_vm(priv_key, "test", "-f", "/var/lib/firecracker-ready").returncode == 0: - print(" firecracker is installed inside the VM.") - return True - time.sleep(10) - return False - - -def _firecracker_zsh_function(priv_key: Path) -> str: - return f"""{_FIRECRACKER_FN_BEGIN} -firecracker() {{ - local vm_dir="{_VM_DIR}" - if [[ ! -f "$vm_dir/{_VM_QCOW2_NAME}" ]]; then - echo "firecracker: Fedora VM not provisioned (expected $vm_dir/{_VM_QCOW2_NAME})." >&2 - return 1 - fi - if ! "$vm_dir/vm-start.sh"; then - echo "firecracker: failed to start backing VM (see $vm_dir/vm.log)." >&2 - return 1 - fi - local i - for i in $(seq 1 60); do - ssh -q -i "{priv_key}" -p {_VM_SSH_PORT} \\ - -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \\ - -o ConnectTimeout=2 -o LogLevel=ERROR \\ - {_VM_USER}@127.0.0.1 true && break - sleep 1 - done - local args=() a - for a in "$@"; do args+=("$(printf %q "$a")"); done - ssh -t -q -i "{priv_key}" -p {_VM_SSH_PORT} \\ - -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \\ - -o LogLevel=ERROR \\ - {_VM_USER}@127.0.0.1 "sudo /usr/local/bin/firecracker ${{args[*]}}" -}} -{_FIRECRACKER_FN_END} -""" - - -def _install_firecracker_zsh_function(content: str) -> None: - zshrc = Path.home() / ".zshrc" - existing = zshrc.read_text() if zshrc.exists() else "" - pattern = re.compile( - re.escape(_FIRECRACKER_FN_BEGIN) + r".*?" + re.escape(_FIRECRACKER_FN_END) + r"\n?", - re.DOTALL, - ) - if pattern.search(existing): - new = pattern.sub(content, existing) - else: - new = (existing.rstrip() + "\n\n" if existing else "") + content - zshrc.write_text(new) - print(f" Wrote firecracker() function block to {zshrc}") - - -def _macos_major() -> int: - """Major version of macOS (e.g. 15 for Sequoia), or 0 if unavailable.""" - if not IS_MACOS: - return 0 - try: - v = platform.mac_ver()[0] - return int(v.split(".")[0]) if v else 0 - except (ValueError, IndexError): - return 0 - - -def _apple_silicon_generation() -> Optional[int]: - """Apple Silicon chip generation (1=M1, 2=M2, 3=M3, ...) or None.""" - if not IS_MACOS or ARCH != "aarch64": - return None - try: - brand = subprocess.run( - ["sysctl", "-n", "machdep.cpu.brand_string"], - capture_output=True, text=True, check=False, timeout=10, - ).stdout.strip() - except (OSError, subprocess.TimeoutExpired): - return None - m = re.search(r"Apple M(\d+)", brand) - return int(m.group(1)) if m else None - - -def _select_vm_backend() -> Optional[str]: - """Choose a hypervisor for the firecracker VM. - - Returns "qemu" (Apple Silicon M3+/Sequoia+ with HVF nested virt), - "virtualbox" (Intel Mac with nested VT-x), or None to skip with a - user-facing notice already printed. - """ - if not IS_MACOS: - return None - if ARCH == "x86_64": - print("\n[firecracker VM] Intel Mac — using VirtualBox " - "(supports nested VT-x for in-guest KVM).") - return "virtualbox" - - # Apple Silicon - gen = _apple_silicon_generation() - macos = _macos_major() - if gen is not None and gen >= 3 and macos >= 15: - print(f"\n[firecracker VM] Apple Silicon M{gen} on macOS {macos} — " - f"using QEMU/HVF with nested virtualization (-cpu host,el2=on).") - return "qemu" - - chip = f"Apple M{gen}" if gen else "Apple Silicon" - os_str = f"macOS {macos}" if macos else "this macOS" - print() - print(f"[firecracker VM] Skipping firecracker VM provisioning.") - print(f" Detected {chip} on {os_str}. HVF only exposes nested") - print(f" virtualization on M3+ chips running macOS 15 Sequoia or later,") - print(f" and VirtualBox does not support Apple Silicon hosts, so there") - print(f" is no local hypervisor that can run firecracker microVMs here.") - print(f" To use firecracker, provision a Linux cloud VM (e.g. AWS EC2,") - print(f" GCP) and run firecracker there over SSH.") - return None - - -def _ensure_virtualbox() -> bool: - """Install VirtualBox via brew cask if not present. Returns True if available.""" - if has_cmd("VBoxManage"): - return True - print(" Installing VirtualBox via brew cask ...") - if run(["brew", "install", "--cask", "virtualbox"], check=False).returncode != 0: - err("VirtualBox cask install failed. macOS may require kernel-extension " - "approval in System Settings → Privacy & Security; once approved, " - "re-run this script.") - return False - if not has_cmd("VBoxManage"): - err("VirtualBox installed but VBoxManage not in PATH. " - "macOS may need a reboot or kext approval.") - return False - return True - - -def _provision_virtualbox_vm(qcow2: Path, seed_iso: Path) -> Optional[Path]: - """Create+configure (idempotently) a VirtualBox VM. Returns the start script.""" - if not _ensure_virtualbox(): - return None - - vm_name = "firecracker-vm" - vbox_base = _VM_DIR / "vbox" - vdi = _VM_DIR / "fedora.vdi" - - try: - exists = subprocess.run( - ["VBoxManage", "showvminfo", vm_name], - capture_output=True, check=False, timeout=30, - ).returncode == 0 - except subprocess.TimeoutExpired: - warn("VBoxManage showvminfo timed out — assuming VM does not exist") - exists = False - - if not exists: - if not vdi.exists(): - print(f" Converting {qcow2.name} → {vdi.name} (VirtualBox VDI) ...") - r = run(["VBoxManage", "clonemedium", "disk", - str(qcow2), str(vdi), "--format", "VDI"], check=False) - if r.returncode != 0: - err("VBoxManage clonemedium failed") - return None - # Match the 10G size we use on QEMU. - run(["VBoxManage", "modifymedium", "disk", str(vdi), - "--resize", "10240"], check=False) - - print(f" Creating VirtualBox VM '{vm_name}' ...") - vbox_base.mkdir(parents=True, exist_ok=True) - if run(["VBoxManage", "createvm", - "--name", vm_name, - "--ostype", "Fedora_64", - "--basefolder", str(vbox_base), - "--register"], check=False).returncode != 0: - err("VBoxManage createvm failed") - return None - - # Nested VT-x is the whole point — without it, in-guest KVM (and thus - # firecracker) cannot start microVMs. - run(["VBoxManage", "modifyvm", vm_name, - "--cpus", "2", - "--memory", "2048", - "--nested-hw-virt", "on", - "--nic1", "nat", - "--natpf1", f"ssh,tcp,,{_VM_SSH_PORT},,22"], check=False) - - run(["VBoxManage", "storagectl", vm_name, - "--name", "SATA", "--add", "sata"], check=False) - run(["VBoxManage", "storageattach", vm_name, - "--storagectl", "SATA", - "--port", "0", "--device", "0", "--type", "hdd", - "--medium", str(vdi)], check=False) - - run(["VBoxManage", "storagectl", vm_name, - "--name", "IDE", "--add", "ide"], check=False) - run(["VBoxManage", "storageattach", vm_name, - "--storagectl", "IDE", - "--port", "0", "--device", "0", "--type", "dvddrive", - "--medium", str(seed_iso)], check=False) - else: - print(f" VirtualBox VM '{vm_name}' already registered — reusing.") - - script_path = _VM_DIR / "vm-start.sh" - script_path.write_text(f"""#!/usr/bin/env bash -# Start the VirtualBox-backed Fedora VM that powers the host firecracker() fn. -# Nested VT-x is on so the Linux guest's KVM (and firecracker) can run microVMs. -set -euo pipefail -if VBoxManage list runningvms | grep -q '"{vm_name}"'; then - exit 0 -fi -exec VBoxManage startvm {vm_name} --type headless -""") - script_path.chmod(0o755) - return script_path - - -def setup_firecracker_vm() -> None: - """Provision a Fedora VM (via QEMU or VirtualBox), install firecracker - inside it, and add a firecracker() wrapper to ~/.zshrc. macOS only. - - Backend selection (see _select_vm_backend): - * Apple Silicon M3+ / macOS 15+ → QEMU + HVF with nested virt (el2=on) - * Intel Mac → VirtualBox with nested VT-x - * Apple Silicon M1/M2 or older → skip with cloud-VM notice - """ - if not IS_MACOS: - return - - backend = _select_vm_backend() - if backend is None: - return # notice already printed - - print("\n=== macOS firecracker VM (Fedora) ===") - _VM_DIR.mkdir(parents=True, exist_ok=True) - - priv_key = _VM_DIR / _VM_KEY_NAME - pub_key = priv_key.with_suffix(priv_key.suffix + ".pub") - if not priv_key.exists(): - print(f" Generating SSH keypair at {priv_key} ...") - if run(["ssh-keygen", "-t", "ed25519", "-N", "", - "-f", str(priv_key), "-q"], check=False).returncode != 0: - err("ssh-keygen failed — aborting VM setup") - return - - qcow2 = _VM_DIR / _VM_QCOW2_NAME - if qcow2.exists(): - print(f" Reusing existing Fedora image at {qcow2}") - else: - print(" Looking up latest Fedora cloud image ...") - info = _latest_fedora_cloud_image() - if info is None: - err("Could not resolve latest Fedora cloud image — aborting VM setup") - return - filename, qcow2_url, checksum_url = info - print(f" Latest: {filename}") - download_dest = _VM_DIR / filename - if not _download_fedora_image(qcow2_url, download_dest): - err("Fedora image download failed — aborting VM setup") - return - if not _verify_fedora_qcow2(download_dest, checksum_url): - download_dest.unlink(missing_ok=True) - return - download_dest.rename(qcow2) - if has_cmd("qemu-img"): - print(" Resizing image to 10G ...") - run(["qemu-img", "resize", str(qcow2), "10G"], check=False) - - print(" Building cloud-init seed ISO ...") - seed_dir = _VM_DIR / "seed" - _write_cloud_init_seed(seed_dir, pub_key.read_text()) - seed_iso = _VM_DIR / _VM_SEED_ISO_NAME - if not _build_seed_iso(seed_dir, seed_iso): - err("hdiutil failed to build seed ISO — aborting VM setup") - return - - if backend == "qemu": - if not has_cmd("qemu-system-aarch64"): - err("qemu-system-aarch64 not found — install qemu via brew first.") - return - print(" Writing QEMU start script ...") - start_script = _write_qemu_start_script() - else: - start_script = _provision_virtualbox_vm(qcow2, seed_iso) - if start_script is None: - return - - print(f" Booting VM via {start_script} ...") - if run([str(start_script)], check=False).returncode != 0: - err(f"VM start failed — see {_VM_DIR / 'vm.log'}") - return - - if not _wait_for_vm_ssh(priv_key): - err(f"VM SSH never came up — see {_VM_DIR / 'vm.log'}") - return - - if not _wait_for_firecracker_in_vm(priv_key): - warn("firecracker did not appear in the VM within the timeout; " - "cloud-init may still be running. Check `sudo cloud-init status` " - "inside the VM (ssh -i ~/.firecracker-vm/id_ed25519 -p 2222 " - "fc@127.0.0.1).") - - print(" Installing firecracker() wrapper into ~/.zshrc ...") - _install_firecracker_zsh_function(_firecracker_zsh_function(priv_key)) - - print(f" firecracker VM ready (backend: {backend}).") - print(f" Start manually with: {start_script}") - if backend == "qemu": - print(f" Nested virt is on (el2=on); the guest's KVM can launch " - f"firecracker microVMs.") - else: - print(f" Nested VT-x is on; the guest's KVM can launch firecracker microVMs.") - -# ── packages module loader ──────────────────────────────────────────────────── - -def load_packages() -> tuple[list[str], list[str], list[CustomPackage]]: - system_pkgs = list(formatted_packages.SYSTEM_PACKAGES) - flatpak_pkgs = list(formatted_packages.FLATPAK_PACKAGES) - custom_pkgs = [CustomPackage(**spec) for spec in formatted_packages.CUSTOM_PACKAGES] - return system_pkgs, flatpak_pkgs, custom_pkgs - -# ── entry point ─────────────────────────────────────────────────────────────── - -def main() -> None: - ap = argparse.ArgumentParser( - description="Bootstrap packages declared in formatted_packages.py" - ) - ap.add_argument("--only", choices=["system", "flatpak", "custom"], - help="Install only the named section") - ap.add_argument("--gui", action="store_true", - help="Include GUI applications (headed environments). " - "Adds GUI system packages and enables the Flatpak " - "section. By default GUI apps and Flatpak are skipped.") - ap.add_argument("--no-vm", action="store_true", - help="macOS only: skip provisioning the Fedora-on-QEMU VM that " - "backs the firecracker() zsh wrapper.") - args = ap.parse_args() - - system_pkgs, flatpak_pkgs, custom_pkgs = load_packages() - - if IS_MACOS: - # firecracker is provisioned inside the Fedora VM (see setup_firecracker_vm), - # not on the host. Drop it from the host custom-package list. - custom_pkgs = [p for p in custom_pkgs if p.name.lower() != "firecracker"] - - print(f"OS: {OS}") - print(f"Architecture: {ARCH}") - print(f"Package manager: {PKG_MGR}") - if not args.gui: - print("Mode: headless (default) — 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 not args.gui: - skipped_gui = [p for p in system_pkgs if p in _GUI_SYSTEM_PKGS] - system_pkgs = [p for p in system_pkgs if p not in _GUI_SYSTEM_PKGS] - if skipped_gui: - print(f" [HEADLESS] Skipping GUI system packages: {_fmt(skipped_gui)}") - flatpak_pkgs = [] - - # Flatpak is Linux-only — macOS has no Flatpak section regardless of flags. - # GUI apps and Flatpak are skipped by default; --gui re-enables them. - do_flatpak = ( - args.only in (None, "flatpak") - and args.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 {} - cust_c = check_custom_packages(custom_pkgs) if args.only in (None, "custom") else {} - - total = print_check_summary(sys_c, flat_c, cust_c, args.only) - - if total == 0: - print("\nAll packages already installed.") - write_run_log() - return - - try: - answer = input(f"\n{total} item(s) to install. Proceed? [y/N] ").strip().lower() - except (EOFError, KeyboardInterrupt): - print() - sys.exit("Aborted.") - if answer != "y": - sys.exit("Aborted.") - - check_sudo() - - if args.only in (None, "system"): - install_system_packages(sys_c["to_install_regular"], sys_c["to_install_special"]) - ensure_zsh_default() - - if do_flatpak: - install_flatpak_packages(flat_c["to_install"]) - - pyenv_thread: Optional[threading.Thread] = None - if args.only in (None, "custom"): - install_custom_packages(cust_c["to_install"]) - ensure_node_lts() - pyenv_thread = ensure_python_latest() - - if args.only is None: - check_and_setup_ssh() - _clone_nvim_config() - if IS_MACOS and not args.no_vm: - setup_firecracker_vm() - - if pyenv_thread is not None: - if pyenv_thread.is_alive(): - print("\n[pyenv] Waiting for background Python install to finish ...") - pyenv_thread.join() - - write_run_log() - print_notices() - print("\nDone.") - - # Final step (user-requested): source ~/.zshrc. - # This runs in a subshell, so it only validates the rc file — the user's - # interactive shell is unaffected and they'll need to open a new terminal - # (or 'exec zsh') to pick up the new default shell. - zshrc = Path.home() / ".zshrc" - if has_cmd("zsh") and zshrc.exists(): - print("\nSourcing ~/.zshrc ...") - shell(f"zsh -c 'source {zshrc}'", check=False) - - -if __name__ == "__main__": - main() diff --git a/check.go b/check.go new file mode 100644 index 0000000..904c702 --- /dev/null +++ b/check.go @@ -0,0 +1,177 @@ +package main + +import ( + "fmt" + "strings" +) + +type systemCheckResult struct { + toInstallRegular []string + toInstallSpecial []string + alreadyInstalled []string + skipped []string + remapped []remap // for display only +} + +type remap struct { + From string + To []string +} + +type flatpakCheckResult struct { + toInstall []string + alreadyInstalled []string +} + +type customCheckResult struct { + toInstall []*CustomPackage + alreadyInstalled []customStatus +} + +type customStatus struct { + pkg *CustomPackage + path string +} + +func checkSystemPackages(names []string) systemCheckResult { + overrides := packageOverrides[pkgMgr] + resolved, skipped := resolveSystemPkgs(names) + + var remapped []remap + for _, n := range names { + if ov, ok := overrides[n]; ok && !ov.Skip { + remapped = append(remapped, remap{From: n, To: ov.Replacement}) + } + } + + specials := specialPkgs() + var special, regular []string + for _, p := range resolved { + if specials[p] { + special = append(special, p) + } else { + regular = append(regular, p) + } + } + + var toR, alreadyR []string + for _, p := range regular { + if isSystemPkgInstalled(p) { + alreadyR = append(alreadyR, p) + } else { + toR = append(toR, p) + } + } + var toS, alreadyS []string + for _, p := range special { + if isSpecialPkgInstalled(p) { + alreadyS = append(alreadyS, p) + } else { + toS = append(toS, p) + } + } + return systemCheckResult{ + toInstallRegular: toR, + toInstallSpecial: toS, + alreadyInstalled: append(alreadyR, alreadyS...), + skipped: skipped, + remapped: remapped, + } +} + +func checkFlatpakPackages(ids []string) flatpakCheckResult { + var to, already []string + for _, p := range ids { + if isFlatpakInstalled(p) { + already = append(already, p) + } else { + to = append(to, p) + } + } + return flatpakCheckResult{toInstall: to, alreadyInstalled: already} +} + +func checkCustomPackages(pkgs []*CustomPackage) customCheckResult { + var to []*CustomPackage + var already []customStatus + for _, p := range pkgs { + installed, path := isCustomPkgInstalled(p) + if installed { + already = append(already, customStatus{pkg: p, path: path}) + } else { + to = append(to, p) + } + } + return customCheckResult{toInstall: to, alreadyInstalled: already} +} + +func fmtList(items []string, limit int) string { + if len(items) <= limit { + return strings.Join(items, " ") + } + return strings.Join(items[:limit], " ") + fmt.Sprintf(" … +%d more", len(items)-limit) +} + +func printCheckSummary(sys systemCheckResult, flat flatpakCheckResult, cust customCheckResult, only string) int { + total := 0 + + if only == "" || only == "system" { + toR := sys.toInstallRegular + toS := sys.toInstallSpecial + ok := sys.alreadyInstalled + fmt.Println("\nSystem packages:") + if len(ok) > 0 { + fmt.Printf(" [OK] %3d already installed\n", len(ok)) + } + n := len(toR) + len(toS) + if n > 0 { + combined := append([]string{}, toR...) + combined = append(combined, toS...) + fmt.Printf(" [INSTALL] %3d to install: %s\n", n, fmtList(combined, 6)) + } + if len(sys.skipped) > 0 { + fmt.Printf(" [SKIP] %3d overridden (→ skip): %s\n", len(sys.skipped), fmtList(sys.skipped, 6)) + } + if len(sys.remapped) > 0 { + var parts []string + for _, r := range sys.remapped { + parts = append(parts, fmt.Sprintf("%s→%s", r.From, strings.Join(r.To, ","))) + } + fmt.Printf(" [REMAP] remapped: %s\n", strings.Join(parts, " ")) + } + total += n + } + + if (only == "" || only == "flatpak") && (len(flat.toInstall) > 0 || len(flat.alreadyInstalled) > 0) { + fmt.Println("\nFlatpak packages:") + if len(flat.alreadyInstalled) > 0 { + fmt.Printf(" [OK] %3d already installed\n", len(flat.alreadyInstalled)) + } + if len(flat.toInstall) > 0 { + fmt.Printf(" [INSTALL] %3d to install: %s\n", len(flat.toInstall), fmtList(flat.toInstall, 6)) + } + total += len(flat.toInstall) + } + + if only == "" || only == "custom" { + fmt.Println("\nCustom packages:") + for _, s := range cust.alreadyInstalled { + suffix := "" + if s.path != "" { + suffix = fmt.Sprintf(" (%s)", s.path) + } + fmt.Printf(" [OK] %s%s\n", s.pkg.displayName(), suffix) + } + for _, p := range cust.toInstall { + _, path := isCustomPkgInstalled(p) + suffix := "" + if path != "" { + suffix = fmt.Sprintf(" → %s", path) + } + fmt.Printf(" [INSTALL] %s%s\n", p.displayName(), suffix) + } + total += len(cust.toInstall) + } + + return total +} diff --git a/custom.go b/custom.go new file mode 100644 index 0000000..241ee19 --- /dev/null +++ b/custom.go @@ -0,0 +1,509 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// resolveURL returns the formatted download URL or "" if no template is set. +func (p *CustomPackage) resolveURL() string { + if p.URLTemplate == "" { + return "" + } + return formatURL(p.URLTemplate, p.Version) +} + +func (p *CustomPackage) resolveSHA256URL() string { + if p.SHA256URLTemplate == "" { + return "" + } + return formatURL(p.SHA256URLTemplate, p.Version) +} + +func (p *CustomPackage) resolvedSHA256() string { + if p.SHA256 != "" { + return strings.ToLower(p.SHA256) + } + if p.SHA256Map != nil { + key := osName + "-" + archName + if v, ok := p.SHA256Map[key]; ok { + return strings.ToLower(v) + } + } + return "" +} + +func (p *CustomPackage) displayName() string { + if p.Version != "" { + return p.Name + "-" + p.Version + } + return p.Name +} + +var defaultInstallPaths = map[string]string{ + "go": "/usr/local/go", + "firecracker": "/usr/local/bin/firecracker", + "zig": "/usr/local/bin/zig", + "nvm": "~/.nvm", + "pyenv": "~/.pyenv", + "neovim": "/usr/local/bin/nvim", + "oh-my-zsh": "~/.oh-my-zsh", +} + +func expandHome(p string) string { + if strings.HasPrefix(p, "~") { + home, err := os.UserHomeDir() + if err == nil { + return filepath.Join(home, strings.TrimPrefix(p, "~")) + } + } + return p +} + +func defaultInstallPath(pkg *CustomPackage) string { + if p, ok := defaultInstallPaths[strings.ToLower(pkg.Name)]; ok { + return p + } + return "" +} + +func pipInstalled() bool { + if !hasCmd("python3") { + return false + } + r, ok := probe([]string{"python3", "-m", "pip", "--version"}, 0) + return ok && r.ExitCode == 0 +} + +// isCustomPkgInstalled returns (installed, checkPath). pip ships inside the +// Python distribution rather than at a fixed path, so it's detected with +// `python3 -m pip --version`. +func isCustomPkgInstalled(pkg *CustomPackage) (bool, string) { + if strings.ToLower(pkg.Name) == "pip" { + return pipInstalled(), "" + } + raw := pkg.InstallPath + if raw == "" { + raw = defaultInstallPath(pkg) + } + if raw == "" { + return false, "" + } + check := expandHome(raw) + if _, err := os.Stat(check); err == nil { + return true, check + } + return false, check +} + +// verifyArchive validates a downloaded archive against either a pinned +// sha256 or a .minisig signature. Returns true when verified or nothing to +// verify (latter case logs a warning). +func verifyArchive(archive string, pkg *CustomPackage) bool { + if expected := pkg.resolvedSHA256(); expected != "" { + actual, err := sha256Of(archive) + if err != nil { + errLog(fmt.Sprintf("hash failed for %s: %v", pkg.Name, err)) + return false + } + if actual != expected { + errLog(fmt.Sprintf("SHA256 mismatch for %s: expected %s, got %s", pkg.Name, expected, actual)) + return false + } + fmt.Println(" SHA256 OK") + return true + } + if sigURL := pkg.resolveSHA256URL(); sigURL != "" { + sigPath := filepath.Join(filepath.Dir(archive), filepath.Base(sigURL)) + if !download(sigURL, sigPath) { + return false + } + if !hasCmd("minisign") { + warn(fmt.Sprintf("minisign not installed — skipping signature verification for %s", pkg.Name)) + return true + } + cmd := []string{"minisign", "-Vm", archive, "-x", sigPath} + if pkg.MinisignKey != "" { + cmd = append(cmd, "-P", pkg.MinisignKey) + } + if !runCmd(cmd, CmdOpts{}).OK() { + errLog(fmt.Sprintf("minisign verification failed for %s", pkg.Name)) + return false + } + fmt.Println(" minisign OK") + } + return true +} + +func urlArchOK(pkg *CustomPackage) bool { + url := pkg.resolveURL() + if url == "" { + return true + } + if archMatches(url, archName) { + return true + } + if hasOtherArchToken(url) { + warn(fmt.Sprintf("%s: URL targets %s but host is %s. Update packages.go with a matching URL/SHA256.", + pkg.Name, otherArch(), archName)) + return false + } + return true +} + +// ── per-package install handlers ──────────────────────────────────────── + +func installGo(archive string) { + goRoot := "/usr/local/go" + if _, err := os.Stat(goRoot); err == nil { + fmt.Printf(" Removing existing Go at %s ...\n", goRoot) + runCmd([]string{"rm", "-rf", goRoot}, CmdOpts{AsSudo: true}) + } + runCmd([]string{"tar", "-C", "/usr/local", "-xzf", archive}, CmdOpts{AsSudo: true}) + appendProfileLine("local_go", "export PATH=$PATH:/usr/local/go/bin") + fmt.Printf(" Go installed to %s\n", goRoot) +} + +func installFirecracker(archive, tmp string) { + if !runCmd([]string{"tar", "-C", tmp, "-xzf", archive}, CmdOpts{}).OK() { + errLog("firecracker tar extraction failed") + return + } + var binary string + filepath.Walk(tmp, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + name := info.Name() + if !strings.HasPrefix(name, "firecracker") { + return nil + } + if strings.HasSuffix(name, ".debug") || strings.Contains(name, "debug") { + return nil + } + if binary == "" { + binary = path + } + return nil + }) + if binary == "" { + errLog("firecracker binary not found in archive") + return + } + dest := "/usr/local/bin/firecracker" + runCmd([]string{"cp", binary, dest}, CmdOpts{AsSudo: true}) + runCmd([]string{"chmod", "755", dest}, CmdOpts{AsSudo: true}) + fmt.Printf(" firecracker installed to %s\n", dest) +} + +func installZig(pkg *CustomPackage, archive string) { + parent := "/usr/local" + zigDir := filepath.Join(parent, "zig-"+pkg.Version) + if _, err := os.Stat(zigDir); err == nil { + runCmd([]string{"rm", "-rf", zigDir}, CmdOpts{AsSudo: true}) + } + runCmd([]string{"tar", "-C", parent, "-xJf", archive}, CmdOpts{AsSudo: true}) + + pattern := filepath.Join(parent, fmt.Sprintf("zig-%s-%s*", archName, osZig[osName])) + matches, _ := filepath.Glob(pattern) + for _, m := range matches { + if m != zigDir { + runCmd([]string{"mv", m, zigDir}, CmdOpts{AsSudo: true}) + break + } + } + symlink := "/usr/local/bin/zig" + runCmd([]string{"ln", "-sf", filepath.Join(zigDir, "zig"), symlink}, CmdOpts{AsSudo: true}) + fmt.Printf(" Zig installed to %s, symlinked at %s\n", zigDir, symlink) +} + +func installNeovim(_ *CustomPackage, tmp string) { + var rel ghRelease + if !fetchJSON("https://api.github.com/repos/neovim/neovim/releases/latest", &rel) { + return + } + archTok := archNvim[archName] + osTok := osNvim[osName] + assetName := fmt.Sprintf("nvim-%s-%s.tar.gz", osTok, archTok) + + var asset *ghAsset + for i := range rel.Assets { + if rel.Assets[i].Name == assetName { + asset = &rel.Assets[i] + break + } + } + if asset == nil { + errLog(fmt.Sprintf("Neovim asset %s not found", assetName)) + return + } + if !strings.HasPrefix(asset.Digest, "sha256:") { + errLog("Neovim asset digest missing or invalid") + return + } + expected := strings.TrimPrefix(asset.Digest, "sha256:") + dest := filepath.Join(tmp, assetName) + if !download(asset.BrowserDownloadURL, dest) { + return + } + actual, err := sha256Of(dest) + if err != nil { + errLog(fmt.Sprintf("Neovim hash failed: %v", err)) + return + } + if actual != expected { + errLog(fmt.Sprintf("Neovim SHA256 mismatch: expected %s, got %s", expected, actual)) + return + } + fmt.Println(" SHA256 OK") + + installDir := fmt.Sprintf("/opt/nvim-%s-%s", osTok, archTok) + fmt.Println(" Extracting Neovim to /opt ...") + runCmd([]string{"mkdir", "-p", "/opt"}, CmdOpts{AsSudo: true}) + runCmd([]string{"rm", "-rf", installDir}, CmdOpts{AsSudo: true}) + runCmd([]string{"tar", "-C", "/opt", "-xzf", dest}, CmdOpts{AsSudo: true}) + + runCmd([]string{"mkdir", "-p", "/usr/local/bin"}, CmdOpts{AsSudo: true}) + symlink := "/usr/local/bin/nvim" + runCmd([]string{"ln", "-sf", filepath.Join(installDir, "bin", "nvim"), symlink}, CmdOpts{AsSudo: true}) + fmt.Printf(" Neovim installed to %s, symlinked at %s\n", installDir, symlink) +} + +// ── latest-version resolvers ──────────────────────────────────────────── + +func resolveLatestGo(_ *CustomPackage) (string, string, bool) { + var raw json.RawMessage + if !fetchJSON("https://go.dev/dl/?mode=json", &raw) { + return "", "", false + } + // API returns an array; first element is the latest stable release. + type goFile struct { + Filename string `json:"filename"` + Kind string `json:"kind"` + SHA256 string `json:"sha256"` + } + type goRelease struct { + Version string `json:"version"` + Files []goFile `json:"files"` + } + var releases []goRelease + if err := json.Unmarshal(raw, &releases); err != nil || len(releases) == 0 { + var single goRelease + if err := json.Unmarshal(raw, &single); err != nil { + return "", "", false + } + releases = []goRelease{single} + } + latest := releases[0] + version := strings.TrimPrefix(latest.Version, "go") + if version == "" { + return "", "", false + } + archiveName := fmt.Sprintf("go%s.%s-%s.tar.gz", version, osGo[osName], archGo[archName]) + for _, f := range latest.Files { + if f.Filename == archiveName && f.Kind == "archive" && f.SHA256 != "" { + return version, f.SHA256, true + } + } + return "", "", false +} + +func resolveLatestFirecracker(_ *CustomPackage) (string, string, bool) { + if isMacOS { + return "", "", false + } + var rel ghRelease + if !fetchJSON("https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest", &rel) { + return "", "", false + } + version := strings.TrimPrefix(rel.TagName, "v") + if version == "" { + return "", "", false + } + archiveName := fmt.Sprintf("firecracker-v%s-%s.tgz", version, archName) + shaAssetName := archiveName + ".sha256.txt" + for _, a := range rel.Assets { + if a.Name == shaAssetName { + sha := fetchText(a.BrowserDownloadURL) + if sha == "" { + return "", "", false + } + return version, strings.Fields(sha)[0], true + } + } + return "", "", false +} + +var zigVersionRe = regexp.MustCompile(`^\d+\.\d+\.\d+$`) + +func resolveLatestZig(_ *CustomPackage) (string, string, bool) { + var data map[string]map[string]any + if !fetchJSON("https://ziglang.org/download/index.json", &data) { + return "", "", false + } + var stable []string + for k := range data { + if k != "master" && zigVersionRe.MatchString(k) { + stable = append(stable, k) + } + } + if len(stable) == 0 { + return "", "", false + } + sort.Slice(stable, func(i, j int) bool { + return cmpSemver(stable[i], stable[j]) < 0 + }) + version := stable[len(stable)-1] + key := archName + "-" + osZig[osName] + entry, ok := data[version][key].(map[string]any) + if !ok { + return "", "", false + } + sha, _ := entry["shasum"].(string) + if sha == "" { + return "", "", false + } + return version, sha, true +} + +func cmpSemver(a, b string) int { + pa := strings.Split(a, ".") + pb := strings.Split(b, ".") + for i := 0; i < len(pa) && i < len(pb); i++ { + ai, _ := strconv.Atoi(pa[i]) + bi, _ := strconv.Atoi(pb[i]) + if ai != bi { + if ai < bi { + return -1 + } + return 1 + } + } + return len(pa) - len(pb) +} + +var latestResolvers = map[string]func(*CustomPackage) (string, string, bool){ + "go": resolveLatestGo, + "firecracker": resolveLatestFirecracker, + "zig": resolveLatestZig, +} + +// resolveLatest best-effort upgrades pkg.Version/SHA256 to the latest release. +// On any failure, warns and leaves the pinned values in place. +func resolveLatest(pkg *CustomPackage) { + resolver, ok := latestResolvers[pkg.FetchLatest] + if !ok { + return + } + fmt.Printf(" Checking latest version for %s ...\n", pkg.Name) + defer func() { + if r := recover(); r != nil { + warn(fmt.Sprintf("%s: latest-version lookup panicked %v; falling back to pinned version %s", + pkg.Name, r, pkg.Version)) + } + }() + version, sha, found := resolver(pkg) + if !found { + warn(fmt.Sprintf("%s: could not resolve latest version; falling back to pinned version %s", + pkg.Name, pkg.Version)) + return + } + if version == pkg.Version { + fmt.Printf(" Pinned version %s is already the latest.\n", pkg.Version) + return + } + fmt.Printf(" Latest is %s (pinned was %s); using latest.\n", version, pkg.Version) + pkg.Version = version + pkg.SHA256 = strings.ToLower(sha) + pkg.SHA256URLTemplate = "" // prefer the freshly resolved sha256 +} + +// ── orchestration ─────────────────────────────────────────────────────── + +func installCustomPackages(toInstall []*CustomPackage) { + fmt.Println("\n=== Custom Packages ===") + for _, pkg := range toInstall { + name := strings.ToLower(pkg.Name) + _, checkPath := isCustomPkgInstalled(pkg) + extra := "" + if checkPath != "" { + extra = fmt.Sprintf(" (install path: %s)", checkPath) + } + fmt.Printf("\n Installing %s ...%s\n", pkg.displayName(), extra) + if checkPath == "" && name != "pip" { + warn(fmt.Sprintf("%s: no known install path — script will not detect future installs", pkg.Name)) + } + + if name == "firecracker" && isMacOS { + warn(fmt.Sprintf("%s: Linux-only — skipping on macOS", pkg.Name)) + continue + } + + switch name { + case "nvm": + installNVM() + continue + case "pyenv": + installPyenv() + continue + case "pip": + installPip() + continue + case "oh-my-zsh": + installOhMyZsh() + continue + case "neovim": + tmp, err := os.MkdirTemp("", "bootstrap-nvim-") + if err != nil { + errLog(fmt.Sprintf("neovim tmp dir failed: %v", err)) + continue + } + installNeovim(pkg, tmp) + os.RemoveAll(tmp) + continue + } + + resolveLatest(pkg) + + url := pkg.resolveURL() + if url == "" { + warn(fmt.Sprintf("No URL or install handler for '%s' — skipping", pkg.Name)) + continue + } + if !urlArchOK(pkg) { + continue + } + + tmp, err := os.MkdirTemp("", "bootstrap-custom-") + if err != nil { + errLog(fmt.Sprintf("tmp dir failed for %s: %v", pkg.Name, err)) + continue + } + archive := filepath.Join(tmp, filepath.Base(url)) + if !download(url, archive) { + os.RemoveAll(tmp) + continue + } + if !verifyArchive(archive, pkg) { + os.RemoveAll(tmp) + continue + } + switch name { + case "go": + installGo(archive) + case "firecracker": + installFirecracker(archive, tmp) + case "zig": + installZig(pkg, archive) + default: + warn(fmt.Sprintf("No install handler for '%s' — skipping", pkg.Name)) + } + os.RemoveAll(tmp) + } +} diff --git a/detect.go b/detect.go new file mode 100644 index 0000000..7a9000a --- /dev/null +++ b/detect.go @@ -0,0 +1,174 @@ +package main + +import ( + "fmt" + "os" + "runtime" + "strings" +) + +// OS / architecture detection. +// +// Vendors disagree on canonical OS/arch tokens used in download URLs, so we +// keep our own normalized values ("linux"/"macos", "x86_64"/"aarch64") and +// translate at URL-construction time. + +var ( + osName string // "linux" or "macos" + archName string // "x86_64" or "aarch64" + isMacOS bool + isRHELFamily bool + isArchFamily bool +) + +func init() { + osName = detectOS() + archName = detectArch() + isMacOS = osName == "macos" +} + +func detectOS() string { + switch runtime.GOOS { + case "linux": + return "linux" + case "darwin": + return "macos" + default: + fmt.Fprintf(os.Stderr, "Unsupported OS: %s (supports Linux, Darwin)\n", runtime.GOOS) + os.Exit(1) + return "" + } +} + +func detectArch() string { + switch runtime.GOARCH { + case "amd64": + return "x86_64" + case "arm64": + return "aarch64" + default: + fmt.Fprintf(os.Stderr, "Unsupported architecture: %s (supports x86_64, aarch64)\n", runtime.GOARCH) + os.Exit(1) + return "" + } +} + +var ( + archGo = map[string]string{"x86_64": "amd64", "aarch64": "arm64"} + archMinikube = map[string]string{"x86_64": "amd64", "aarch64": "arm64"} + archDeb = map[string]string{"x86_64": "amd64", "aarch64": "arm64"} + archNvim = map[string]string{"x86_64": "x86_64", "aarch64": "arm64"} + archPulumi = map[string]string{"x86_64": "x64", "aarch64": "arm64"} + + osGo = map[string]string{"linux": "linux", "macos": "darwin"} + osZig = map[string]string{"linux": "linux", "macos": "macos"} + osNvim = map[string]string{"linux": "linux", "macos": "macos"} + + archTokens = map[string][]string{ + "x86_64": {"x86_64", "amd64", "x64"}, + "aarch64": {"aarch64", "arm64"}, + } +) + +// formatURL interpolates {version}, {arch}, {arch_go}, {os}, {os_go}, +// {os_zig}, {os_nvim} into a download URL template. +func formatURL(template, version string) string { + r := strings.NewReplacer( + "{version}", version, + "{arch}", archName, + "{arch_go}", archGo[archName], + "{os}", osName, + "{os_go}", osGo[osName], + "{os_zig}", osZig[osName], + "{os_nvim}", osNvim[osName], + ) + return r.Replace(template) +} + +func otherArch() string { + if archName == "x86_64" { + return "aarch64" + } + return "x86_64" +} + +func archMatches(name, arch string) bool { + n := strings.ToLower(name) + for _, tok := range archTokens[arch] { + if strings.Contains(n, tok) { + return true + } + } + return false +} + +func hasOtherArchToken(name string) bool { + n := strings.ToLower(name) + for _, tok := range archTokens[otherArch()] { + if strings.Contains(n, tok) { + return true + } + } + return false +} + +// osReleaseField returns the value of /etc/os-release's NAME=value pair, +// stripped of surrounding quotes. Returns "" if the file is missing or the +// field is absent. +func osReleaseField(field string) string { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return "" + } + prefix := field + "=" + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, prefix) { + return strings.Trim(strings.TrimPrefix(line, prefix), `"`) + } + } + return "" +} + +func detectRHELFamily() bool { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return pkgMgr == "dnf" + } + tokens := []string{} + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "ID=") || strings.HasPrefix(line, "ID_LIKE=") { + _, val, _ := strings.Cut(line, "=") + val = strings.Trim(val, `"`) + tokens = append(tokens, strings.Fields(val)...) + } + } + rhel := map[string]bool{"rhel": true, "fedora": true, "centos": true, "rocky": true, "almalinux": true} + for _, t := range tokens { + if rhel[t] { + return true + } + } + return false +} + +func detectArchFamily() bool { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return pkgMgr == "pacman" + } + tokens := []string{} + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "ID=") || strings.HasPrefix(line, "ID_LIKE=") { + _, val, _ := strings.Cut(line, "=") + val = strings.Trim(val, `"`) + tokens = append(tokens, strings.Fields(val)...) + } + } + arch := map[string]bool{"arch": true, "manjaro": true, "endeavouros": true, "artix": true} + for _, t := range tokens { + if arch[t] { + return true + } + } + return false +} diff --git a/exec.go b/exec.go new file mode 100644 index 0000000..2028117 --- /dev/null +++ b/exec.go @@ -0,0 +1,181 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// Default per-call cap for runCmd and runShell. Generous enough for heavy +// installs (apt, brew, large downloads) but bounded so a stuck command can't +// hang the bootstrap forever. Override per-call for genuinely longer +// operations (e.g. pyenv compiles). +const defaultSubprocessTimeout = 30 * time.Minute + +// CmdOpts captures the optional knobs on runCmd / runShell. +type CmdOpts struct { + AsSudo bool + Check bool // exit on failure (kept for parity but treated as advisory — we return the error instead) + Input []byte + Capture bool + Cwd string + Timeout time.Duration // zero = defaultSubprocessTimeout +} + +// CmdResult holds the outcome of a subprocess invocation. +type CmdResult struct { + ExitCode int + Stdout []byte + Stderr []byte + Err error +} + +func (r CmdResult) OK() bool { return r.Err == nil && r.ExitCode == 0 } + +// runCmd executes argv with the supplied options. +func runCmd(argv []string, opts CmdOpts) CmdResult { + if opts.Timeout == 0 { + opts.Timeout = defaultSubprocessTimeout + } + if opts.AsSudo && os.Geteuid() != 0 { + argv = append([]string{"sudo"}, argv...) + } + fmt.Printf(" $ %s\n", strings.Join(argv, " ")) + + ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + if opts.Cwd != "" { + cmd.Dir = opts.Cwd + } + if opts.Input != nil { + cmd.Stdin = bytes.NewReader(opts.Input) + } + + var stdout, stderr bytes.Buffer + if opts.Capture { + cmd.Stdout = &stdout + cmd.Stderr = &stderr + } else { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } + + err := cmd.Run() + res := CmdResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()} + + if ctx.Err() == context.DeadlineExceeded { + warn(fmt.Sprintf("%q timed out after %s", argv[0], opts.Timeout)) + res.ExitCode = 124 + res.Err = ctx.Err() + return res + } + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + res.ExitCode = exitErr.ExitCode() + res.Err = err + return res + } + warn(fmt.Sprintf("error launching %q: %v", argv[0], err)) + res.ExitCode = 1 + res.Err = err + } + return res +} + +// runShell executes a single shell string via /bin/sh -c (matching the Python +// version's subprocess.run(..., shell=True)). +func runShell(cmd string, opts CmdOpts) CmdResult { + if opts.Timeout == 0 { + opts.Timeout = defaultSubprocessTimeout + } + fmt.Printf(" $ %s\n", cmd) + + ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout) + defer cancel() + + c := exec.CommandContext(ctx, "/bin/sh", "-c", cmd) + if opts.Cwd != "" { + c.Dir = opts.Cwd + } + if opts.Input != nil { + c.Stdin = bytes.NewReader(opts.Input) + } + + var stdout, stderr bytes.Buffer + if opts.Capture { + c.Stdout = &stdout + c.Stderr = &stderr + } else { + c.Stdout = os.Stdout + c.Stderr = os.Stderr + } + + err := c.Run() + res := CmdResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()} + + if ctx.Err() == context.DeadlineExceeded { + warn(fmt.Sprintf("shell command timed out after %s", opts.Timeout)) + res.ExitCode = 124 + res.Err = ctx.Err() + return res + } + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + res.ExitCode = exitErr.ExitCode() + res.Err = err + return res + } + warn(fmt.Sprintf("OSError in shell command: %v", err)) + res.ExitCode = 1 + res.Err = err + } + return res +} + +// hasCmd is shutil.which() — returns true if name resolves on PATH. +func hasCmd(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +// probe is a short, read-only command invocation used for "is this installed" +// checks. Returns (result, true) on completion (including non-zero exit) and +// (zero, false) on timeout/launch failure. +func probe(argv []string, timeout time.Duration) (CmdResult, bool) { + if timeout == 0 { + timeout = 30 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + + res := CmdResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()} + if ctx.Err() == context.DeadlineExceeded { + warn(fmt.Sprintf("%q probe timed out", argv[0])) + return res, false + } + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + res.ExitCode = exitErr.ExitCode() + return res, true + } + warn(fmt.Sprintf("%q probe failed: %v", argv[0], err)) + return res, false + } + return res, true +} diff --git a/flatpak.go b/flatpak.go new file mode 100644 index 0000000..6c2f444 --- /dev/null +++ b/flatpak.go @@ -0,0 +1,33 @@ +package main + +import "fmt" + +func installFlatpakPackages(toInstall []string) { + fmt.Println("\n=== Flatpak Packages ===") + + if !hasCmd("flatpak") { + fmt.Println(" flatpak is not installed.") + if !askYN(" Install flatpak now? [y/N] ") { + warn("flatpak not installed — skipping Flatpak section") + return + } + res := pkgInstall("flatpak") + if !res.OK() || !hasCmd("flatpak") { + errLog("flatpak installation failed — skipping Flatpak section") + return + } + } + + runCmd([]string{ + "flatpak", "remote-add", "--if-not-exists", "flathub", + "https://dl.flathub.org/repo/flathub.flatpakrepo", + }, CmdOpts{AsSudo: true}) + + for _, pkgID := range toInstall { + fmt.Printf("\n Installing %s ...\n", pkgID) + res := runCmd([]string{"flatpak", "install", "--noninteractive", "flathub", pkgID}, CmdOpts{}) + if !res.OK() { + errLog(fmt.Sprintf("Flatpak failed to install: %s", pkgID)) + } + } +} diff --git a/formatted_packages.py b/formatted_packages.py deleted file mode 100644 index c85e342..0000000 --- a/formatted_packages.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Package definitions consumed by bootstrap_environment.py. - -System and Flatpak packages are flat lists of names. - -Custom packages declare a URL template plus an optional ``fetch_latest`` hint. -At install time the bootstrap script will attempt to look up the most recent -release and fall back to the pinned (version, sha256) tuple on failure. - -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] = [ - "ansible", - "ansible-core", - "aria2", - "bashtop", - "build-essential", - "buildah", - "containerd.io", - "docker-buildx-plugin", - "docker-ce-cli", - "docker-ce-rootless-extras", - "docker-ce", - "docker-compose-plugin", - "dotnet-sdk-10.0", - "ffmpeg-free", - "gcc", - "gh", - "git", - "github-desktop", - "google-chrome-stable", - "lazygit", - "lua", - "minisign", - "minikube", - "obs-studio", - "obsidian", - "pipx", - "poetry", - "pulumi", - "podman", - "qemu", - "restic", - "rg", - "shutter", - "temurin-25-jdk", - "vagrant", - "virt-manager", - "vivaldi-stable", - "webcamoid", - "wireshark", - "yt-dlp", - "zoom", - "zsh", - "bzip2", - "bzip2-devel", - "curl", - "gdbm-libs", - "libffi-devel", - "libnsl2", - "libuuid-devel", - "libxml2-devel", - "libzstd-devel", - "make", - "ncurses-devel", - "openssl-devel", - "patch", - "readline-devel", - "sqlite", - "sqlite-devel", - "tk-devel", - "xmlsec1-devel", - "xz", - "xz-devel", - "zlib-devel", -] - -FLATPAK_PACKAGES: list[str] = [ - "com.obsproject.Studio", - "fr.handbrake.ghb", - "io.github.webcamoid.Webcamoid", - "one.ablaze.floorp", - "com.vivaldi.Vivaldi", - "org.darktable.Darktable", -] - -CUSTOM_PACKAGES: list[dict] = [ - { - "name": "go", - "version": "1.26.3", - "url_template": "https://go.dev/dl/go{version}.{os_go}-{arch_go}.tar.gz", - "sha256_map": { - "linux-x86_64": "2b2cfc7148493da5e73981bffbf3353af381d5f93e789c82c79aff64962eb556", - "linux-aarch64": "9d89a3ea57d141c2b22d70083f2c8459ba3890f2d9e818e7e933b75614936565", - "macos-x86_64": "278d580b32e299fe4a9c990fcf2d02acfe538c7e551a6ee18f9c7164573d2c63", - "macos-aarch64": "875cf54a15311eee2c99b9dd67c68c4a49351d489ab622bf2cfd28c8f2078d3c", - }, - "fetch_latest": "go", - }, - {"name": "neovim"}, - { - "name": "firecracker", - "version": "1.15.1", - "url_template": ( - "https://github.com/firecracker-microvm/firecracker/releases/download/" - "v{version}/firecracker-v{version}-{arch}.tgz" - ), - "sha256_map": { - "linux-x86_64": "d4a32ab2322d887ca1bc4a4e7afa9cc35393e6362dfc2b3becb389d362e4275a", - "linux-aarch64": "00654ac1e702a22744121ea9f10a4f792ebd7c3a744cba587dfac9fcb79b41a5", - }, - "fetch_latest": "firecracker", - }, - { - "name": "zig", - "version": "0.16.0", - "url_template": "https://ziglang.org/download/{version}/zig-{arch}-{os_zig}-{version}.tar.xz", - "sha256_url_template": ( - "https://ziglang.org/download/{version}/zig-{arch}-{os_zig}-{version}.tar.xz.minisig" - ), - "minisign_key": "RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U", - "fetch_latest": "zig", - }, - {"name": "nvm"}, - {"name": "pyenv"}, - {"name": "pip"}, - {"name": "oh-my-zsh"}, -] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6a761ef --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/JMR-dev/bootstrap_dev_env + +go 1.24.7 diff --git a/issues.go b/issues.go new file mode 100644 index 0000000..6363a03 --- /dev/null +++ b/issues.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// Issue log accumulated over the run; written to bootstrap_run.log at the end +// when there's something to report. + +var ( + issuesMu sync.Mutex + issues []string + notices []string +) + +func logIssue(level, msg string) { + issuesMu.Lock() + defer issuesMu.Unlock() + fmt.Printf(" [%s] %s\n", level, msg) + issues = append(issues, fmt.Sprintf("[%s] %s", level, msg)) +} + +func warn(msg string) { logIssue("WARN", msg) } +func errLog(msg string) { logIssue("ERROR", msg) } + +func notice(msg string) { + issuesMu.Lock() + defer issuesMu.Unlock() + notices = append(notices, msg) +} + +func runLogPath() string { + exe, err := os.Executable() + if err != nil { + return "bootstrap_run.log" + } + return filepath.Join(filepath.Dir(exe), "bootstrap_run.log") +} + +func writeRunLog() { + issuesMu.Lock() + defer issuesMu.Unlock() + if len(issues) == 0 { + fmt.Println("\nNo issues — log file not written.") + return + } + path := runLogPath() + ts := time.Now().Format("2006-01-02 15:04:05") + lines := []string{fmt.Sprintf("# Bootstrap run — %s", ts), ""} + lines = append(lines, issues...) + content := strings.Join(lines, "\n") + "\n" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "failed to write run log: %v\n", err) + return + } + fmt.Printf("\n%d issue(s) logged to: %s\n", len(issues), path) +} + +func printNotices() { + issuesMu.Lock() + defer issuesMu.Unlock() + if len(notices) == 0 { + return + } + fmt.Println("\nNotices:") + for _, n := range notices { + fmt.Printf(" • %s\n", n) + } +} diff --git a/macos.go b/macos.go new file mode 100644 index 0000000..9590083 --- /dev/null +++ b/macos.go @@ -0,0 +1,659 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +// ── Xcode + Homebrew prereqs ──────────────────────────────────────────── + +func brewPrefix() string { + if archName == "aarch64" { + return "/opt/homebrew" + } + return "/usr/local" +} + +func ensureXcodeCLT() { + if !isMacOS { + return + } + res, ok := probe([]string{"xcode-select", "-p"}, 10*time.Second) + if ok && res.ExitCode == 0 { + fmt.Printf("[Xcode CLT] Already installed at %s\n", strings.TrimSpace(string(res.Stdout))) + return + } + fmt.Println("[Xcode CLT] Installing Xcode Command Line Tools ...") + fmt.Println(" A GUI dialog will appear — click 'Install' to proceed.") + runCmd([]string{"xcode-select", "--install"}, CmdOpts{Timeout: 30 * time.Second}) + fmt.Println(" Waiting for installation to complete ...") + for { + r, ok := probe([]string{"xcode-select", "-p"}, 10*time.Second) + if ok && r.ExitCode == 0 { + break + } + time.Sleep(5 * time.Second) + } + fmt.Println("[Xcode CLT] Installation complete.") +} + +func ensureHomebrew() { + if !isMacOS { + return + } + if hasCmd("brew") { + path, _ := exec.LookPath("brew") + fmt.Printf("[Homebrew] Already installed at %s\n", path) + return + } + fmt.Println("[Homebrew] Installing Homebrew ...") + installer := `NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"` + if !runShell(installer, CmdOpts{}).OK() { + fmt.Fprintln(os.Stderr, "Homebrew installation failed") + os.Exit(1) + } + + brewBinDir := filepath.Join(brewPrefix(), "bin") + brewPath := filepath.Join(brewBinDir, "brew") + if _, err := os.Stat(brewPath); err != nil { + fmt.Fprintf(os.Stderr, "Homebrew installed but brew not found at %s\n", brewPath) + os.Exit(1) + } + + os.Setenv("PATH", brewBinDir+":"+os.Getenv("PATH")) + + shellenvLine := fmt.Sprintf(`eval "$(%s shellenv)"`, brewPath) + runCmd([]string{ + "bash", "-c", + fmt.Sprintf("grep -qxF %q /etc/zprofile 2>/dev/null || echo %q >> /etc/zprofile", + shellenvLine, shellenvLine), + }, CmdOpts{AsSudo: true}) + fmt.Printf("[Homebrew] Installed at %s; added shellenv to /etc/zprofile\n", brewPrefix()) +} + +// ── macOS firecracker VM bridge ───────────────────────────────────────── +// +// Firecracker is Linux-only (needs KVM). On macOS we provision a Fedora cloud +// VM via QEMU/HVF, install firecracker inside it, and expose a `firecracker` +// zsh function on the host that proxies invocations over SSH. + +const ( + vmSSHPort = 2222 + vmUser = "fc" + vmQcow2Name = "fedora.qcow2" + vmSeedISOName = "seed.iso" + vmPIDName = "vm.pid" + vmKeyName = "id_ed25519" + firecrackerFnBeg = "# >>> firecracker-vm wrapper >>>" + firecrackerFnEnd = "# <<< firecracker-vm wrapper <<<" +) + +func vmDir() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".firecracker-vm") +} + +func macosMajor() int { + if !isMacOS { + return 0 + } + r, ok := probe([]string{"sw_vers", "-productVersion"}, 10*time.Second) + if !ok || r.ExitCode != 0 { + return 0 + } + v := strings.TrimSpace(string(r.Stdout)) + if v == "" { + return 0 + } + parts := strings.Split(v, ".") + n, err := strconv.Atoi(parts[0]) + if err != nil { + return 0 + } + return n +} + +func appleSiliconGeneration() int { + if !isMacOS || archName != "aarch64" { + return 0 + } + r, ok := probe([]string{"sysctl", "-n", "machdep.cpu.brand_string"}, 10*time.Second) + if !ok || r.ExitCode != 0 { + return 0 + } + brand := strings.TrimSpace(string(r.Stdout)) + idx := strings.Index(brand, "Apple M") + if idx < 0 { + return 0 + } + rest := brand[idx+len("Apple M"):] + var digits []byte + for i := 0; i < len(rest) && rest[i] >= '0' && rest[i] <= '9'; i++ { + digits = append(digits, rest[i]) + } + if len(digits) == 0 { + return 0 + } + n, _ := strconv.Atoi(string(digits)) + return n +} + +func selectVMBackend() string { + if !isMacOS { + return "" + } + if archName == "x86_64" { + fmt.Println("\n[firecracker VM] Intel Mac — using VirtualBox (supports nested VT-x for in-guest KVM).") + return "virtualbox" + } + gen := appleSiliconGeneration() + macos := macosMajor() + if gen >= 3 && macos >= 15 { + fmt.Printf("\n[firecracker VM] Apple Silicon M%d on macOS %d — using QEMU/HVF with nested virtualization (-cpu host,el2=on).\n", gen, macos) + return "qemu" + } + chip := "Apple Silicon" + if gen != 0 { + chip = fmt.Sprintf("Apple M%d", gen) + } + osStr := "this macOS" + if macos != 0 { + osStr = fmt.Sprintf("macOS %d", macos) + } + fmt.Println() + fmt.Println("[firecracker VM] Skipping firecracker VM provisioning.") + fmt.Printf(" Detected %s on %s. HVF only exposes nested\n", chip, osStr) + fmt.Println(" virtualization on M3+ chips running macOS 15 Sequoia or later,") + fmt.Println(" and VirtualBox does not support Apple Silicon hosts, so there") + fmt.Println(" is no local hypervisor that can run firecracker microVMs here.") + fmt.Println(" To use firecracker, provision a Linux cloud VM (e.g. AWS EC2,") + fmt.Println(" GCP) and run firecracker there over SSH.") + return "" +} + +func latestFedoraCloudImage() (filename, qcowURL, checksumURL string, ok bool) { + base := "https://dl.fedoraproject.org/pub/fedora/linux/releases/" + listing := fetchText(base) + if listing == "" { + return + } + verRe := regexp.MustCompile(`href="(\d+)/?"`) + seen := map[int]bool{} + var versions []int + for _, m := range verRe.FindAllStringSubmatch(listing, -1) { + v, err := strconv.Atoi(m[1]) + if err != nil { + continue + } + if !seen[v] { + seen[v] = true + versions = append(versions, v) + } + } + sort.Sort(sort.Reverse(sort.IntSlice(versions))) + for _, ver := range versions { + imagesURL := fmt.Sprintf("%s%d/Cloud/%s/images/", base, ver, archName) + idx := fetchText(imagesURL) + if idx == "" { + continue + } + qcowRe := regexp.MustCompile(fmt.Sprintf(`href="(Fedora-Cloud-Base[A-Za-z0-9_-]*-%d-[\d.]+\.%s\.qcow2)"`, ver, archName)) + ckRe := regexp.MustCompile(`href="([^"]*CHECKSUM)"`) + qm := qcowRe.FindStringSubmatch(idx) + cm := ckRe.FindStringSubmatch(idx) + if qm == nil || cm == nil { + continue + } + return qm[1], imagesURL + qm[1], imagesURL + cm[1], true + } + return +} + +func verifyFedoraQcow2(qcow2, checksumURL string) bool { + text := fetchText(checksumURL) + if text == "" { + return false + } + name := filepath.Base(qcow2) + re := regexp.MustCompile(fmt.Sprintf(`SHA256 \(%s\) = ([0-9a-fA-F]+)`, regexp.QuoteMeta(name))) + m := re.FindStringSubmatch(text) + if m == nil { + errLog(fmt.Sprintf("No SHA256 entry for %s in checksum file", name)) + return false + } + expected := strings.ToLower(m[1]) + fmt.Println(" Verifying SHA256 (this can take a minute) ...") + actual, err := sha256Of(qcow2) + if err != nil { + errLog(fmt.Sprintf("Fedora image hash failed: %v", err)) + return false + } + if strings.ToLower(actual) != expected { + errLog(fmt.Sprintf("Fedora image SHA256 mismatch (got %s, expected %s)", actual, expected)) + return false + } + fmt.Println(" SHA256 OK") + return true +} + +func downloadFedoraImage(qcowURL, dest string) bool { + if !hasCmd("curl") { + return download(qcowURL, dest) + } + fmt.Printf(" Downloading %s ...\n", filepath.Base(dest)) + return runCmd([]string{"curl", "-L", "--fail", "-#", "-o", dest, qcowURL}, CmdOpts{}).OK() +} + +const firecrackerUserdataTmpl = `#cloud-config +hostname: firecracker-vm +users: + - name: %s + sudo: ALL=(ALL) NOPASSWD:ALL + shell: /bin/bash + ssh_authorized_keys: + - %s +ssh_pwauth: false +packages: + - curl + - tar + - qemu-kvm +write_files: + - path: /usr/local/sbin/install-firecracker.sh + permissions: '0755' + content: | + #!/usr/bin/env bash + set -euo pipefail + ARCH=$(uname -m) + TAG=$(curl -fsSL https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest \ + | grep -oE '"tag_name":[[:space:]]*"v[^"]+"' | head -1 \ + | sed -E 's/.*"v([^"]+)"/\1/') + cd /tmp + curl -fsSL -o fc.tgz \ + "https://github.com/firecracker-microvm/firecracker/releases/download/v${TAG}/firecracker-v${TAG}-${ARCH}.tgz" + tar -xzf fc.tgz + BIN=$(find . -maxdepth 3 -type f -name "firecracker-v${TAG}-${ARCH}" ! -name '*.debug' | head -1) + install -m 0755 "$BIN" /usr/local/bin/firecracker + touch /var/lib/firecracker-ready +runcmd: + - /usr/local/sbin/install-firecracker.sh +` + +func writeCloudInitSeed(seedDir, pubkey string) error { + if err := os.MkdirAll(seedDir, 0o755); err != nil { + return err + } + userData := fmt.Sprintf(firecrackerUserdataTmpl, vmUser, strings.TrimSpace(pubkey)) + if err := os.WriteFile(filepath.Join(seedDir, "user-data"), []byte(userData), 0o644); err != nil { + return err + } + return os.WriteFile(filepath.Join(seedDir, "meta-data"), + []byte("instance-id: firecracker-vm\nlocal-hostname: firecracker-vm\n"), 0o644) +} + +func buildSeedISO(seedDir, isoPath string) bool { + os.Remove(isoPath) + return runCmd([]string{ + "hdiutil", "makehybrid", "-iso", "-joliet", + "-default-volume-name", "cidata", + "-o", isoPath, seedDir, + }, CmdOpts{}).OK() +} + +func writeQEMUStartScript() string { + dir := vmDir() + brewShare := filepath.Join(brewPrefix(), "share", "qemu") + scriptPath := filepath.Join(dir, "vm-start.sh") + edkCode := filepath.Join(brewShare, "edk2-aarch64-code.fd") + edkVarsTemplate := filepath.Join(brewShare, "edk2-arm-vars.fd") + + qemuBlock := fmt.Sprintf(`# Ensure a writable NVRAM file exists (UEFI vars persist here). +if [[ ! -f edk2-aarch64-vars.fd ]]; then + if [[ -f "%s" ]]; then + cp "%s" edk2-aarch64-vars.fd + else + truncate -s 64M edk2-aarch64-vars.fd + fi +fi + +exec qemu-system-aarch64 \ + -machine virt,accel=hvf,highmem=on \ + -cpu host,el2=on \ + -smp 2 -m 2048 \ + -drive if=pflash,format=raw,readonly=on,file="%s" \ + -drive if=pflash,format=raw,file=edk2-aarch64-vars.fd \ + -drive file=%s,if=virtio,format=qcow2 \ + -drive file=%s,format=raw,if=virtio,readonly=on \ + -display none -serial file:vm.log \ + -netdev user,id=net0,hostfwd=tcp::%d-:22 \ + -device virtio-net-device,netdev=net0 \ + -daemonize -pidfile %s +`, edkVarsTemplate, edkVarsTemplate, edkCode, vmQcow2Name, vmSeedISOName, vmSSHPort, vmPIDName) + + script := fmt.Sprintf(`#!/usr/bin/env bash +# Start the Fedora-on-QEMU VM that backs the host firecracker zsh function. +set -euo pipefail +cd "%s" +if [[ -f %s ]] && kill -0 "$(cat %s)" 2>/dev/null; then + exit 0 +fi +rm -f %s +%s`, dir, vmPIDName, vmPIDName, vmPIDName, qemuBlock) + + os.WriteFile(scriptPath, []byte(script), 0o755) + return scriptPath +} + +func sshToVM(privKey string, remote []string, timeout time.Duration) CmdResult { + if timeout == 0 { + timeout = 3 * time.Second + } + args := []string{ + "-q", + "-i", privKey, + "-p", strconv.Itoa(vmSSHPort), + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", fmt.Sprintf("ConnectTimeout=%d", int(timeout.Seconds())), + "-o", "LogLevel=ERROR", + fmt.Sprintf("%s@127.0.0.1", vmUser), + } + args = append(args, remote...) + r, ok := probe(append([]string{"ssh"}, args...), timeout+30*time.Second) + if !ok { + return CmdResult{ExitCode: 124} + } + return r +} + +func waitForVMSSH(privKey string, timeout time.Duration) bool { + fmt.Printf(" Waiting for VM SSH on port %d (up to %s) ...\n", vmSSHPort, timeout) + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if sshToVM(privKey, []string{"true"}, 0).OK() { + fmt.Println(" VM SSH ready.") + return true + } + time.Sleep(5 * time.Second) + } + return false +} + +func waitForFirecrackerInVM(privKey string, timeout time.Duration) bool { + fmt.Printf(" Waiting for cloud-init to install firecracker inside the VM (up to %s) ...\n", timeout) + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if sshToVM(privKey, []string{"test", "-f", "/var/lib/firecracker-ready"}, 0).OK() { + fmt.Println(" firecracker is installed inside the VM.") + return true + } + time.Sleep(10 * time.Second) + } + return false +} + +func firecrackerZshFunction(privKey string) string { + return fmt.Sprintf(`%s +firecracker() { + local vm_dir="%s" + if [[ ! -f "$vm_dir/%s" ]]; then + echo "firecracker: Fedora VM not provisioned (expected $vm_dir/%s)." >&2 + return 1 + fi + if ! "$vm_dir/vm-start.sh"; then + echo "firecracker: failed to start backing VM (see $vm_dir/vm.log)." >&2 + return 1 + fi + local i + for i in $(seq 1 60); do + ssh -q -i "%s" -p %d \ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=2 -o LogLevel=ERROR \ + %s@127.0.0.1 true && break + sleep 1 + done + local args=() a + for a in "$@"; do args+=("$(printf %%q "$a")"); done + ssh -t -q -i "%s" -p %d \ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o LogLevel=ERROR \ + %s@127.0.0.1 "sudo /usr/local/bin/firecracker ${args[*]}" +} +%s +`, + firecrackerFnBeg, + vmDir(), + vmQcow2Name, vmQcow2Name, + privKey, vmSSHPort, vmUser, + privKey, vmSSHPort, vmUser, + firecrackerFnEnd, + ) +} + +func installFirecrackerZshFunction(content string) { + home, _ := os.UserHomeDir() + zshrc := filepath.Join(home, ".zshrc") + existing := "" + if b, err := os.ReadFile(zshrc); err == nil { + existing = string(b) + } + pattern := regexp.MustCompile(`(?s)` + regexp.QuoteMeta(firecrackerFnBeg) + `.*?` + regexp.QuoteMeta(firecrackerFnEnd) + `\n?`) + var newContent string + if pattern.MatchString(existing) { + newContent = pattern.ReplaceAllString(existing, content) + } else { + if existing != "" { + newContent = strings.TrimRight(existing, "\n") + "\n\n" + content + } else { + newContent = content + } + } + os.WriteFile(zshrc, []byte(newContent), 0o644) + fmt.Printf(" Wrote firecracker() function block to %s\n", zshrc) +} + +func ensureVirtualBox() bool { + if hasCmd("VBoxManage") { + return true + } + fmt.Println(" Installing VirtualBox via brew cask ...") + if !runCmd([]string{"brew", "install", "--cask", "virtualbox"}, CmdOpts{}).OK() { + errLog("VirtualBox cask install failed. macOS may require kernel-extension " + + "approval in System Settings → Privacy & Security; once approved, re-run this script.") + return false + } + if !hasCmd("VBoxManage") { + errLog("VirtualBox installed but VBoxManage not in PATH. macOS may need a reboot or kext approval.") + return false + } + return true +} + +func provisionVirtualBoxVM(qcow2, seedISO string) string { + if !ensureVirtualBox() { + return "" + } + vmName := "firecracker-vm" + dir := vmDir() + vboxBase := filepath.Join(dir, "vbox") + vdi := filepath.Join(dir, "fedora.vdi") + + r, ok := probe([]string{"VBoxManage", "showvminfo", vmName}, 30*time.Second) + exists := ok && r.ExitCode == 0 + + if !exists { + if _, err := os.Stat(vdi); os.IsNotExist(err) { + fmt.Printf(" Converting %s → %s (VirtualBox VDI) ...\n", filepath.Base(qcow2), filepath.Base(vdi)) + if !runCmd([]string{"VBoxManage", "clonemedium", "disk", qcow2, vdi, "--format", "VDI"}, CmdOpts{}).OK() { + errLog("VBoxManage clonemedium failed") + return "" + } + runCmd([]string{"VBoxManage", "modifymedium", "disk", vdi, "--resize", "10240"}, CmdOpts{}) + } + fmt.Printf(" Creating VirtualBox VM '%s' ...\n", vmName) + os.MkdirAll(vboxBase, 0o755) + if !runCmd([]string{ + "VBoxManage", "createvm", + "--name", vmName, + "--ostype", "Fedora_64", + "--basefolder", vboxBase, + "--register", + }, CmdOpts{}).OK() { + errLog("VBoxManage createvm failed") + return "" + } + runCmd([]string{ + "VBoxManage", "modifyvm", vmName, + "--cpus", "2", + "--memory", "2048", + "--nested-hw-virt", "on", + "--nic1", "nat", + "--natpf1", fmt.Sprintf("ssh,tcp,,%d,,22", vmSSHPort), + }, CmdOpts{}) + runCmd([]string{"VBoxManage", "storagectl", vmName, "--name", "SATA", "--add", "sata"}, CmdOpts{}) + runCmd([]string{ + "VBoxManage", "storageattach", vmName, + "--storagectl", "SATA", + "--port", "0", "--device", "0", "--type", "hdd", + "--medium", vdi, + }, CmdOpts{}) + runCmd([]string{"VBoxManage", "storagectl", vmName, "--name", "IDE", "--add", "ide"}, CmdOpts{}) + runCmd([]string{ + "VBoxManage", "storageattach", vmName, + "--storagectl", "IDE", + "--port", "0", "--device", "0", "--type", "dvddrive", + "--medium", seedISO, + }, CmdOpts{}) + } else { + fmt.Printf(" VirtualBox VM '%s' already registered — reusing.\n", vmName) + } + + scriptPath := filepath.Join(dir, "vm-start.sh") + script := fmt.Sprintf(`#!/usr/bin/env bash +# Start the VirtualBox-backed Fedora VM that powers the host firecracker() fn. +set -euo pipefail +if VBoxManage list runningvms | grep -q '"%s"'; then + exit 0 +fi +exec VBoxManage startvm %s --type headless +`, vmName, vmName) + os.WriteFile(scriptPath, []byte(script), 0o755) + return scriptPath +} + +func setupFirecrackerVM() { + if !isMacOS { + return + } + backend := selectVMBackend() + if backend == "" { + return + } + + fmt.Println("\n=== macOS firecracker VM (Fedora) ===") + dir := vmDir() + os.MkdirAll(dir, 0o755) + + privKey := filepath.Join(dir, vmKeyName) + pubKey := privKey + ".pub" + if _, err := os.Stat(privKey); os.IsNotExist(err) { + fmt.Printf(" Generating SSH keypair at %s ...\n", privKey) + if !runCmd([]string{"ssh-keygen", "-t", "ed25519", "-N", "", "-f", privKey, "-q"}, CmdOpts{}).OK() { + errLog("ssh-keygen failed — aborting VM setup") + return + } + } + + qcow2 := filepath.Join(dir, vmQcow2Name) + if _, err := os.Stat(qcow2); err == nil { + fmt.Printf(" Reusing existing Fedora image at %s\n", qcow2) + } else { + fmt.Println(" Looking up latest Fedora cloud image ...") + filename, qcowURL, checksumURL, ok := latestFedoraCloudImage() + if !ok { + errLog("Could not resolve latest Fedora cloud image — aborting VM setup") + return + } + fmt.Printf(" Latest: %s\n", filename) + downloadDest := filepath.Join(dir, filename) + if !downloadFedoraImage(qcowURL, downloadDest) { + errLog("Fedora image download failed — aborting VM setup") + return + } + if !verifyFedoraQcow2(downloadDest, checksumURL) { + os.Remove(downloadDest) + return + } + os.Rename(downloadDest, qcow2) + if hasCmd("qemu-img") { + fmt.Println(" Resizing image to 10G ...") + runCmd([]string{"qemu-img", "resize", qcow2, "10G"}, CmdOpts{}) + } + } + + fmt.Println(" Building cloud-init seed ISO ...") + seedDir := filepath.Join(dir, "seed") + pubKeyBytes, err := os.ReadFile(pubKey) + if err != nil { + errLog(fmt.Sprintf("could not read public key: %v", err)) + return + } + if err := writeCloudInitSeed(seedDir, string(pubKeyBytes)); err != nil { + errLog(fmt.Sprintf("cloud-init seed write failed: %v", err)) + return + } + seedISO := filepath.Join(dir, vmSeedISOName) + if !buildSeedISO(seedDir, seedISO) { + errLog("hdiutil failed to build seed ISO — aborting VM setup") + return + } + + var startScript string + if backend == "qemu" { + if !hasCmd("qemu-system-aarch64") { + errLog("qemu-system-aarch64 not found — install qemu via brew first.") + return + } + fmt.Println(" Writing QEMU start script ...") + startScript = writeQEMUStartScript() + } else { + startScript = provisionVirtualBoxVM(qcow2, seedISO) + if startScript == "" { + return + } + } + + fmt.Printf(" Booting VM via %s ...\n", startScript) + if !runCmd([]string{startScript}, CmdOpts{}).OK() { + errLog(fmt.Sprintf("VM start failed — see %s", filepath.Join(dir, "vm.log"))) + return + } + + if !waitForVMSSH(privKey, 5*time.Minute) { + errLog(fmt.Sprintf("VM SSH never came up — see %s", filepath.Join(dir, "vm.log"))) + return + } + + if !waitForFirecrackerInVM(privKey, 15*time.Minute) { + warn("firecracker did not appear in the VM within the timeout; " + + "cloud-init may still be running. Check `sudo cloud-init status` " + + "inside the VM (ssh -i ~/.firecracker-vm/id_ed25519 -p 2222 fc@127.0.0.1).") + } + + fmt.Println(" Installing firecracker() wrapper into ~/.zshrc ...") + installFirecrackerZshFunction(firecrackerZshFunction(privKey)) + + fmt.Printf(" firecracker VM ready (backend: %s).\n", backend) + fmt.Printf(" Start manually with: %s\n", startScript) + if backend == "qemu" { + fmt.Println(" Nested virt is on (el2=on); the guest's KVM can launch firecracker microVMs.") + } else { + fmt.Println(" Nested VT-x is on; the guest's KVM can launch firecracker microVMs.") + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..2722ddf --- /dev/null +++ b/main.go @@ -0,0 +1,187 @@ +// bootstrap_environment ports the Python bootstrap script to Go. +// +// Sections handled: +// +// System Packages — installed via dnf, apt-get, pacman, or brew (macOS) +// Flatpak Packages — installed via flatpak from Flathub (Linux only; +// skipped by default and skipped entirely on macOS; use --gui) +// Custom Packages — downloaded, verified, extracted +// macOS firecracker VM — provisions a Fedora cloud image under a hypervisor +// that supports nested virtualization. Suppress with --no-vm. +// +// 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: +// +// Linux: sudo bootstrap_environment [--only system|flatpak|custom] [--gui] +// macOS: bootstrap_environment [--only system|custom] [--gui] [--no-vm] +// (do NOT use sudo on macOS — Homebrew refuses to run as root) +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +func main() { + only := flag.String("only", "", "Install only the named section (system|flatpak|custom)") + gui := flag.Bool("gui", false, "Include GUI applications (headed environments).") + noVM := flag.Bool("no-vm", false, "macOS only: skip provisioning the Fedora-on-QEMU VM that backs the firecracker() zsh wrapper.") + flag.Parse() + + switch *only { + case "", "system", "flatpak", "custom": + default: + fmt.Fprintf(os.Stderr, "invalid --only value %q (use system|flatpak|custom)\n", *only) + os.Exit(2) + } + + initPkgMgr() + + systemPkgs := append([]string(nil), SystemPackages...) + flatpakPkgs := append([]string(nil), FlatpakPackages...) + custom := customPackages() + customPtrs := make([]*CustomPackage, 0, len(custom)) + for i := range custom { + // Drop firecracker on macOS — it's provisioned inside the Fedora VM + // (see setupFirecrackerVM), not on the host. + if isMacOS && strings.ToLower(custom[i].Name) == "firecracker" { + continue + } + customPtrs = append(customPtrs, &custom[i]) + } + + fmt.Printf("OS: %s\n", osName) + fmt.Printf("Architecture: %s\n", archName) + fmt.Printf("Package manager: %s\n", pkgMgr) + if !*gui { + fmt.Println("Mode: headless (default) — skipping GUI apps and Flatpak") + } + + if isMacOS { + // Refuse to run as root before doing anything (brew won't run as root). + checkSudo() + ensureXcodeCLT() + ensureHomebrew() + } + + fmt.Println("Checking installed packages ...") + + if !*gui { + var skippedGUI, kept []string + for _, p := range systemPkgs { + if guiSystemPkgs[p] { + skippedGUI = append(skippedGUI, p) + } else { + kept = append(kept, p) + } + } + systemPkgs = kept + if len(skippedGUI) > 0 { + fmt.Printf(" [HEADLESS] Skipping GUI system packages: %s\n", fmtList(skippedGUI, 6)) + } + flatpakPkgs = nil + } + + doFlatpak := (*only == "" || *only == "flatpak") && *gui && !isMacOS + + var sysCheck systemCheckResult + var flatCheck flatpakCheckResult + var custCheck customCheckResult + + if *only == "" || *only == "system" { + sysCheck = checkSystemPackages(systemPkgs) + } + if doFlatpak { + flatCheck = checkFlatpakPackages(flatpakPkgs) + } + if *only == "" || *only == "custom" { + custCheck = checkCustomPackages(customPtrs) + } + + total := printCheckSummary(sysCheck, flatCheck, custCheck, *only) + + if total == 0 { + fmt.Println("\nAll packages already installed.") + writeRunLog() + return + } + + if !askYN(fmt.Sprintf("\n%d item(s) to install. Proceed? [y/N] ", total)) { + fmt.Fprintln(os.Stderr, "Aborted.") + os.Exit(1) + } + + checkSudo() + + if *only == "" || *only == "system" { + installSystemPackages(sysCheck.toInstallRegular, sysCheck.toInstallSpecial) + ensureZshDefault() + } + + if doFlatpak { + installFlatpakPackages(flatCheck.toInstall) + } + + var pyenvWG interface{ Wait() } + if *only == "" || *only == "custom" { + installCustomPackages(custCheck.toInstall) + ensureNodeLTS() + if wg := ensurePythonLatest(); wg != nil { + pyenvWG = wg + } + } + + if *only == "" { + checkAndSetupSSH() + cloneNvimConfig() + if isMacOS && !*noVM { + setupFirecrackerVM() + } + } + + if pyenvWG != nil { + fmt.Println("\n[pyenv] Waiting for background Python install to finish ...") + pyenvWG.Wait() + } + + writeRunLog() + printNotices() + fmt.Println("\nDone.") + + home, _ := os.UserHomeDir() + zshrc := filepath.Join(home, ".zshrc") + if hasCmd("zsh") { + if _, err := os.Stat(zshrc); err == nil { + fmt.Println("\nSourcing ~/.zshrc ...") + runShell(fmt.Sprintf("zsh -c 'source %s'", zshrc), CmdOpts{}) + } + } +} + +func checkSudo() { + if os.Geteuid() == 0 { + if isMacOS { + fmt.Fprintln(os.Stderr, "Do not run this with sudo on macOS — Homebrew refuses to run as root. "+ + "Re-run as your regular user; the tool will request sudo for the operations that need it.") + os.Exit(1) + } + return + } + if !hasCmd("sudo") { + fmt.Fprintln(os.Stderr, "sudo is required but not installed.") + os.Exit(1) + } + fmt.Println("Validating sudo access ...") + r := runCmd([]string{"sudo", "-v"}, CmdOpts{Timeout: 2 * time.Minute}) + if r.ExitCode != 0 { + fmt.Fprintln(os.Stderr, "sudo authentication failed.") + os.Exit(1) + } +} diff --git a/net.go b/net.go new file mode 100644 index 0000000..7449df8 --- /dev/null +++ b/net.go @@ -0,0 +1,116 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +const httpClientTimeout = 30 * time.Minute + +var httpClient = &http.Client{Timeout: httpClientTimeout} + +// download streams url -> dest. Returns true on success. +func download(url, dest string) bool { + fmt.Printf(" Downloading %s ...\n", filepath.Base(url)) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + errLog(fmt.Sprintf("Download failed for %s: %v", url, err)) + return false + } + resp, err := httpClient.Do(req) + if err != nil { + errLog(fmt.Sprintf("Download failed for %s: %v", url, err)) + return false + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + errLog(fmt.Sprintf("Download failed for %s: HTTP %d", url, resp.StatusCode)) + return false + } + f, err := os.Create(dest) + if err != nil { + errLog(fmt.Sprintf("Download failed for %s: %v", url, err)) + return false + } + defer f.Close() + if _, err := io.Copy(f, resp.Body); err != nil { + errLog(fmt.Sprintf("Download failed for %s: %v", url, err)) + return false + } + return true +} + +// fetchJSON GETs url with the GitHub API Accept header and decodes the body +// into v. Returns true on success. +func fetchJSON(url string, v any) bool { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + errLog(fmt.Sprintf("API request failed for %s: %v", url, err)) + return false + } + req.Header.Set("Accept", "application/vnd.github+json") + if tok := os.Getenv("GITHUB_TOKEN"); tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } + resp, err := httpClient.Do(req) + if err != nil { + errLog(fmt.Sprintf("API request failed for %s: %v", url, err)) + return false + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + errLog(fmt.Sprintf("API request failed for %s: HTTP %d", url, resp.StatusCode)) + return false + } + if err := json.NewDecoder(resp.Body).Decode(v); err != nil { + errLog(fmt.Sprintf("API request failed for %s: %v", url, err)) + return false + } + return true +} + +// fetchText returns the trimmed body of url. Returns empty string on failure. +func fetchText(url string) string { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + errLog(fmt.Sprintf("Fetch failed for %s: %v", url, err)) + return "" + } + resp, err := httpClient.Do(req) + if err != nil { + errLog(fmt.Sprintf("Fetch failed for %s: %v", url, err)) + return "" + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + errLog(fmt.Sprintf("Fetch failed for %s: HTTP %d", url, resp.StatusCode)) + return "" + } + b, err := io.ReadAll(resp.Body) + if err != nil { + errLog(fmt.Sprintf("Fetch failed for %s: %v", url, err)) + return "" + } + return strings.TrimSpace(string(b)) +} + +func sha256Of(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/packages.go b/packages.go new file mode 100644 index 0000000..f17945b --- /dev/null +++ b/packages.go @@ -0,0 +1,140 @@ +package main + +// Package definitions consumed by the bootstrap entry point. +// +// SystemPackages and FlatpakPackages are flat name lists. CustomPackages +// declare a URL template plus an optional FetchLatest hint; at install time +// the resolver attempts to look up the most recent release and falls back to +// the pinned (Version, sha256) tuple on failure. +// +// URL templates use the substitutions described in formatURL. + +var SystemPackages = []string{ + "ansible", + "ansible-core", + "aria2", + "bashtop", + "build-essential", + "buildah", + "containerd.io", + "docker-buildx-plugin", + "docker-ce-cli", + "docker-ce-rootless-extras", + "docker-ce", + "docker-compose-plugin", + "dotnet-sdk-10.0", + "ffmpeg-free", + "gcc", + "gh", + "git", + "github-desktop", + "google-chrome-stable", + "lazygit", + "lua", + "minisign", + "minikube", + "obs-studio", + "obsidian", + "pipx", + "poetry", + "pulumi", + "podman", + "qemu", + "restic", + "rg", + "shutter", + "temurin-25-jdk", + "vagrant", + "virt-manager", + "vivaldi-stable", + "webcamoid", + "wireshark", + "yt-dlp", + "zoom", + "zsh", + "bzip2", + "bzip2-devel", + "curl", + "gdbm-libs", + "libffi-devel", + "libnsl2", + "libuuid-devel", + "libxml2-devel", + "libzstd-devel", + "make", + "ncurses-devel", + "openssl-devel", + "patch", + "readline-devel", + "sqlite", + "sqlite-devel", + "tk-devel", + "xmlsec1-devel", + "xz", + "xz-devel", + "zlib-devel", +} + +var FlatpakPackages = []string{ + "com.obsproject.Studio", + "fr.handbrake.ghb", + "io.github.webcamoid.Webcamoid", + "one.ablaze.floorp", + "com.vivaldi.Vivaldi", + "org.darktable.Darktable", +} + +// CustomPackage describes a third-party tarball/binary we fetch directly +// (i.e. not via the host package manager). +type CustomPackage struct { + Name string + Version string // pinned fallback version + URLTemplate string // see formatURL for substitutions + SHA256 string // single-arch hex digest (set by resolveLatest) + SHA256Map map[string]string // per-platform pinned digests: {"os-arch": hex} + SHA256URLTemplate string // template for a .minisig URL + MinisignKey string // base64 public key for minisign verification + FetchLatest string // latest-version resolver hint ("go", "firecracker", "zig") + InstallPath string // override the default install-check path +} + +func customPackages() []CustomPackage { + return []CustomPackage{ + { + Name: "go", + Version: "1.26.3", + URLTemplate: "https://go.dev/dl/go{version}.{os_go}-{arch_go}.tar.gz", + SHA256Map: map[string]string{ + "linux-x86_64": "2b2cfc7148493da5e73981bffbf3353af381d5f93e789c82c79aff64962eb556", + "linux-aarch64": "9d89a3ea57d141c2b22d70083f2c8459ba3890f2d9e818e7e933b75614936565", + "macos-x86_64": "278d580b32e299fe4a9c990fcf2d02acfe538c7e551a6ee18f9c7164573d2c63", + "macos-aarch64": "875cf54a15311eee2c99b9dd67c68c4a49351d489ab622bf2cfd28c8f2078d3c", + }, + FetchLatest: "go", + }, + {Name: "neovim"}, + { + Name: "firecracker", + Version: "1.15.1", + URLTemplate: "https://github.com/firecracker-microvm/firecracker/releases/download/" + + "v{version}/firecracker-v{version}-{arch}.tgz", + SHA256Map: map[string]string{ + "linux-x86_64": "d4a32ab2322d887ca1bc4a4e7afa9cc35393e6362dfc2b3becb389d362e4275a", + "linux-aarch64": "00654ac1e702a22744121ea9f10a4f792ebd7c3a744cba587dfac9fcb79b41a5", + }, + FetchLatest: "firecracker", + }, + { + Name: "zig", + Version: "0.16.0", + URLTemplate: "https://ziglang.org/download/{version}/zig-{arch}-{os_zig}-{version}.tar.xz", + SHA256URLTemplate: "https://ziglang.org/download/{version}/zig-{arch}-{os_zig}-{version}.tar.xz.minisig", + MinisignKey: "RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U", + FetchLatest: "zig", + }, + {Name: "nvm"}, + {Name: "pyenv"}, + {Name: "pip"}, + {Name: "oh-my-zsh"}, + } +} diff --git a/pkgmgr.go b/pkgmgr.go new file mode 100644 index 0000000..86e925d --- /dev/null +++ b/pkgmgr.go @@ -0,0 +1,233 @@ +package main + +import ( + "fmt" + "os" + "strings" + "time" +) + +var pkgMgr string // "dnf", "apt-get", "pacman", "brew" + +func initPkgMgr() { + pkgMgr = detectPkgMgr() + isRHELFamily = detectRHELFamily() + isArchFamily = detectArchFamily() +} + +func detectPkgMgr() string { + if isMacOS { + // brew may not be installed yet — ensureHomebrew runs before any + // call that actually invokes brew. + return "brew" + } + for _, mgr := range []string{"dnf", "apt-get", "pacman"} { + if hasCmd(mgr) { + return mgr + } + } + fmt.Fprintln(os.Stderr, "No supported package manager found (expected dnf, apt-get, pacman, or brew on macOS).") + os.Exit(1) + return "" +} + +// pkgOverrides maps a (PKG_MGR, generic_name) pair to a distro-specific +// replacement. An empty []string{} means "skip with a warning". +// +// Use overrideEntry to distinguish "skip" (Skip=true) from "replace with +// these packages" (Replacement=[...]). +type overrideEntry struct { + Skip bool + Replacement []string +} + +func skipOverride() overrideEntry { return overrideEntry{Skip: true} } +func replace(names ...string) overrideEntry { + return overrideEntry{Replacement: names} +} + +var packageOverrides = map[string]map[string]overrideEntry{ + "dnf": { + "build-essential": replace("gcc", "gcc-c++", "make"), + "rg": replace("ripgrep"), + "docker-compose": skipOverride(), + "webcamoid": skipOverride(), + }, + "apt-get": { + "ffmpeg-free": replace("ffmpeg"), + "lua": replace("lua5.4"), + "qemu": replace("qemu-system"), + "rg": replace("ripgrep"), + "bzip2-devel": replace("libbz2-dev"), + "gdbm-libs": replace("libgdbm-dev"), + "libffi-devel": replace("libffi-dev"), + "libnsl2": replace("libnsl-dev"), + "libuuid-devel": replace("uuid-dev"), + "libxml2-devel": replace("libxml2-dev"), + "libzstd-devel": replace("libzstd-dev"), + "ncurses-devel": replace("libncursesw5-dev"), + "openssl-devel": replace("libssl-dev"), + "readline-devel": replace("libreadline-dev"), + "sqlite": replace("sqlite3"), + "sqlite-devel": replace("libsqlite3-dev"), + "tk-devel": replace("tk-dev"), + "xmlsec1-devel": replace("libxmlsec1-dev"), + "xz": replace("xz-utils"), + "xz-devel": replace("liblzma-dev"), + "zlib-devel": replace("zlib1g-dev"), + }, + "pacman": { + "build-essential": replace("base-devel"), + "ansible-core": skipOverride(), // bundled with ansible + "containerd.io": replace("containerd"), + "docker-ce": replace("docker"), + "docker-ce-cli": skipOverride(), // covered by docker + "docker-ce-rootless-extras": replace("docker-rootless-extras"), + "docker-buildx-plugin": replace("docker-buildx"), + "docker-compose-plugin": replace("docker-compose"), + "dotnet-sdk-10.0": replace("dotnet-sdk"), + "ffmpeg-free": replace("ffmpeg"), + "gh": replace("github-cli"), + "github-desktop": skipOverride(), // AUR-only + "google-chrome-stable": skipOverride(), // AUR-only + "lua": replace("lua"), + "obs-studio": replace("obs-studio"), + "obsidian": skipOverride(), // AUR-only; provided via Flatpak when --gui + "pulumi": skipOverride(), // AUR-only; installed via custom path + "qemu": replace("qemu-full"), + "rg": replace("ripgrep"), + "shutter": skipOverride(), // AUR-only + "temurin-25-jdk": replace("jdk-openjdk"), + "vagrant": replace("vagrant"), + "vivaldi-stable": replace("vivaldi"), + "webcamoid": skipOverride(), // AUR-only; provided via Flatpak when --gui + "wireshark": replace("wireshark-qt"), + "yt-dlp": replace("yt-dlp"), + "zoom": skipOverride(), // AUR-only + "bzip2-devel": skipOverride(), + "gdbm-libs": replace("gdbm"), + "libffi-devel": replace("libffi"), + "libnsl2": replace("libnsl"), + "libuuid-devel": replace("util-linux-libs"), + "libxml2-devel": replace("libxml2"), + "libzstd-devel": replace("zstd"), + "ncurses-devel": replace("ncurses"), + "openssl-devel": replace("openssl"), + "readline-devel": replace("readline"), + "sqlite-devel": skipOverride(), + "tk-devel": replace("tk"), + "xmlsec1-devel": replace("xmlsec"), + "xz-devel": skipOverride(), + "zlib-devel": replace("zlib"), + }, + "brew": { + "build-essential": skipOverride(), + "gcc": skipOverride(), + "make": skipOverride(), + "patch": skipOverride(), + "zsh": skipOverride(), + "ansible-core": skipOverride(), + "containerd.io": skipOverride(), + "docker-buildx-plugin": skipOverride(), + "docker-ce-cli": skipOverride(), + "docker-ce-rootless-extras": skipOverride(), + "docker-ce": replace("docker"), + "docker-compose-plugin": replace("docker-compose"), + "dotnet-sdk-10.0": replace("dotnet"), + "ffmpeg-free": replace("ffmpeg"), + "github-desktop": replace("github"), + "google-chrome-stable": replace("google-chrome"), + "obs-studio": replace("obs"), + "rg": replace("ripgrep"), + "temurin-25-jdk": replace("temurin"), + "vivaldi-stable": replace("vivaldi"), + "shutter": skipOverride(), + "virt-manager": skipOverride(), + "webcamoid": skipOverride(), + "bzip2-devel": skipOverride(), + "curl": skipOverride(), + "gdbm-libs": replace("gdbm"), + "libffi-devel": replace("libffi"), + "libnsl2": skipOverride(), + "libuuid-devel": skipOverride(), + "libxml2-devel": replace("libxml2"), + "libzstd-devel": replace("zstd"), + "ncurses-devel": skipOverride(), + "openssl-devel": replace("openssl@3"), + "readline-devel": replace("readline"), + "sqlite-devel": skipOverride(), + "tk-devel": replace("tcl-tk"), + "xmlsec1-devel": replace("libxmlsec1"), + "xz": replace("xz"), + "xz-devel": skipOverride(), + "zlib-devel": skipOverride(), + }, +} + +// brewCasks: brew packages that must be installed with `brew install --cask`. +// Names are post-override. +var brewCasks = map[string]bool{ + "docker": true, + "github": true, + "google-chrome": true, + "obs": true, + "obsidian": true, + "temurin": true, + "vagrant": true, + "vivaldi": true, + "zoom": true, +} + +// resolveSystemPkgs applies distro overrides. Returns (resolved, skipped). +func resolveSystemPkgs(names []string) ([]string, []string) { + overrides := packageOverrides[pkgMgr] + var resolved, skipped []string + for _, pkg := range names { + ov, ok := overrides[pkg] + if !ok { + resolved = append(resolved, pkg) + continue + } + if ov.Skip { + skipped = append(skipped, pkg) + continue + } + resolved = append(resolved, ov.Replacement...) + } + return resolved, skipped +} + +// isSystemPkgInstalled queries the host package manager. +func isSystemPkgInstalled(pkg string) bool { + switch pkgMgr { + case "dnf": + r, ok := probe([]string{"rpm", "-q", pkg}, 0) + return ok && r.ExitCode == 0 + case "apt-get": + r, ok := probe([]string{"dpkg-query", "-W", "-f=${Status}", pkg}, 0) + return ok && strings.Contains(string(r.Stdout), "install ok installed") + case "pacman": + r, ok := probe([]string{"pacman", "-Qi", pkg}, 0) + return ok && r.ExitCode == 0 + case "brew": + if !hasCmd("brew") { + return false + } + for _, kind := range []string{"--formula", "--cask"} { + r, ok := probe([]string{"brew", "list", kind, pkg}, 60*time.Second) + if ok && r.ExitCode == 0 { + return true + } + } + return false + } + return false +} + +func isFlatpakInstalled(appID string) bool { + if !hasCmd("flatpak") { + return false + } + r, ok := probe([]string{"flatpak", "info", appID}, 0) + return ok && r.ExitCode == 0 +} diff --git a/post.go b/post.go new file mode 100644 index 0000000..e9c7375 --- /dev/null +++ b/post.go @@ -0,0 +1,457 @@ +package main + +import ( + "fmt" + "io" + "os" + "os/user" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// ── pyenv / Python ────────────────────────────────────────────────────── + +func installPyenv() { + fmt.Println(" Installing pyenv via curl ...") + if !runShell("curl https://pyenv.run | bash", CmdOpts{}).OK() { + errLog("pyenv installation failed") + return + } + fmt.Println(" pyenv installed to ~/.pyenv") +} + +func python3DecimalOK() bool { + if !hasCmd("python3") { + return false + } + r, ok := probe([]string{"python3", "-c", "from decimal import Decimal"}, 10*time.Second) + return ok && r.ExitCode == 0 +} + +func fixPython3Decimal() bool { + switch pkgMgr { + case "apt-get": + runCmd([]string{"apt-get", "install", "-y", "python3-full"}, CmdOpts{AsSudo: true}) + case "dnf": + runCmd([]string{"dnf", "install", "-y", "python3-libs"}, CmdOpts{AsSudo: true}) + case "pacman": + runCmd([]string{"pacman", "-S", "--noconfirm", "--needed", "python"}, CmdOpts{AsSudo: true}) + } + return python3DecimalOK() +} + +func installPip() { + if !hasCmd("python3") { + errLog("python3 is not installed — cannot install pip") + return + } + if !python3DecimalOK() { + warn("Python 3 _decimal C extension failed to import — attempting fix ...") + if fixPython3Decimal() { + fmt.Println(" Python 3 _decimal extension restored.") + } else { + errLog("Python 3 _decimal C extension could not be fixed. " + + "Run: sudo apt-get install python3-full (Debian/Ubuntu), " + + "sudo dnf install python3-libs (Fedora/RHEL), or " + + "sudo pacman -S python (Arch)") + return + } + } + + fmt.Println(" Bootstrapping pip via 'python3 -m ensurepip --upgrade' ...") + bootstrap := runCmd([]string{"python3", "-m", "ensurepip", "--upgrade"}, CmdOpts{AsSudo: true}) + if !bootstrap.OK() { + switch pkgMgr { + case "apt-get": + warn("ensurepip unavailable in system Python — installing python3-pip via apt-get") + if !runCmd([]string{"apt-get", "install", "-y", "python3-pip"}, CmdOpts{AsSudo: true}).OK() { + errLog("python3-pip failed to install via apt-get — skipping pip bootstrap") + return + } + case "pacman": + warn("ensurepip unavailable in system Python — installing python-pip via pacman") + if !runCmd([]string{"pacman", "-S", "--noconfirm", "--needed", "python-pip"}, CmdOpts{AsSudo: true}).OK() { + errLog("python-pip failed to install via pacman — skipping pip bootstrap") + return + } + default: + errLog("python3 -m ensurepip failed (system Python may need a distro 'python3-pip' package)") + return + } + } + fmt.Println(" Upgrading pip to the latest version ...") + upgrade := runCmd([]string{"python3", "-m", "pip", "install", "--upgrade", "pip"}, CmdOpts{AsSudo: true}) + if !upgrade.OK() { + warn("pip self-upgrade failed (likely PEP 668 externally-managed); ensurepip-provided pip remains") + } +} + +func latestStablePython(pyenvBin string) string { + r, ok := probe([]string{pyenvBin, "install", "--list"}, 2*time.Minute) + if !ok { + errLog("pyenv install --list failed") + return "" + } + if r.ExitCode != 0 { + errLog("pyenv install --list failed") + return "" + } + stableRe := regexp.MustCompile(`^\s*(\d+)\.(\d+)\.(\d+)\s*$`) + type ver struct{ a, b, c int } + var versions []ver + for _, line := range strings.Split(string(r.Stdout), "\n") { + m := stableRe.FindStringSubmatch(line) + if m == nil { + continue + } + a, _ := strconv.Atoi(m[1]) + b, _ := strconv.Atoi(m[2]) + c, _ := strconv.Atoi(m[3]) + if a >= 3 { + versions = append(versions, ver{a, b, c}) + } + } + if len(versions) == 0 { + return "" + } + sort.Slice(versions, func(i, j int) bool { + if versions[i].a != versions[j].a { + return versions[i].a < versions[j].a + } + if versions[i].b != versions[j].b { + return versions[i].b < versions[j].b + } + return versions[i].c < versions[j].c + }) + v := versions[len(versions)-1] + return fmt.Sprintf("%d.%d.%d", v.a, v.b, v.c) +} + +// ensurePythonLatest installs the latest stable Python via pyenv if not +// present, then sets it as global. Returns a wait group if an install was +// kicked off in the background; the caller must call .Wait() before exiting. +func ensurePythonLatest() *sync.WaitGroup { + home, _ := os.UserHomeDir() + pyenvDir := filepath.Join(home, ".pyenv") + if _, err := os.Stat(pyenvDir); err != nil { + return nil + } + pyenvBin := filepath.Join(pyenvDir, "bin", "pyenv") + if _, err := os.Stat(pyenvBin); err != nil { + warn(fmt.Sprintf("pyenv binary not found at %s", pyenvBin)) + return nil + } + + latest := latestStablePython(pyenvBin) + if latest == "" { + errLog("Could not determine latest stable Python from pyenv") + return nil + } + + r, ok := probe([]string{pyenvBin, "versions", "--bare"}, 30*time.Second) + if !ok { + return nil + } + installed := strings.Fields(string(r.Stdout)) + for _, v := range installed { + if v == latest { + fmt.Printf("\n[pyenv] Python %s already installed.\n", latest) + fmt.Printf("[pyenv] Setting Python %s as global default ...\n", latest) + if !runCmd([]string{pyenvBin, "global", latest}, CmdOpts{}).OK() { + errLog(fmt.Sprintf("pyenv global %s failed", latest)) + } + return nil + } + } + + fmt.Printf("\n[pyenv] Backgrounding install of Python %s (compile may take several minutes) ...\n", latest) + start := time.Now() + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + r := runCmd([]string{pyenvBin, "install", "--skip-existing", latest}, + CmdOpts{Timeout: 60 * time.Minute, Capture: true}) + elapsed := int(time.Since(start).Seconds()) + if !r.OK() { + errLog(fmt.Sprintf("pyenv install %s failed after %ds", latest, elapsed)) + if len(r.Stderr) > 0 { + lines := strings.Split(string(r.Stderr), "\n") + if len(lines) > 20 { + lines = lines[len(lines)-20:] + } + fmt.Printf("\n[pyenv stderr tail]\n%s\n", strings.Join(lines, "\n")) + } + return + } + if !runCmd([]string{pyenvBin, "global", latest}, + CmdOpts{Timeout: time.Minute}).OK() { + errLog(fmt.Sprintf("pyenv global %s failed", latest)) + return + } + fmt.Printf("\n[pyenv] Python %s installed and set as global default (%ds).\n", latest, elapsed) + }() + return &wg +} + +// ── nvm / Node ────────────────────────────────────────────────────────── + +func installNVM() { + var rel ghRelease + if !fetchJSON("https://api.github.com/repos/nvm-sh/nvm/releases/latest", &rel) { + return + } + version := rel.TagName + if version == "" { + errLog("NVM tag_name missing") + return + } + installURL := fmt.Sprintf("https://raw.githubusercontent.com/nvm-sh/nvm/%s/install.sh", version) + fmt.Printf(" Installing NVM %s via curl ...\n", version) + if !runShell(fmt.Sprintf("curl -o- %s | bash", installURL), CmdOpts{}).OK() { + errLog("NVM installation failed") + return + } + fmt.Printf(" NVM %s installed to ~/.nvm\n", version) +} + +func ensureNodeLTS() { + home, _ := os.UserHomeDir() + if _, err := os.Stat(filepath.Join(home, ".nvm")); err != nil { + return + } + check := runShell(`bash -c "source ~/.nvm/nvm.sh 2>/dev/null && nvm version lts/* 2>/dev/null"`, + CmdOpts{Capture: true}) + installed := strings.TrimSpace(string(check.Stdout)) + if installed != "" && installed != "N/A" { + fmt.Printf("\n[NVM] Node LTS (%s) already installed.\n", installed) + } else { + fmt.Println("\n[NVM] Installing Node.js LTS ...") + if !runShell(`bash -c "source ~/.nvm/nvm.sh && nvm install --lts"`, CmdOpts{}).OK() { + errLog("Node.js LTS install via nvm failed") + return + } + fmt.Println(" Node.js LTS installed.") + } + + fmt.Println("[NVM] Setting Node LTS as default ...") + if !runShell(`bash -c "source ~/.nvm/nvm.sh && nvm alias default 'lts/*' && nvm use --lts"`, CmdOpts{}).OK() { + errLog("Setting nvm default to LTS failed") + } +} + +// ── oh-my-zsh ─────────────────────────────────────────────────────────── + +func installOhMyZsh() { + if !hasCmd("zsh") { + errLog("zsh is not installed — required by oh-my-zsh") + return + } + if !hasCmd("git") { + errLog("git is not installed — required by oh-my-zsh") + return + } + home, _ := os.UserHomeDir() + target := filepath.Join(home, ".oh-my-zsh") + if _, err := os.Stat(target); err == nil { + fmt.Printf(" oh-my-zsh already present at %s; updating theme only\n", target) + } else { + fmt.Println(" Installing oh-my-zsh via the official installer ...") + installer := `sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended` + if !runShell(installer, CmdOpts{}).OK() { + errLog("oh-my-zsh installer failed") + return + } + } + + zshrc := filepath.Join(home, ".zshrc") + data, err := os.ReadFile(zshrc) + if err != nil { + warn("~/.zshrc not present after oh-my-zsh install; cannot set theme") + return + } + text := string(data) + re := regexp.MustCompile(`(?m)^\s*ZSH_THEME=.*$`) + var newText string + if re.MatchString(text) { + newText = re.ReplaceAllString(text, `ZSH_THEME="gnzh"`) + } else { + newText = strings.TrimRight(text, "\n") + "\nZSH_THEME=\"gnzh\"\n" + } + if newText != text { + if err := os.WriteFile(zshrc, []byte(newText), 0o644); err != nil { + errLog(fmt.Sprintf("could not write ~/.zshrc: %v", err)) + return + } + fmt.Println(` Set ZSH_THEME="gnzh" in ~/.zshrc`) + } else { + fmt.Println(` ~/.zshrc already has ZSH_THEME="gnzh"`) + } +} + +// ── default-shell + neovim config + gh auth ───────────────────────────── + +func invokingUser() string { + if u := os.Getenv("SUDO_USER"); u != "" { + return u + } + if u, err := user.Current(); err == nil { + return u.Username + } + return "" +} + +func ensureZshDefault() { + if !hasCmd("zsh") { + warn("zsh not installed — skipping default-shell change") + return + } + zshPath := "/bin/zsh" + if r, ok := probe([]string{"which", "zsh"}, 5*time.Second); ok && r.ExitCode == 0 { + if p := strings.TrimSpace(string(r.Stdout)); p != "" { + zshPath = p + } + } + username := invokingUser() + if username == "" { + warn("could not determine invoking user; skipping default-shell change") + return + } + u, err := user.Lookup(username) + if err != nil { + warn(fmt.Sprintf("user %s not found in passwd; skipping default-shell change", username)) + return + } + current := userLoginShell(u.Uid) + if current == zshPath { + fmt.Printf("\n[zsh] %s's default shell is already %s.\n", username, zshPath) + return + } + + family := "Debian-family" + if isRHELFamily { + family = "RHEL-family" + } else if isArchFamily { + family = "Arch-family" + } else if isMacOS { + family = "macOS" + } + fmt.Printf("\n[zsh] Setting default shell for %s to %s (%s) ...\n", username, zshPath, family) + + var cmd []string + if isRHELFamily { + cmd = []string{"usermod", "-s", zshPath, username} + } else { + cmd = []string{"chsh", "-s", zshPath, username} + } + if !runCmd(cmd, CmdOpts{AsSudo: true}).OK() { + errLog(fmt.Sprintf("Failed to set default shell to zsh for %s", username)) + } else { + fmt.Println("[zsh] Default shell updated. Log out and back in for it to take effect.") + } +} + +// userLoginShell returns the login shell for uid by parsing /etc/passwd. On +// macOS the shell may be set by dscl; getent isn't available either, so we +// just read passwd directly which works on every supported platform. +func userLoginShell(uid string) string { + data, err := os.ReadFile("/etc/passwd") + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + parts := strings.Split(line, ":") + if len(parts) < 7 { + continue + } + if parts[2] == uid { + return parts[6] + } + } + return "" +} + +func cloneNvimConfig() { + home, _ := os.UserHomeDir() + configDir := filepath.Join(home, ".config", "nvim") + repoURL := "git@github.com:JMR-dev/nvim-config.git" + + fmt.Printf("\n[Neovim] Setting up configuration from %s ...\n", repoURL) + + if _, err := os.Stat(configDir); err == nil { + n := 1 + var backup string + for { + backup = filepath.Join(filepath.Dir(configDir), fmt.Sprintf("nvim-%d", n)) + if _, err := os.Stat(backup); os.IsNotExist(err) { + break + } + n++ + } + fmt.Printf(" Renaming existing %s → %s ...\n", configDir, backup) + if err := os.Rename(configDir, backup); err != nil { + errLog(fmt.Sprintf("could not back up existing nvim config: %v", err)) + return + } + notice(fmt.Sprintf("Previous Neovim config preserved at %s", backup)) + } + + os.MkdirAll(filepath.Dir(configDir), 0o755) + + repoName := strings.TrimSuffix(filepath.Base(repoURL), ".git") + tempClone := filepath.Join(filepath.Dir(configDir), repoName) + os.RemoveAll(tempClone) + + fmt.Printf(" Cloning to %s ...\n", configDir) + if !runCmd([]string{"git", "clone", repoURL, tempClone}, CmdOpts{}).OK() { + errLog("Neovim configuration clone failed") + return + } + if tempClone != configDir { + fmt.Printf(" Renaming %s to %s ...\n", filepath.Base(tempClone), filepath.Base(configDir)) + os.Rename(tempClone, configDir) + } + fmt.Printf(" Neovim configuration ready at %s\n", configDir) +} + +func ghLoggedIn() bool { + r, ok := probe([]string{"gh", "auth", "status"}, 30*time.Second) + return ok && r.ExitCode == 0 +} + +func checkAndSetupSSH() { + if !hasCmd("gh") { + fmt.Println("\n[GitHub CLI] gh not installed — skipping authentication.") + return + } + if ghLoggedIn() { + fmt.Println("\n[GitHub CLI] Already authenticated.") + return + } + if !askYN("\n[GitHub CLI] Would you like to authenticate the GitHub CLI? [y/N] ") { + return + } + res := runCmd([]string{"gh", "auth", "login"}, CmdOpts{Timeout: 15 * time.Minute}) + if !res.OK() { + errLog("gh auth login failed — skipping key upload.") + return + } +} + +// askYN prompts on stdin. Returns true only for an exact "y" (case-insensitive). +func askYN(prompt string) bool { + fmt.Print(prompt) + var buf [256]byte + n, err := os.Stdin.Read(buf[:]) + if err != nil && err != io.EOF { + fmt.Println() + return false + } + answer := strings.ToLower(strings.TrimSpace(string(buf[:n]))) + return answer == "y" +} diff --git a/repos.go b/repos.go new file mode 100644 index 0000000..b3b7ad9 --- /dev/null +++ b/repos.go @@ -0,0 +1,234 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +// repoFileExists returns true if any of the given paths exists. +func repoFileExists(paths ...string) bool { + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + return true + } + } + return false +} + +func writeDNFRepo(name, displayName, baseurl, gpgkey string) { + content := fmt.Sprintf( + "[%s]\nname=%s\nbaseurl=%s\nenabled=1\ngpgcheck=1\ngpgkey=%s\n", + name, displayName, baseurl, gpgkey, + ) + path := "/etc/yum.repos.d/" + name + ".repo" + runCmd([]string{"tee", path}, CmdOpts{AsSudo: true, Input: []byte(content), Capture: true}) +} + +func setupDockerRepo() { + switch pkgMgr { + case "dnf": + if repoFileExists("/etc/yum.repos.d/docker-ce.repo") { + return + } + runCmd([]string{"dnf", "config-manager", "addrepo", "--from-repofile", + "https://download.docker.com/linux/fedora/docker-ce.repo"}, CmdOpts{AsSudo: true}) + case "apt-get": + if repoFileExists("/etc/apt/sources.list.d/docker.list") { + return + } + runCmd([]string{"apt-get", "update"}, CmdOpts{AsSudo: true}) + runCmd([]string{"apt-get", "install", "-y", "ca-certificates", "curl", "gnupg"}, CmdOpts{AsSudo: true}) + distroID := osReleaseField("ID") + dockerDistro := "ubuntu" + if distroID == "debian" || distroID == "ubuntu" { + dockerDistro = distroID + } + runShell( + "install -m 0755 -d /etc/apt/keyrings && "+ + "curl -fsSL https://download.docker.com/linux/"+dockerDistro+"/gpg | "+ + "sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg && "+ + "sudo chmod a+r /etc/apt/keyrings/docker.gpg", + CmdOpts{}, + ) + codenameRes := runShell(". /etc/os-release && echo $VERSION_CODENAME", + CmdOpts{Capture: true}) + codename := strings.TrimSpace(string(codenameRes.Stdout)) + debArch := archDeb[archName] + runCmd( + []string{"tee", "/etc/apt/sources.list.d/docker.list"}, + CmdOpts{ + AsSudo: true, + Input: []byte(fmt.Sprintf("deb [arch=%s signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/%s %s stable\n", debArch, dockerDistro, codename)), + Capture: true, + }, + ) + runCmd([]string{"apt-get", "update"}, CmdOpts{AsSudo: true}) + } + // pacman: docker is in official repos — no extra repo needed. +} + +func setupGHRepo() { + switch pkgMgr { + case "dnf": + if repoFileExists("/etc/yum.repos.d/gh-cli.repo") { + return + } + runCmd([]string{"dnf", "config-manager", "addrepo", "--from-repofile", + "https://cli.github.com/packages/rpm/gh-cli.repo"}, CmdOpts{AsSudo: true}) + case "apt-get": + if repoFileExists("/etc/apt/sources.list.d/github-cli.list") { + return + } + debArch := archDeb[archName] + runShell( + "curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | "+ + "sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && "+ + "sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg && "+ + fmt.Sprintf("echo 'deb [arch=%s signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main' | ", debArch)+ + "sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null", + CmdOpts{}, + ) + runCmd([]string{"apt-get", "update"}, CmdOpts{AsSudo: true}) + } + // pacman: github-cli is in community repo. +} + +func setupChromeRepo() { + if archName != "x86_64" { + warn("Google Chrome has no Linux build for this arch — skipping repo") + return + } + switch pkgMgr { + case "dnf": + if repoFileExists("/etc/yum.repos.d/google-chrome.repo") { + return + } + writeDNFRepo( + "google-chrome", "Google Chrome", + "https://dl.google.com/linux/chrome/rpm/stable/x86_64", + "https://dl.google.com/linux/linux_signing_key.pub", + ) + case "apt-get": + if repoFileExists("/etc/apt/sources.list.d/google-chrome.list") { + return + } + runShell( + "curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | "+ + "sudo gpg --dearmor -o /etc/apt/keyrings/google-chrome.gpg && "+ + "echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.gpg] "+ + "https://dl.google.com/linux/chrome/deb/ stable main' | "+ + "sudo tee /etc/apt/sources.list.d/google-chrome.list > /dev/null && "+ + "sudo apt-get update", + CmdOpts{}, + ) + } +} + +func setupVivaldiRepo() { + if archName != "x86_64" { + warn("Vivaldi repo on this arch is not supported by this script — skipping") + return + } + switch pkgMgr { + case "dnf": + if repoFileExists("/etc/yum.repos.d/vivaldi.repo") { + return + } + writeDNFRepo( + "vivaldi", "Vivaldi", + "https://repo.vivaldi.com/archive/rpm/x86_64", + "https://repo.vivaldi.com/archive/linux_signing_key.pub", + ) + case "apt-get": + if repoFileExists("/etc/apt/sources.list.d/vivaldi.list") { + return + } + runShell( + "curl -fsSL https://repo.vivaldi.com/archive/linux_signing_key.pub | "+ + "sudo gpg --dearmor -o /etc/apt/keyrings/vivaldi.gpg && "+ + "echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/vivaldi.gpg] "+ + "https://repo.vivaldi.com/archive/deb/ stable main' | "+ + "sudo tee /etc/apt/sources.list.d/vivaldi.list > /dev/null && "+ + "sudo apt-get update", + CmdOpts{}, + ) + } +} + +func setupTemurinRepo() { + switch pkgMgr { + case "dnf": + if repoFileExists("/etc/yum.repos.d/adoptium.repo") { + return + } + writeDNFRepo( + "Adoptium", "Adoptium", + "https://packages.adoptium.net/artifactory/rpm/fedora/$releasever/$basearch", + "https://packages.adoptium.net/artifactory/api/gpg/key/public", + ) + case "apt-get": + if repoFileExists("/etc/apt/sources.list.d/adoptium.list") { + return + } + runShell( + "wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | "+ + "sudo gpg --dearmor | sudo tee /etc/apt/keyrings/adoptium.gpg > /dev/null && "+ + `echo "deb [signed-by=/etc/apt/keyrings/adoptium.gpg] `+ + `https://packages.adoptium.net/artifactory/deb/ `+ + `$(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" | `+ + "sudo tee /etc/apt/sources.list.d/adoptium.list > /dev/null && "+ + "sudo apt-get update", + CmdOpts{}, + ) + } +} + +func setupDotnetRepo() { + // .NET is in Fedora repos directly — no extra repo needed. + if pkgMgr != "apt-get" { + return + } + if repoFileExists( + "/etc/apt/sources.list.d/microsoft-prod.list", + "/etc/apt/sources.list.d/dotnet.list", + ) { + return + } + distroID := strings.Trim(osReleaseField("ID"), `"`) + versionID := strings.Trim(osReleaseField("VERSION_ID"), `"`) + debURL := fmt.Sprintf( + "https://packages.microsoft.com/config/%s/%s/packages-microsoft-prod.deb", + distroID, versionID, + ) + runShell( + fmt.Sprintf("curl -fsSL %s -o /tmp/packages-microsoft-prod.deb && "+ + "sudo dpkg -i /tmp/packages-microsoft-prod.deb && "+ + "sudo apt-get update", debURL), + CmdOpts{}, + ) +} + +type repoGroup struct { + members map[string]bool + setup func() +} + +func repoGroups() []repoGroup { + mk := func(names ...string) map[string]bool { + m := make(map[string]bool, len(names)) + for _, n := range names { + m[n] = true + } + return m + } + return []repoGroup{ + {mk("containerd.io", "docker-buildx-plugin", "docker-ce-cli", + "docker-ce-rootless-extras", "docker-ce", "docker-compose-plugin"), setupDockerRepo}, + {mk("gh"), setupGHRepo}, + {mk("google-chrome-stable"), setupChromeRepo}, + {mk("vivaldi-stable"), setupVivaldiRepo}, + {mk("temurin-25-jdk"), setupTemurinRepo}, + {mk("dotnet-sdk-10.0"), setupDotnetRepo}, + } +} diff --git a/system.go b/system.go new file mode 100644 index 0000000..fed1f90 --- /dev/null +++ b/system.go @@ -0,0 +1,444 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Special packages: installed outside the regular package manager because +// they're not in standard repos, or because they need extra setup. Linux only; +// on macOS brew covers all of these. +func specialPkgs() map[string]bool { + if isMacOS { + return map[string]bool{} + } + return map[string]bool{ + "github-desktop": true, "zoom": true, "obsidian": true, + "minikube": true, "bashtop": true, "pipx": true, + "poetry": true, "pulumi": true, + } +} + +// guiSystemPkgs are skipped by default (headless mode) and included only +// when --gui is passed. +var guiSystemPkgs = map[string]bool{ + "github-desktop": true, + "google-chrome-stable": true, + "obs-studio": true, + "obsidian": true, + "shutter": true, + "virt-manager": true, + "vivaldi-stable": true, + "webcamoid": true, + "wireshark": true, + "zoom": true, +} + +func isSpecialPkgInstalled(pkg string) bool { + home, _ := os.UserHomeDir() + exists := func(p string) bool { _, err := os.Stat(p); return err == nil } + switch pkg { + case "obsidian": + return exists("/usr/local/bin/obsidian") + case "minikube": + return exists("/usr/local/bin/minikube") || hasCmd("minikube") + case "bashtop": + return exists("/usr/local/bin/bashtop") || exists(filepath.Join(home, "bashtop")) + case "pulumi": + return exists("/opt/pulumi/pulumi") || hasCmd("pulumi") + case "pipx": + return hasCmd("pipx") + case "poetry": + return hasCmd("poetry") + } + return isSystemPkgInstalled(pkg) +} + +// ── special installers ──────────────────────────────────────────────────── + +type ghAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Digest string `json:"digest"` +} + +type ghRelease struct { + TagName string `json:"tag_name"` + Assets []ghAsset `json:"assets"` +} + +func installGitHubDesktop(tmp string) { + var rel ghRelease + if !fetchJSON("https://api.github.com/repos/shiftkey/desktop/releases/latest", &rel) { + return + } + var suffix string + switch pkgMgr { + case "dnf": + suffix = ".rpm" + case "apt-get": + suffix = ".deb" + default: + warn("github-desktop has no installer for this package manager — skipping") + return + } + hostTokens := archTokens[archName] + excludeTokens := archTokens[otherArch()] + + matches := func(name string) bool { + n := strings.ToLower(name) + if !strings.HasSuffix(n, suffix) { + return false + } + matched := false + for _, t := range hostTokens { + if strings.Contains(n, t) { + matched = true + break + } + } + if !matched { + return false + } + for _, t := range excludeTokens { + inHost := false + for _, h := range hostTokens { + if h == t { + inHost = true + break + } + } + if !inHost && strings.Contains(n, t) { + return false + } + } + return true + } + + var asset *ghAsset + for i := range rel.Assets { + if matches(rel.Assets[i].Name) { + asset = &rel.Assets[i] + break + } + } + if asset == nil { + errLog(fmt.Sprintf("No GitHub Desktop %s asset found for %s", suffix, archName)) + return + } + dest := filepath.Join(tmp, asset.Name) + if !download(asset.BrowserDownloadURL, dest) { + return + } + installer := pkgMgr + if pkgMgr == "apt-get" { + installer = "apt-get" + } + runCmd([]string{installer, "install", "-y", dest}, CmdOpts{AsSudo: true}) +} + +func installZoom(tmp string) { + if archName != "x86_64" { + warn("Zoom has no aarch64 Linux client — skipping") + return + } + switch pkgMgr { + case "dnf": + dest := filepath.Join(tmp, "zoom.rpm") + if !download("https://zoom.us/client/latest/zoom_x86_64.rpm", dest) { + return + } + runCmd([]string{"dnf", "install", "-y", dest}, CmdOpts{AsSudo: true}) + case "apt-get": + dest := filepath.Join(tmp, "zoom.deb") + if !download("https://zoom.us/client/latest/zoom_amd64.deb", dest) { + return + } + runCmd([]string{"apt-get", "install", "-y", dest}, CmdOpts{AsSudo: true}) + default: + warn("zoom: no installer for this distro — skipping") + } +} + +func installObsidian(tmp string) { + var rel ghRelease + if !fetchJSON("https://api.github.com/repos/obsidianmd/obsidian-releases/releases/latest", &rel) { + return + } + hostTokens := archTokens[archName] + otherTokens := archTokens[otherArch()] + + matches := func(name string) bool { + n := strings.ToLower(name) + if !strings.HasSuffix(n, ".appimage") { + return false + } + matched := false + for _, t := range hostTokens { + if strings.Contains(n, t) { + matched = true + break + } + } + if !matched { + return false + } + for _, t := range otherTokens { + inHost := false + for _, h := range hostTokens { + if h == t { + inHost = true + break + } + } + if !inHost && strings.Contains(n, t) { + return false + } + } + return true + } + + var asset *ghAsset + for i := range rel.Assets { + if matches(rel.Assets[i].Name) { + asset = &rel.Assets[i] + break + } + } + if asset == nil { + errLog(fmt.Sprintf("No Obsidian AppImage found for %s", archName)) + return + } + dest := filepath.Join(tmp, asset.Name) + if !download(asset.BrowserDownloadURL, dest) { + return + } + installPath := "/usr/local/bin/obsidian" + runCmd([]string{"cp", dest, installPath}, CmdOpts{AsSudo: true}) + runCmd([]string{"chmod", "755", installPath}, CmdOpts{AsSudo: true}) + fmt.Printf(" Obsidian AppImage installed at %s\n", installPath) +} + +func installMinikube(tmp string) { + archTok := archMinikube[archName] + baseURL := fmt.Sprintf("https://storage.googleapis.com/minikube/releases/latest/minikube-linux-%s", archTok) + dest := filepath.Join(tmp, "minikube") + if !download(baseURL, dest) { + return + } + fmt.Println(" Fetching SHA256 ...") + shaText := fetchText(baseURL + ".sha256") + if shaText == "" { + return + } + expected := strings.Fields(shaText)[0] + actual, err := sha256Of(dest) + if err != nil { + errLog(fmt.Sprintf("minikube hash failed: %v", err)) + return + } + if actual != expected { + errLog(fmt.Sprintf("minikube SHA256 mismatch: expected %s, got %s", expected, actual)) + return + } + fmt.Println(" SHA256 OK") + installPath := "/usr/local/bin/minikube" + runCmd([]string{"cp", dest, installPath}, CmdOpts{AsSudo: true}) + runCmd([]string{"chmod", "755", installPath}, CmdOpts{AsSudo: true}) + fmt.Printf(" minikube installed to %s\n", installPath) +} + +func installBashtop(_ string) { + home, _ := os.UserHomeDir() + cloneDir := filepath.Join(home, "bashtop") + if _, err := os.Stat(cloneDir); err == nil { + fmt.Printf(" Updating existing clone at %s ...\n", cloneDir) + if !runCmd([]string{"git", "-C", cloneDir, "pull"}, CmdOpts{}).OK() { + errLog("bashtop git pull failed") + return + } + } else { + fmt.Printf(" Cloning bashtop to %s ...\n", cloneDir) + if !runCmd([]string{"git", "clone", "https://github.com/aristocratos/bashtop.git", cloneDir}, CmdOpts{}).OK() { + errLog("bashtop git clone failed") + return + } + } + if !runCmd([]string{"make", "install"}, CmdOpts{AsSudo: true, Cwd: cloneDir}).OK() { + errLog("bashtop 'make install' failed") + return + } + appendProfileLine("bashtop", "export PATH=$PATH:"+cloneDir) + fmt.Printf(" bashtop installed. Clone at %s, binary at /usr/local/bin/bashtop\n", cloneDir) +} + +func installPulumi(tmp string) { + version := fetchText("https://www.pulumi.com/latest-version") + if version == "" { + errLog("Could not determine latest Pulumi version") + return + } + osTok := osGo[osName] + archTok := archPulumi[archName] + tarball := fmt.Sprintf("pulumi-v%s-%s-%s.tar.gz", version, osTok, archTok) + base := fmt.Sprintf("https://github.com/pulumi/pulumi/releases/download/v%s", version) + dest := filepath.Join(tmp, tarball) + if !download(base+"/"+tarball, dest) { + return + } + + checksums := fetchText(fmt.Sprintf("%s/pulumi-%s-checksums.txt", base, version)) + if checksums == "" { + errLog("Could not fetch Pulumi checksums") + return + } + var expected string + for _, line := range strings.Split(checksums, "\n") { + if strings.HasSuffix(strings.TrimSpace(line), tarball) { + expected = strings.Fields(line)[0] + break + } + } + if expected == "" { + errLog(fmt.Sprintf("No checksum entry for %s", tarball)) + return + } + actual, err := sha256Of(dest) + if err != nil { + errLog(fmt.Sprintf("Pulumi hash failed: %v", err)) + return + } + if actual != expected { + errLog(fmt.Sprintf("Pulumi SHA256 mismatch: expected %s, got %s", expected, actual)) + return + } + fmt.Println(" SHA256 OK") + + installDir := "/opt/pulumi" + fmt.Println(" Extracting Pulumi to /opt ...") + runCmd([]string{"mkdir", "-p", "/opt"}, CmdOpts{AsSudo: true}) + runCmd([]string{"rm", "-rf", installDir}, CmdOpts{AsSudo: true}) + runCmd([]string{"tar", "-C", "/opt", "-xzf", dest}, CmdOpts{AsSudo: true}) + + appendProfileLine("pulumi", fmt.Sprintf(`export PATH="$PATH:%s"`, installDir)) + fmt.Printf(" Pulumi %s installed to %s\n", version, installDir) +} + +func installPipx(_ string) { + if !hasCmd("python3") { + errLog("Python 3 is not installed — cannot install pipx") + return + } + pkgInstall("pipx") + if hasCmd("pipx") { + runCmd([]string{"pipx", "ensurepath"}, CmdOpts{}) + } else { + errLog("pipx command not found after install") + } +} + +func installPoetry(_ string) { + if !hasCmd("pipx") { + errLog("pipx is not installed — cannot install poetry") + return + } + runCmd([]string{"pipx", "install", "poetry"}, CmdOpts{}) +} + +func installSpecialPkg(pkg, tmp string) { + switch pkg { + case "github-desktop": + installGitHubDesktop(tmp) + case "zoom": + installZoom(tmp) + case "obsidian": + installObsidian(tmp) + case "minikube": + installMinikube(tmp) + case "bashtop": + installBashtop(tmp) + case "pulumi": + installPulumi(tmp) + case "pipx": + installPipx(tmp) + case "poetry": + installPoetry(tmp) + } +} + +// pkgInstall invokes the host package manager to install a single name. +// Centralized so pacman's "--noconfirm" doesn't leak everywhere. +func pkgInstall(pkg string) CmdResult { + switch pkgMgr { + case "pacman": + return runCmd([]string{"pacman", "-S", "--noconfirm", "--needed", pkg}, CmdOpts{AsSudo: true}) + case "brew": + if brewCasks[pkg] { + return runCmd([]string{"brew", "install", "--cask", pkg}, CmdOpts{}) + } + return runCmd([]string{"brew", "install", pkg}, CmdOpts{}) + default: + return runCmd([]string{pkgMgr, "install", "-y", pkg}, CmdOpts{AsSudo: true}) + } +} + +// installSystemPackages installs the regular + special package lists. +func installSystemPackages(regular, special []string) { + fmt.Println("\n=== System Packages ===") + + if pkgMgr == "brew" { + for _, pkg := range regular { + res := pkgInstall(pkg) + if !res.OK() { + errLog(fmt.Sprintf("System package failed to install: %s", pkg)) + } + } + // No special packages on macOS — brew covers all of them. + return + } + + seenRepos := map[int]bool{} + groups := repoGroups() + for _, pkg := range regular { + for i, g := range groups { + if g.members[pkg] && !seenRepos[i] { + fmt.Printf(" [REPO] Setting up repository for %s ...\n", pkg) + g.setup() + seenRepos[i] = true + } + } + } + + for _, pkg := range regular { + res := pkgInstall(pkg) + if !res.OK() { + errLog(fmt.Sprintf("System package failed to install: %s", pkg)) + } + } + + if len(special) > 0 { + tmp, err := os.MkdirTemp("", "bootstrap-special-") + if err != nil { + errLog(fmt.Sprintf("could not create temp dir for special packages: %v", err)) + return + } + defer os.RemoveAll(tmp) + for _, pkg := range special { + fmt.Printf("\n [SPECIAL] Installing %s ...\n", pkg) + installSpecialPkg(pkg, tmp) + } + } +} + +// appendProfileLine adds a PATH/env line to a system-wide login-shell profile, +// idempotently. On Linux uses /etc/profile.d/.sh; macOS uses /etc/zprofile. +func appendProfileLine(scriptName, line string) { + target := fmt.Sprintf("/etc/profile.d/%s.sh", scriptName) + if isMacOS { + target = "/etc/zprofile" + } + cmd := fmt.Sprintf("grep -qxF %q %s 2>/dev/null || echo %q >> %s", line, target, line, target) + runCmd([]string{"bash", "-c", cmd}, CmdOpts{AsSudo: true}) +}