Wire archinstall adapter + concrete phase implementations

Chunks 2-6 of the C3 refactor. With the adapter in place, the phase
list collapses from 12 stubs to 5 real phases:

  prepare_live          parse user_*.json into archinstall handlers
  arch_install          partition + base + bootloader + limine config +
                        early omarchy + useradd + runtime omarchy (all
                        inside a single Installer context manager)
  run_chroot_finalizer  arch-chroot -u $user → finalize.sh
  validate_boot         halt if /boot/limine.conf, /etc/kernel/cmdline,
                        or UKI are missing/malformed
  finish                reboot prompt

Key reordering vs upstream guided.py: write_limine_config runs BETWEEN
add_bootloader and the first add_additional_packages call. By the time
omarchy-limine pulls limine-mkinitcpio-hook (in the runtime package
install), /etc/default/limine and /etc/kernel/cmdline are in place, so
the UKI is built correctly on the first try — no stale-UKI purge,
no follow-up limine-update.

add_additional_packages is split into two calls so /etc/skel is
populated (via omarchy-settings in EARLY_PACKAGES) BEFORE useradd:

  EARLY_PACKAGES = base-devel git omarchy-keyring omarchy-settings omarchy-installer
  → installer.create_users(users)
  → runtime: omarchy + omarchy-base.packages

archinstall_adapter.py is the ONLY module that imports from archinstall.
If archinstall's API churns, the blast radius is contained here. Tested
against archinstall 4.3 / Python 3.14.

Limine template lookup tries the new install/assets/limine/ path first
and falls back to the legacy default/limine/ location during the
template-ownership migration (lands in a follow-up chunk).
This commit is contained in:
Ryan Hughes
2026-06-04 18:34:35 -04:00
parent df645a202d
commit c6ccd62550
4 changed files with 342 additions and 72 deletions
+95 -16
View File
@@ -1,37 +1,116 @@
"""Thin compatibility wall around the archinstall Python library.
ONLY this module imports from archinstall. Everything else uses these functions.
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
# Phase 2 will populate this module. For now we declare the contract so the
# rest of the orchestrator can be wired up against the eventual surface area.
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 prepare_live() -> None:
"""pacman-key init/populate, mount checks, etc. Currently a no-op stub."""
raise NotImplementedError("populated in Chunk 2")
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 cleanup_disk() -> None:
raise NotImplementedError("populated in Chunk 2")
def make_mirror_handler(offline: bool = True) -> MirrorListHandler:
return MirrorListHandler(offline=offline, verbose=False)
def create_partitions_and_mounts(install_ctx) -> None:
raise NotImplementedError("populated in Chunk 2")
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()
def install_base_system(install_ctx) -> None:
raise NotImplementedError("populated in Chunk 2")
@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 install_limine_bootloader(install_ctx) -> None:
raise NotImplementedError("populated in Chunk 2")
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 create_users(install_ctx) -> None:
raise NotImplementedError("populated in Chunk 2")
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)
+10 -3
View File
@@ -1,13 +1,16 @@
"""Install context: parsed configurator output + invocation paths."""
"""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
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
@dataclass
class InstallContext:
config_path: Path
creds_path: Path
@@ -24,6 +27,10 @@ class InstallContext:
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)
+6 -20
View File
@@ -33,32 +33,18 @@ def build_phases():
"""
from .phases_impl import (
prepare_live,
cleanup_disk,
partition_and_mount,
install_base,
install_bootloader,
write_limine_config,
install_early_omarchy_packages,
create_user,
install_omarchy_runtime,
arch_install,
run_chroot_finalizer,
validate_boot,
finish,
)
return [
("Preparing live environment", prepare_live),
("Cleaning install disk", cleanup_disk),
("Partitioning + mounting", partition_and_mount),
("Installing base system", install_base),
("Installing bootloader", install_bootloader),
("Writing Limine config", write_limine_config),
("Installing Omarchy keyring + settings", install_early_omarchy_packages),
("Creating user", create_user),
("Installing Omarchy runtime", install_omarchy_runtime),
("Finalizing in chroot", run_chroot_finalizer),
("Validating boot setup", validate_boot),
("Finishing", finish),
("Preparing live environment", prepare_live),
("Installing Arch + Omarchy", arch_install),
("Finalizing in chroot", run_chroot_finalizer),
("Validating boot setup", validate_boot),
("Finishing", finish),
]
+231 -33
View File
@@ -1,63 +1,261 @@
"""Concrete phase implementations.
Each function takes the InstallContext and either returns or raises. Phases are
small wrappers — heavy lifting lives in archinstall_adapter (for Arch substrate
work) and helpers.* (for Omarchy-specific work).
Phase ordering:
Most are stubbed until Chunks 26 land. Keeping them here so the phase wiring
in main.py is testable end-to-end as a smoke check ('every phase imports
cleanly') from Chunk 1 onwards.
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 ships the install/
# tree + finalize.sh that the in-chroot finalizer needs.
EARLY_PACKAGES = [
"base-devel",
"git",
"omarchy-keyring",
"omarchy-settings",
"omarchy-installer",
]
# ─────────────────────────────────────────────────────────────────────────────
# 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:
raise NotImplementedError("Chunk 2")
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)
def cleanup_disk(ctx: InstallContext) -> None:
raise NotImplementedError("Chunk 2")
# ─────────────────────────────────────────────────────────────────────────────
# 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()
def partition_and_mount(ctx: InstallContext) -> None:
raise NotImplementedError("Chunk 2")
# ─────────────────────────────────────────────────────────────────────────────
# 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 install_base(ctx: InstallContext) -> None:
raise NotImplementedError("Chunk 2")
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 install_bootloader(ctx: InstallContext) -> None:
raise NotImplementedError("Chunk 2")
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
def write_limine_config(ctx: InstallContext) -> None:
raise NotImplementedError("Chunk 3")
def install_early_omarchy_packages(ctx: InstallContext) -> None:
raise NotImplementedError("Chunk 4")
def create_user(ctx: InstallContext) -> None:
raise NotImplementedError("Chunk 2")
def install_omarchy_runtime(ctx: InstallContext) -> None:
raise NotImplementedError("Chunk 4")
# ─────────────────────────────────────────────────────────────────────────────
# 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:
raise NotImplementedError("Chunk 5")
env_extras = [
f"OMARCHY_INSTALL_MODE=offline",
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",
f"bash {ctx.omarchy_path}/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:
raise NotImplementedError("Chunk 6")
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:
raise NotImplementedError("Chunk 6")
from .ui import confirm
info("Installation finished. Reboot when ready.")
if confirm("Reboot now?", default=True):
os.system("reboot")