From 131e84bb3f47017fb0cd491a007cf8c91c63bc04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 27 Aug 2026 20:18:30 +0200 Subject: [PATCH] feat: interactive TUI with rich/questionary, fakeroot mode, failsafes - 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 --- euri_mklive/cli.py | 81 +++++++++ euri_mklive/distros/base.py | 15 +- euri_mklive/rootfs.py | 2 + euri_mklive/tui.py | 339 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 5 files changed, 437 insertions(+), 2 deletions(-) create mode 100644 euri_mklive/tui.py diff --git a/euri_mklive/cli.py b/euri_mklive/cli.py index 9be0957..2a8d5fc 100644 --- a/euri_mklive/cli.py +++ b/euri_mklive/cli.py @@ -4,6 +4,7 @@ from __future__ import annotations import os import sys +import tempfile from pathlib import Path import click @@ -35,6 +36,9 @@ def cli(ctx: click.Context) -> None: click.echo(BANNER) click.echo(f" \033[90msupported distros:\033[0m {SUPPORTED}") click.echo() + click.echo(" \033[1;36meuri-mklive build-iso \033[0m build from YAML config") + click.echo(" \033[1;36meuri-mklive interactive \033[0m launch interactive TUI") + click.echo() click.echo(ctx.get_help()) @@ -75,6 +79,83 @@ def build_iso(config: Path, output: Path, keep_rootfs: bool) -> None: sys.exit(1) +@cli.command() +@click.option("--fake", "mode", flag_value="fake", help="Run in fakeroot mode (no root needed)") +@click.option("--root", "mode", flag_value="root", default=True, help="Run as root (default)") +@click.option("-o", "--output", type=click.Path(path_type=Path), default=None, help="Output directory") +@click.option("--keep-rootfs", is_flag=True, help="Don't remove rootfs after ISO generation") +@click.option("-y", "--yes", is_flag=True, help="Skip confirmation prompts") +def interactive(mode: str | None, output: Path | None, keep_rootfs: bool, yes: bool) -> None: + """Launch interactive TUI — select distro, packages, and build.""" + from .tui import run_interactive, banner as tui_banner + + cfg = run_interactive() + + # override mode if flag given + if mode: + cfg["_mode"] = mode + + # override output + out = output or Path(".") + out.mkdir(parents=True, exist_ok=True) + + # inject skip confirmations + if yes: + cfg["_skip_confirm"] = True + + tui_banner() + + from rich.console import Console + from rich.panel import Panel + console = Console() + + # check mode and re-exec if needed + import shutil as _shutil + if cfg["_mode"] == "root" and os.geteuid() != 0: + console.print("[bold yellow]Root mode selected but not root. Re-executing with sudo...[/]") + import subprocess as _sp + args = [sys.executable, "-m", "euri_mklive", "interactive", "--root"] + if keep_rootfs: + args.append("--keep-rootfs") + if output: + args.extend(["-o", str(output)]) + _sp.run(["sudo"] + args, check=True) + return + + if cfg["_mode"] == "fakeroot": + import shutil as _shutil + if not _shutil.which("fakeroot"): + console.print("[bold red]Error:[/] fakeroot not found. Install it first.") + sys.exit(1) + console.print(Panel( + "[bold yellow]Fakeroot mode[/]\n" + "Performance degraded. Some post-install scripts may fail.\n" + "For full functionality, run with sudo.", + border_style="yellow", + )) + + # build + try: + from .rootfs import build as _build + from .iso import generate as _generate + + rootfs = _build(cfg, out) + iso = _generate(rootfs, cfg, out) + + if not keep_rootfs: + cleanup(rootfs) + + console.print(Panel( + f"[bold green]ISO ready![/]\n{iso}", + border_style="green", + title="Success", + )) + + except Exception as e: + console.print(Panel(f"[bold red]Build failed:[/] {e}", border_style="red")) + sys.exit(1) + + @cli.command() def supported() -> None: """List all supported distros.""" diff --git a/euri_mklive/distros/base.py b/euri_mklive/distros/base.py index f31eaf8..aa5f1d2 100644 --- a/euri_mklive/distros/base.py +++ b/euri_mklive/distros/base.py @@ -10,6 +10,13 @@ from pathlib import Path _BUNDLED = Path(__file__).resolve().parent.parent / "bin" _LIB = Path(__file__).resolve().parent.parent / "lib" +_mode = "root" + + +def set_mode(m: str) -> None: + global _mode + _mode = m + class Distro(ABC): """Base class every distro backend must implement.""" @@ -60,6 +67,12 @@ class Distro(ABC): @staticmethod def _run_chroot(rootfs: Path, cmd: list[str], **kw) -> subprocess.CompletedProcess: - full = ["sudo", "chroot", str(rootfs), "/bin/sh", "-c", " ".join(cmd)] + prefix = [] + if _mode == "fakeroot" and shutil.which("fakeroot"): + prefix = ["fakeroot"] + elif _mode == "root": + prefix = ["sudo"] + + full = [*prefix, "chroot", str(rootfs), "/bin/sh", "-c", " ".join(cmd)] print(f" \033[90m$\033[0m \033[33m[chroot]\033[0m \033[1m{' '.join(cmd)}\033[0m") return subprocess.run(full, check=True, **kw) diff --git a/euri_mklive/rootfs.py b/euri_mklive/rootfs.py index b9c8aba..a85f06f 100644 --- a/euri_mklive/rootfs.py +++ b/euri_mklive/rootfs.py @@ -7,10 +7,12 @@ import tempfile from pathlib import Path from .distros import get as get_distro +from .distros.base import set_mode def build(config: dict, output: Path) -> Path: """Build a rootfs and return its path.""" + set_mode(config.get("_mode", "root")) distro_name = config["distro"] distro_cls = get_distro(distro_name) diff --git a/euri_mklive/tui.py b/euri_mklive/tui.py new file mode 100644 index 0000000..9fb6592 --- /dev/null +++ b/euri_mklive/tui.py @@ -0,0 +1,339 @@ +"""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 [/]") + 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 diff --git a/pyproject.toml b/pyproject.toml index 59ae6c9..a146bc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "euri-mklive" version = "0.1.0" description = "Universal CLI ISO builder — any distro, from any distro" requires-python = ">=3.10" -dependencies = ["pyyaml>=6", "click>=8"] +dependencies = ["pyyaml>=6", "click>=8", "rich>=13", "questionary>=2"] [project.scripts] euri-mklive = "euri_mklive.cli:cli"