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
This commit is contained in:
2026-08-26 19:01:19 +02:00
commit 3c5a357cb4
16 changed files with 880 additions and 0 deletions
+81
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
"""euri-mklive: Universal CLI ISO builder — any distro, from any distro."""
__version__ = "0.1.0"
+89
View File
@@ -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")
+35
View File
@@ -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
+27
View File
@@ -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
+61
View File
@@ -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"])
+78
View File
@@ -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"])
+42
View File
@@ -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)
+67
View File
@@ -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"])
+62
View File
@@ -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"])
+73
View File
@@ -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"])
+83
View File
@@ -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)
+55
View File
@@ -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)
+49
View File
@@ -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
+59
View File
@@ -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
+16
View File
@@ -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*"]