Drop orchestrator from omarchy-installer (moved to omarchy-iso)

The orchestrator only runs on the live ISO, so it lives in omarchy-iso
where the rest of the ISO-specific code (configurator, .automated_script
.sh, archinstall pacman config) lives. omarchy-installer stays the
monorepo of Omarchy source content (configs, themes, default files,
install scripts) without a Python install driver mixed in.

bin/omarchy-install simplifies to a one-liner: it's the online entry
point now, no --config dispatcher. The orchestrator never invokes it;
it runs finalize.sh directly via arch-chroot.
This commit is contained in:
Ryan Hughes
2026-06-04 18:34:35 -04:00
parent c6ccd62550
commit 9800b5b73b
8 changed files with 4 additions and 622 deletions
+4 -19
View File
@@ -1,26 +1,11 @@
#!/bin/bash
# omarchy:summary=Run the Omarchy installer (shipped via omarchy-installer)
# omarchy:summary=Online Omarchy install (on an existing Arch system)
# omarchy:group=install
# omarchy:args=[--config <json> --creds <json> ...] | [install.sh args]
set -eEo pipefail
# Dispatcher:
# --config <json> → Python orchestrator (ISO install)
# anything else → bash install.sh (online rerun on an installed system)
#
# The Python orchestrator owns the ISO install end-to-end: partitioning,
# pacstrap, bootloader, user creation, package install, and finally calling
# finalize.sh inside the chroot. install.sh is the simpler online path:
# ensures the omarchy runtime is up to date, then exec's finalize.sh.
for arg in "$@"; do
case "$arg" in
--config|--config=*)
cd "${OMARCHY_PATH:-/usr/share/omarchy}/install"
exec python -m orchestrator.main "$@"
;;
esac
done
# Online entry point. The ISO install is driven by omarchy-iso's Python
# orchestrator instead, which arch-chroots into /mnt and runs finalize.sh
# directly — never touches this script.
exec bash "${OMARCHY_PATH:-/usr/share/omarchy}/install.sh" "$@"
View File
-116
View File
@@ -1,116 +0,0 @@
"""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)
-59
View File
@@ -1,59 +0,0 @@
"""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()
-81
View File
@@ -1,81 +0,0 @@
"""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())
-57
View File
@@ -1,57 +0,0 @@
"""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))
-261
View File
@@ -1,261 +0,0 @@
"""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 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:
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:
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:
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")
-29
View File
@@ -1,29 +0,0 @@
"""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")