From a4d1dfb9fbda92bfce1a3f55203a81b77280a6fe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 13:44:39 +0000 Subject: [PATCH 1/3] Refactor package list to Python module with dynamic versions - Move SYSTEM_PACKAGES, FLATPAK_PACKAGES, and CUSTOM_PACKAGES into formatted_packages.py as typed Python data; drop the bespoke parser - Custom package URLs are now string templates with {version}, {arch}, and {arch_go} substitutions, so the same entry works on x86_64 and aarch64 without hand-editing the URL - Add best-effort latest-version resolvers for Go, Firecracker, and Zig (using go.dev JSON, GitHub releases, and ziglang index.json respectively); on any failure the pinned version + sha256 are used and the fallback is logged - Make Neovim's archive name and install dir arch-aware (x86_64/arm64) - Document that --no-gui skips the entire Flatpak section, including installing flatpak itself --- bootstrap_environment.py | 269 +++++++++++++++++++++++++-------------- formatted_packages.py | 113 ++++++++++++++++ formatted_packages.txt | 88 ------------- 3 files changed, 286 insertions(+), 184 deletions(-) create mode 100644 formatted_packages.py delete mode 100644 formatted_packages.txt diff --git a/bootstrap_environment.py b/bootstrap_environment.py index 20a7423..0872d32 100755 --- a/bootstrap_environment.py +++ b/bootstrap_environment.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 """ -Bootstrap packages listed in formatted_packages.txt. +Bootstrap packages declared in formatted_packages.py. Sections handled: - === System Packages === — installed via dnf or apt-get - === Flatpak Packages === — installed via flatpak from Flathub - === Custom Installed Packages === — downloaded, verified, extracted + System Packages — installed via dnf or apt-get + Flatpak Packages — installed via flatpak from Flathub (skipped with --no-gui) + Custom Packages — downloaded, verified, extracted Usage: sudo python3 bootstrap_environment.py [--only system|flatpak|custom] [--no-gui] @@ -32,8 +32,9 @@ from dataclasses import dataclass from pathlib import Path from typing import Optional +import formatted_packages + SCRIPT_DIR = Path(__file__).parent -PACKAGES_FILE = SCRIPT_DIR / "formatted_packages.txt" RUN_LOG = SCRIPT_DIR / "bootstrap_run.log" # ── issue log ───────────────────────────────────────────────────────────────── @@ -118,6 +119,11 @@ ARCH = detect_arch() _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"} + +def _url_format(template: str, version: Optional[str]) -> str: + """Interpolate {version}, {arch}, {arch_go} into a URL template.""" + return template.format(version=version or "", arch=ARCH, arch_go=_ARCH_GO[ARCH]) def _arch_matches(name: str, arch: str = ARCH) -> bool: n = name.lower() @@ -174,6 +180,14 @@ def _fetch_json(url: str) -> Optional[dict]: 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 is_system_pkg_installed(pkg: str) -> bool: @@ -625,28 +639,41 @@ def install_flatpak_packages(to_install: list[str]) -> None: @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 + version: Optional[str] = None # pinned fallback version + url_template: Optional[str] = None # uses {version}, {arch}, {arch_go} + sha256: Optional[str] = None # hex digest of the pinned archive + 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 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(f"/opt/nvim-linux-{_ARCH_NVIM[ARCH]}"), +} 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") - if n == "neovim": - return Path("/opt/nvim-linux-x86_64") - return None + return _DEFAULT_INSTALL_PATHS.get(pkg.name.lower()) def is_custom_pkg_installed(pkg: CustomPackage) -> tuple[bool, Optional[Path]]: @@ -741,8 +768,7 @@ def _install_firecracker(archive: Path, tmp: Path) -> None: 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}" + 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) @@ -780,7 +806,8 @@ def _install_neovim(pkg: CustomPackage, tmp: Path) -> None: if data is None: return - asset_name = "nvim-linux-x86_64.tar.gz" + arch_token = _ARCH_NVIM[ARCH] + asset_name = f"nvim-linux-{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") @@ -802,17 +829,18 @@ def _install_neovim(pkg: CustomPackage, tmp: Path) -> None: return print(" SHA256 OK") - print(" Extracting Neovim to /opt ...") - run(["rm", "-rf", "/opt/nvim-linux-x86_64"], as_sudo=True) + install_dir = f"/opt/nvim-linux-{arch_token}" + print(f" Extracting Neovim to /opt ...") + run(["rm", "-rf", install_dir], as_sudo=True) run(["tar", "-C", "/opt", "-xzf", str(dest)], as_sudo=True) - profile_line = 'export PATH="$PATH:/opt/nvim-linux-x86_64/bin"' + profile_line = f'export PATH="$PATH:{install_dir}/bin"' profile_script = "/etc/profile.d/neovim.sh" run(["bash", "-c", f"grep -qxF {profile_line!r} {profile_script} 2>/dev/null || " f"echo {profile_line!r} >> {profile_script}"], as_sudo=True, check=False) - print(f" Neovim installed to /opt/nvim-linux-x86_64") + print(f" Neovim installed to {install_dir}") def _clone_nvim_config() -> None: @@ -962,11 +990,106 @@ def ensure_python_latest() -> Optional[threading.Thread]: 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}.linux-{_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]]: + 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}-linux") + 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 + 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: _, check_path = is_custom_pkg_installed(pkg) - print(f"\n Installing {pkg.name} ..." + print(f"\n Installing {pkg.display_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") @@ -985,6 +1108,8 @@ def install_custom_packages(to_install: list[CustomPackage]) -> None: _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 @@ -999,11 +1124,11 @@ def install_custom_packages(to_install: list[CustomPackage]) -> None: continue if not _verify(archive, pkg): continue - if name_lower.startswith("go-"): + if name_lower == "go": _install_go(archive) - elif "firecracker" in name_lower: + elif name_lower == "firecracker": _install_firecracker(archive, tmp) - elif "zig" in name_lower: + elif name_lower == "zig": _install_zig(pkg, archive, tmp) else: warn(f"No install handler for '{pkg.name}' — skipping") @@ -1096,10 +1221,10 @@ def print_check_summary(sys_c: dict, flat_c: dict, cust_c: dict, only: Optional[ ok = cust_c["already_installed"] print("\nCustom packages:") for pkg, path in ok: - print(f" [OK] {pkg.name} ({path})") + print(f" [OK] {pkg.display_name} ({path})") for pkg in to: _, path = is_custom_pkg_installed(pkg) - print(f" [INSTALL] {pkg.name}" + (f" → {path}" if path else "")) + print(f" [INSTALL] {pkg.display_name}" + (f" → {path}" if path else "")) total += len(to) return total @@ -1154,79 +1279,29 @@ def check_and_setup_ssh() -> None: err("gh auth login failed — skipping key upload.") return -# ── file parser ─────────────────────────────────────────────────────────────── +# ── packages module loader ──────────────────────────────────────────────────── -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() +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 from formatted_packages.txt" + 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("--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)") + "Excludes GUI system packages and skips the entire Flatpak " + "section, including installing flatpak itself.") 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) + system_pkgs, flatpak_pkgs, custom_pkgs = load_packages() print(f"Architecture: {ARCH}") print(f"Package manager: {PKG_MGR}") @@ -1241,6 +1316,8 @@ def main() -> None: print(f" [NO-GUI] Skipping GUI system packages: {_fmt(skipped_gui)}") flatpak_pkgs = [] + # --no-gui suppresses the Flatpak section entirely (both `flatpak` itself + # and the Flathub apps), even when --only=flatpak is requested. do_flatpak = args.only in (None, "flatpak") and not args.no_gui sys_c = check_system_packages(system_pkgs) if args.only in (None, "system") else {} diff --git a/formatted_packages.py b/formatted_packages.py new file mode 100644 index 0000000..f33f86f --- /dev/null +++ b/formatted_packages.py @@ -0,0 +1,113 @@ +"""Package definitions consumed by bootstrap_environment.py. + +System and Flatpak packages are flat lists of names. + +Custom packages declare a URL template plus an optional ``fetch_latest`` hint. +At install time the bootstrap script will attempt to look up the most recent +release and fall back to the pinned (version, sha256) tuple on failure. + +URL templates use ``str.format`` with the following substitutions: + {version} pkg.version (or the latest resolved version) + {arch} "x86_64" or "aarch64" + {arch_go} Go-style: "amd64" or "arm64" +""" + +SYSTEM_PACKAGES: list[str] = [ + "ansible", + "ansible-core", + "aria2", + "bashtop", + "build-essential", + "buildah", + "containerd.io", + "docker-buildx-plugin", + "docker-ce-cli", + "docker-ce-rootless-extras", + "docker-ce", + "docker-compose-plugin", + "dotnet-sdk-10.0", + "ffmpeg-free", + "gcc", + "gh", + "git", + "github-desktop", + "google-chrome-stable", + "lua", + "minisign", + "minikube", + "obs-studio", + "obsidian", + "pipx", + "poetry", + "podman", + "qemu", + "restic", + "rg", + "shutter", + "temurin-25-jdk", + "vagrant", + "virt-manager", + "vivaldi-stable", + "webcamoid", + "wireshark", + "yt-dlp", + "zoom", + "bzip2", + "bzip2-devel", + "gdbm-libs", + "libffi-devel", + "libnsl2", + "libuuid-devel", + "libzstd-devel", + "make", + "openssl-devel", + "patch", + "readline-devel", + "sqlite", + "sqlite-devel", + "tk-devel", + "xz-devel", + "zlib-devel", +] + +FLATPAK_PACKAGES: list[str] = [ + "com.obsproject.Studio", + "fr.handbrake.ghb", + "io.github.webcamoid.Webcamoid", + "one.ablaze.floorp", + "com.vivaldi.Vivaldi", + "org.darktable.Darktable", +] + +CUSTOM_PACKAGES: list[dict] = [ + { + "name": "go", + "version": "1.26.3", + "url_template": "https://go.dev/dl/go{version}.linux-{arch_go}.tar.gz", + "sha256": "2b2cfc7148493da5e73981bffbf3353af381d5f93e789c82c79aff64962eb556", + "fetch_latest": "go", + }, + {"name": "neovim"}, + { + "name": "firecracker", + "version": "1.15.1", + "url_template": ( + "https://github.com/firecracker-microvm/firecracker/releases/download/" + "v{version}/firecracker-v{version}-{arch}.tgz" + ), + "sha256": "d4a32ab2322d887ca1bc4a4e7afa9cc35393e6362dfc2b3becb389d362e4275a", + "fetch_latest": "firecracker", + }, + { + "name": "zig", + "version": "0.16.0", + "url_template": "https://ziglang.org/download/{version}/zig-{arch}-linux-{version}.tar.xz", + "sha256_url_template": ( + "https://ziglang.org/download/{version}/zig-{arch}-linux-{version}.tar.xz.minisig" + ), + "minisign_key": "RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U", + "fetch_latest": "zig", + }, + {"name": "nvm"}, + {"name": "pyenv"}, +] diff --git a/formatted_packages.txt b/formatted_packages.txt deleted file mode 100644 index a63e155..0000000 --- a/formatted_packages.txt +++ /dev/null @@ -1,88 +0,0 @@ -=== System Packages === -ansible -ansible-core -aria2 -bashtop -build-essential -buildah -containerd.io -docker-buildx-plugin -docker-ce-cli -docker-ce-rootless-extras -docker-ce -docker-compose-plugin -dotnet-sdk-10.0 -ffmpeg-free -gcc -gh -git -github-desktop -google-chrome-stable -lua -minisign -minikube -obs-studio -obsidian -pipx -poetry -podman -qemu -restic -rg -shutter -temurin-25-jdk -vagrant -virt-manager -vivaldi-stable -webcamoid -wireshark -yt-dlp -zoom -bzip2 -bzip2-devel -gdbm-libs -libffi-devel -libnsl2 -libuuid-devel -libzstd-devel -make -openssl-devel -patch -readline-devel -sqlite -sqlite-devel -tk-devel -xz-devel -zlib-devel - - - - -=== Flatpak Packages === -com.obsproject.Studio -fr.handbrake.ghb -io.github.webcamoid.Webcamoid -one.ablaze.floorp -com.vivaldi.Vivaldi -org.darktable.Darktable - -=== Custom Installed Packages === -Name - go-1.26.3 -URL - https://go.dev/dl/go1.26.3.linux-amd64.tar.gz -SHA256 - 2b2cfc7148493da5e73981bffbf3353af381d5f93e789c82c79aff64962eb556 - -Name - neovim -URL - https://github.com/neovim/neovim/releases/latest/download/nvim-linux-x86_64.tar.gz - -Name - firecracker-v1.15.1 -URL - https://github.com/firecracker-microvm/firecracker/releases/download/v1.15.1/firecracker-v1.15.1-x86_64.tgz -SHA256 - d4a32ab2322d887ca1bc4a4e7afa9cc35393e6362dfc2b3becb389d362e4275a - -Name - zig-0.16.0 -URL - https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz -SHA256_URL_TAR - https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz.minisig -MINISIGN_KEY - RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U - -Name - nvm - -Name - pyenv -- 2.47.3 From a05df917f72f03f4e5e68845552c71b2bcd2b627 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 13:54:46 +0000 Subject: [PATCH 2/3] Add pip as a custom package via python -m ensurepip pip is unusual among the custom packages because it ships inside CPython itself, so it doesn't need a URL or archive. The handler bootstraps it from the standard-library wheel via 'python3 -m ensurepip --upgrade' and then self-upgrades to the latest version. Detection uses 'python3 -m pip --version' instead of a filesystem path. --- bootstrap_environment.py | 55 ++++++++++++++++++++++++++++++++++++---- formatted_packages.py | 1 + 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/bootstrap_environment.py b/bootstrap_environment.py index 0872d32..26ff097 100755 --- a/bootstrap_environment.py +++ b/bootstrap_environment.py @@ -676,8 +676,24 @@ def _default_install_path(pkg: CustomPackage) -> Optional[Path]: return _DEFAULT_INSTALL_PATHS.get(pkg.name.lower()) +def _pip_installed() -> bool: + if not has_cmd("python3"): + return False + return subprocess.run( + ["python3", "-m", "pip", "--version"], + capture_output=True, check=False, + ).returncode == 0 + + def is_custom_pkg_installed(pkg: CustomPackage) -> tuple[bool, Optional[Path]]: - """Return (is_installed, check_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 @@ -788,6 +804,33 @@ def _install_pyenv() -> None: 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. + """ + if not has_cmd("python3"): + err("python3 is not installed — cannot install pip") + return + print(" Bootstrapping pip via 'python3 -m ensurepip --upgrade' ...") + bootstrap = run( + ["python3", "-m", "ensurepip", "--upgrade"], + as_sudo=True, check=False, + ) + if bootstrap.returncode != 0: + 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_nvm() -> None: data = _fetch_json("https://api.github.com/repos/nvm-sh/nvm/releases/latest") if data is None: @@ -1088,14 +1131,13 @@ def _resolve_latest(pkg: CustomPackage) -> None: 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: + if check_path is None and name_lower != "pip": 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() @@ -1103,6 +1145,9 @@ def install_custom_packages(to_install: list[CustomPackage]) -> None: if name_lower == "pyenv": _install_pyenv() continue + if name_lower == "pip": + _install_pip() + continue if name_lower == "neovim": with tempfile.TemporaryDirectory() as tmp_str: _install_neovim(pkg, Path(tmp_str)) @@ -1221,7 +1266,7 @@ def print_check_summary(sys_c: dict, flat_c: dict, cust_c: dict, only: Optional[ ok = cust_c["already_installed"] print("\nCustom packages:") for pkg, path in ok: - print(f" [OK] {pkg.display_name} ({path})") + 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 "")) diff --git a/formatted_packages.py b/formatted_packages.py index f33f86f..c5fa3a6 100644 --- a/formatted_packages.py +++ b/formatted_packages.py @@ -110,4 +110,5 @@ CUSTOM_PACKAGES: list[dict] = [ }, {"name": "nvm"}, {"name": "pyenv"}, + {"name": "pip"}, ] -- 2.47.3 From 8acdb30e83dd1ffe6075a3630e08cd179e9949d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 14:05:09 +0000 Subject: [PATCH 3/3] Add zsh + oh-my-zsh, set default shell per distro family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add zsh to SYSTEM_PACKAGES - Detect RHEL-family vs Debian-family from /etc/os-release ID/ID_LIKE (falls back to PKG_MGR if os-release is unreadable) - ensure_zsh_default sets zsh as the invoking user's login shell after system install: usermod -s on RHEL-family (chsh under default authselect refuses other-user changes), chsh -s elsewhere; SUDO_USER is preferred so sudo invocations target the real user - Add oh-my-zsh to CUSTOM_PACKAGES; the installer runs the official unattended script (requires zsh + git, which are already installed by this point) and rewrites ZSH_THEME to "gnzh" in ~/.zshrc - As a final step, run 'zsh -c "source ~/.zshrc"' to validate the rc file (the user's interactive shell is unaffected — they need a new terminal to pick up the new default shell) --- bootstrap_environment.py | 118 +++++++++++++++++++++++++++++++++++++++ formatted_packages.py | 2 + 2 files changed, 120 insertions(+) diff --git a/bootstrap_environment.py b/bootstrap_environment.py index 26ff097..684cf74 100755 --- a/bootstrap_environment.py +++ b/bootstrap_environment.py @@ -158,6 +158,22 @@ def detect_pkg_mgr() -> str: PKG_MGR = detect_pkg_mgr() + +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: @@ -670,6 +686,7 @@ _DEFAULT_INSTALL_PATHS: dict[str, Path] = { "nvm": Path("~/.nvm"), "pyenv": Path("~/.pyenv"), "neovim": Path(f"/opt/nvim-linux-{_ARCH_NVIM[ARCH]}"), + "oh-my-zsh": Path("~/.oh-my-zsh"), } def _default_install_path(pkg: CustomPackage) -> Optional[Path]: @@ -831,6 +848,45 @@ def _install_pip() -> None: "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: @@ -918,6 +974,55 @@ def _clone_nvim_config() -> None: 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" @@ -1148,6 +1253,9 @@ def install_custom_packages(to_install: list[CustomPackage]) -> None: 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)) @@ -1388,6 +1496,7 @@ def main() -> None: 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"]) @@ -1410,6 +1519,15 @@ def main() -> None: write_run_log() print("\nDone.") + # Final step (user-requested): source ~/.zshrc. + # This runs in a subshell, so it only validates the rc file — the user's + # interactive shell is unaffected and they'll need to open a new terminal + # (or 'exec zsh') to pick up the new default shell. + zshrc = Path.home() / ".zshrc" + if has_cmd("zsh") and zshrc.exists(): + print("\nSourcing ~/.zshrc ...") + shell(f"zsh -c 'source {zshrc}'", check=False) + if __name__ == "__main__": main() diff --git a/formatted_packages.py b/formatted_packages.py index c5fa3a6..3d47dec 100644 --- a/formatted_packages.py +++ b/formatted_packages.py @@ -52,6 +52,7 @@ SYSTEM_PACKAGES: list[str] = [ "wireshark", "yt-dlp", "zoom", + "zsh", "bzip2", "bzip2-devel", "gdbm-libs", @@ -111,4 +112,5 @@ CUSTOM_PACKAGES: list[dict] = [ {"name": "nvm"}, {"name": "pyenv"}, {"name": "pip"}, + {"name": "oh-my-zsh"}, ] -- 2.47.3