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
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")