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:
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user