diff --git a/bin/omarchy-install b/bin/omarchy-install index c1e05203..e3267431 100755 --- a/bin/omarchy-install +++ b/bin/omarchy-install @@ -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 --creds ...] | [install.sh args] set -eEo pipefail -# Dispatcher: -# --config → 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" "$@" diff --git a/install/orchestrator/__init__.py b/install/orchestrator/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/install/orchestrator/archinstall_adapter.py b/install/orchestrator/archinstall_adapter.py deleted file mode 100644 index c4747c31..00000000 --- a/install/orchestrator/archinstall_adapter.py +++ /dev/null @@ -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) diff --git a/install/orchestrator/context.py b/install/orchestrator/context.py deleted file mode 100644 index c85ee504..00000000 --- a/install/orchestrator/context.py +++ /dev/null @@ -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() diff --git a/install/orchestrator/main.py b/install/orchestrator/main.py deleted file mode 100644 index 38f93225..00000000 --- a/install/orchestrator/main.py +++ /dev/null @@ -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()) diff --git a/install/orchestrator/phases.py b/install/orchestrator/phases.py deleted file mode 100644 index d84bd66c..00000000 --- a/install/orchestrator/phases.py +++ /dev/null @@ -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)) diff --git a/install/orchestrator/phases_impl.py b/install/orchestrator/phases_impl.py deleted file mode 100644 index dde1cb57..00000000 --- a/install/orchestrator/phases_impl.py +++ /dev/null @@ -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") diff --git a/install/orchestrator/ui.py b/install/orchestrator/ui.py deleted file mode 100644 index 812c9964..00000000 --- a/install/orchestrator/ui.py +++ /dev/null @@ -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")