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}) +}