diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..8de9215b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ + +# Python bytecode (orchestrator) +__pycache__/ +*.pyc diff --git a/bin/omarchy-install b/bin/omarchy-install index a2275d75..c1e05203 100755 --- a/bin/omarchy-install +++ b/bin/omarchy-install @@ -2,6 +2,25 @@ # omarchy:summary=Run the Omarchy installer (shipped via omarchy-installer) # omarchy:group=install -# omarchy:args=[install.sh args] +# omarchy:args=[--config --creds ...] | [install.sh args] -exec bash "$OMARCHY_PATH/install.sh" "$@" +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 + +exec bash "${OMARCHY_PATH:-/usr/share/omarchy}/install.sh" "$@" diff --git a/finalize.sh b/finalize.sh new file mode 100644 index 00000000..6f201df1 --- /dev/null +++ b/finalize.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# +# The in-target portion of an Omarchy install: preflight checks, package-level +# setup, system configuration, login wiring, post-install steps. Run AFTER the +# orchestrator (offline mode) or install.sh (online mode) has ensured the +# omarchy runtime + omarchy-base.packages set is already installed. +# +# Called by: +# - install.sh (online mode, after pacman -Syu) +# - orchestrator/main.py (offline mode, via arch-chroot -u $USER) +# +# Anything that *must* happen as root pre-user lives in the orchestrator; this +# script runs as the install user. + +set -eEo pipefail + +_OMARCHY_INSTALLER_DIR=$(dirname "$(realpath "${BASH_SOURCE[0]}")") +export OMARCHY_PATH="${OMARCHY_PATH:-$_OMARCHY_INSTALLER_DIR}" +export OMARCHY_INSTALL="$OMARCHY_PATH/install" +export OMARCHY_INSTALL_LOG_FILE="${OMARCHY_INSTALL_LOG_FILE:-/var/log/omarchy-install.log}" +export PATH="$OMARCHY_PATH/bin:$PATH" + +source "$OMARCHY_INSTALL/helpers/mode.sh" +detect_install_mode +export_legacy_mode_flags + +source "$OMARCHY_INSTALL/helpers/all.sh" + +source "$OMARCHY_INSTALL/preflight/all.sh" +source "$OMARCHY_INSTALL/packaging/all.sh" +source "$OMARCHY_INSTALL/config/all.sh" +source "$OMARCHY_INSTALL/login/all.sh" +source "$OMARCHY_INSTALL/post-install/all.sh" diff --git a/install.sh b/install.sh index b7e76441..d2a358bb 100644 --- a/install.sh +++ b/install.sh @@ -1,15 +1,19 @@ #!/bin/bash +# +# Online install entry point. Run by the user on an existing system to install +# / refresh Omarchy. Ensures the omarchy runtime + omarchy-base.packages set +# is up to date, then hands off to finalize.sh for the actual configure work. +# +# Offline installs (from the ISO) skip this script entirely: the Python +# orchestrator handles partitioning, base install, package install, then calls +# finalize.sh directly inside the chroot. set -eEo pipefail -# Derive OMARCHY_PATH from the script location so install.sh works the same -# whether it's run from /usr/share/omarchy/install.sh (package mode) or -# $HOME/.local/share/omarchy/install.sh (git mode). An explicit OMARCHY_PATH -# in the caller env still wins. _OMARCHY_INSTALLER_DIR=$(dirname "$(realpath "${BASH_SOURCE[0]}")") export OMARCHY_PATH="${OMARCHY_PATH:-$_OMARCHY_INSTALLER_DIR}" export OMARCHY_INSTALL="$OMARCHY_PATH/install" -export OMARCHY_INSTALL_LOG_FILE="/var/log/omarchy-install.log" +export OMARCHY_INSTALL_LOG_FILE="${OMARCHY_INSTALL_LOG_FILE:-/var/log/omarchy-install.log}" export PATH="$OMARCHY_PATH/bin:$PATH" source "$OMARCHY_INSTALL/helpers/mode.sh" @@ -18,29 +22,18 @@ export_legacy_mode_flags source "$OMARCHY_INSTALL/helpers/all.sh" -# The install scripts assume the full Omarchy default install set is present -# (preflight guards check for limine; config scripts call omarchy-* commands; -# user scripts call apps from omarchy-base.packages; etc.). -# -# omarchy itself only hard-depends on the bricking set (~18 packages). The -# rest of the default install set lives in install/omarchy-base.packages so -# users can remove non-essential apps without pacman blocking on a depend. -# -# Online: install omarchy + everything in omarchy-base.packages now. -# Offline: the ISO pacstraps the same set before user creation; just assert. +# The finalize scripts assume the omarchy runtime + the default install set are +# already on disk. In online mode we install them here; offline mode asserts +# (the orchestrator pacstraps them before calling finalize.sh directly). _omarchy_runtime_pkg="${OMARCHY_RUNTIME_PACKAGE:-omarchy}" mapfile -t _omarchy_base_pkgs < <(grep -v '^#\|^$' "$OMARCHY_PATH/install/omarchy-base.packages") if install_mode_is offline; then pacman -Q "$_omarchy_runtime_pkg" >/dev/null 2>&1 || { - echo "Error: $_omarchy_runtime_pkg must be pacstrapped before omarchy-install runs in offline mode" >&2 + echo "Error: $_omarchy_runtime_pkg must be installed before install.sh runs in offline mode" >&2 exit 1 } else sudo pacman -Syu --noconfirm --needed "$_omarchy_runtime_pkg" "${_omarchy_base_pkgs[@]}" fi -source "$OMARCHY_INSTALL/preflight/all.sh" -source "$OMARCHY_INSTALL/packaging/all.sh" -source "$OMARCHY_INSTALL/config/all.sh" -source "$OMARCHY_INSTALL/login/all.sh" -source "$OMARCHY_INSTALL/post-install/all.sh" +exec bash "$OMARCHY_PATH/finalize.sh" diff --git a/install/orchestrator/__init__.py b/install/orchestrator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/install/orchestrator/archinstall_adapter.py b/install/orchestrator/archinstall_adapter.py new file mode 100644 index 00000000..3149bef7 --- /dev/null +++ b/install/orchestrator/archinstall_adapter.py @@ -0,0 +1,37 @@ +"""Thin compatibility wall around the archinstall Python library. + +ONLY this module imports from archinstall. Everything else uses these functions. +If archinstall's API churns, the blast radius is contained here. + +Tested against archinstall 4.3 (Python 3.14). +""" + +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. + + +def prepare_live() -> None: + """pacman-key init/populate, mount checks, etc. Currently a no-op stub.""" + raise NotImplementedError("populated in Chunk 2") + + +def cleanup_disk() -> None: + raise NotImplementedError("populated in Chunk 2") + + +def create_partitions_and_mounts(install_ctx) -> None: + raise NotImplementedError("populated in Chunk 2") + + +def install_base_system(install_ctx) -> None: + raise NotImplementedError("populated in Chunk 2") + + +def install_limine_bootloader(install_ctx) -> None: + raise NotImplementedError("populated in Chunk 2") + + +def create_users(install_ctx) -> None: + raise NotImplementedError("populated in Chunk 2") diff --git a/install/orchestrator/context.py b/install/orchestrator/context.py new file mode 100644 index 00000000..65d76313 --- /dev/null +++ b/install/orchestrator/context.py @@ -0,0 +1,52 @@ +"""Install context: parsed configurator output + invocation paths.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +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") + + @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 new file mode 100644 index 00000000..3c261e9e --- /dev/null +++ b/install/orchestrator/main.py @@ -0,0 +1,95 @@ +"""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, + cleanup_disk, + partition_and_mount, + install_base, + install_bootloader, + write_limine_config, + install_early_omarchy_packages, + create_user, + install_omarchy_runtime, + 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), + ] + + +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 new file mode 100644 index 00000000..d84bd66c --- /dev/null +++ b/install/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/install/orchestrator/phases_impl.py b/install/orchestrator/phases_impl.py new file mode 100644 index 00000000..a0c8cd56 --- /dev/null +++ b/install/orchestrator/phases_impl.py @@ -0,0 +1,63 @@ +"""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). + +Most are stubbed until Chunks 2–6 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. +""" + +from __future__ import annotations + +from . import archinstall_adapter as arch +from .context import InstallContext + + +def prepare_live(ctx: InstallContext) -> None: + raise NotImplementedError("Chunk 2") + + +def cleanup_disk(ctx: InstallContext) -> None: + raise NotImplementedError("Chunk 2") + + +def partition_and_mount(ctx: InstallContext) -> None: + raise NotImplementedError("Chunk 2") + + +def install_base(ctx: InstallContext) -> None: + raise NotImplementedError("Chunk 2") + + +def install_bootloader(ctx: InstallContext) -> None: + raise NotImplementedError("Chunk 2") + + +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") + + +def run_chroot_finalizer(ctx: InstallContext) -> None: + raise NotImplementedError("Chunk 5") + + +def validate_boot(ctx: InstallContext) -> None: + raise NotImplementedError("Chunk 6") + + +def finish(ctx: InstallContext) -> None: + raise NotImplementedError("Chunk 6") diff --git a/install/orchestrator/ui.py b/install/orchestrator/ui.py new file mode 100644 index 00000000..812c9964 --- /dev/null +++ b/install/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")