diff --git a/configs/airootfs/usr/local/bin/omarchy-iso-install b/configs/airootfs/usr/local/bin/omarchy-iso-install new file mode 100755 index 0000000..3be64e6 --- /dev/null +++ b/configs/airootfs/usr/local/bin/omarchy-iso-install @@ -0,0 +1,14 @@ +#!/bin/bash +# +# Live-ISO entry point for the Omarchy install orchestrator. Invoked from +# /root/.automated_script.sh after the configurator wizard has produced +# user_configuration.json / user_credentials.json / user_full_name.txt / +# user_email_address.txt / user_encrypt_installation.txt. +# +# Just sets cwd so 'python -m orchestrator.main' can find the package, +# then hands off all args. + +set -eEo pipefail + +cd /usr/share/omarchy-iso +exec python -m orchestrator.main "$@" diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/__init__.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py new file mode 100644 index 0000000..c4747c3 --- /dev/null +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py @@ -0,0 +1,116 @@ +"""Thin compatibility wall around the archinstall Python library. + +ONLY this module imports from archinstall. Everything else uses these helpers. +If archinstall's API churns, the blast radius is contained here. + +Tested against archinstall 4.3 (Python 3.14). + +The canonical call sequence (mirrored from archinstall.scripts.guided.py) is: + + FilesystemHandler(disk_config).perform_filesystem_operations() + with Installer(mountpoint, disk_config, kernels=, silent=) as inst: + inst.mount_ordered_layout() + inst.sanity_check(offline=, skip_ntp=, skip_wkd=) + inst.generate_key_files() # encrypted only + inst.set_mirrors(handler, mirror_config, on_target=False) + inst.minimal_installation(...) # base + linux pacstrap + inst.set_mirrors(handler, mirror_config, on_target=True) + inst.setup_swap(algo=...) + inst.add_bootloader(bootloader, uki, removable) + inst.create_users(users) + inst.add_additional_packages(packages) + inst.set_timezone(tz) + inst.activate_time_synchronization() + inst.set_user_password(root_user) + inst.enable_service(services) + inst.genfstab() + +Our orchestrator interleaves `write_limine_config` between `add_bootloader` +and the first `add_additional_packages` so the limine UKI hook fires once, +correctly, on its first install. +""" + +from __future__ import annotations + +import os +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + +# Imports are top-level so a missing/incompatible archinstall surfaces at +# orchestrator startup, not deep inside a phase. +from archinstall.lib.args import ArchConfig, ArchConfigHandler +from archinstall.lib.authentication.authentication_handler import AuthenticationHandler +from archinstall.lib.configuration import ConfigurationOutput +from archinstall.lib.disk.filesystem import FilesystemHandler +from archinstall.lib.installer import Installer +from archinstall.lib.mirror.mirror_handler import MirrorListHandler +from archinstall.lib.models import Bootloader +from archinstall.lib.models.device import DiskLayoutType, EncryptionType +from archinstall.lib.models.users import User + + +def load_arch_config(config_path: Path, creds_path: Path) -> ArchConfigHandler: + """Build an ArchConfigHandler from on-disk JSON. archinstall reads the + paths via env vars, so we set them before instantiating the handler.""" + os.environ["ARCHINSTALL_CONFIG"] = str(config_path) + os.environ["ARCHINSTALL_CREDS"] = str(creds_path) + return ArchConfigHandler() + + +def make_mirror_handler(offline: bool = True) -> MirrorListHandler: + return MirrorListHandler(offline=offline, verbose=False) + + +def perform_filesystem_operations(arch_config: ArchConfig) -> None: + """Partition, format, encrypt. archinstall's FilesystemHandler is its own + object (separate from Installer) so we run it before opening the + Installer context manager.""" + if not arch_config.disk_config: + raise RuntimeError("disk_config missing from arch config") + FilesystemHandler(arch_config.disk_config).perform_filesystem_operations() + + +@contextmanager +def open_installer( + arch_config: ArchConfig, + mountpoint: Path, + silent: bool = True, +) -> Iterator[Installer]: + """Yield an open Installer; ensures __exit__ runs even on exception so + /mnt is left clean for a retry.""" + if not arch_config.disk_config: + raise RuntimeError("disk_config missing from arch config") + with Installer( + str(mountpoint), + arch_config.disk_config, + kernels=arch_config.kernels, + silent=silent, + ) as installer: + yield installer + + +def is_encrypted(arch_config: ArchConfig) -> bool: + disk = arch_config.disk_config + if not disk or not disk.disk_encryption: + return False + return disk.disk_encryption.encryption_type != EncryptionType.NO_ENCRYPTION + + +def is_pre_mount(arch_config: ArchConfig) -> bool: + return bool( + arch_config.disk_config + and arch_config.disk_config.config_type == DiskLayoutType.Pre_mount + ) + + +def is_limine(arch_config: ArchConfig) -> bool: + bl = arch_config.bootloader_config + return bool(bl and bl.bootloader == Bootloader.Limine) + + +def root_user(arch_config: ArchConfig) -> User | None: + auth = arch_config.auth_config + if not auth or not auth.root_enc_password: + return None + return User("root", auth.root_enc_password, False) diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py new file mode 100644 index 0000000..c85ee50 --- /dev/null +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -0,0 +1,59 @@ +"""Install context: parsed configurator output, invocation paths, and a +mutable `state` dict for objects that live across phases (e.g., the +archinstall config handler and mirror list handler).""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass +class InstallContext: + config_path: Path + creds_path: Path + full_name: str + email: str + encrypt: bool + + user_configuration: dict + user_credentials: dict + + target: Path = Path("/mnt") + omarchy_path: Path = Path("/usr/share/omarchy") + state_dir: Path = Path("/run/omarchy-install") + log_path: Path = Path("/var/log/omarchy-install.log") + target_log_path: Path = Path("/mnt/var/log/omarchy-install.log") + + # Mutable per-run state shared across phases (e.g., 'arch_config_handler', + # 'mirror_handler'). Phases populate as needed; later phases read. + state: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_args(cls, args) -> "InstallContext": + config_path = Path(args.config) + creds_path = Path(args.creds) + return cls( + config_path=config_path, + creds_path=creds_path, + full_name=_read_text(args.full_name_file), + email=_read_text(args.email_file), + encrypt=_read_text(args.encrypt_file).lower() in ("true", "yes", "1"), + user_configuration=json.loads(config_path.read_text()), + user_credentials=json.loads(creds_path.read_text()), + ) + + @property + def username(self) -> str: + return self.user_credentials["users"][0]["username"] + + +def _read_text(path: str | None) -> str: + if not path: + return "" + p = Path(path) + if not p.exists(): + return "" + return p.read_text().strip() diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py new file mode 100644 index 0000000..38f9322 --- /dev/null +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py @@ -0,0 +1,81 @@ +"""Omarchy install orchestrator. + +Single tool that owns the full install phase ordering, with archinstall used as +a library subsystem (not as the top-level installer). + +Usage (typically invoked by bin/omarchy-install on the live ISO): + + omarchy-install \\ + --config user_configuration.json \\ + --creds user_credentials.json \\ + --full-name-file user_full_name.txt \\ + --email-file user_email_address.txt \\ + --encrypt-file user_encrypt_installation.txt +""" + +from __future__ import annotations + +import argparse +import sys + +from . import archinstall_adapter as arch +from .context import InstallContext +from .phases import PhaseError, run +from .ui import error, info + + +def build_phases(): + """Phase order. Each entry is (display name, callable taking InstallContext). + + The ordering is the whole point of this orchestrator: package-install + hooks (limine-mkinitcpio-hook, in particular) and useradd happen at + points where their prerequisites are guaranteed to be in place. + """ + from .phases_impl import ( + prepare_live, + arch_install, + run_chroot_finalizer, + validate_boot, + finish, + ) + + return [ + ("Preparing live environment", prepare_live), + ("Installing Arch + Omarchy", arch_install), + ("Finalizing in chroot", run_chroot_finalizer), + ("Validating boot setup", validate_boot), + ("Finishing", finish), + ] + + +def parse_args(argv): + p = argparse.ArgumentParser(prog="omarchy-install") + p.add_argument("--config", required=True, help="archinstall user_configuration.json") + p.add_argument("--creds", required=True, help="archinstall user_credentials.json") + p.add_argument("--full-name-file", help="text file with the user's full name") + p.add_argument("--email-file", help="text file with the user's email address") + p.add_argument("--encrypt-file", help="text file holding 'true' if root encryption enabled") + return p.parse_args(argv) + + +def main(argv=None) -> int: + args = parse_args(argv or sys.argv[1:]) + ctx = InstallContext.from_args(args) + + info(f"Installing Omarchy for {ctx.username} → {ctx.target}") + + try: + run(ctx, build_phases()) + except PhaseError: + error("Installation halted.") + return 1 + except KeyboardInterrupt: + error("Installation interrupted.") + return 130 + + info("Installation complete.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases.py new file mode 100644 index 0000000..d84bd66 --- /dev/null +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases.py @@ -0,0 +1,57 @@ +"""Phase state machine. Each phase is a (name, callable) pair; callables take +the InstallContext and either return cleanly or raise to abort the install.""" + +from __future__ import annotations + +import json +import time +import traceback +from collections.abc import Callable +from pathlib import Path + +from .context import InstallContext +from .ui import error, info + + +PhaseFn = Callable[[InstallContext], None] + + +class PhaseError(Exception): + """Raised when a phase fails. Wrapped with the phase name.""" + + +def run(ctx: InstallContext, phases: list[tuple[str, PhaseFn]]) -> None: + ctx.state_dir.mkdir(parents=True, exist_ok=True) + state_path = ctx.state_dir / "state.json" + state = {"started_at": time.time(), "phases": []} + _write_state(state_path, state) + + for name, fn in phases: + info(f"› {name}") + started = time.time() + try: + fn(ctx) + except Exception as exc: # noqa: BLE001 + elapsed = time.time() - started + state["phases"].append({ + "name": name, + "status": "failed", + "elapsed": elapsed, + "error": str(exc), + }) + _write_state(state_path, state) + + error(f"Phase '{name}' failed after {elapsed:.1f}s: {exc}") + traceback.print_exc() + raise PhaseError(f"phase {name} failed: {exc}") from exc + + elapsed = time.time() - started + state["phases"].append({"name": name, "status": "ok", "elapsed": elapsed}) + _write_state(state_path, state) + + state["finished_at"] = time.time() + _write_state(state_path, state) + + +def _write_state(path: Path, state: dict) -> None: + path.write_text(json.dumps(state, indent=2, default=str)) diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py new file mode 100644 index 0000000..7987118 --- /dev/null +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -0,0 +1,274 @@ +"""Concrete phase implementations. + +Phase ordering: + + prepare_live → live ISO env + load arch config + arch_install → archinstall-driven install (partition, base, + bootloader, write limine config, early omarchy + pkgs, useradd, runtime omarchy pkgs) + run_chroot_finalizer → arch-chroot finalize.sh as the install user + validate_boot → assert UKI / limine.conf / kernel cmdline are sane + finish → reboot prompt + +Heavy lifting in arch_install lives in archinstall_adapter (for the Installer +context manager) and in this file (for our limine-config write + omarchy +package selection). Other phases are kept small. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +from . import archinstall_adapter as arch +from .context import InstallContext +from .ui import info + + +# Packages installed BEFORE useradd. omarchy-settings populates /etc/skel so +# the user's home gets seeded correctly. omarchy-installer is INTENTIONALLY +# absent — it's live-ISO-only install tooling, never installed on the target. +# finalize.sh + install/ scripts get copied to /mnt/tmp/ in run_chroot_finalizer. +EARLY_PACKAGES = [ + "base-devel", + "git", + "omarchy-keyring", + "omarchy-settings", +] + + +# ───────────────────────────────────────────────────────────────────────────── +# prepare_live: parse user_configuration.json/user_credentials.json, build the +# archinstall handlers. Cached on ctx.state for downstream phases. +# ───────────────────────────────────────────────────────────────────────────── + +def prepare_live(ctx: InstallContext) -> None: + ctx.state["arch_config_handler"] = arch.load_arch_config( + ctx.config_path, ctx.creds_path + ) + ctx.state["mirror_handler"] = arch.make_mirror_handler(offline=True) + + +# ───────────────────────────────────────────────────────────────────────────── +# arch_install: everything inside a single Installer context manager. Mirrors +# guided.py's perform_installation() but reorders so our limine config write +# lands between add_bootloader and the first add_additional_packages call, and +# user creation happens AFTER early omarchy packages populate /etc/skel. +# ───────────────────────────────────────────────────────────────────────────── + +def arch_install(ctx: InstallContext) -> None: + handler = ctx.state["arch_config_handler"] + mirror_handler = ctx.state["mirror_handler"] + config = handler.config + + info("› partitioning + formatting + encrypting") + arch.perform_filesystem_operations(config) + + info("› opening installer context") + with arch.open_installer(config, ctx.target, silent=True) as installer: + if not arch.is_pre_mount(config): + installer.mount_ordered_layout() + + installer.sanity_check( + offline=True, + skip_ntp=True, + skip_wkd=True, + ) + + if not arch.is_pre_mount(config) and arch.is_encrypted(config): + installer.generate_key_files() + + if config.mirror_config: + installer.set_mirrors(mirror_handler, config.mirror_config, on_target=False) + + info("› installing base system") + installer.minimal_installation( + optional_repositories=( + config.mirror_config.optional_repositories + if config.mirror_config else [] + ), + mkinitcpio=True, + hostname=config.hostname, + locale_config=config.locale_config, + pacman_config=config.pacman_config, + ) + + if config.mirror_config: + installer.set_mirrors(mirror_handler, config.mirror_config, on_target=True) + + if config.swap and config.swap.enabled: + installer.setup_swap(algo=config.swap.algorithm) + + info("› installing bootloader (Limine)") + if config.bootloader_config: + installer.add_bootloader( + config.bootloader_config.bootloader, + config.bootloader_config.uki, + config.bootloader_config.removable, + ) + + info("› writing Limine config (so limine-mkinitcpio-hook fires correctly)") + _write_limine_defaults(ctx) + + info(f"› installing early Omarchy packages: {', '.join(EARLY_PACKAGES)}") + installer.add_additional_packages(EARLY_PACKAGES) + + info("› creating user (with /etc/skel populated)") + if config.auth_config and config.auth_config.users: + installer.create_users(config.auth_config.users) + + info("› installing Omarchy runtime + omarchy-base.packages") + runtime_pkgs = _runtime_package_list(ctx) + installer.add_additional_packages(runtime_pkgs) + + # Standard arch finishers. + if config.timezone: + installer.set_timezone(config.timezone) + if config.ntp: + installer.activate_time_synchronization() + if root := arch.root_user(config): + installer.set_user_password(root) + + installer.genfstab() + + +# ───────────────────────────────────────────────────────────────────────────── +# Limine config write — extracted from install/login/limine-snapper.sh logic. +# Reads cmdline from /mnt/boot/limine.conf (which add_bootloader wrote), +# substitutes @@CMDLINE@@ into the template, writes /etc/default/limine + +# /etc/kernel/cmdline. +# ───────────────────────────────────────────────────────────────────────────── + +def _write_limine_defaults(ctx: InstallContext) -> None: + if not arch.is_limine(ctx.state["arch_config_handler"].config): + return + + limine_conf = ctx.target / "boot" / "limine.conf" + if not limine_conf.exists(): + raise RuntimeError(f"{limine_conf} not found after add_bootloader") + + cmdline = _extract_cmdline(limine_conf) + if not cmdline.strip(): + raise RuntimeError("Could not extract kernel cmdline from limine.conf") + if "root=" not in cmdline: + raise RuntimeError(f"Extracted cmdline has no root=: {cmdline!r}") + + # The template lives in omarchy-installer (this package), so it's + # available from /mnt/usr/share/omarchy/... as soon as the early + # omarchy-installer pacstrap completes — but we want it BEFORE that. + # Read from our own runtime tree on the live ISO instead. + template = ctx.omarchy_path / "install" / "assets" / "limine" / "default.conf" + if not template.exists(): + # Fallback to the legacy path while we migrate templates between packages. + template = ctx.omarchy_path / "default" / "limine" / "default.conf" + if not template.exists(): + raise RuntimeError(f"Limine template not found at {template}") + + default_limine = ctx.target / "etc" / "default" / "limine" + default_limine.parent.mkdir(parents=True, exist_ok=True) + default_limine.write_text(template.read_text().replace("@@CMDLINE@@", cmdline)) + + kernel_cmdline = ctx.target / "etc" / "kernel" / "cmdline" + kernel_cmdline.parent.mkdir(parents=True, exist_ok=True) + kernel_cmdline.write_text(cmdline + "\n") + + +def _extract_cmdline(limine_conf: Path) -> str: + for line in limine_conf.read_text().splitlines(): + stripped = line.strip() + if stripped.startswith("cmdline:"): + return stripped[len("cmdline:"):].strip() + return "" + + +def _runtime_package_list(ctx: InstallContext) -> list[str]: + """omarchy + every package in install/omarchy-base.packages that isn't + already in EARLY_PACKAGES.""" + base_pkgs_file = ctx.omarchy_path / "install" / "omarchy-base.packages" + pkgs = ["omarchy"] + early = set(EARLY_PACKAGES) + for raw in base_pkgs_file.read_text().splitlines(): + s = raw.strip() + if not s or s.startswith("#"): + continue + if s not in early and s not in pkgs: + pkgs.append(s) + return pkgs + + +# ───────────────────────────────────────────────────────────────────────────── +# run_chroot_finalizer: arch-chroot -u $user into /mnt and run finalize.sh. +# Inherits stdout/stderr so the in-target output streams to our log capture. +# ───────────────────────────────────────────────────────────────────────────── + +def run_chroot_finalizer(ctx: InstallContext) -> None: + # omarchy-installer (the package) is NOT on the target system. Copy its + # tree from the live ISO into /mnt/tmp/omarchy-install/ so finalize.sh + # and install/ are reachable from inside the chroot. /tmp is ephemeral, + # so this leaves zero install detritus on the target after reboot. + target_tooling = ctx.target / "tmp" / "omarchy-install" + target_tooling.parent.mkdir(exist_ok=True) + subprocess.run( + ["cp", "-a", f"{ctx.omarchy_path}/.", str(target_tooling)], + check=True, + ) + subprocess.run(["chown", "-R", f"{ctx.username}:{ctx.username}", str(target_tooling)]) + + env_extras = [ + "OMARCHY_INSTALL_MODE=offline", + "OMARCHY_PATH=/tmp/omarchy-install", + f"OMARCHY_USER_NAME={ctx.full_name}", + f"OMARCHY_USER_EMAIL={ctx.email}", + f"USER={ctx.username}", + f"HOME=/home/{ctx.username}", + ] + cmd = [ + "arch-chroot", + "-u", ctx.username, + str(ctx.target), + "env", "--unset=XDG_RUNTIME_DIR", + *env_extras, + "/bin/bash", "-lc", + "bash /tmp/omarchy-install/finalize.sh", + ] + subprocess.run(cmd, check=True) + + +# ───────────────────────────────────────────────────────────────────────────── +# validate_boot: hard checks before reboot. If the install ran but produced +# a UKI that can't actually boot, we want to halt here, not surprise the user. +# ───────────────────────────────────────────────────────────────────────────── + +def validate_boot(ctx: InstallContext) -> None: + limine_conf = ctx.target / "boot" / "limine.conf" + if not limine_conf.exists(): + raise RuntimeError(f"{limine_conf} missing") + + content = limine_conf.read_text() + if "^/+Omarchy" not in content and "Omarchy" not in content: + raise RuntimeError("/boot/limine.conf has no Omarchy entry") + + if ctx.encrypt and "cryptdevice=" not in content: + raise RuntimeError("Encrypted install but /boot/limine.conf has no cryptdevice=") + + kernel_cmdline = ctx.target / "etc" / "kernel" / "cmdline" + if not kernel_cmdline.exists(): + raise RuntimeError(f"{kernel_cmdline} missing — UKI would have no cmdline") + + uki_dir = ctx.target / "boot" / "EFI" / "Linux" + if uki_dir.exists(): + ukis = list(uki_dir.glob("*_linux*.efi")) + if not ukis: + raise RuntimeError(f"No UKI found in {uki_dir}") + + +# ───────────────────────────────────────────────────────────────────────────── +# finish: show completion + offer reboot. No mutation. +# ───────────────────────────────────────────────────────────────────────────── + +def finish(ctx: InstallContext) -> None: + from .ui import confirm + info("Installation finished. Reboot when ready.") + if confirm("Reboot now?", default=True): + os.system("reboot") diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/ui.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/ui.py new file mode 100644 index 0000000..812c996 --- /dev/null +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/ui.py @@ -0,0 +1,29 @@ +"""Thin gum wrapper so the orchestrator keeps the same terminal UX as the +existing bash installer.""" + +from __future__ import annotations + +import subprocess + + +def style(text: str, *, foreground: str | None = None, padding: str | None = None) -> None: + cmd = ["gum", "style"] + if foreground: + cmd += ["--foreground", foreground] + if padding: + cmd += ["--padding", padding] + cmd.append(text) + subprocess.run(cmd, check=False) + + +def confirm(prompt: str, *, default: bool = True) -> bool: + cmd = ["gum", "confirm", "--default" if default else "--no-default", prompt] + return subprocess.run(cmd).returncode == 0 + + +def info(text: str) -> None: + style(text, foreground="3", padding="1 0 0 4") + + +def error(text: str) -> None: + style(text, foreground="1", padding="1 0 0 4")