Scaffold Python orchestrator + split install.sh / finalize.sh

Chunk 1 of the C3 refactor: replace the two-pass install model (archinstall
CLI + custom shell dance + chroot bash installer) with a single Python
orchestrator that owns phase ordering, using archinstall as a library
subsystem.

Splits the bash entry point into two:
- install.sh: online entry point. Ensures omarchy runtime + base.packages
  are installed/up to date, then exec's finalize.sh.
- finalize.sh: the in-target portion (preflight + packaging + config +
  login + post-install). Called by install.sh (online) AND by the
  orchestrator after arch-chroot -u $USER (offline).

Adds the orchestrator skeleton under install/orchestrator/:
- main.py: entry point, builds + runs the phase list
- context.py: InstallContext (parsed configurator JSON + invocation paths)
- phases.py: phase state machine (logging + state.json + error wrapping)
- phases_impl.py: stubbed phase implementations (filled in by chunks 2-6)
- archinstall_adapter.py: thin compat wall around archinstall lib imports
  (only this module imports from archinstall.*)
- ui.py: gum subprocess wrappers so the orchestrator keeps the same
  styled-terminal UX as the bash installer

Updates bin/omarchy-install to dispatch:
- --config <json> in args → python -m orchestrator.main (ISO install)
- anything else → bash install.sh (online rerun on installed system)

Concrete phase logic lands in subsequent chunks. All phases currently
raise NotImplementedError; the orchestrator imports cleanly and --help
works as a smoke check.
This commit is contained in:
Ryan Hughes
2026-06-04 18:34:35 -04:00
parent d0ec7c481f
commit 2beda6abed
11 changed files with 405 additions and 23 deletions
+4
View File
@@ -0,0 +1,4 @@
# Python bytecode (orchestrator)
__pycache__/
*.pyc
+21 -2
View File
@@ -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 <json> --creds <json> ...] | [install.sh args]
exec bash "$OMARCHY_PATH/install.sh" "$@"
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
exec bash "${OMARCHY_PATH:-/usr/share/omarchy}/install.sh" "$@"
+33
View File
@@ -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"
+14 -21
View File
@@ -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"
View File
@@ -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")
+52
View File
@@ -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()
+95
View File
@@ -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())
+57
View File
@@ -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))
+63
View File
@@ -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 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.
"""
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")
+29
View File
@@ -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")