Files
scripts/workstation-setup.py

1760 lines
58 KiB
Python

#!/usr/bin/env python3
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
# SPDX-License-Identifier: MIT
"""Idempotent Fedora workstation setup — development tools, editors, and multimedia.
No external dependencies — stdlib and system tools only. Every operation
checks current state before acting; running the script repeatedly produces
the same result with no errors and no duplicate work.
Supply-chain posture:
- Bun: pinned release, ZIP downloaded from GitHub and verified against the
release's published SHA-256 (SHASUMS.txt) before installation.
- uv: installed from the official Fedora repositories (dnf).
- Zed: no checksums/signatures are published for install.sh — the version is
pinned and an explicit trust-on-first-use warning is printed.
- Brave: the repo signing key is fingerprint-verified against the official
fingerprints from https://brave.com/signing-keys/ before `rpm --import`.
Usage:
workstation-setup.py # full setup
workstation-setup.py --dry-run # preview without changes
workstation-setup.py --skip-update # skip system update
workstation-setup.py --skip-rpm # skip RPM package installation
workstation-setup.py --skip-flatpak # skip Flatpak apps
workstation-setup.py --skip-ssh # skip SSH and Git setup
workstation-setup.py --skip-repos # skip adding third-party repos
workstation-setup.py --skip-remove # skip removing pre-installed apps
workstation-setup.py --skip-bun # skip Bun installation
workstation-setup.py --skip-zed # skip Zed editor installation
workstation-setup.py --skip-uv # skip uv installation
workstation-setup.py --skip-rust # skip Rust toolchain
workstation-setup.py --skip-firewall # skip firewall setup
workstation-setup.py --skip-selinux # skip SELinux setup
workstation-setup.py --version
"""
from __future__ import annotations
import argparse
import getpass
import hashlib
import os
import platform
import pwd
import shutil
import subprocess
import sys
import tempfile
import time
import zipfile
from typing import Any
BOLD = "\033[1m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
DIM = "\033[2m"
RESET = "\033[0m"
VERSION = "4.1.1"
# dnf transactions (fresh installs download hundreds of packages) need far
# more than a generic 60-second timeout.
DNF_TIMEOUT = 1800
DNF_UPDATE_TIMEOUT = 3600
# Pinned versions for the curl-based installers (bump deliberately).
BUN_VERSION = "1.3.14"
ZED_VERSION = "0.216.0"
BRAVE_REPO_URL = "https://brave-browser-rpm-release.s3.brave.com/brave-browser.repo"
BRAVE_KEY_URL = "https://brave-browser-rpm-release.s3.brave.com/brave-core.asc"
BRAVE_REPO_FILE = "/etc/yum.repos.d/brave-browser.repo"
# Official Brave "Linux Package Repositories - Release Channel" fingerprints,
# from https://brave.com/signing-keys/ (2023 primary key plus the 2025
# rotations). If Brave rotates again, verification fails loudly — update this
# set from the same page rather than weakening the check.
BRAVE_KEY_FINGERPRINTS: frozenset[str] = frozenset(
{
"DBF1A116C220B8C7164F98230686B78420388257", # Brave Linux Release (2023)
"47D32A74E9A9E013A4B4926C68D513D36A73CD96", # Brave Linux Release (2025, _mrk)
"B2A3DCA350E67256740DF904DE4EC67BE4B0DCA0", # Brave Linux Release (2025, _v2)
}
)
BUN_BASHRC_LINES: tuple[str, ...] = (
'export BUN_INSTALL="$HOME/.bun"',
'export PATH="$BUN_INSTALL/bin:$PATH"',
)
# Map os-release ID to display name.
SUPPORTED_OS: dict[str, str] = {
"fedora": "Fedora",
"openeuler": "openEuler",
}
# Pre-installed packages to remove (skipped if already absent).
REMOVE_PACKAGES: list[str] = [
"firefox",
"gnome-system-monitor",
"yelp",
"mediawriter",
]
# RPM packages to install.
RPM_PACKAGES: list[str] = [
"brave-browser",
"git",
"golang",
"rustup",
"just",
"mediainfo",
"ruby",
]
# Flatpak applications to install.
FLATPAK_APPS: list[str] = [
"org.remmina.Remmina",
"org.telegram.desktop",
"fr.handbrake.ghb",
"com.rustdesk.RustDesk",
"org.gimp.GIMP",
"net.nokyan.Resources",
"app.drey.EarTag",
"org.bunkus.mkvtoolnix-gui",
"org.gnome.Firmware",
]
# ---------------------------------------------------------------------------
# Progress helpers — write to stderr so stdout stays clean for machine output
# ---------------------------------------------------------------------------
def _status(msg: str) -> None:
"""Print a progress message to stderr — stays on the same line until ``_status_done``."""
print(f" {msg}...", end="", flush=True, file=sys.stderr)
def _status_done(msg: str = "done") -> None:
"""Complete the current progress line on stderr."""
print(f" {msg}", file=sys.stderr)
def _info(msg: str) -> None:
"""Print an informational line to stderr."""
print(f" {DIM}{msg}{RESET}", file=sys.stderr)
def _warn(msg: str) -> None:
"""Print a warning to stderr."""
print(f" {YELLOW}{msg}{RESET}", file=sys.stderr)
def _ok(msg: str) -> None:
"""Print a success message to stderr."""
print(f" {GREEN}{msg}{RESET}", file=sys.stderr)
def _fail(msg: str) -> None:
"""Print a failure message to stderr."""
print(f" {RED}{msg}{RESET}", file=sys.stderr)
# ---------------------------------------------------------------------------
# Low-level command runners
# ---------------------------------------------------------------------------
def run(
cmd: list[str],
timeout: int = 60,
check: bool = False,
use_sudo: bool = False,
) -> subprocess.CompletedProcess[str]:
"""Execute a command and return the CompletedProcess.
Sets LC_ALL=C for reliable English-language output parsing and replaces
undecodable bytes in the output. When *use_sudo* is True, ``sudo`` is
prepended. Timeout and missing-binary conditions are converted into a
failed CompletedProcess (124/127) instead of raising. Raises
subprocess.CalledProcessError when *check* is True and the exit code is
non-zero.
"""
merged_env = {**os.environ, "LANG": "C", "LC_ALL": "C"}
full_cmd = ["sudo"] + cmd if use_sudo else cmd
try:
result = subprocess.run(
full_cmd,
capture_output=True,
text=True,
errors="replace",
timeout=timeout,
env=merged_env,
)
except subprocess.TimeoutExpired:
result = subprocess.CompletedProcess(full_cmd, 124, "", f"timed out after {timeout}s")
except FileNotFoundError:
result = subprocess.CompletedProcess(full_cmd, 127, "", f"command not found: {full_cmd[0]}")
except OSError as exc:
result = subprocess.CompletedProcess(full_cmd, 126, "", str(exc))
if check and result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode, full_cmd, result.stdout, result.stderr
)
return result
def _run_as_user(
cmd: list[str],
timeout: int = 60,
check: bool = False,
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
"""Run a command as the original user (not root) when running via sudo.
Extra environment variables are injected via env(1) so they survive
sudo's environment reset.
"""
sudo_user = os.environ.get("SUDO_USER")
if sudo_user:
full_cmd = ["sudo", "-u", sudo_user]
if env:
full_cmd.append("env")
full_cmd.extend(f"{key}={value}" for key, value in env.items())
return run(full_cmd + cmd, timeout=timeout, check=check)
return run(cmd, timeout=timeout, check=check)
# ---------------------------------------------------------------------------
# OS detection
# ---------------------------------------------------------------------------
def parse_os_release() -> dict[str, str]:
"""Parse /etc/os-release into a dict of KEY=VALUE pairs."""
result: dict[str, str] = {}
try:
with open("/etc/os-release", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
# Strip surrounding quotes if present.
value = value.strip().strip('"').strip("'")
result[key] = value
except OSError:
pass
return result
def detect_os() -> tuple[str, str, str]:
"""Detect the operating system.
Returns a tuple of (os_id, display_name, version_id).
Exits with an error message when the OS is not supported.
"""
release = parse_os_release()
os_id = release.get("ID", "").lower()
version_id = release.get("VERSION_ID", "unknown")
if os_id not in SUPPORTED_OS:
print(
f"{RED}{BOLD}Error:{RESET} Unsupported operating system: "
f"'{release.get('ID', 'unknown')}' (detected from /etc/os-release).",
file=sys.stderr,
)
print(
f" Supported systems: {', '.join(SUPPORTED_OS.values())}",
file=sys.stderr,
)
sys.exit(1)
return os_id, SUPPORTED_OS[os_id], version_id
# ---------------------------------------------------------------------------
# Section helpers
# ---------------------------------------------------------------------------
def _rpm_installed(pkg: str) -> bool:
"""Return True if the RPM package is already installed."""
result = run(["rpm", "-q", pkg])
return result.returncode == 0
def _have_flatpak(app: str) -> bool:
"""Return True if the Flatpak application is already installed (system-wide)."""
result = run(["flatpak", "info", app], check=False)
return result.returncode == 0
def _real_user() -> pwd.struct_passwd | None:
"""Return the passwd entry of the real (non-root) user, or None.
None means "no separate user to drop to" — e.g. the script is running in
a real root shell, or SUDO_USER does not resolve.
"""
sudo_user = os.environ.get("SUDO_USER")
if sudo_user:
try:
return pwd.getpwnam(sudo_user)
except KeyError:
return None
if os.geteuid() == 0:
return None
try:
return pwd.getpwuid(os.geteuid())
except KeyError:
return None
def _real_home() -> str:
"""Return the real user's home directory, even when running via sudo."""
real = _real_user()
if real is not None:
return real.pw_dir
return os.path.expanduser("~")
def _chown_user(path: str) -> None:
"""Chown *path* to the real user; no-op when there is no separate user."""
real = _real_user()
if real is not None:
os.chown(path, real.pw_uid, real.pw_gid)
def _ensure_user_owns(path: str) -> bool:
"""Verify *path* is owned by the real user, fixing root ownership.
Returns True when ownership is correct (or there is no separate user).
"""
real = _real_user()
if real is None:
return True
try:
if os.stat(path).st_uid == real.pw_uid:
return True
os.chown(path, real.pw_uid, real.pw_gid)
return os.stat(path).st_uid == real.pw_uid
except OSError:
return False
def _tool_path(name: str, *home_rel: str) -> str | None:
"""Absolute path to *name*: PATH first, then user-home fallback locations."""
found = shutil.which(name)
if found is not None:
return found
home = _real_home()
for rel in home_rel:
candidate = os.path.join(home, rel)
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
def _probe_version(cmd: list[str], as_user: bool = True) -> str:
"""Run a --version probe and return the first line of output, or 'unknown'."""
runner = _run_as_user if as_user else run
result = runner(cmd)
if result.returncode != 0 or not result.stdout.strip():
return "unknown"
return result.stdout.strip().split("\n")[0]
def _download(url: str, dest: str, timeout: int = 300) -> bool:
"""Download *url* to *dest* with curl; return True on success."""
result = run(
["curl", "-fsSL", "-o", dest, url],
timeout=timeout,
use_sudo=True,
)
return result.returncode == 0
def _sha256_file(path: str) -> str:
"""Return the hex SHA-256 digest of a file, read in chunks."""
digest = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def _sha256_from_sums(sums_file: str, filename: str) -> str | None:
"""Extract the SHA-256 for *filename* from a sha256sum-format file."""
try:
with open(sums_file, encoding="utf-8") as fh:
for line in fh:
parts = line.split()
if len(parts) == 2 and parts[1].lstrip("*") == filename:
return parts[0].lower()
except OSError:
pass
return None
def _asc_fingerprints(key_file: str) -> set[str]:
"""Fingerprints of every key in an ASCII-armoured key file (via gpg)."""
if shutil.which("gpg") is None:
return set()
result = run(["gpg", "--show-keys", "--with-colons", key_file])
if result.returncode != 0:
return set()
fingerprints: set[str] = set()
for line in result.stdout.splitlines():
parts = line.split(":")
if parts[0] == "fpr" and len(parts) > 9 and parts[9]:
fingerprints.add(parts[9].upper())
return fingerprints
# ---------------------------------------------------------------------------
# 1. System update
# ---------------------------------------------------------------------------
def system_update(dry_run: bool = False) -> dict[str, Any]:
"""Update the system via dnf.
Returns a dict with summary info: {'updated', 'skipped', 'would_update',
'error'}.
"""
info: dict[str, Any] = {
"updated": False,
"skipped": False,
"would_update": False,
"error": False,
}
_status("Checking for system updates")
check_result = run(["dnf", "check-update"], timeout=300, use_sudo=True)
# dnf check-update: 0 = no updates, 100 = updates available, else = error.
if check_result.returncode == 0:
_status_done("already up to date")
info["skipped"] = True
return info
if check_result.returncode != 100:
_status_done("check failed")
_fail(f"dnf check-update failed: {check_result.stderr.strip() or 'unknown error'}")
info["error"] = True
return info
_status_done("updates available")
if dry_run:
_info("Would run: dnf update -y")
info["would_update"] = True
return info
_status("Applying system updates")
update_result = run(["dnf", "update", "-y"], timeout=DNF_UPDATE_TIMEOUT, use_sudo=True)
if update_result.returncode == 0:
_status_done()
info["updated"] = True
else:
_status_done("failed")
_fail("System update returned non-zero exit code")
info["error"] = True
return info
# ---------------------------------------------------------------------------
# 2. Remove pre-installed apps
# ---------------------------------------------------------------------------
def remove_packages(dry_run: bool = False) -> dict[str, Any]:
"""Remove unwanted pre-installed packages.
Returns a dict with 'removed' (list) and 'skipped' (list).
"""
removed: list[str] = []
skipped: list[str] = []
for pkg in REMOVE_PACKAGES:
_status(f"Checking {pkg}")
if not _rpm_installed(pkg):
_status_done("not installed, skipping")
skipped.append(pkg)
continue
if dry_run:
_status_done("would remove")
removed.append(pkg)
else:
result = run(["dnf", "remove", "-y", pkg], timeout=DNF_TIMEOUT, use_sudo=True)
if result.returncode == 0:
_status_done("removed")
removed.append(pkg)
else:
_status_done("failed")
_fail(f"Failed to remove {pkg}")
return {"removed": removed, "skipped": skipped}
# ---------------------------------------------------------------------------
# 3. Third-party RPM repos
# ---------------------------------------------------------------------------
def _import_brave_key() -> bool:
"""Download, fingerprint-verify, and import Brave's RPM signing key."""
if shutil.which("gpg") is None:
_fail("gpg not found — cannot verify the Brave signing key")
return False
with tempfile.TemporaryDirectory() as tmp:
key_file = os.path.join(tmp, "brave-core.asc")
if not _download(BRAVE_KEY_URL, key_file, timeout=60):
_fail("Failed to download the Brave signing key")
return False
fingerprints = _asc_fingerprints(key_file)
if not fingerprints:
_fail("No keys found in the downloaded Brave key file")
return False
if not fingerprints.issubset(BRAVE_KEY_FINGERPRINTS):
_fail(
"Brave signing key fingerprint mismatch — expected one of: "
+ ", ".join(sorted(BRAVE_KEY_FINGERPRINTS))
+ "; got: "
+ ", ".join(sorted(fingerprints))
)
return False
_ok("Brave signing key fingerprint verified")
result = run(["rpm", "--import", key_file], use_sudo=True)
if result.returncode != 0:
_fail("rpm --import failed for the Brave signing key")
return False
return True
def _add_brave_repo() -> bool:
"""Add the Brave repo using the correct config-manager syntax (dnf5/dnf4)."""
if shutil.which("dnf5") is not None:
cmd = ["dnf", "config-manager", "addrepo", f"--from-repofile={BRAVE_REPO_URL}"]
else:
# dnf4 (Fedora <= 40, openEuler) has no 'addrepo' subcommand.
cmd = ["dnf", "config-manager", "--add-repo", BRAVE_REPO_URL]
result = run(cmd, timeout=120, use_sudo=True)
if result.returncode != 0:
_fail(
"Failed to add the Brave repo (on dnf4 systems this requires "
"the dnf-plugins-core package)"
)
return False
return True
def setup_repos(dry_run: bool = False) -> dict[str, Any]:
"""Add third-party RPM repositories.
Returns a dict with 'added' (list) and 'skipped' (list).
"""
added: list[str] = []
skipped: list[str] = []
# --- Brave Browser ---
repo_name = "brave-browser"
_status(f"Checking {repo_name} repo")
if os.path.exists(BRAVE_REPO_FILE):
_status_done("already present")
skipped.append(repo_name)
elif dry_run:
_status_done("would verify + import GPG key, would add repo")
added.append(repo_name)
else:
if _import_brave_key() and _add_brave_repo():
_status_done("added")
added.append(repo_name)
else:
_status_done("failed")
return {"added": added, "skipped": skipped}
# ---------------------------------------------------------------------------
# 4. RPM packages
# ---------------------------------------------------------------------------
def install_rpm_packages(dry_run: bool = False) -> dict[str, Any]:
"""Install RPM packages, skipping any that are already present.
Installs in a single dnf transaction, falling back to per-package
installs if the batch fails. Returns a dict with 'installed' (list),
'skipped' (list), 'failed' (list).
"""
installed: list[str] = []
skipped: list[str] = []
failed: list[str] = []
to_install: list[str] = []
_status("Checking RPM packages")
for pkg in RPM_PACKAGES:
if _rpm_installed(pkg):
skipped.append(pkg)
else:
to_install.append(pkg)
already = len(skipped)
missing = len(to_install)
total = len(RPM_PACKAGES)
_status_done(f"{already}/{total} already installed")
if not to_install:
_ok("All RPM packages already present")
return {"installed": installed, "skipped": skipped, "failed": failed}
_info(f"Installing {missing} package(s): {' '.join(to_install)}")
if dry_run:
for pkg in to_install:
_info(f" Would install: {pkg}")
installed.append(pkg)
return {"installed": installed, "skipped": skipped, "failed": failed}
_status(f"Installing {missing} package(s)")
batch = run(
["dnf", "install", "-y", *to_install],
timeout=DNF_TIMEOUT,
use_sudo=True,
)
if batch.returncode == 0:
_status_done()
installed.extend(to_install)
return {"installed": installed, "skipped": skipped, "failed": failed}
_status_done("batch failed, retrying one by one")
for pkg in to_install:
_status(f"Installing {pkg}")
result = run(["dnf", "install", "-y", pkg], timeout=DNF_TIMEOUT, use_sudo=True)
if result.returncode == 0:
_status_done()
installed.append(pkg)
else:
_status_done("failed")
_fail(f"Failed to install {pkg}")
failed.append(pkg)
return {"installed": installed, "skipped": skipped, "failed": failed}
# ---------------------------------------------------------------------------
# 5. Dev tool installers (Bun, Zed, uv)
# ---------------------------------------------------------------------------
def _add_bashrc_lines(lines: tuple[str, ...], comment: str) -> bool:
"""Append export lines to ~/.bashrc unless each is already present.
Matches on the export line content, not on the surrounding block, so a
block previously written by an upstream installer is recognised too.
Returns True when a block was appended.
"""
home = _real_home()
bashrc = os.path.join(home, ".bashrc")
if not os.path.exists(bashrc):
return False
try:
with open(bashrc, encoding="utf-8") as fh:
content = fh.read()
except OSError:
return False
if all(line in content for line in lines):
return False
try:
with open(bashrc, "a", encoding="utf-8") as fh:
fh.write(f"\n# {comment}\n" + "\n".join(lines) + "\n")
return True
except OSError:
return False
def _bun_target() -> str:
"""Bun release artifact name for this machine (mirrors the install script)."""
machine = platform.machine()
if machine in ("aarch64", "arm64"):
return "bun-linux-aarch64"
target = "bun-linux-x64"
try:
with open("/proc/cpuinfo", encoding="utf-8") as fh:
if "avx2" not in fh.read():
target = "bun-linux-x64-baseline"
except OSError:
pass
return target
def _install_bun_verified() -> str | None:
"""Download pinned Bun, verify its SHA-256, install to ~/.bun/bin.
Returns the installed binary path on success, None on failure.
"""
target = _bun_target()
zip_name = f"{target}.zip"
base_url = f"https://github.com/oven-sh/bun/releases/download/bun-v{BUN_VERSION}"
with tempfile.TemporaryDirectory() as tmp:
zip_path = os.path.join(tmp, zip_name)
sums_path = os.path.join(tmp, "SHASUMS.txt")
if not _download(f"{base_url}/{zip_name}", zip_path):
_fail(f"Failed to download {zip_name} (bun v{BUN_VERSION})")
return None
if not _download(f"{base_url}/SHASUMS.txt", sums_path, timeout=60):
_fail("Failed to download Bun SHASUMS.txt")
return None
expected = _sha256_from_sums(sums_path, zip_name)
if expected is None:
_fail(f"No checksum for {zip_name} in SHASUMS.txt")
return None
actual = _sha256_file(zip_path)
if actual != expected:
_fail(f"SHA-256 mismatch for {zip_name}: expected {expected}, got {actual}")
return None
_info("SHA-256 checksum verified")
home = _real_home()
bin_dir = os.path.join(home, ".bun", "bin")
made = _run_as_user(["install", "-d", "-m", "755", bin_dir])
if made.returncode != 0:
_fail(f"Failed to create {bin_dir}")
return None
try:
with zipfile.ZipFile(zip_path) as archive:
data = archive.read(f"{target}/bun")
except (OSError, KeyError, zipfile.BadZipFile) as exc:
_fail(f"Failed to extract bun from {zip_name}: {exc}")
return None
bun_path = os.path.join(bin_dir, "bun")
try:
with open(bun_path, "wb") as fh:
fh.write(data)
os.chmod(bun_path, 0o755)
_chown_user(bun_path)
except OSError as exc:
_fail(f"Failed to install bun to {bun_path}: {exc}")
return None
return bun_path
def setup_bun(dry_run: bool = False) -> dict[str, Any]:
"""Install Bun from the pinned, checksum-verified GitHub release.
Returns a dict with 'installed' (bool) and 'version' (str).
"""
info: dict[str, Any] = {"installed": False, "version": "", "planned": False}
_status("Checking Bun")
bun = _tool_path("bun", ".bun/bin/bun")
if bun is not None:
info["version"] = _probe_version([bun, "--version"])
_status_done(info["version"])
info["installed"] = True
return info
_status_done("not installed")
if dry_run:
_info(
f"Would download bun v{BUN_VERSION} + SHASUMS.txt from GitHub, "
"verify SHA-256, and install to ~/.bun/bin"
)
info["planned"] = True
return info
_status(f"Installing Bun v{BUN_VERSION}")
bun_path = _install_bun_verified()
if bun_path is None:
_status_done("failed")
return info
_status_done()
info["installed"] = True
if _add_bashrc_lines(BUN_BASHRC_LINES, "bun"):
_info("Added Bun PATH to ~/.bashrc")
info["version"] = _probe_version([bun_path, "--version"])
return info
def setup_zed(dry_run: bool = False) -> dict[str, Any]:
"""Install Zed via the official install script with a pinned version.
Zed publishes no checksums or signatures for install.sh; the download is
trust-on-first-use over HTTPS and a warning is printed accordingly.
Returns a dict with 'installed' (bool) and 'version' (str).
"""
info: dict[str, Any] = {"installed": False, "version": "", "planned": False}
_status("Checking Zed")
zed = _tool_path("zed", ".local/bin/zed")
if zed is not None:
info["version"] = _probe_version([zed, "--version"])
_status_done(info["version"])
info["installed"] = True
return info
_status_done("not installed")
if dry_run:
_info(f"Would run the Zed install script with ZED_VERSION={ZED_VERSION}")
info["planned"] = True
return info
_warn(
"Zed publishes no checksums/signatures for install.sh — "
"trusting https://zed.dev over HTTPS (version pinned)"
)
_status(f"Installing Zed {ZED_VERSION}")
with tempfile.TemporaryDirectory() as tmp:
script = os.path.join(tmp, "zed-install.sh")
if not _download("https://zed.dev/install.sh", script, timeout=60):
_status_done("download failed")
_fail("Failed to download the Zed install script")
return info
# The script runs as the real user, who cannot enter root's 0700 tmpdir.
os.chmod(tmp, 0o755)
os.chmod(script, 0o644)
result = _run_as_user(
["sh", script],
timeout=300,
env={"ZED_VERSION": ZED_VERSION},
)
if result.returncode == 0:
_status_done()
info["installed"] = True
zed_path = _tool_path("zed", ".local/bin/zed")
if zed_path is not None:
info["version"] = _probe_version([zed_path, "--version"])
else:
_status_done("failed")
_fail("Zed installation returned non-zero exit code")
return info
def setup_uv(dry_run: bool = False) -> dict[str, Any]:
"""Install uv from the official Fedora repositories.
Returns a dict with 'installed' (bool) and 'version' (str).
"""
info: dict[str, Any] = {"installed": False, "version": "", "planned": False}
_status("Checking uv")
uv = _tool_path("uv", ".local/bin/uv")
if uv is not None:
info["version"] = _probe_version([uv, "--version"])
_status_done(info["version"])
info["installed"] = True
return info
_status_done("not installed")
if dry_run:
_info("Would install uv via: dnf install -y uv (official Fedora package)")
info["planned"] = True
return info
_status("Installing uv")
result = run(["dnf", "install", "-y", "uv"], timeout=DNF_TIMEOUT, use_sudo=True)
if result.returncode == 0:
_status_done()
info["installed"] = True
uv_path = _tool_path("uv", ".local/bin/uv")
if uv_path is not None:
info["version"] = _probe_version([uv_path, "--version"], as_user=False)
else:
_status_done("failed")
_fail("uv installation via dnf returned non-zero exit code")
return info
# ---------------------------------------------------------------------------
# 6. Rust toolchain
# ---------------------------------------------------------------------------
def setup_rust(dry_run: bool = False) -> dict[str, Any]:
"""Install the Rust toolchain via rustup-init if not present.
Returns a dict with 'installed' (bool) and 'version' (str).
"""
info: dict[str, Any] = {"installed": False, "version": "", "planned": False}
_status("Checking Rust")
rustc = _tool_path("rustc", ".cargo/bin/rustc")
if rustc is not None:
info["version"] = _probe_version([rustc, "--version"])
_status_done(info["version"])
info["installed"] = True
return info
_status_done("not installed")
if dry_run:
_info("Would run: rustup-init -y")
info["planned"] = True
return info
rustup_init = _tool_path("rustup-init")
if rustup_init is None:
_fail("rustup-init not found — install the 'rustup' RPM first (or do not pass --skip-rpm)")
return info
_status("Installing Rust toolchain")
result = _run_as_user([rustup_init, "-y"], timeout=300)
if result.returncode == 0:
rustc_path = os.path.join(_real_home(), ".cargo/bin/rustc")
info["version"] = _probe_version([rustc_path, "--version"])
_status_done(info["version"])
info["installed"] = True
else:
_status_done("failed")
_fail("Rust installation returned non-zero exit code")
return info
# ---------------------------------------------------------------------------
# 7. Flatpak apps
# ---------------------------------------------------------------------------
def _flatpak_remote_names() -> set[str]:
"""Names of configured system-wide Flatpak remotes (exact match, no substrings)."""
result = run(["flatpak", "remotes"], check=False)
names: set[str] = set()
for line in result.stdout.splitlines():
tokens = line.split()
if tokens and tokens[0] != "Name": # skip the table header
names.add(tokens[0])
return names
def setup_flatpak(dry_run: bool = False) -> dict[str, Any]:
"""Set up Flathub remote and install Flatpak applications.
Installs are system-wide (run as root). Caveat: apps previously installed
per-user (e.g. via GNOME Software) live in the user installation and are
NOT detected here — they would be duplicated system-wide.
Returns a dict with 'remote_added' (bool), 'installed' (list),
'skipped' (list), and 'failed' (list).
"""
installed: list[str] = []
skipped: list[str] = []
failed: list[str] = []
remote_added = False
# --- Ensure Flathub remote ---
_status("Checking Flathub remote")
if "flathub" in _flatpak_remote_names():
_status_done("already present")
elif dry_run:
_status_done("would add")
remote_added = True
else:
result = run(
[
"flatpak",
"remote-add",
"--if-not-exists",
"flathub",
"https://flathub.org/repo/flathub.flatpakrepo",
],
timeout=120,
use_sudo=True,
)
if result.returncode == 0:
_status_done("added")
remote_added = True
else:
_status_done("failed")
_fail("Failed to add Flathub remote")
# --- Install each app ---
for app in FLATPAK_APPS:
_status(f"Checking {app}")
if _have_flatpak(app):
_status_done("already installed")
skipped.append(app)
continue
if dry_run:
_status_done("would install")
installed.append(app)
else:
result = run(
["flatpak", "install", "-y", "flathub", app],
timeout=300,
use_sudo=True,
)
if result.returncode == 0:
_status_done("installed")
installed.append(app)
else:
_status_done("failed")
_fail(f"Failed to install {app}")
failed.append(app)
return {
"remote_added": remote_added,
"installed": installed,
"skipped": skipped,
"failed": failed,
}
# ---------------------------------------------------------------------------
# 8. SSH + Git
# ---------------------------------------------------------------------------
def _prompt_ssh_passphrase() -> str | None:
"""Prompt for an SSH key passphrase. Returns None to abort the section.
Empty is allowed but loudly warned about; non-interactive runs get an
empty passphrase with an equally loud warning.
"""
if not sys.stdin.isatty():
_warn("Non-interactive run — the SSH key will be generated WITHOUT a passphrase")
return ""
try:
first = getpass.getpass("SSH key passphrase (empty = no passphrase): ")
second = getpass.getpass("Confirm passphrase: ")
except (EOFError, KeyboardInterrupt):
print(file=sys.stderr)
return None
if first != second:
_fail("Passphrases do not match")
return None
if not first:
_warn(
"Empty passphrase — anyone who can read the key file can use it. "
"Consider re-generating with a passphrase and using ssh-agent."
)
return first
def setup_ssh(dry_run: bool = False) -> dict[str, Any]:
"""Set up SSH keys and Git configuration for sourcedock.dev — interactively.
Everything is created as the real user (via sudo -u / chown), never
left root-owned in the user's home. Returns a dict with 'skipped',
'key_generated', 'key_existed', 'config_updated', 'git_configured',
and 'dry_run'.
"""
info: dict[str, Any] = {
"skipped": False,
"key_generated": False,
"key_existed": False,
"config_updated": False,
"git_configured": False,
"dry_run": dry_run,
}
ssh_dir = os.path.join(_real_home(), ".ssh")
key_path = os.path.join(ssh_dir, "id_ed25519")
config_path = os.path.join(ssh_dir, "config")
pub_path = key_path + ".pub"
if dry_run:
_info("Would prompt for Git identity (name, email)")
_info("Would generate an ed25519 SSH key if missing")
_info("Would prompt for a key passphrase (empty only with a warning)")
_info("Would configure ~/.ssh/config for sourcedock.dev")
_info("Would set git user.name, user.email and the sourcedock.dev insteadOf rule")
return info
# Ask whether to set up SSH at all
try:
answer = input("Set up sourcedock.dev SSH + Git? [Y/n]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
print(file=sys.stderr)
_info("SSH + Git: skipped")
info["skipped"] = True
return info
if answer in ("n", "no"):
_info("SSH + Git: skipped")
info["skipped"] = True
return info
# Ask for user details with defaults
try:
user_name = input("Git user name [Petr Balvín]: ").strip()
except (EOFError, KeyboardInterrupt):
print(file=sys.stderr)
_info("SSH + Git: skipped")
info["skipped"] = True
return info
if not user_name:
user_name = "Petr Balvín"
try:
user_email = input("Git email [opensource@petrbalvin.org]: ").strip()
except (EOFError, KeyboardInterrupt):
print(file=sys.stderr)
_info("SSH + Git: skipped")
info["skipped"] = True
return info
if not user_email:
user_email = "opensource@petrbalvin.org"
info["key_existed"] = os.path.exists(key_path)
# --- Generate ED25519 SSH key (as the real user) ---
_status("Checking SSH key")
if info["key_existed"]:
_status_done("already exists")
else:
passphrase = _prompt_ssh_passphrase()
if passphrase is None:
info["skipped"] = True
return info
made = _run_as_user(["install", "-d", "-m", "700", ssh_dir])
if made.returncode != 0:
_status_done("failed")
_fail(f"Failed to create {ssh_dir}")
return info
# NOTE: the passphrase passes through the process argv briefly
# (visible to the user and root in ps) — standard for unattended
# ssh-keygen; interactive alternative would block automation.
result = _run_as_user(
[
"ssh-keygen",
"-t",
"ed25519",
"-C",
user_email,
"-N",
passphrase,
"-f",
key_path,
],
)
if result.returncode == 0:
_status_done("generated")
info["key_generated"] = True
else:
_status_done("failed")
_fail("Failed to generate SSH key")
# --- Configure ~/.ssh/config ---
host_entry = (
f"Host sourcedock.dev\n User git\n IdentityFile {key_path}\n IdentitiesOnly yes\n"
)
_status("Checking SSH config")
made = _run_as_user(["install", "-d", "-m", "700", ssh_dir])
if made.returncode != 0:
_status_done("failed")
_fail(f"Failed to create {ssh_dir}")
return info
existing = ""
if os.path.exists(config_path):
try:
with open(config_path, encoding="utf-8") as f:
existing = f.read()
except OSError:
existing = ""
if "Host sourcedock.dev" in existing:
_status_done("already configured")
else:
try:
with open(config_path, "a", encoding="utf-8") as f:
f.write("\n" + host_entry if existing else host_entry)
os.chmod(config_path, 0o600)
_chown_user(config_path)
_status_done("updated")
info["config_updated"] = True
except OSError:
_status_done("failed")
_fail("Failed to update SSH config")
# --- Verify nothing in ~/.ssh ended up root-owned ---
for path in (ssh_dir, key_path, pub_path, config_path):
if os.path.exists(path) and not _ensure_user_owns(path):
_fail(f"{path} is root-owned and could not be fixed")
# --- Git SSH configuration (as the real user) ---
git_configs = {
"user.name": user_name,
"user.email": user_email,
'url."git@sourcedock.dev:".insteadOf': "https://sourcedock.dev/",
}
for key, value in git_configs.items():
_status(f"Checking git config {key}")
result = _run_as_user(["git", "config", "--global", "--get", key])
if result.returncode == 0 and result.stdout.strip() == value:
_status_done("already set")
else:
result = _run_as_user(["git", "config", "--global", key, value])
if result.returncode == 0:
_status_done("set")
info["git_configured"] = True
else:
_status_done("failed")
_fail(f"Failed to set git config {key}")
# --- Show public key ---
if info["key_generated"] or info["key_existed"]:
try:
with open(pub_path, encoding="utf-8") as f:
pub_key = f.read().strip()
print(file=sys.stderr)
print(
f"\n{BOLD} Your SSH public key"
f" (add to https://sourcedock.dev/user/settings/keys):{RESET}\n",
file=sys.stderr,
)
print(f" {GREEN}{pub_key}{RESET}", file=sys.stderr)
print(file=sys.stderr)
except OSError:
_warn("SSH public key not found")
return info
# ---------------------------------------------------------------------------
# 9. Firewall
# ---------------------------------------------------------------------------
def setup_firewall(dry_run: bool = False) -> dict[str, Any]:
"""Ensure firewalld is installed, enabled, and running.
Unlike server-setup, does NOT change the default zone — the
workstation default (FedoraWorkstation) is left as-is.
Returns a dict with summary of what was configured; 'enabled' and
'running' always reflect the actual probed state.
"""
info: dict[str, Any] = {
"installed": False,
"enabled": False,
"running": False,
}
# --- Install firewalld if missing ---
_status("Checking firewalld")
if not _rpm_installed("firewalld"):
_status_done("not installed")
if dry_run:
_info("Would install: firewalld")
else:
result = run(
["dnf", "install", "-y", "firewalld"],
timeout=DNF_TIMEOUT,
use_sudo=True,
)
if result.returncode == 0:
_ok("Installed firewalld")
info["installed"] = True
else:
_fail("Failed to install firewalld")
return info
else:
_status_done("installed")
info["installed"] = True
# --- Enable and start ---
_status("Enabling firewalld service")
enabled = run(["systemctl", "is-enabled", "firewalld.service"], check=False)
if enabled.returncode != 0:
if dry_run:
_status_done("would enable")
else:
result = run(["systemctl", "enable", "firewalld.service"], use_sudo=True)
if result.returncode == 0:
_status_done("enabled")
else:
_status_done("failed")
_fail(f"Failed to enable firewalld: {result.stderr.strip()}")
else:
_status_done("already enabled")
_status("Starting firewalld service")
active = run(["systemctl", "is-active", "firewalld.service"], check=False)
if active.returncode != 0:
if dry_run:
_status_done("would start")
else:
result = run(["systemctl", "start", "firewalld.service"], use_sudo=True)
if result.returncode == 0:
_status_done("started")
else:
_status_done("failed")
_fail(f"Failed to start firewalld: {result.stderr.strip()}")
else:
_status_done("already running")
# Reflect the real probed state, never assumed success.
if not dry_run:
info["enabled"] = (
run(["systemctl", "is-enabled", "firewalld.service"], check=False).returncode == 0
)
info["running"] = (
run(["systemctl", "is-active", "firewalld.service"], check=False).returncode == 0
)
return info
# ---------------------------------------------------------------------------
# 10. SELinux
# ---------------------------------------------------------------------------
def setup_selinux(dry_run: bool = False) -> dict[str, Any]:
"""Ensure SELinux is enforcing.
A Disabled SELinux cannot be switched at runtime — only the config file
is fixed and a reboot warning is printed (setenforce always fails there).
Returns a dict with summary of changes.
"""
info: dict[str, Any] = {
"mode_changed": False,
"config_changed": False,
"current_mode": "unknown",
"reboot_required": False,
}
# --- Check current mode ---
_status("Checking SELinux mode")
mode_result = run(["getenforce"], check=False)
current_mode = mode_result.stdout.strip()
info["current_mode"] = current_mode
_status_done(current_mode)
if current_mode == "Permissive":
if dry_run:
_info("Would set SELinux to enforcing mode")
else:
_status("Setting SELinux to enforcing")
result = run(["setenforce", "1"], use_sudo=True)
if result.returncode == 0:
_status_done()
info["mode_changed"] = True
else:
_status_done("failed")
_fail(f"setenforce 1 failed: {result.stderr.strip()}")
elif current_mode == "Disabled":
_warn(
"SELinux is disabled — it cannot be enabled at runtime. "
"Setting SELINUX=enforcing in the config; a REBOOT is required."
)
info["reboot_required"] = True
# --- Ensure SELINUX=enforcing in config ---
_status("Checking /etc/selinux/config")
try:
with open("/etc/selinux/config", encoding="utf-8") as fh:
lines = fh.read().splitlines()
has_enforcing = False
for line in lines:
stripped = line.strip()
if stripped.startswith("SELINUX=") and not stripped.startswith("#"):
if stripped.split("=", 1)[1].strip().lower() == "enforcing":
has_enforcing = True
break
if not has_enforcing:
if dry_run:
_status_done("would update to SELINUX=enforcing")
else:
new_lines: list[str] = []
found = False
for line in lines:
stripped = line.strip()
if stripped.startswith("SELINUX=") and not stripped.startswith("#"):
if not found:
new_lines.append("SELINUX=enforcing")
found = True
# Drop any duplicate active SELINUX= lines.
else:
new_lines.append(line)
if not found:
new_lines.append("SELINUX=enforcing")
with open("/etc/selinux/config", "w", encoding="utf-8") as fh:
fh.write("\n".join(new_lines) + "\n")
_status_done("updated to enforcing")
info["config_changed"] = True
else:
_status_done("already enforcing")
except OSError as exc:
_status_done("error")
_warn(f"Cannot read/write /etc/selinux/config: {exc}")
return info
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
def print_summary(
os_display: str,
version_id: str,
results: dict[str, Any],
warnings: list[str],
elapsed: float,
dry_run: bool = False,
) -> None:
"""Print a final summary of all sections to stderr.
In dry-run mode nothing is phrased as accomplished — only "would …".
"""
print(f"\n{BOLD}══ Setup Summary ══{RESET}", file=sys.stderr)
print(f" OS: {os_display} {version_id}", file=sys.stderr)
# System update
ru = results.get("update", {})
if ru.get("error"):
_fail("System update failed")
elif ru.get("would_update"):
_info("Would apply system updates")
elif ru.get("updated"):
_ok("System updated")
elif ru.get("skipped"):
_info("System already up to date")
elif ru:
_info("System update skipped")
# Remove packages
rr = results.get("remove", {})
if rr:
removed = len(rr.get("removed", []))
r_skipped = len(rr.get("skipped", []))
if dry_run:
_info(f"Remove: would remove {removed}, {r_skipped} already absent")
elif removed:
_ok(f"Removed: {removed} package(s), {r_skipped} already absent")
else:
_info(f"Remove: {r_skipped} package(s) already absent")
# Repos
rp = results.get("repos", {})
if rp:
r_added = len(rp.get("added", []))
rp_skipped = len(rp.get("skipped", []))
if dry_run:
_info(f"Repos: would add {r_added}, {rp_skipped} already present")
else:
_ok(f"Repos: {r_added} added, {rp_skipped} already present")
# RPM packages
rpkg = results.get("rpm", {})
if rpkg:
r_installed = len(rpkg.get("installed", []))
r_skip = len(rpkg.get("skipped", []))
r_fail = len(rpkg.get("failed", []))
if dry_run:
_info(f"RPM packages: would install {r_installed}, {r_skip} already present")
else:
_ok(f"RPM packages: {r_skip} already present, {r_installed} installed")
if r_fail:
_fail(f" {r_fail} package(s) failed to install")
# Curl-based / packaged dev tools
for section, label in (("bun", "Bun"), ("zed", "Zed"), ("uv", "uv"), ("rust", "Rust")):
rt = results.get(section, {})
if not rt:
continue
if rt.get("version"):
_ok(f"{label}: {rt['version']}")
elif rt.get("planned"):
_info(f"{label}: would be installed")
elif rt.get("installed"):
_ok(f"{label}: installed")
else:
_info(f"{label}: not installed")
# Flatpak
rf = results.get("flatpak", {})
if rf:
f_installed = len(rf.get("installed", []))
f_skipped = len(rf.get("skipped", []))
f_failed = len(rf.get("failed", []))
if dry_run:
_info(f"Flatpak: would install {f_installed}, {f_skipped} already present")
else:
_ok(f"Flatpak: {f_skipped} already present, {f_installed} installed")
if f_failed:
_fail(f" {f_failed} app(s) failed to install")
# SSH + Git
rsh = results.get("ssh", {})
if rsh:
if rsh.get("skipped"):
_info("SSH + Git: skipped")
elif rsh.get("dry_run"):
_info("SSH + Git: would be configured (key, ssh config, git identity)")
else:
key_status = (
"generated"
if rsh.get("key_generated")
else ("exists" if rsh.get("key_existed") else "not set up")
)
config_status = "updated" if rsh.get("config_updated") else "OK"
git_status = "configured" if rsh.get("git_configured") else "OK"
_ok(f"SSH + Git: key {key_status}, config {config_status}, git {git_status}")
# Firewall
rfw = results.get("firewall", {})
if rfw:
if dry_run:
_info("Firewall: would ensure firewalld is installed, enabled and running")
elif rfw.get("running"):
_ok("Firewall: installed & running")
else:
_fail("Firewall: not running")
# SELinux
rs = results.get("selinux", {})
if rs:
mode = rs.get("current_mode", "?")
if rs.get("reboot_required"):
_warn(f"SELinux: mode={mode} — config set to enforcing, REBOOT required")
else:
_ok(f"SELinux: mode={mode}")
if warnings:
print(file=sys.stderr)
for w in warnings:
_warn(w)
print(f"\n{BOLD}{'═' * 60}{RESET}", file=sys.stderr)
print(f" {BOLD}Total time: {elapsed:.1f}s{RESET}", file=sys.stderr)
print(f"{BOLD}{'═' * 60}{RESET}", file=sys.stderr)
print(file=sys.stderr)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Idempotent workstation setup for Fedora",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print what would be done without making changes",
)
parser.add_argument(
"--skip-update",
action="store_true",
help="Skip system update",
)
parser.add_argument(
"--skip-rpm",
action="store_true",
help="Skip RPM package installation",
)
parser.add_argument(
"--skip-flatpak",
action="store_true",
help="Skip Flatpak apps",
)
parser.add_argument(
"--skip-ssh",
action="store_true",
help="Skip SSH and Git setup",
)
parser.add_argument(
"--skip-repos",
action="store_true",
help="Skip adding third-party repos",
)
parser.add_argument(
"--skip-remove",
action="store_true",
help="Skip removing pre-installed apps",
)
parser.add_argument(
"--skip-bun",
action="store_true",
help="Skip Bun installation",
)
parser.add_argument(
"--skip-zed",
action="store_true",
help="Skip Zed editor installation",
)
parser.add_argument(
"--skip-uv",
action="store_true",
help="Skip uv installation",
)
parser.add_argument(
"--skip-rust",
action="store_true",
help="Skip Rust toolchain installation",
)
parser.add_argument(
"--skip-firewall",
action="store_true",
help="Skip firewall setup",
)
parser.add_argument(
"--skip-selinux",
action="store_true",
help="Skip SELinux setup",
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {VERSION}",
)
return parser.parse_args(argv)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
"""Entry point — parse args, detect OS, then run enabled sections."""
start_time = time.monotonic()
warnings: list[str] = []
results: dict[str, Any] = {}
# Parsed first so --help/--version work on any OS, root or not.
args = parse_args()
os_id, os_display, version_id = detect_os()
# Header
print(file=sys.stderr)
print(f"{BOLD}{'═' * 60}{RESET}", file=sys.stderr)
print(
f"{BOLD} Workstation Setup v{VERSION}{os_display} {version_id}{RESET}",
file=sys.stderr,
)
print(f"{BOLD}{'═' * 60}{RESET}", file=sys.stderr)
if args.dry_run:
print(
f"\n {YELLOW}{BOLD}DRY RUN — no changes will be made{RESET}",
file=sys.stderr,
)
print(file=sys.stderr)
# 1. System update
print(f"\n{BOLD}── System Update ──{RESET}", file=sys.stderr)
if not args.skip_update:
results["update"] = system_update(dry_run=args.dry_run)
else:
_info("System update: skipped (--skip-update)")
# 2. Remove pre-installed apps
print(f"\n{BOLD}── Remove Pre-installed Apps ──{RESET}", file=sys.stderr)
if not args.skip_remove:
results["remove"] = remove_packages(dry_run=args.dry_run)
else:
_info("Remove apps: skipped (--skip-remove)")
# 3. Third-party repos
print(f"\n{BOLD}── Repositories ──{RESET}", file=sys.stderr)
if not args.skip_repos:
results["repos"] = setup_repos(dry_run=args.dry_run)
else:
_info("Repos: skipped (--skip-repos)")
# 4. RPM packages
print(f"\n{BOLD}── RPM Packages ──{RESET}", file=sys.stderr)
if not args.skip_rpm:
results["rpm"] = install_rpm_packages(dry_run=args.dry_run)
if results["rpm"].get("failed"):
warnings.append(
f"Some RPM packages failed to install: {', '.join(results['rpm']['failed'])}"
)
else:
_info("RPM packages: skipped (--skip-rpm)")
# 5. Dev tool installers
print(f"\n{BOLD}── Dev Tool Installers ──{RESET}", file=sys.stderr)
if not args.skip_bun:
results["bun"] = setup_bun(dry_run=args.dry_run)
else:
_info("Bun: skipped (--skip-bun)")
if not args.skip_zed:
results["zed"] = setup_zed(dry_run=args.dry_run)
else:
_info("Zed: skipped (--skip-zed)")
if not args.skip_uv:
results["uv"] = setup_uv(dry_run=args.dry_run)
else:
_info("uv: skipped (--skip-uv)")
# 6. Rust toolchain
print(f"\n{BOLD}── Rust Toolchain ──{RESET}", file=sys.stderr)
if not args.skip_rust:
results["rust"] = setup_rust(dry_run=args.dry_run)
else:
_info("Rust: skipped (--skip-rust)")
# 7. Flatpak apps
print(f"\n{BOLD}── Flatpak Apps ──{RESET}", file=sys.stderr)
if not args.skip_flatpak:
results["flatpak"] = setup_flatpak(dry_run=args.dry_run)
if results["flatpak"].get("failed"):
warnings.append(
f"Some Flatpak apps failed to install: {', '.join(results['flatpak']['failed'])}"
)
else:
_info("Flatpak: skipped (--skip-flatpak)")
# 8. SSH + Git
print(f"\n{BOLD}── SSH + Git ──{RESET}", file=sys.stderr)
if not args.skip_ssh:
results["ssh"] = setup_ssh(dry_run=args.dry_run)
else:
_info("SSH + Git: skipped (--skip-ssh)")
# 9. Firewall
print(f"\n{BOLD}── Firewall ──{RESET}", file=sys.stderr)
if not args.skip_firewall:
results["firewall"] = setup_firewall(dry_run=args.dry_run)
else:
_info("Firewall: skipped (--skip-firewall)")
# 10. SELinux
print(f"\n{BOLD}── SELinux ──{RESET}", file=sys.stderr)
if not args.skip_selinux:
results["selinux"] = setup_selinux(dry_run=args.dry_run)
else:
_info("SELinux: skipped (--skip-selinux)")
elapsed = time.monotonic() - start_time
print_summary(os_display, version_id, results, warnings, elapsed, dry_run=args.dry_run)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)