When python3-full is not installed on Debian/Ubuntu (common with Python 3.13), the _decimal C extension fails to import, crashing any pip invocation with RuntimeError. Add _python3_decimal_ok() to probe for this, and _fix_python3_decimal() to install python3-full (apt) or python3-libs (dnf). _install_pip() now calls these before attempting any ensurepip/pip operations so the error is fixed automatically rather than producing an unrecoverable crash. https://claude.ai/code/session_01Lrg3UV7AJN9KRXyTTWGgZi
2552 lines
96 KiB
Python
Executable File
2552 lines
96 KiB
Python
Executable File
#!/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()
|