#!/usr/bin/env python3 """ Bootstrap packages listed in formatted_packages.txt. Sections handled: === System Packages === — installed via dnf or apt-get === Flatpak Packages === — installed via flatpak from Flathub === Custom Installed Packages === — downloaded, verified, extracted Usage: sudo python3 bootstrap_environment.py [--only system|flatpak|custom] [--no-gui] """ 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 SCRIPT_DIR = Path(__file__).parent PACKAGES_FILE = SCRIPT_DIR / "formatted_packages.txt" 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}") # ── subprocess helpers ──────────────────────────────────────────────────────── def run( cmd: list, *, as_sudo: bool = False, check: bool = True, input: Optional[bytes] = None, capture_output: bool = False, cwd: Optional[str] = None, ) -> subprocess.CompletedProcess: if as_sudo and os.geteuid() != 0: cmd = ["sudo"] + cmd print(f" $ {' '.join(str(c) for c in cmd)}") return subprocess.run( cmd, check=check, input=input, capture_output=capture_output, cwd=cwd, ) def shell( cmd: str, *, check: bool = True, capture_output: bool = False, text: bool = False, ) -> subprocess.CompletedProcess: print(f" $ {cmd}") return subprocess.run( cmd, shell=True, check=check, capture_output=capture_output, text=text, ) def has_cmd(name: str) -> bool: return shutil.which(name) is not None # ── 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"} 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: return if not has_cmd("sudo"): sys.exit("sudo is required but not installed.") print("Validating sudo access ...") result = subprocess.run(["sudo", "-v"], check=False) if result.returncode != 0: sys.exit("sudo authentication failed.") # ── package manager detection ───────────────────────────────────────────────── def detect_pkg_mgr() -> str: for mgr in ("dnf", "apt-get"): if has_cmd(mgr): return mgr sys.exit("No supported package manager found (expected dnf or apt-get).") PKG_MGR = detect_pkg_mgr() # ── 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 # ── installation checks ─────────────────────────────────────────────────────── def is_system_pkg_installed(pkg: str) -> bool: if PKG_MGR == "dnf": return subprocess.run(["rpm", "-q", pkg], capture_output=True).returncode == 0 elif PKG_MGR == "apt-get": result = subprocess.run( ["dpkg-query", "-W", "-f=${Status}", pkg], capture_output=True, text=True, check=False, ) return "install ok installed" in result.stdout return False def is_flatpak_installed(app_id: str) -> bool: if not has_cmd("flatpak"): return False return subprocess.run(["flatpak", "info", app_id], capture_output=True).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 == "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 "rg": ["ripgrep"], }, } 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", "install", "-y", "ca-certificates", "curl", "gnupg"], as_sudo=True) shell( "install -m 0755 -d /etc/apt/keyrings && " "curl -fsSL https://download.docker.com/linux/ubuntu/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/ubuntu {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" ) _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), ] # ── special package installers ──────────────────────────────────────────────── _SPECIAL_PKGS = {"github-desktop", "zoom", "obsidian", "minikube", "bashtop", "pipx", "poetry"} # GUI apps — skipped when --no-gui is passed (headless environments). _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. profile_line = f"export PATH=$PATH:{clone_dir}" profile_script = "/etc/profile.d/bashtop.sh" run(["bash", "-c", f"grep -qxF {profile_line!r} {profile_script} 2>/dev/null || " f"echo {profile_line!r} >> {profile_script}"], as_sudo=True, check=False) print(f" bashtop installed. Clone at {clone_dir}, binary at /usr/local/bin/bashtop") 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 == "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 ===") 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 url: Optional[str] = None # archive download URL (not required for all install methods) sha256: Optional[str] = None # hex digest to compare against sha256_url: Optional[str] = None # URL to a minisig file minisign_key: Optional[str] = None # base64 public key for minisign verification install_path: Optional[str] = None # override the default install-check path def _default_install_path(pkg: CustomPackage) -> Optional[Path]: n = pkg.name.lower() if n.startswith("go-"): return Path("/usr/local/go") if "firecracker" in n: return Path("/usr/local/bin/firecracker") if "zig" in n: return Path("/usr/local/bin/zig") if n == "nvm": return Path("~/.nvm") if n == "pyenv": return Path("~/.pyenv") return None def is_custom_pkg_installed(pkg: CustomPackage) -> tuple[bool, Optional[Path]]: """Return (is_installed, check_path).""" 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.""" if pkg.sha256: actual = _sha256_of(archive) if actual != pkg.sha256: err(f"SHA256 mismatch for {pkg.name}: expected {pkg.sha256}, 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) profile_line = "export PATH=$PATH:/usr/local/go/bin" profile_script = "/etc/profile.d/local_go.sh" run(["bash", "-c", f"grep -qxF {profile_line!r} {profile_script} 2>/dev/null || " f"echo {profile_line!r} >> {profile_script}"], as_sudo=True, check=False) 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") version = pkg.name.split("-", 1)[1] # "zig-0.16.0" → "0.16.0" zig_dir = parent / f"zig-{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}-linux*"), 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_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 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`.""" result = subprocess.run( [str(pyenv_bin), "install", "--list"], capture_output=True, text=True, check=False, ) 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 installed = subprocess.run( [str(pyenv_bin), "versions", "--bare"], capture_output=True, text=True, check=False, ).stdout.split() 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] p1 = subprocess.run(install_cmd, capture_output=True, text=True, check=False) 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 p2 = subprocess.run( [str(pyenv_bin), "global", latest], capture_output=True, text=True, check=False, ) 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 install_custom_packages(to_install: list[CustomPackage]) -> None: print("\n=== Custom Packages ===") for pkg in to_install: _, check_path = is_custom_pkg_installed(pkg) print(f"\n Installing {pkg.name} ..." + (f" (install path: {check_path})" if check_path else "")) if check_path is None: warn(f"{pkg.name}: no known install path — script will not detect future installs") name_lower = pkg.name.lower() # Handlers that manage their own download/install if name_lower == "nvm": _install_nvm() continue if name_lower == "pyenv": _install_pyenv() continue 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.startswith("go-"): _install_go(archive) elif "firecracker" in name_lower: _install_firecracker(archive, tmp) elif "zig" in name_lower: _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.name} ({path})") for pkg in to: _, path = is_custom_pkg_installed(pkg) print(f" [INSTALL] {pkg.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: return subprocess.run( ["gh", "auth", "status"], capture_output=True, check=False ).returncode == 0 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 result = subprocess.run(["gh", "auth", "login"], check=False) if result.returncode != 0: err("gh auth login failed — skipping key upload.") return # ── file parser ─────────────────────────────────────────────────────────────── def parse_packages_file(path: Path) -> tuple[list[str], list[str], list[CustomPackage]]: system_pkgs: list[str] = [] flatpak_pkgs: list[str] = [] custom_pkgs: list[CustomPackage] = [] current: dict = {} section: Optional[str] = None def flush_custom(): if "name" in current: custom_pkgs.append(CustomPackage(**current)) current.clear() for raw in path.read_text().splitlines(): line = raw.strip() if line == "=== System Packages ===": section = "system" elif line == "=== Flatpak Packages ===": flush_custom() section = "flatpak" elif line == "=== Custom Installed Packages ===": flush_custom() section = "custom" elif not line: if section == "custom": flush_custom() elif line.startswith("==="): pass elif section == "system": system_pkgs.append(line) elif section == "flatpak": flatpak_pkgs.append(line) elif section == "custom" and " - " in line: key, _, val = line.partition(" - ") key = key.strip().lower().replace(" ", "_") val = val.strip() if key == "name": current["name"] = val elif key == "url": current["url"] = val elif key == "sha256": current["sha256"] = val elif key == "sha256_url_tar": current["sha256_url"] = val elif key == "minisign_key": current["minisign_key"] = val elif key == "install_path": current["install_path"] = val flush_custom() return system_pkgs, flatpak_pkgs, custom_pkgs # ── entry point ─────────────────────────────────────────────────────────────── def main() -> None: ap = argparse.ArgumentParser( description="Bootstrap packages from formatted_packages.txt" ) ap.add_argument("--only", choices=["system", "flatpak", "custom"], help="Install only the named section") ap.add_argument("--no-gui", action="store_true", help="Skip GUI applications (suitable for headless environments). " "Excludes GUI system packages and the entire Flatpak section.") ap.add_argument("--file", default=str(PACKAGES_FILE), metavar="PATH", help="Path to packages file (default: formatted_packages.txt next to this script)") args = ap.parse_args() pkg_file = Path(args.file) if not pkg_file.exists(): sys.exit(f"Packages file not found: {pkg_file}") system_pkgs, flatpak_pkgs, custom_pkgs = parse_packages_file(pkg_file) print(f"Architecture: {ARCH}") print(f"Package manager: {PKG_MGR}") if args.no_gui: print("Mode: headless (--no-gui) — skipping GUI apps and Flatpak") print("Checking installed packages ...") if args.no_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" [NO-GUI] Skipping GUI system packages: {_fmt(skipped_gui)}") flatpak_pkgs = [] do_flatpak = args.only in (None, "flatpak") and not args.no_gui 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"]) 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() 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("\nDone.") if __name__ == "__main__": main()