- Interactive TUI: distro/init/libc/arch/package selection, ISO config - Root vs fakeroot mode detection with warnings - Disk space check before build - Confirmation prompts with build summary - Fakeroot support via set_mode() in base.py
340 lines
11 KiB
Python
340 lines
11 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"],
|
|
"Desktop": ["firefox", "foot", "dmenu", "sway", "xorg-server-xwayland"],
|
|
"Fonts": ["font-noto", "font-dejavu"],
|
|
"Audio": ["pipewire", "wireplumber", "alsa-utils"],
|
|
"Firmware": ["linux-firmware", "linux-firmware-network", "linux-firmware-amd"],
|
|
}
|
|
|
|
|
|
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 apt install fakeroot[/] / [cyan]sudo pacman -S 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=[
|
|
questionary.Choice("fakeroot", "[yellow]fakeroot — no root needed, degraded perf[/]"),
|
|
questionary.Choice("root", "[red]root — run this command with sudo instead[/]"),
|
|
],
|
|
default="fakeroot",
|
|
).ask()
|
|
if choice is None:
|
|
sys.exit(0)
|
|
if choice == "root":
|
|
console.print("\n[bold]Run with:[/] [cyan]sudo python -m euri_mklive build-iso <config>[/]")
|
|
sys.exit(0)
|
|
return "fakeroot"
|
|
|
|
|
|
def select_distro() -> str:
|
|
"""Pick a distro."""
|
|
console.print("[bold]Step 1: Distribution[/]\n")
|
|
choices = []
|
|
for name, info in DISTRO_INFO.items():
|
|
choices.append(questionary.Choice(name, f"{info['desc']}"))
|
|
choices.sort(key=lambda c: c.title)
|
|
|
|
choice = questionary.select("Pick a distro:", choices=choices).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=[questionary.Choice(i, i) for i in 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=[questionary.Choice(c, c) for c in 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=[
|
|
questionary.Choice("x86_64", "x86_64 (amd64)"),
|
|
questionary.Choice("aarch64", "aarch64 (arm64)"),
|
|
],
|
|
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] = []
|
|
|
|
# preset groups
|
|
console.print(" [dim]Select package groups:[/]")
|
|
groups = questionary.checkbox(
|
|
"Package groups (space to select, enter to confirm):",
|
|
choices=[
|
|
questionary.Choice(name, f"{name}: {', '.join(pkgs[:3])}...")
|
|
for name, pkgs in COMMON_PACKAGES.items()
|
|
],
|
|
).ask()
|
|
if groups is None:
|
|
sys.exit(0)
|
|
|
|
for group in groups:
|
|
selected.extend(COMMON_PACKAGES[group])
|
|
|
|
# custom packages
|
|
console.print()
|
|
custom = questionary.text(
|
|
"Additional packages (comma-separated, or empty):",
|
|
default="",
|
|
).ask()
|
|
if custom and custom.strip():
|
|
selected.extend(p.strip() for p in custom.split(",") if p.strip())
|
|
|
|
# dedupe while preserving order
|
|
seen = set()
|
|
deduped = []
|
|
for p in selected:
|
|
if 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("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["iso"]["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 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()
|
|
iso = configure_iso()
|
|
|
|
cfg = {
|
|
"distro": distro,
|
|
"arch": arch,
|
|
"init": init,
|
|
"libc": libc,
|
|
"hostname": iso["hostname"],
|
|
"packages": pkgs,
|
|
"services": [],
|
|
"users": [],
|
|
"iso": {
|
|
"name": iso["name"],
|
|
"version": iso["version"],
|
|
"label": iso["label"],
|
|
},
|
|
"_mode": mode,
|
|
}
|
|
|
|
if not confirm_build(cfg):
|
|
sys.exit(0)
|
|
|
|
return cfg
|