547 lines
21 KiB
Python
547 lines
21 KiB
Python
"""Interactive TUI — guided ISO builder with rich + questionary."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import questionary
|
|
from rich.console import Console
|
|
from rich.panel import Panel
|
|
from rich.table import Table
|
|
from rich.text import Text
|
|
from rich.columns import Columns
|
|
|
|
from .distros import REGISTRY
|
|
|
|
console = Console()
|
|
|
|
DISTRO_INFO = {
|
|
"void": {"desc": "Void Linux — musl/glibc, runit/dinit, independent", "libc": ["musl", "glibc"], "inits": ["dinit", "runit"]},
|
|
"debian": {"desc": "Debian — stable, apt, systemd", "libc": ["glibc"], "inits": ["systemd"]},
|
|
"ubuntu": {"desc": "Ubuntu — Debian-based, apt, systemd", "libc": ["glibc"], "inits": ["systemd"]},
|
|
"arch": {"desc": "Arch Linux — rolling release, pacman", "libc": ["glibc"], "inits": ["dinit", "runit", "s6", "openrc", "systemd"]},
|
|
"artix": {"desc": "Artix Linux — Arch without systemd", "libc": ["glibc"], "inits": ["dinit", "runit", "s6", "openrc"]},
|
|
"alpine": {"desc": "Alpine Linux — minimal, musl, apk", "libc": ["musl"], "inits": ["openrc", "s6"]},
|
|
"fedora": {"desc": "Fedora — cutting edge, dnf/rpm", "libc": ["glibc"], "inits": ["systemd"]},
|
|
"rocky": {"desc": "Rocky Linux — RHEL rebuild, dnf/rpm", "libc": ["glibc"], "inits": ["systemd"]},
|
|
"almalinux": {"desc": "AlmaLinux — RHEL rebuild, dnf/rpm", "libc": ["glibc"], "inits": ["systemd"]},
|
|
}
|
|
|
|
COMMON_PACKAGES = {
|
|
"Base": ["base-system", "sudo", "bash"],
|
|
"Networking": ["NetworkManager", "dhcpcd", "openssh", "iw", "wpa_supplicant"],
|
|
"Development": ["gcc", "make", "git", "python3", "cmake"],
|
|
"Fonts": ["font-noto", "font-dejavu"],
|
|
"Audio": ["pipewire", "wireplumber", "alsa-utils"],
|
|
"Firmware": ["linux-firmware", "linux-firmware-network", "linux-firmware-amd"],
|
|
}
|
|
|
|
# Cross-distro DE/WM package mapping
|
|
# Keys: distro families (void, arch, debian, alpine, fedora)
|
|
DE_WM_PACKAGES = {
|
|
"Sway": {
|
|
"desc": "Wayland tiling WM — minimal, keyboard-driven",
|
|
"server": "wayland",
|
|
"void": ["sway", "foot", "waybar", "swaybg", "swaylock", "swayidle", "xorg-server-xwayland", "wmenu", "grim", "slurp", "wl-clipboard", "dinit"],
|
|
"arch": ["sway", "foot", "waybar", "swaybg", "swaylock", "swayidle", "xorg-xwayland", "wmenu", "grim", "slurp", "wl-clipboard"],
|
|
"debian": ["sway", "foot", "waybar", "swaybg", "swaylock", "swayidle", "xwayland", "wmenu", "grim", "slurp", "wl-clipboard"],
|
|
"alpine": ["sway", "foot", "waybar", "swaybg", "swaylockd", "swayidle", "xwayland", "wmenu", "grim", "wl-clipboard", "brightnessctl"],
|
|
"fedora": ["sway", "foot", "waybar", "swaybg", "swaylock", "swayidle", "xorg-x11-server-Xwayland", "wofi", "grim", "slurp", "wl-clipboard"],
|
|
},
|
|
"i3": {
|
|
"desc": "X11 tiling WM — keyboard-driven, highly configurable",
|
|
"server": "x11",
|
|
"void": ["i3", "i3status", "dmenu", "rofi", "dunst", "xorg", "lightdm"],
|
|
"arch": ["i3-wm", "i3status", "i3blocks", "dmenu", "rofi", "dunst", "xorg-server", "lightdm", "lightdm-gtk-greeter"],
|
|
"debian": ["i3", "i3status", "dmenu", "rofi", "dunst", "xorg", "lightdm"],
|
|
"alpine": ["i3wm", "i3status", "dmenu", "rofi", "xorg-server", "lightdm"],
|
|
"fedora": ["i3", "i3status", "dmenu", "rofi", "dunst", "xorg-x11-server-Xorg", "lightdm"],
|
|
},
|
|
"Hyprland": {
|
|
"desc": "Wayland dynamic tiling — animations, eye candy",
|
|
"server": "wayland",
|
|
"void": [], # not in official repos
|
|
"arch": ["hyprland", "kitty", "waybar", "wofi", "dunst", "xdg-desktop-portal-hyprland", "hypridle", "hyprlock", "qt5-wayland", "qt6-wayland", "polkit"],
|
|
"debian": ["hyprland", "kitty", "waybar", "wofi", "dunst", "xdg-desktop-portal-hyprland", "xwayland", "polkit"],
|
|
"alpine": [], # not in official repos
|
|
"fedora": ["hyprland", "kitty", "waybar", "wofi", "dunst", "xdg-desktop-portal-hyprland", "hypridle", "hyprlock", "polkit"],
|
|
},
|
|
"GNOME": {
|
|
"desc": "Full desktop — polished, Wayland by default",
|
|
"server": "wayland",
|
|
"void": ["gnome", "gnome-terminal", "gdm", "gnome-tweaks", "xorg"],
|
|
"arch": ["gnome", "gnome-extra", "gdm", "gnome-terminal", "xdg-desktop-portal-gnome"],
|
|
"debian": ["gnome", "gdm3", "gnome-terminal", "xdg-desktop-portal-gnome", "xorg"],
|
|
"alpine": ["gnome", "gdm", "gnome-terminal", "xorg-server"],
|
|
"fedora": ["gnome", "gdm", "gnome-terminal", "xdg-desktop-portal-gnome", "gnome-tweaks"],
|
|
},
|
|
"KDE Plasma": {
|
|
"desc": "Full desktop — customizable, feature-rich",
|
|
"server": "wayland",
|
|
"void": ["plasma-desktop", "konsole", "sddm", "dolphin", "kate", "xorg"],
|
|
"arch": ["plasma-meta", "konsole", "sddm", "dolphin", "kate", "xdg-desktop-portal-kde", "xorg-server"],
|
|
"debian": ["kde-plasma-desktop", "konsole", "sddm", "dolphin", "kate", "xdg-desktop-portal-kde", "xorg"],
|
|
"alpine": ["plasma-desktop-meta", "konsole", "sddm", "oxygen", "xorg-server"],
|
|
"fedora": ["plasma-desktop", "konsole", "sddm", "dolphin", "kate", "xdg-desktop-portal-kde", "qt6-wayland"],
|
|
},
|
|
"XFCE": {
|
|
"desc": "Lightweight desktop — traditional, fast",
|
|
"server": "x11",
|
|
"void": ["xfce4", "xfce4-terminal", "lightdm", "gvfs", "xorg"],
|
|
"arch": ["xfce4", "xfce4-goodies", "xfce4-terminal", "lightdm", "lightdm-gtk-greeter", "xorg-server"],
|
|
"debian": ["xfce4", "xfce4-goodies", "xfce4-terminal", "lightdm", "xorg", "gvfs"],
|
|
"alpine": ["xfce4", "xfce4-terminal", "lightdm", "xorg-server", "elogind"],
|
|
"fedora": ["xfce4-session", "xfce4-terminal", "lightdm", "xorg-x11-server-Xorg"],
|
|
},
|
|
"Cinnamon": {
|
|
"desc": "Modern traditional desktop — Windows-like",
|
|
"server": "x11",
|
|
"void": ["cinnamon", "gnome-terminal", "lightdm", "nemo", "xorg"],
|
|
"arch": ["cinnamon", "gnome-terminal", "lightdm", "lightdm-gtk-greeter", "nemo", "xorg-server"],
|
|
"debian": ["cinnamon", "gnome-terminal", "lightdm", "nemo", "xorg"],
|
|
"alpine": [], # limited support
|
|
"fedora": ["cinnamon", "gnome-terminal", "lightdm", "nemo", "xorg-x11-server-Xorg"],
|
|
},
|
|
"MATE": {
|
|
"desc": "Traditional desktop — GNOME 2 fork, lightweight",
|
|
"server": "x11",
|
|
"void": ["mate", "mate-terminal", "lightdm", "caja", "xorg"],
|
|
"arch": ["mate", "mate-extra", "mate-terminal", "lightdm", "lightdm-gtk-greeter", "caja", "xorg-server"],
|
|
"debian": ["mate-desktop-environment", "mate-terminal", "lightdm", "caja", "xorg"],
|
|
"alpine": ["mate-desktop-environment", "mate-terminal", "lightdm", "xorg-server"],
|
|
"fedora": ["mate-desktop", "mate-terminal", "lightdm", "caja", "xorg-x11-server-Xorg"],
|
|
},
|
|
"LXQt": {
|
|
"desc": "Ultra-lightweight desktop — Qt-based",
|
|
"server": "x11",
|
|
"void": ["lxqt", "qterminal", "sddm", "openbox", "xorg"],
|
|
"arch": ["lxqt", "qterminal", "sddm", "openbox", "xorg-server"],
|
|
"debian": ["lxqt", "qterminal", "sddm", "openbox", "xorg"],
|
|
"alpine": ["lxqt-desktop", "qterminal", "sddm", "openbox", "xorg-server"],
|
|
"fedora": ["lxqt", "qterminal", "sddm", "openbox", "xorg-x11-server-Xorg"],
|
|
},
|
|
"bspwm": {
|
|
"desc": "X11 tiling WM — binary space partitioning",
|
|
"server": "x11",
|
|
"void": ["bspwm", "sxhkd", "alacritty", "polybar", "dmenu", "rofi", "picom", "dunst", "xorg"],
|
|
"arch": ["bspwm", "sxhkd", "alacritty", "polybar", "dmenu", "rofi", "picom", "dunst", "xorg-server"],
|
|
"debian": ["bspwm", "sxhkd", "alacritty", "polybar", "dmenu", "rofi", "picom", "dunst", "xorg"],
|
|
"alpine": ["bspwm", "sxhkd", "alacritty", "polybar", "dmenu", "rofi", "xorg-server"],
|
|
"fedora": ["bspwm", "sxhkd", "alacritty", "polybar", "dmenu", "rofi", "picom", "dunst", "xorg-x11-server-Xorg"],
|
|
},
|
|
"openbox": {
|
|
"desc": "X11 stacking WM — minimalist, scriptable",
|
|
"server": "x11",
|
|
"void": ["openbox", "alacritty", "obconf", "tint2", "rofi", "picom", "dunst", "xorg"],
|
|
"arch": ["openbox", "alacritty", "obconf", "tint2", "rofi", "picom", "dunst", "xorg-server"],
|
|
"debian": ["openbox", "alacritty", "obconf", "tint2", "rofi", "picom", "dunst", "xorg"],
|
|
"alpine": ["openbox", "alacritty", "obconf", "tint2", "rofi", "xorg-server"],
|
|
"fedora": ["openbox", "alacritty", "obconf", "tint2", "rofi", "picom", "dunst", "xorg-x11-server-Xorg"],
|
|
},
|
|
"Headless (no DE)": {
|
|
"desc": "Server / minimal — no graphical environment",
|
|
"server": "none",
|
|
"void": [],
|
|
"arch": [],
|
|
"debian": [],
|
|
"alpine": [],
|
|
"fedora": [],
|
|
},
|
|
}
|
|
|
|
|
|
def _check_fakeroot() -> bool:
|
|
"""Check if fakeroot is available."""
|
|
return shutil.which("fakeroot") is not None
|
|
|
|
|
|
def _check_root() -> bool:
|
|
"""Check if running as root."""
|
|
return os.geteuid() == 0
|
|
|
|
|
|
def _disk_free(path: str = "/") -> int:
|
|
"""Return free disk space in bytes."""
|
|
st = os.statvfs(path)
|
|
return st.f_bavail * st.f_frsize
|
|
|
|
|
|
def _format_size(b: int) -> str:
|
|
for unit in ("B", "KB", "MB", "GB"):
|
|
if b < 1024:
|
|
return f"{b:.0f} {unit}"
|
|
b /= 1024
|
|
return f"{b:.1f} TB"
|
|
|
|
|
|
def banner() -> None:
|
|
"""Show the app banner."""
|
|
console.print()
|
|
console.print(Panel(
|
|
"[bold cyan] ________ ______ ____[/]\n"
|
|
" [bold cyan]/ ____/ / / / __ \\/ _/[/]\n"
|
|
" [bold cyan]/ __/ / / / / /_/ // /[/]\n"
|
|
" [bold cyan]/ /___/ /_/ / _, _// /[/]\n"
|
|
"[bold cyan]/_____/\\____/_/ |_/___/[/]\n"
|
|
"[bold white] universal iso builder[/]\n"
|
|
"[dim] any distro, from any distro[/]",
|
|
border_style="cyan",
|
|
padding=(0, 2),
|
|
))
|
|
console.print()
|
|
|
|
|
|
def select_mode() -> str:
|
|
"""Select root or fakeroot mode."""
|
|
root = _check_root()
|
|
fake = _check_fakeroot()
|
|
|
|
console.print("[bold]Build mode[/]")
|
|
console.print(f" Root detected: {'[green]yes[/]' if root else '[red]no[/]'}")
|
|
console.print(f" Fakeroot: {'[green]available[/]' if fake else '[red]not found[/]'}")
|
|
console.print()
|
|
|
|
if root:
|
|
console.print(" [green]>> Running as root — full performance[/]")
|
|
return "root"
|
|
|
|
if not fake:
|
|
console.print("[bold red]Error:[/] not root and fakeroot not installed.")
|
|
console.print(" Install fakeroot: [cyan]sudo pacman -S fakeroot[/] / [cyan]sudo emerge sys-apps/fakeroot[/]")
|
|
sys.exit(1)
|
|
|
|
console.print("[bold yellow]!! Non-root mode: performance degraded, some post-install scripts may fail.[/]")
|
|
console.print()
|
|
choice = questionary.select(
|
|
"Select mode:",
|
|
choices=[
|
|
"fakeroot -- no root needed, degraded perf",
|
|
"root -- enter sudo password to continue as root",
|
|
],
|
|
default="fakeroot -- no root needed, degraded perf",
|
|
).ask()
|
|
if choice is None:
|
|
sys.exit(0)
|
|
if choice.startswith("root"):
|
|
console.print("\n[bold]Entering root mode...[/]")
|
|
ret = subprocess.run(["sudo", "true"], capture_output=True)
|
|
if ret.returncode != 0:
|
|
console.print("[red]sudo authentication failed[/]")
|
|
sys.exit(1)
|
|
console.print("[green]Authenticated.[/]\n")
|
|
return "root"
|
|
return "fakeroot"
|
|
|
|
|
|
def select_distro() -> str:
|
|
"""Pick a distro."""
|
|
console.print("[bold]Step 1: Distribution[/]\n")
|
|
|
|
table = Table(show_header=True, border_style="dim", pad_edge=False)
|
|
table.add_column("#", style="cyan", width=3)
|
|
table.add_column("Distro", style="bold")
|
|
table.add_column("Description")
|
|
for i, (name, info) in enumerate(sorted(DISTRO_INFO.items()), 1):
|
|
table.add_row(str(i), name, info["desc"])
|
|
console.print(table)
|
|
console.print()
|
|
|
|
names = sorted(DISTRO_INFO.keys())
|
|
choice = questionary.select(
|
|
"Pick a distro:",
|
|
choices=names,
|
|
).ask()
|
|
if choice is None:
|
|
sys.exit(0)
|
|
console.print()
|
|
return choice
|
|
|
|
|
|
def select_init(distro: str) -> str:
|
|
"""Pick init system."""
|
|
inits = DISTRO_INFO[distro]["inits"]
|
|
if len(inits) == 1:
|
|
console.print(f" Init system: [cyan]{inits[0]}[/] (only option)\n")
|
|
return inits[0]
|
|
|
|
console.print("[bold]Step 2: Init system[/]\n")
|
|
choice = questionary.select(
|
|
"Pick init system:",
|
|
choices=inits,
|
|
).ask()
|
|
if choice is None:
|
|
sys.exit(0)
|
|
console.print()
|
|
return choice
|
|
|
|
|
|
def select_libc(distro: str) -> str:
|
|
"""Pick libc."""
|
|
libcs = DISTRO_INFO[distro]["libc"]
|
|
if len(libcs) == 1:
|
|
console.print(f" Libc: [cyan]{libcs[0]}[/] (only option)\n")
|
|
return libcs[0]
|
|
|
|
console.print("[bold]Step 3: Libc[/]\n")
|
|
choice = questionary.select(
|
|
"Pick libc:",
|
|
choices=libcs,
|
|
).ask()
|
|
if choice is None:
|
|
sys.exit(0)
|
|
console.print()
|
|
return choice
|
|
|
|
|
|
def select_arch() -> str:
|
|
"""Pick architecture."""
|
|
console.print("[bold]Step 4: Architecture[/]\n")
|
|
choice = questionary.select(
|
|
"Pick architecture:",
|
|
choices=["x86_64", "aarch64"],
|
|
default="x86_64",
|
|
).ask()
|
|
if choice is None:
|
|
sys.exit(0)
|
|
console.print()
|
|
return choice
|
|
|
|
|
|
def select_packages() -> list[str]:
|
|
"""Pick packages interactively."""
|
|
console.print("[bold]Step 5: Packages[/]\n")
|
|
|
|
selected: list[str] = []
|
|
|
|
# show available groups
|
|
grp_table = Table(show_header=True, border_style="dim", pad_edge=False)
|
|
grp_table.add_column("Group", style="cyan")
|
|
grp_table.add_column("Packages")
|
|
for name, pkgs in COMMON_PACKAGES.items():
|
|
grp_table.add_row(name, ", ".join(pkgs))
|
|
console.print(grp_table)
|
|
console.print()
|
|
|
|
# preset groups — use select per group to avoid toggle-all color bug
|
|
groups = []
|
|
for gname in COMMON_PACKAGES:
|
|
pick = questionary.confirm(f" Include {gname}?", default=False).ask()
|
|
if pick is None:
|
|
sys.exit(0)
|
|
if pick:
|
|
groups.append(gname)
|
|
|
|
for group_name in groups:
|
|
selected.extend(COMMON_PACKAGES[group_name])
|
|
|
|
# dedupe after groups
|
|
seen = set()
|
|
deduped = []
|
|
for p in selected:
|
|
if p not in seen:
|
|
seen.add(p)
|
|
deduped.append(p)
|
|
|
|
# let user remove individual packages
|
|
if deduped:
|
|
console.print(f"\n [dim]{len(deduped)} packages selected:[/]")
|
|
for i, p in enumerate(deduped, 1):
|
|
console.print(f" [cyan]{i:3}.[/] {p}")
|
|
console.print()
|
|
remove_input = questionary.text(
|
|
"Remove packages? Enter numbers comma-separated (or empty to keep all):",
|
|
default="",
|
|
).ask()
|
|
if remove_input is None:
|
|
sys.exit(0)
|
|
if remove_input.strip():
|
|
try:
|
|
remove_idx = {int(x.strip()) - 1 for x in remove_input.split(",")}
|
|
deduped = [p for i, p in enumerate(deduped) if i not in remove_idx]
|
|
except ValueError:
|
|
console.print(" [yellow]Invalid input, keeping all packages.[/]")
|
|
|
|
# custom packages
|
|
console.print()
|
|
custom = questionary.text(
|
|
"Additional packages (comma-separated, or empty):",
|
|
default="",
|
|
).ask()
|
|
if custom and custom.strip():
|
|
for p in custom.split(","):
|
|
p = p.strip()
|
|
if p and p not in seen:
|
|
seen.add(p)
|
|
deduped.append(p)
|
|
|
|
console.print(f" [green]{len(deduped)} packages selected[/]\n")
|
|
return deduped
|
|
|
|
|
|
def configure_iso() -> dict:
|
|
"""Configure ISO metadata."""
|
|
console.print("[bold]Step 6: ISO metadata[/]\n")
|
|
|
|
name = questionary.text("ISO name:", default="Euri Linux").ask()
|
|
if name is None:
|
|
sys.exit(0)
|
|
version = questionary.text("Version:", default="1.0").ask()
|
|
if version is None:
|
|
sys.exit(0)
|
|
hostname = questionary.text("Hostname:", default="euri").ask()
|
|
if hostname is None:
|
|
sys.exit(0)
|
|
label = questionary.text("ISO label:", default="EURI").ask()
|
|
if label is None:
|
|
sys.exit(0)
|
|
|
|
console.print()
|
|
return {"name": name, "version": version, "hostname": hostname, "label": label}
|
|
|
|
|
|
def confirm_build(cfg: dict) -> bool:
|
|
"""Show summary and ask for confirmation."""
|
|
console.print("[bold]Build summary[/]\n")
|
|
|
|
table = Table(show_header=False, border_style="dim", pad_edge=False)
|
|
table.add_column("Key", style="cyan", width=14)
|
|
table.add_column("Value")
|
|
|
|
table.add_row("Distro", cfg["distro"])
|
|
table.add_row("Arch", cfg["arch"])
|
|
table.add_row("Init", cfg["init"])
|
|
table.add_row("Libc", cfg["libc"])
|
|
table.add_row("DE/WM", cfg.get("de_wm", "none"))
|
|
table.add_row("Packages", str(len(cfg.get("packages", []))))
|
|
table.add_row("ISO name", cfg["iso"]["name"])
|
|
table.add_row("Version", cfg["iso"]["version"])
|
|
table.add_row("Hostname", cfg["hostname"])
|
|
table.add_row("Mode", cfg["_mode"])
|
|
|
|
console.print(table)
|
|
console.print()
|
|
|
|
# disk space check
|
|
free = _disk_free("/tmp")
|
|
est_rootfs = 800 * 1024 * 1024 # ~800MB for base-system
|
|
if free < est_rootfs:
|
|
console.print(f"[bold yellow]Warning:[/] only {_format_size(free)} free in /tmp, need ~{_format_size(est_rootfs)}")
|
|
if not questionary.confirm("Continue anyway?", default=False).ask():
|
|
sys.exit(0)
|
|
else:
|
|
console.print(f" [dim]Disk free: {_format_size(free)}[/]")
|
|
|
|
console.print()
|
|
return questionary.confirm("Start build?", default=True).ask() or sys.exit(0)
|
|
|
|
|
|
def select_de_wm(distro: str) -> tuple[str, list[str]]:
|
|
"""Pick a DE or window manager — returns (name, distro-specific packages)."""
|
|
console.print("[bold]Step 5b: Desktop Environment / Window Manager[/]\n")
|
|
|
|
# map distro to family
|
|
distro_family = {
|
|
"void": "void", "arch": "arch", "artix": "arch",
|
|
"debian": "debian", "ubuntu": "debian",
|
|
"alpine": "alpine",
|
|
"fedora": "fedora", "rocky": "fedora", "almalinux": "fedora",
|
|
}.get(distro, "arch")
|
|
|
|
# show table
|
|
de_table = Table(show_header=True, border_style="dim", pad_edge=False)
|
|
de_table.add_column("#", style="cyan", width=3)
|
|
de_table.add_column("DE/WM", style="bold")
|
|
de_table.add_column("Type")
|
|
de_table.add_column("Description")
|
|
for i, (name, info) in enumerate(DE_WM_PACKAGES.items(), 1):
|
|
pkgs = info.get(distro_family, [])
|
|
status = f"[green]{len(pkgs)} pkgs[/]" if pkgs else "[red]unavailable[/]"
|
|
server = info.get("server", "")
|
|
de_table.add_row(str(i), name, server, f"{info['desc']} ({status})")
|
|
console.print(de_table)
|
|
console.print()
|
|
|
|
names = list(DE_WM_PACKAGES.keys())
|
|
choice = questionary.select(
|
|
"Pick a DE/WM:",
|
|
choices=names,
|
|
).ask()
|
|
if choice is None:
|
|
sys.exit(0)
|
|
|
|
pkgs = DE_WM_PACKAGES.get(choice, {}).get(distro_family, [])
|
|
if not pkgs:
|
|
console.print(f" [yellow]Warning:[/] {choice} is not available in {distro} repos.")
|
|
console.print(f" [dim]Continuing without DE/WM packages.[/]\n")
|
|
else:
|
|
console.print(f" [green]{len(pkgs)} packages[/] for {choice} on {distro}\n")
|
|
|
|
return choice, pkgs
|
|
|
|
|
|
def build_progress(cfg: dict, func, *args, **kwargs):
|
|
"""Run a function with a rich progress spinner."""
|
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
|
|
with Progress(
|
|
SpinnerColumn(),
|
|
TextColumn("[progress.description]{task.description}"),
|
|
console=console,
|
|
) as progress:
|
|
task = progress.add_task(f"Building {cfg['distro']}...", total=None)
|
|
try:
|
|
result = func(*args, **kwargs)
|
|
progress.update(task, description="[green]Done![/]")
|
|
return result
|
|
except Exception as e:
|
|
progress.update(task, description=f"[red]Failed: {e}[/]")
|
|
raise
|
|
|
|
|
|
def run_interactive() -> dict:
|
|
"""Full interactive TUI flow. Returns a config dict."""
|
|
banner()
|
|
mode = select_mode()
|
|
distro = select_distro()
|
|
init = select_init(distro)
|
|
libc = select_libc(distro)
|
|
arch = select_arch()
|
|
pkgs = select_packages()
|
|
de_name, de_pkgs = select_de_wm(distro)
|
|
iso = configure_iso()
|
|
|
|
# merge packages: base groups + DE/WM
|
|
seen = set()
|
|
merged = []
|
|
for p in pkgs + de_pkgs:
|
|
if p not in seen:
|
|
seen.add(p)
|
|
merged.append(p)
|
|
|
|
cfg = {
|
|
"distro": distro,
|
|
"arch": arch,
|
|
"init": init,
|
|
"libc": libc,
|
|
"hostname": iso["hostname"],
|
|
"packages": merged,
|
|
"de_wm": de_name,
|
|
"services": [],
|
|
"users": [],
|
|
"iso": {
|
|
"name": iso["name"],
|
|
"version": iso["version"],
|
|
"label": iso["label"],
|
|
},
|
|
"_mode": mode,
|
|
}
|
|
|
|
if not confirm_build(cfg):
|
|
sys.exit(0)
|
|
|
|
return cfg
|