Refactor package list to Python module with dynamic versions #1
+342
-102
@@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Bootstrap packages listed in formatted_packages.txt.
|
Bootstrap packages declared in formatted_packages.py.
|
||||||
|
|
||||||
Sections handled:
|
Sections handled:
|
||||||
=== System Packages === — installed via dnf or apt-get
|
System Packages — installed via dnf or apt-get
|
||||||
=== Flatpak Packages === — installed via flatpak from Flathub
|
Flatpak Packages — installed via flatpak from Flathub (skipped with --no-gui)
|
||||||
=== Custom Installed Packages === — downloaded, verified, extracted
|
Custom Packages — downloaded, verified, extracted
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
sudo python3 bootstrap_environment.py [--only system|flatpak|custom] [--no-gui]
|
sudo python3 bootstrap_environment.py [--only system|flatpak|custom] [--no-gui]
|
||||||
@@ -32,8 +32,9 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import formatted_packages
|
||||||
|
|
||||||
SCRIPT_DIR = Path(__file__).parent
|
SCRIPT_DIR = Path(__file__).parent
|
||||||
PACKAGES_FILE = SCRIPT_DIR / "formatted_packages.txt"
|
|
||||||
RUN_LOG = SCRIPT_DIR / "bootstrap_run.log"
|
RUN_LOG = SCRIPT_DIR / "bootstrap_run.log"
|
||||||
|
|
||||||
# ── issue log ─────────────────────────────────────────────────────────────────
|
# ── issue log ─────────────────────────────────────────────────────────────────
|
||||||
@@ -118,6 +119,11 @@ ARCH = detect_arch()
|
|||||||
_ARCH_GO = {"x86_64": "amd64", "aarch64": "arm64"}
|
_ARCH_GO = {"x86_64": "amd64", "aarch64": "arm64"}
|
||||||
_ARCH_MINIKUBE = {"x86_64": "amd64", "aarch64": "arm64"}
|
_ARCH_MINIKUBE = {"x86_64": "amd64", "aarch64": "arm64"}
|
||||||
_ARCH_DEB = {"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:
|
def _arch_matches(name: str, arch: str = ARCH) -> bool:
|
||||||
n = name.lower()
|
n = name.lower()
|
||||||
@@ -152,6 +158,22 @@ def detect_pkg_mgr() -> str:
|
|||||||
|
|
||||||
PKG_MGR = detect_pkg_mgr()
|
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 ───────────────────────────────────────────────────────────
|
# ── network helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _download(url: str, dest: Path) -> bool:
|
def _download(url: str, dest: Path) -> bool:
|
||||||
@@ -174,6 +196,14 @@ def _fetch_json(url: str) -> Optional[dict]:
|
|||||||
err(f"API request failed for {url}: {e}")
|
err(f"API request failed for {url}: {e}")
|
||||||
return None
|
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 ───────────────────────────────────────────────────────
|
# ── installation checks ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
def is_system_pkg_installed(pkg: str) -> bool:
|
def is_system_pkg_installed(pkg: str) -> bool:
|
||||||
@@ -625,32 +655,62 @@ def install_flatpak_packages(to_install: list[str]) -> None:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class CustomPackage:
|
class CustomPackage:
|
||||||
name: str
|
name: str
|
||||||
url: Optional[str] = None # archive download URL (not required for all install methods)
|
version: Optional[str] = None # pinned fallback version
|
||||||
sha256: Optional[str] = None # hex digest to compare against
|
url_template: Optional[str] = None # uses {version}, {arch}, {arch_go}
|
||||||
sha256_url: Optional[str] = None # URL to a minisig file
|
sha256: Optional[str] = None # hex digest of the pinned archive
|
||||||
minisign_key: Optional[str] = None # base64 public key for minisign verification
|
sha256_url_template: Optional[str] = None # template for a .minisig URL
|
||||||
install_path: Optional[str] = None # override the default install-check path
|
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]}"),
|
||||||
|
"oh-my-zsh": Path("~/.oh-my-zsh"),
|
||||||
|
}
|
||||||
|
|
||||||
def _default_install_path(pkg: CustomPackage) -> Optional[Path]:
|
def _default_install_path(pkg: CustomPackage) -> Optional[Path]:
|
||||||
n = pkg.name.lower()
|
return _DEFAULT_INSTALL_PATHS.get(pkg.name.lower())
|
||||||
if n.startswith("go-"):
|
|
||||||
return Path("/usr/local/go")
|
|
||||||
if "firecracker" in n:
|
def _pip_installed() -> bool:
|
||||||
return Path("/usr/local/bin/firecracker")
|
if not has_cmd("python3"):
|
||||||
if "zig" in n:
|
return False
|
||||||
return Path("/usr/local/bin/zig")
|
return subprocess.run(
|
||||||
if n == "nvm":
|
["python3", "-m", "pip", "--version"],
|
||||||
return Path("~/.nvm")
|
capture_output=True, check=False,
|
||||||
if n == "pyenv":
|
).returncode == 0
|
||||||
return Path("~/.pyenv")
|
|
||||||
if n == "neovim":
|
|
||||||
return Path("/opt/nvim-linux-x86_64")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_custom_pkg_installed(pkg: CustomPackage) -> tuple[bool, Optional[Path]]:
|
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)
|
raw = Path(pkg.install_path) if pkg.install_path else _default_install_path(pkg)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return False, None
|
return False, None
|
||||||
@@ -741,8 +801,7 @@ def _install_firecracker(archive: Path, tmp: Path) -> None:
|
|||||||
|
|
||||||
def _install_zig(pkg: CustomPackage, archive: Path, tmp: Path) -> None:
|
def _install_zig(pkg: CustomPackage, archive: Path, tmp: Path) -> None:
|
||||||
parent = Path("/usr/local")
|
parent = Path("/usr/local")
|
||||||
version = pkg.name.split("-", 1)[1] # "zig-0.16.0" → "0.16.0"
|
zig_dir = parent / f"zig-{pkg.version}"
|
||||||
zig_dir = parent / f"zig-{version}"
|
|
||||||
if zig_dir.exists():
|
if zig_dir.exists():
|
||||||
run(["rm", "-rf", str(zig_dir)], as_sudo=True)
|
run(["rm", "-rf", str(zig_dir)], as_sudo=True)
|
||||||
run(["tar", "-C", str(parent), "-xJf", str(archive)], as_sudo=True)
|
run(["tar", "-C", str(parent), "-xJf", str(archive)], as_sudo=True)
|
||||||
@@ -762,6 +821,72 @@ def _install_pyenv() -> None:
|
|||||||
print(" pyenv installed to ~/.pyenv")
|
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_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:
|
def _install_nvm() -> None:
|
||||||
data = _fetch_json("https://api.github.com/repos/nvm-sh/nvm/releases/latest")
|
data = _fetch_json("https://api.github.com/repos/nvm-sh/nvm/releases/latest")
|
||||||
if data is None:
|
if data is None:
|
||||||
@@ -780,7 +905,8 @@ def _install_neovim(pkg: CustomPackage, tmp: Path) -> None:
|
|||||||
if data is None:
|
if data is None:
|
||||||
return
|
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)
|
asset = next((a for a in data["assets"] if a["name"] == asset_name), None)
|
||||||
if asset is None:
|
if asset is None:
|
||||||
err(f"Neovim asset {asset_name} not found")
|
err(f"Neovim asset {asset_name} not found")
|
||||||
@@ -802,17 +928,18 @@ def _install_neovim(pkg: CustomPackage, tmp: Path) -> None:
|
|||||||
return
|
return
|
||||||
print(" SHA256 OK")
|
print(" SHA256 OK")
|
||||||
|
|
||||||
print(" Extracting Neovim to /opt ...")
|
install_dir = f"/opt/nvim-linux-{arch_token}"
|
||||||
run(["rm", "-rf", "/opt/nvim-linux-x86_64"], as_sudo=True)
|
print(f" Extracting Neovim to /opt ...")
|
||||||
|
run(["rm", "-rf", install_dir], as_sudo=True)
|
||||||
run(["tar", "-C", "/opt", "-xzf", str(dest)], 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"
|
profile_script = "/etc/profile.d/neovim.sh"
|
||||||
run(["bash", "-c",
|
run(["bash", "-c",
|
||||||
f"grep -qxF {profile_line!r} {profile_script} 2>/dev/null || "
|
f"grep -qxF {profile_line!r} {profile_script} 2>/dev/null || "
|
||||||
f"echo {profile_line!r} >> {profile_script}"],
|
f"echo {profile_line!r} >> {profile_script}"],
|
||||||
as_sudo=True, check=False)
|
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:
|
def _clone_nvim_config() -> None:
|
||||||
@@ -847,6 +974,55 @@ def _clone_nvim_config() -> None:
|
|||||||
print(f" Neovim configuration ready at {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:
|
def ensure_node_lts() -> None:
|
||||||
"""If nvm is present, ensure Node LTS is installed and set as the default."""
|
"""If nvm is present, ensure Node LTS is installed and set as the default."""
|
||||||
nvm_dir = Path.home() / ".nvm"
|
nvm_dir = Path.home() / ".nvm"
|
||||||
@@ -962,16 +1138,110 @@ def ensure_python_latest() -> Optional[threading.Thread]:
|
|||||||
return t
|
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:
|
def install_custom_packages(to_install: list[CustomPackage]) -> None:
|
||||||
print("\n=== Custom Packages ===")
|
print("\n=== Custom Packages ===")
|
||||||
for pkg in to_install:
|
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()
|
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")
|
||||||
|
|
||||||
# Handlers that manage their own download/install
|
# Handlers that manage their own download/install
|
||||||
if name_lower == "nvm":
|
if name_lower == "nvm":
|
||||||
@@ -980,11 +1250,19 @@ def install_custom_packages(to_install: list[CustomPackage]) -> None:
|
|||||||
if name_lower == "pyenv":
|
if name_lower == "pyenv":
|
||||||
_install_pyenv()
|
_install_pyenv()
|
||||||
continue
|
continue
|
||||||
|
if name_lower == "pip":
|
||||||
|
_install_pip()
|
||||||
|
continue
|
||||||
|
if name_lower == "oh-my-zsh":
|
||||||
|
_install_oh_my_zsh()
|
||||||
|
continue
|
||||||
if name_lower == "neovim":
|
if name_lower == "neovim":
|
||||||
with tempfile.TemporaryDirectory() as tmp_str:
|
with tempfile.TemporaryDirectory() as tmp_str:
|
||||||
_install_neovim(pkg, Path(tmp_str))
|
_install_neovim(pkg, Path(tmp_str))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
_resolve_latest(pkg)
|
||||||
|
|
||||||
if not pkg.url:
|
if not pkg.url:
|
||||||
warn(f"No URL or install handler for '{pkg.name}' — skipping")
|
warn(f"No URL or install handler for '{pkg.name}' — skipping")
|
||||||
continue
|
continue
|
||||||
@@ -999,11 +1277,11 @@ def install_custom_packages(to_install: list[CustomPackage]) -> None:
|
|||||||
continue
|
continue
|
||||||
if not _verify(archive, pkg):
|
if not _verify(archive, pkg):
|
||||||
continue
|
continue
|
||||||
if name_lower.startswith("go-"):
|
if name_lower == "go":
|
||||||
_install_go(archive)
|
_install_go(archive)
|
||||||
elif "firecracker" in name_lower:
|
elif name_lower == "firecracker":
|
||||||
_install_firecracker(archive, tmp)
|
_install_firecracker(archive, tmp)
|
||||||
elif "zig" in name_lower:
|
elif name_lower == "zig":
|
||||||
_install_zig(pkg, archive, tmp)
|
_install_zig(pkg, archive, tmp)
|
||||||
else:
|
else:
|
||||||
warn(f"No install handler for '{pkg.name}' — skipping")
|
warn(f"No install handler for '{pkg.name}' — skipping")
|
||||||
@@ -1096,10 +1374,10 @@ def print_check_summary(sys_c: dict, flat_c: dict, cust_c: dict, only: Optional[
|
|||||||
ok = cust_c["already_installed"]
|
ok = cust_c["already_installed"]
|
||||||
print("\nCustom packages:")
|
print("\nCustom packages:")
|
||||||
for pkg, path in ok:
|
for pkg, path in ok:
|
||||||
print(f" [OK] {pkg.name} ({path})")
|
print(f" [OK] {pkg.display_name}" + (f" ({path})" if path else ""))
|
||||||
for pkg in to:
|
for pkg in to:
|
||||||
_, path = is_custom_pkg_installed(pkg)
|
_, 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)
|
total += len(to)
|
||||||
|
|
||||||
return total
|
return total
|
||||||
@@ -1154,79 +1432,29 @@ def check_and_setup_ssh() -> None:
|
|||||||
err("gh auth login failed — skipping key upload.")
|
err("gh auth login failed — skipping key upload.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# ── file parser ───────────────────────────────────────────────────────────────
|
# ── packages module loader ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def parse_packages_file(path: Path) -> tuple[list[str], list[str], list[CustomPackage]]:
|
def load_packages() -> tuple[list[str], list[str], list[CustomPackage]]:
|
||||||
system_pkgs: list[str] = []
|
system_pkgs = list(formatted_packages.SYSTEM_PACKAGES)
|
||||||
flatpak_pkgs: list[str] = []
|
flatpak_pkgs = list(formatted_packages.FLATPAK_PACKAGES)
|
||||||
custom_pkgs: list[CustomPackage] = []
|
custom_pkgs = [CustomPackage(**spec) for spec in formatted_packages.CUSTOM_PACKAGES]
|
||||||
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
|
return system_pkgs, flatpak_pkgs, custom_pkgs
|
||||||
|
|
||||||
# ── entry point ───────────────────────────────────────────────────────────────
|
# ── entry point ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
ap = argparse.ArgumentParser(
|
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"],
|
ap.add_argument("--only", choices=["system", "flatpak", "custom"],
|
||||||
help="Install only the named section")
|
help="Install only the named section")
|
||||||
ap.add_argument("--no-gui", action="store_true",
|
ap.add_argument("--no-gui", action="store_true",
|
||||||
help="Skip GUI applications (suitable for headless environments). "
|
help="Skip GUI applications (suitable for headless environments). "
|
||||||
"Excludes GUI system packages and the entire Flatpak section.")
|
"Excludes GUI system packages and skips the entire Flatpak "
|
||||||
ap.add_argument("--file", default=str(PACKAGES_FILE), metavar="PATH",
|
"section, including installing flatpak itself.")
|
||||||
help="Path to packages file (default: formatted_packages.txt next to this script)")
|
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
pkg_file = Path(args.file)
|
system_pkgs, flatpak_pkgs, custom_pkgs = load_packages()
|
||||||
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"Architecture: {ARCH}")
|
||||||
print(f"Package manager: {PKG_MGR}")
|
print(f"Package manager: {PKG_MGR}")
|
||||||
@@ -1241,6 +1469,8 @@ def main() -> None:
|
|||||||
print(f" [NO-GUI] Skipping GUI system packages: {_fmt(skipped_gui)}")
|
print(f" [NO-GUI] Skipping GUI system packages: {_fmt(skipped_gui)}")
|
||||||
flatpak_pkgs = []
|
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
|
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 {}
|
sys_c = check_system_packages(system_pkgs) if args.only in (None, "system") else {}
|
||||||
@@ -1266,6 +1496,7 @@ def main() -> None:
|
|||||||
|
|
||||||
if args.only in (None, "system"):
|
if args.only in (None, "system"):
|
||||||
install_system_packages(sys_c["to_install_regular"], sys_c["to_install_special"])
|
install_system_packages(sys_c["to_install_regular"], sys_c["to_install_special"])
|
||||||
|
ensure_zsh_default()
|
||||||
|
|
||||||
if do_flatpak:
|
if do_flatpak:
|
||||||
install_flatpak_packages(flat_c["to_install"])
|
install_flatpak_packages(flat_c["to_install"])
|
||||||
@@ -1288,6 +1519,15 @@ def main() -> None:
|
|||||||
write_run_log()
|
write_run_log()
|
||||||
print("\nDone.")
|
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""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",
|
||||||
|
"zsh",
|
||||||
|
"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"},
|
||||||
|
{"name": "pip"},
|
||||||
|
{"name": "oh-my-zsh"},
|
||||||
|
]
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user