commit 3c5a357cb4d1dfd3959661e5b06d69da2331d811 Author: Michał Date: Wed Aug 26 19:01:19 2026 +0200 euri-mklive: universal CLI ISO builder - any distro from any distro (debian, ubuntu, arch, artix, void, alpine, fedora) - YAML config for distro, arch, init, packages, users, services, bootloader - each distro backend knows how to bootstrap itself - squashfs + xorriso for ISO generation - sexy CLI with banner and colored output - examples for Artix+Euri and Project Horizon diff --git a/README.md b/README.md new file mode 100644 index 0000000..d715aef --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# euri-mklive + +universal CLI iso builder. any distro, from any distro. + +build a debian iso on arch. an arch iso on fedora. an artix iso on ubuntu. doesn't matter. + +## install + +```bash +pip install . +# or +pipx install . +``` + +## usage + +```bash +# see what's supported +euri-mklive supported + +# build from a config +euri-mklive build my-config.yaml -o ./output +``` + +## config + +yaml config that defines everything: + +```yaml +distro: artix # debian | ubuntu | arch | artix | void | alpine | fedora +arch: x86_64 +init: dinit # dinit | openrc | runit | s6 + +hostname: mydistro + +iso: + name: My Distro + version: "1.0" + label: MYDISTRO + +users: + - name: user + shell: /bin/bash + groups: [wheel] + +packages: + - linux + - plasma-desktop + - nano + - git + +services: + - connmand + - sddm +``` + +## supported distros + +| distro | bootstrap tool | init systems | +|--------|---------------|--------------| +| debian | debootstrap | systemd | +| ubuntu | debootstrap | systemd | +| arch | pacstrap | systemd | +| artix | basestrap | openrc, runit, s6, dinit | +| void | xbps-install | runit, dinit | +| alpine | apk | openrc | +| fedora | dnf | systemd | + +## how it works + +1. reads your yaml config +2. bootstraps the target distro into a temporary rootfs +3. installs your packages +4. configures users, services, hostname +5. installs the bootloader +6. squashes the rootfs into an ISO +7. done. + +## license + +GPL-2.0 diff --git a/euri_mklive/__init__.py b/euri_mklive/__init__.py new file mode 100644 index 0000000..e024093 --- /dev/null +++ b/euri_mklive/__init__.py @@ -0,0 +1,3 @@ +"""euri-mklive: Universal CLI ISO builder — any distro, from any distro.""" + +__version__ = "0.1.0" diff --git a/euri_mklive/cli.py b/euri_mklive/cli.py new file mode 100644 index 0000000..960b5c6 --- /dev/null +++ b/euri_mklive/cli.py @@ -0,0 +1,89 @@ +"""euri-mklive — Universal CLI ISO builder.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import click + +from . import __version__ +from .config import load +from .rootfs import build, cleanup +from .iso import generate +from .distros import REGISTRY + +BANNER = r""" +\033[1;36m + ██╗███╗ ██╗████████╗██╗ ██████╗ + ██║████╗ ██║╚══██╔══╝██║ ██╔══██╗ + ██║██╔██╗ ██║ ██║ ██║ ██████╔╝ + ██║██║╚██╗██║ ██║ ██║ ██╔══██╗ + ██║██║ ╚████║ ██║ ███████╗██║ ██║ + ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ +\033[0m +\033[1m universal iso builder\033[0m +\033[90m any distro, from any distro\033[0m +""" + +SUPPORTED = ", ".join(sorted(REGISTRY.keys())) + + +@click.group(invoke_without_command=True) +@click.version_option(__version__, prog_name="euri-mklive") +@click.pass_context +def cli(ctx: click.Context) -> None: + """\033[1mBuild bootable ISOs for any Linux distribution.\033[0m""" + if ctx.invoked_subcommand is None: + click.echo(BANNER) + click.echo(f" \033[90msupported distros:\033[0m {SUPPORTED}") + click.echo() + click.echo(ctx.get_help()) + + +@cli.command() +@click.argument("config", type=click.Path(exists=True, path_type=Path)) +@click.option("-o", "--output", type=click.Path(path_type=Path), default=".", help="Output directory") +@click.option("--keep-rootfs", is_flag=True, help="Don't remove rootfs after ISO generation") +def build_iso(config: Path, output: Path, keep_rootfs: bool) -> None: + """Build an ISO from a YAML config file.""" + click.echo(BANNER) + + cfg = load(config) + output.mkdir(parents=True, exist_ok=True) + + distro = cfg["distro"] + arch = cfg["arch"] + init = cfg.get("init", "dinit") + pkgs = len(cfg.get("packages", [])) + + click.echo(f" \033[36mdistro:\033[0m {distro}") + click.echo(f" \033[36march:\033[0m {arch}") + click.echo(f" \033[36minit:\033[0m {init}") + click.echo(f" \033[36mpackages:\033[0m {pkgs}") + click.echo() + + try: + rootfs = build(cfg, output) + iso = generate(rootfs, cfg, output) + + if not keep_rootfs: + cleanup(rootfs) + click.echo(f"\n\033[90mRootfs cleaned up.\033[0m") + + click.echo(f"\n\033[1;32mDone! ISO ready at {iso}\033[0m") + + except Exception as e: + click.echo(f"\n\033[1;31mError: {e}\033[0m", err=True) + sys.exit(1) + + +@cli.command() +def supported() -> None: + """List all supported distros.""" + click.echo(BANNER) + click.echo(" \033[1mSupported distros:\033[0m") + for name, cls in sorted(REGISTRY.items()): + click.echo(f" \033[36m{name:<15}\033[0m {cls.__doc__ or ''}") + click.echo() + click.echo(" \033[90mEach distro can be built FROM any other distro.\033[0m") diff --git a/euri_mklive/config.py b/euri_mklive/config.py new file mode 100644 index 0000000..d1c5302 --- /dev/null +++ b/euri_mklive/config.py @@ -0,0 +1,35 @@ +"""YAML config loader and validator.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + + +REQUIRED = {"distro", "arch"} + + +def load(path: Path) -> dict[str, Any]: + """Load and validate a YAML config file.""" + with open(path) as f: + cfg = yaml.safe_load(f) + + if not isinstance(cfg, dict): + raise ValueError(f"Config must be a YAML mapping, got {type(cfg).__name__}") + + missing = REQUIRED - cfg.keys() + if missing: + raise ValueError(f"Missing required fields: {', '.join(sorted(missing))}") + + # defaults + cfg.setdefault("hostname", "euri") + cfg.setdefault("users", []) + cfg.setdefault("services", []) + cfg.setdefault("packages", []) + cfg.setdefault("init", "dinit") + cfg.setdefault("bootloader", "grub") + cfg.setdefault("iso", {}) + + return cfg diff --git a/euri_mklive/distros/__init__.py b/euri_mklive/distros/__init__.py new file mode 100644 index 0000000..8b904f4 --- /dev/null +++ b/euri_mklive/distros/__init__.py @@ -0,0 +1,27 @@ +"""Distro backends — each knows how to bootstrap itself.""" + +from .base import Distro +from .debian import Debian +from .arch import Arch +from .void import Void +from .alpine import Alpine +from .fedora import Fedora + +REGISTRY: dict[str, type[Distro]] = { + "debian": Debian, + "ubuntu": Debian, + "arch": Arch, + "artix": Arch, + "void": Void, + "alpine": Alpine, + "fedora": Fedora, + "rocky": Fedora, + "almalinux": Fedora, +} + + +def get(name: str) -> type[Distro]: + cls = REGISTRY.get(name.lower()) + if cls is None: + raise ValueError(f"Unknown distro: {name!r}. Available: {', '.join(sorted(REGISTRY))}") + return cls diff --git a/euri_mklive/distros/alpine.py b/euri_mklive/distros/alpine.py new file mode 100644 index 0000000..aa6d892 --- /dev/null +++ b/euri_mklive/distros/alpine.py @@ -0,0 +1,61 @@ +"""Alpine Linux backend — uses apk with --root.""" + +from pathlib import Path + +from .base import Distro + + +class Alpine(Distro): + name = "alpine" + host_deps = ["apk-tools", "squashfs-tools", "xorriso", "mtools", "dosfstools"] + + MIRROR = "https://dl-cdn.alpinelinux.org/alpine/latest-stable" + + def bootstrap(self, rootfs: Path, config: dict) -> None: + mirror = config.get("mirror", self.MIRROR) + branch = config.get("suite", "edge") + init = config.get("init", "openrc") + + cmd = [ + "apk", + "-X", f"{mirror}/{branch}/main", + "-U", + "--allow-untrusted", + "--root", str(rootfs), + "--initdb", + "add", + "alpine-base", + "linux-lts", + "linux-firmware", + init, + *config.get("base_packages", []), + ] + self._run(cmd) + + def install_packages(self, rootfs: Path, packages: list[str]) -> None: + if not packages: + return + self._run_chroot(rootfs, ["apk", "add", *packages]) + + def configure_base(self, rootfs: Path, config: dict) -> None: + hostname = config.get("hostname", "euri") + (rootfs / "etc/hostname").write_text(f"{hostname}\n") + + for user in config.get("users", []): + name = user["name"] + shell = user.get("shell", "/bin/bash") + self._run_chroot(rootfs, ["adduser", "-D", "-s", shell, name]) + if "password" in user: + self._run_chroot(rootfs, ["bash", "-c", f"echo '{name}:{user[\"password\"]}' | chpasswd"]) + for group in user.get("groups", []): + self._run_chroot(rootfs, ["adduser", name, group]) + + init = config.get("init", "openrc") + for svc in config.get("services", []): + self._run_chroot(rootfs, ["rc-update", "add", svc, "default"]) + + def install_bootloader(self, rootfs: Path, config: dict) -> None: + self._run_chroot(rootfs, ["apk", "add", "grub", "grub-efi", "os-prober"]) + self._run_chroot(rootfs, ["grub-install", "--target=i386-pc", "/dev/sda"]) + self._run_chroot(rootfs, ["grub-install", "--target=x86_64-efi", "--efi-directory=/boot/efi"]) + self._run_chroot(rootfs, ["grub-mkconfig", "-o", "/boot/grub/grub.cfg"]) diff --git a/euri_mklive/distros/arch.py b/euri_mklive/distros/arch.py new file mode 100644 index 0000000..4642a70 --- /dev/null +++ b/euri_mklive/distros/arch.py @@ -0,0 +1,78 @@ +"""Arch/Artix backend — uses pacstrap (Arch) or basestrap (Artix).""" + +from pathlib import Path + +from .base import Distro + + +class Arch(Distro): + name = "arch" + host_deps = ["squashfs-tools", "xorriso", "mtools", "dosfstools"] + + INITS = {"openrc", "runit", "s6", "dinit"} + + def __init__(self, family: str = "arch"): + self.family = family + + def bootstrap(self, rootfs: Path, config: dict) -> None: + init = config.get("init", "dinit") + base_pkgs = ["base", "base-devel", f"{init}", *config.get("base_packages", [])] + + if self.family == "artix": + # basestrap for Artix + elogind_pkg = f"elogind-{init}" + cmd = ["basestrap", "-i", str(rootfs), *base_pkgs, elogind_pkg] + else: + # pacstrap for Arch + cmd = ["pacstrap", "-K", str(rootfs), *base_pkgs] + + self._run(cmd) + + def install_packages(self, rootfs: Path, packages: list[str]) -> None: + if not packages: + return + self._run_chroot(rootfs, ["pacman", "-Syu", "--noconfirm", *packages]) + + def configure_base(self, rootfs: Path, config: dict) -> None: + hostname = config.get("hostname", "euri") + (rootfs / "etc/hostname").write_text(f"{hostname}\n") + + # hosts file + hosts = ( + "127.0.0.1\tlocalhost\n" + "::1\t\tlocalhost\n" + f"127.0.1.1\t{hostname}.localdomain\t{hostname}\n" + ) + (rootfs / "etc/hosts").write_text(hosts) + + for user in config.get("users", []): + name = user["name"] + shell = user.get("shell", "/bin/bash") + self._run_chroot(rootfs, ["useradd", "-m", "-s", shell, name]) + if "password" in user: + self._run_chroot(rootfs, ["bash", "-c", f"echo '{name}:{user[\"password\"]}' | chpasswd"]) + for group in user.get("groups", []): + self._run_chroot(rootfs, ["usermod", "-aG", group, name]) + + init = config.get("init", "dinit") + + # enable services per init system + for svc in config.get("services", []): + if self.family == "artix": + if init == "openrc": + self._run_chroot(rootfs, ["rc-update", "add", svc]) + elif init == "runit": + self._run_chroot(rootfs, ["ln", "-s", f"/etc/runit/sv/{svc}", "/etc/runit/runsvdir/default"]) + elif init == "s6": + self._run_chroot(rootfs, ["touch", f"/etc/s6/adminsv/default/contents.d/{svc}"]) + elif init == "dinit": + self._run_chroot(rootfs, ["ln", "-s", f"../{svc}", "/etc/dinit.d/boot.d/"]) + else: + self._run_chroot(rootfs, ["systemctl", "enable", svc]) + + def install_bootloader(self, rootfs: Path, config: dict) -> None: + init = config.get("init", "dinit") + self._run_chroot(rootfs, ["pacman", "-S", "--noconfirm", "grub", "efibootmgr", "os-prober"]) + self._run_chroot(rootfs, ["grub-install", "--target=i386-pc", "/dev/sda"]) + self._run_chroot(rootfs, ["grub-install", "--target=x86_64-efi", "--efi-directory=/boot/efi", "--bootloader-id=grub"]) + self._run_chroot(rootfs, ["grub-mkconfig", "-o", "/boot/grub/grub.cfg"]) diff --git a/euri_mklive/distros/base.py b/euri_mklive/distros/base.py new file mode 100644 index 0000000..e9f3db9 --- /dev/null +++ b/euri_mklive/distros/base.py @@ -0,0 +1,42 @@ +"""Base class for distro backends.""" + +from __future__ import annotations + +import subprocess +from abc import ABC, abstractmethod +from pathlib import Path + + +class Distro(ABC): + """Base class every distro backend must implement.""" + + name: str + # host packages needed to bootstrap (e.g. debootstrap, pacstrap) + host_deps: list[str] + + @abstractmethod + def bootstrap(self, rootfs: Path, config: dict) -> None: + """Create the rootfs by installing the base system.""" + + @abstractmethod + def install_packages(self, rootfs: Path, packages: list[str]) -> None: + """Install packages into an existing rootfs.""" + + @abstractmethod + def configure_base(self, rootfs: Path, config: dict) -> None: + """Write hostname, fstab, locale, users, services, etc.""" + + @abstractmethod + def install_bootloader(self, rootfs: Path, config: dict) -> None: + """Install and configure the bootloader inside the rootfs.""" + + @staticmethod + def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess: + print(f" \033[90m$\033[0m \033[1m{' '.join(cmd)}\033[0m") + return subprocess.run(cmd, check=True, **kw) + + @staticmethod + def _run_chroot(rootfs: Path, cmd: list[str], **kw) -> subprocess.CompletedProcess: + full = ["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/distros/debian.py b/euri_mklive/distros/debian.py new file mode 100644 index 0000000..e0a00e0 --- /dev/null +++ b/euri_mklive/distros/debian.py @@ -0,0 +1,67 @@ +"""Debian/Ubuntu backend — uses debootstrap.""" + +from pathlib import Path + +from .base import Distro + + +class Debian(Distro): + name = "debian" + host_deps = ["debootstrap", "squashfs-tools", "xorriso", "mtools", "dosfstools"] + + SUITES = { + "debian": "bookworm", + "ubuntu": "noble", + } + + MIRRORS = { + "debian": "http://deb.debian.org/debian", + "ubuntu": "http://archive.ubuntu.com/ubuntu", + } + + def __init__(self, family: str = "debian"): + self.family = family + + def bootstrap(self, rootfs: Path, config: dict) -> None: + suite = config.get("suite", self.SUITES.get(self.family, "bookworm")) + mirror = config.get("mirror", self.MIRRORS.get(self.family)) + variant = config.get("variant", "minbase") + + cmd = [ + "debootstrap", + "--variant", variant, + "--include", ",".join(config.get("base_packages", ["base-files", "apt"])), + "--arch", config.get("arch", "amd64"), + suite, + str(rootfs), + mirror, + ] + self._run(cmd) + + def install_packages(self, rootfs: Path, packages: list[str]) -> None: + if not packages: + return + self._run_chroot(rootfs, ["apt-get", "update"]) + self._run_chroot(rootfs, ["apt-get", "install", "-y", *packages]) + + def configure_base(self, rootfs: Path, config: dict) -> None: + hostname = config.get("hostname", "euri") + (rootfs / "etc/hostname").write_text(f"{hostname}\n") + + for user in config.get("users", []): + name = user["name"] + shell = user.get("shell", "/bin/bash") + self._run_chroot(rootfs, ["useradd", "-m", "-s", shell, name]) + if "password" in user: + self._run_chroot(rootfs, ["bash", "-c", f"echo '{name}:{user[\"password\"]}' | chpasswd"]) + for group in user.get("groups", []): + self._run_chroot(rootfs, ["usermod", "-aG", group, name]) + + for svc in config.get("services", []): + self._run_chroot(rootfs, ["systemctl", "enable", svc]) + + def install_bootloader(self, rootfs: Path, config: dict) -> None: + self._run_chroot(rootfs, ["apt-get", "install", "-y", "grub-pc", "grub-efi-amd64"]) + self._run_chroot(rootfs, ["grub-install", "--target=i386-pc", "/dev/sda"]) + self._run_chroot(rootfs, ["grub-install", "--target=x86_64-efi", "--efi-directory=/boot/efi"]) + self._run_chroot(rootfs, ["update-grub"]) diff --git a/euri_mklive/distros/fedora.py b/euri_mklive/distros/fedora.py new file mode 100644 index 0000000..4f395c5 --- /dev/null +++ b/euri_mklive/distros/fedora.py @@ -0,0 +1,62 @@ +"""Fedora/Rocky/Alma backend — uses dnf --installroot.""" + +from pathlib import Path + +from .base import Distro + + +class Fedora(Distro): + name = "fedora" + host_deps = ["dnf", "squashfs-tools", "xorriso", "mtools", "dosfstools"] + + GROUPS = { + "fedora": "@core", + "rocky": "@core", + "almalinux": "@core", + } + + def __init__(self, family: str = "fedora"): + self.family = family + + def bootstrap(self, rootfs: Path, config: dict) -> None: + release = config.get("suite", "40") + group = self.GROUPS.get(self.family, "@core") + + cmd = [ + "dnf", + "--installroot", str(rootfs), + "-y", + f"--releasever={release}", + "install", + group, + "kernel", + "grub2-efi-x64", + *config.get("base_packages", []), + ] + self._run(cmd) + + def install_packages(self, rootfs: Path, packages: list[str]) -> None: + if not packages: + return + self._run_chroot(rootfs, ["dnf", "-y", "install", *packages]) + + def configure_base(self, rootfs: Path, config: dict) -> None: + hostname = config.get("hostname", "euri") + (rootfs / "etc/hostname").write_text(f"{hostname}\n") + + for user in config.get("users", []): + name = user["name"] + shell = user.get("shell", "/bin/bash") + self._run_chroot(rootfs, ["useradd", "-m", "-s", shell, name]) + if "password" in user: + self._run_chroot(rootfs, ["bash", "-c", f"echo '{name}:{user[\"password\"]}' | chpasswd"]) + for group in user.get("groups", []): + self._run_chroot(rootfs, ["usermod", "-aG", group, name]) + + for svc in config.get("services", []): + self._run_chroot(rootfs, ["systemctl", "enable", svc]) + + def install_bootloader(self, rootfs: Path, config: dict) -> None: + self._run_chroot(rootfs, ["grub2-install", "--target=i386-pc", "/dev/sda"]) + self._run_chroot(rootfs, ["grub2-install", "--target=x86_64-efi", "--efi-directory=/boot/efi"]) + self._run_chroot(rootfs, ["grub2-mkconfig", "-o", "/boot/grub2/grub.cfg"]) diff --git a/euri_mklive/distros/void.py b/euri_mklive/distros/void.py new file mode 100644 index 0000000..7b7273f --- /dev/null +++ b/euri_mklive/distros/void.py @@ -0,0 +1,73 @@ +"""Void Linux backend — uses xbps-install with a rootfs tarball or chroot.""" + +from pathlib import Path + +from .base import Distro + + +class Void(Distro): + name = "void" + host_deps = ["xbps-install", "squashfs-tools", "xorriso", "mtools", "dosfstools"] + + REPOS = { + "glibc": "https://repo-default.voidlinux.org/current", + "musl": "https://repo-default.voidlinux.org/current", + } + + def bootstrap(self, rootfs: Path, config: dict) -> None: + libc = config.get("libc", "musl") + arch = config.get("arch", "x86_64") + repo = config.get("mirror", self.REPOS[libc]) + init = config.get("init", "dinit") + + # base packages — swap runit for dinit if requested + base = ["base-system"] + if init == "dinit": + # Void ships runit in base-system; we'll replace later + base.append("dinit") + + cmd = [ + "xbps-install", + "-S", + "-r", str(rootfs), + "-R", repo, + *base, + ] + env = {**config.get("env", {}), "XBPS_ARCH": f"{arch}-{libc}"} + self._run(cmd, env=env) + + # swap runit for dinit + if init == "dinit": + self._run_chroot(rootfs, ["xbps-remove", "-y", "runit"]) + self._run_chroot(rootfs, ["ln", "-s", "/etc/dinit.d/boot.d", "/etc/runit/runsvdir/default"]) + + def install_packages(self, rootfs: Path, packages: list[str]) -> None: + if not packages: + return + self._run_chroot(rootfs, ["xbps-install", "-Sy", *packages]) + + def configure_base(self, rootfs: Path, config: dict) -> None: + hostname = config.get("hostname", "euri") + (rootfs / "etc/hostname").write_text(f"{hostname}\n") + + for user in config.get("users", []): + name = user["name"] + shell = user.get("shell", "/bin/bash") + self._run_chroot(rootfs, ["useradd", "-m", "-s", shell, name]) + if "password" in user: + self._run_chroot(rootfs, ["bash", "-c", f"echo '{name}:{user[\"password\"]}' | chpasswd"]) + for group in user.get("groups", []): + self._run_chroot(rootfs, ["usermod", "-aG", group, name]) + + init = config.get("init", "dinit") + for svc in config.get("services", []): + if init == "dinit": + self._run_chroot(rootfs, ["ln", "-s", f"/etc/dinit.d/{svc}", "/etc/dinit.d/boot.d/"]) + else: + self._run_chroot(rootfs, ["ln", "-s", f"/etc/runit/sv/{svc}", "/etc/runit/runsvdir/default"]) + + def install_bootloader(self, rootfs: Path, config: dict) -> None: + self._run_chroot(rootfs, ["xbps-install", "-Sy", "grub", "grub-x86_64-efi", "efibootmgr"]) + self._run_chroot(rootfs, ["grub-install", "--target=i386-pc", "/dev/sda"]) + self._run_chroot(rootfs, ["grub-install", "--target=x86_64-efi", "--efi-directory=/boot/efi", "--bootloader-id=Void"]) + self._run_chroot(rootfs, ["grub-mkconfig", "-o", "/boot/grub/grub.cfg"]) diff --git a/euri_mklive/iso.py b/euri_mklive/iso.py new file mode 100644 index 0000000..d1b9be4 --- /dev/null +++ b/euri_mklive/iso.py @@ -0,0 +1,83 @@ +"""ISO generator — squashes rootfs and creates bootable ISO.""" + +from __future__ import annotations + +import subprocess +import tempfile +from pathlib import Path + + +def generate(rootfs: Path, config: dict, output: Path) -> Path: + """Generate a bootable ISO from a rootfs.""" + iso_cfg = config.get("iso", {}) + label = iso_cfg.get("label", "EURI") + name = iso_cfg.get("name", "Euri Linux") + version = iso_cfg.get("version", "1.0") + + print(f"\n\033[1;36m==>\033[0m \033[1mGenerating ISO: {name} {version}\033[0m") + + workdir = Path(tempfile.mkdtemp(prefix="mklive-iso-")) + iso_dir = workdir / "iso" + iso_dir.mkdir() + + # copy rootfs into ISO staging + _run(["cp", "-a", str(rootfs) + "/.", str(iso_dir)]) + + # squashfs + squash = workdir / "filesystem.squashfs" + print(f"\033[1;36m==>\033[0m \033[1mCreating squashfs image...\033[0m") + _run(["mksquashfs", str(iso_dir), str(squash), "-comp", "xz", "-b", "1M"]) + + # create ISO layout + live_dir = iso_dir / "boot" / "x86_64" + live_dir.mkdir(parents=True, exist_ok=True) + _run(["cp", str(squash), str(live_dir / "filesystem.squashfs")]) + + # EFI boot + efi_dir = iso_dir / "EFI" / "BOOT" + efi_dir.mkdir(parents=True, exist_ok=True) + _run(["cp", "/usr/lib/grub/x86_64-efi/monolithic/grubx64.efi", str(efi_dir / "BOOTX64.EFI")]) + + # GRUB config + grub_cfg = iso_dir / "boot" / "grub" / "grub.cfg" + grub_cfg.parent.mkdir(parents=True, exist_ok=True) + grub_cfg.write_text(f""" +set timeout=10 +set default=0 + +menuentry "{name}" {{ + linux /boot/x86_64/vmlinuz boot=live components + initrd /boot/x86_64/initrd +}} + +menuentry "{name} (copy to RAM)" {{ + linux /boot/x86_64/vmlinuz boot=live components toram + initrd /boot/x86_64/initrd +}} +""") + + # xorriso to create ISO + iso_path = output / f"{name.lower().replace(' ', '-')}-{version}-x86_64.iso" + print(f"\033[1;36m==>\033[0m \033[1mCreating ISO: {iso_path.name}\033[0m") + _run([ + "xorriso", "-as", "mkisofs", + "-V", label, + "-o", str(iso_path), + "-J", "-joliet-long", + "-b", "boot/x86_64/filesystem.squashfs", + "-no-emul-boot", "-boot-load-size", "4", + "-boot-info-table", + "-eltorito-alt-boot", + "-e", "boot/x86_64/filesystem.squashfs", + "-no-emul-boot", + str(iso_dir), + ]) + + size_mb = iso_path.stat().st_size / (1024 * 1024) + print(f"\n\033[1;32m==>\033[0m \033[1mISO ready: {iso_path} ({size_mb:.1f} MB)\033[0m") + return iso_path + + +def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess: + print(f" \033[90m$\033[0m \033[1m{' '.join(cmd)}\033[0m") + return subprocess.run(cmd, check=True, **kw) diff --git a/euri_mklive/rootfs.py b/euri_mklive/rootfs.py new file mode 100644 index 0000000..b9c8aba --- /dev/null +++ b/euri_mklive/rootfs.py @@ -0,0 +1,55 @@ +"""Rootfs builder — creates the root filesystem.""" + +from __future__ import annotations + +import shutil +import tempfile +from pathlib import Path + +from .distros import get as get_distro + + +def build(config: dict, output: Path) -> Path: + """Build a rootfs and return its path.""" + distro_name = config["distro"] + distro_cls = get_distro(distro_name) + + # artix vs arch + if distro_name in ("artix",): + distro = distro_cls(family="artix") + elif distro_name in ("debian", "ubuntu"): + distro = distro_cls(family=distro_name) + elif distro_name in ("fedora", "rocky", "almalinux"): + distro = distro_cls(family=distro_name) + else: + distro = distro_cls() + + rootfs = Path(tempfile.mkdtemp(prefix="mklive-")) + print(f"\n\033[1;36m==>\033[0m \033[1mBuilding rootfs in {rootfs}\033[0m") + + # 1. bootstrap + print(f"\033[1;36m==>\033[0m \033[1m[1/4] Bootstrapping {distro_name}...\033[0m") + distro.bootstrap(rootfs, config) + + # 2. install packages + pkgs = config.get("packages", []) + if pkgs: + print(f"\033[1;36m==>\033[0m \033[1m[2/4] Installing {len(pkgs)} packages...\033[0m") + distro.install_packages(rootfs, pkgs) + else: + print(f"\033[1;36m==>\033[0m \033[1m[2/4] No extra packages to install\033[0m") + + # 3. configure + print(f"\033[1;36m==>\033[0m \033[1m[3/4] Configuring base system...\033[0m") + distro.configure_base(rootfs, config) + + # 4. bootloader + print(f"\033[1;36m==>\033[0m \033[1m[4/4] Installing bootloader...\033[0m") + distro.install_bootloader(rootfs, config) + + return rootfs + + +def cleanup(rootfs: Path) -> None: + """Remove the temporary rootfs.""" + shutil.rmtree(rootfs, ignore_errors=True) diff --git a/examples/artix-euri.yaml b/examples/artix-euri.yaml new file mode 100644 index 0000000..79647ce --- /dev/null +++ b/examples/artix-euri.yaml @@ -0,0 +1,49 @@ +# euri-mklive config — build any distro from any distro +# +# usage: euri-mklive build config.yaml -o ./output +# +# this example builds an Euri Linux (Artix+dinit) ISO + +distro: artix # target distro: debian, ubuntu, arch, artix, void, alpine, fedora +arch: x86_64 +init: dinit # init system (for arch/artix/void/alpine) +suite: # optional: release version (debian bookworm, fedora 40, etc.) +mirror: # optional: custom mirror URL + +hostname: euri + +iso: + name: Euri Linux + version: "1.0" + label: EURI + +users: + - name: euri + shell: /bin/bash + groups: [wheel] + # password: set at install time, not in config + +packages: + # base + - linux-firmware + - nano + - git + - sudo + - base-devel + + # network + - connman-dinit + - wpa_supplicant + + # desktop + - plasma-desktop + - sddm-dinit + - konsole + - dolphin + +services: + - connmand + - sddm + +# boot: grub (default) or systemd-boot +bootloader: grub diff --git a/examples/horizon.yaml b/examples/horizon.yaml new file mode 100644 index 0000000..6721ea2 --- /dev/null +++ b/examples/horizon.yaml @@ -0,0 +1,59 @@ +# euri-mklive — Project Horizon +# +# builds the independent Euri Linux ISO +# requires: horizon-packages repo built with xbps-src +# +# usage: +# 1. build packages: cd xbps-src && ./xbps-src -A x86_64-musl pkg base-system +# 2. build iso: euri-mklive build horizon.yaml -o ./output + +distro: void +arch: x86_64 +libc: musl +init: dinit + +hostname: euri + +# point to our own repo, not void's +mirror: file:///path/to/horizon-packages/x86_64-musl + +iso: + name: Euri Linux + version: "0.1.0" + label: EURI + +users: + - name: euri + shell: /bin/bash + groups: [wheel] + +base_packages: + - base-system + - dinit + +packages: + # kernel + - linux + - linux-firmware + + # network + - connman-dinit + - wpa_supplicant + + # build tools + - base-devel + - git + - sudo + - nano + + # desktop (optional — uncomment for graphical ISO) + # - plasma-desktop + # - sddm-dinit + # - konsole + # - dolphin + +services: + - connmand + # - sddm + +bootloader: grub diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..476a1de --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.backends._legacy:_Backend" + +[project] +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"] + +[project.scripts] +euri-mklive = "euri_mklive.cli:main" + +[tool.setuptools.packages.find] +include = ["euri_mklive*"]