Add Python install orchestrator (moved from omarchy-installer)
The orchestrator drives an offline install on the live ISO using
archinstall as a library subsystem. Lands under configs/airootfs/ so
mkarchiso bakes it into the live squashfs directly:
/usr/share/omarchy-iso/orchestrator/ (the Python package)
/usr/local/bin/omarchy-iso-install (thin shell wrapper that
execs python -m orchestrator.main)
Two corrections vs the previous omarchy-installer location:
1. omarchy-installer dropped from EARLY_PACKAGES \u2014 it's live-ISO-only
install tooling, must never land on the target. /etc/skel is still
populated pre-useradd via omarchy-settings.
2. run_chroot_finalizer now copies /usr/share/omarchy from the live
ISO into /mnt/tmp/omarchy-install/ before arch-chroot. finalize.sh
+ install/ scripts run from /tmp inside the chroot; /tmp is wiped
on first reboot, so the target boots with zero install detritus.
This commit is contained in:
8 files changed
+630
No files matched your search
+14
@@ -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 "$@"
|
||||||
Whitespace-only changes.
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -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))
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
Reference in new issue
Block a user