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
This commit is contained in:
2026-08-27 20:18:30 +02:00
parent 15b1da544d
commit 131e84bb3f
5 changed files with 437 additions and 2 deletions
+81
View File
@@ -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 <config> \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."""
+14 -1
View File
@@ -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)
+2
View File
@@ -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)
+339
View File
@@ -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 <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
+1 -1
View File
@@ -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"