Add OEM install mode and factory snapshots

OEM mode installs the entire system but defers user creation to first boot,
where the runtime's omarchy-oem-setup asks for the owner and lands them in the
already-installed system — and the same machinery arms omarchy-reset-computer.

The configurator's install-mode step gains an OEM choice, which required
moving the user step after mode selection (OEM skips it entirely). Autoinstall
rigs select OEM with an `oem` marker file on the cidata drive, which also
replaces the user_credentials.json requirement.

In the orchestrator, OEM swaps the user phases for a stage_oem_state phase:
it stashes the bundled Node tarball (every install, for offline factory
resets), arms omarchy-oem-setup.service in the target, and on encrypted
installs stages the throwaway LUKS passphrase — generated by InstallContext
when the input carries none — with a keyfile embedded in the UKI so boot
auto-unlocks until first-boot setup re-keys to the owner's password. The
install fails with a clear error if the bundled runtime lacks OEM support.

Every install now also ends with a read-only @factory snapshot of @ at the
btrfs top level — outside snapper's reach — which is what makes
omarchy-reset-computer a true factory reset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier HanssonandClaude Fable 5 committed 2026-08-07 15:41:04 -07:00
1 parent 6004e80485
commit 679edce2e3
12 files changed
+927 -108

No files matched your search

+2 -1
View File
@@ -117,4 +117,5 @@ rm -f /run/omarchy-install/state.json
--email-file /root/user_email_address.txt \
--encrypt-file /root/user_encrypt_installation.txt \
--authorized-keys-file /root/authorized_keys \
--tailscale-authkey-file /root/tailscale_authkey
--tailscale-authkey-file /root/tailscale_authkey \
--oem-file /root/oem
+105 -45
View File
@@ -136,7 +136,7 @@ Ukrainian|ua'
keyboard_form
# STEP 2: USER
# STEP 2: USER (run after the install mode is chosen — OEM installs skip it)
user_form() {
step "Let's setup your user account..."
@@ -197,11 +197,12 @@ user_form() {
fi
}
user_form
user_step() {
user_form
while true; do
# Add manual padding since gum table -p doesn't respect padding
echo -e "Field,Value
while true; do
# Add manual padding since gum table -p doesn't respect padding
echo -e "Field,Value
Username,$username
Password,$(printf "%${#password}s" | tr ' ' '*')
Full name,${full_name:-[Skipped]}
@@ -209,16 +210,17 @@ Email address,${email_address:-[Skipped]}
Hostname,$hostname
Timezone,$timezone
Keyboard,$keyboard" |
gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/"
gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/"
echo
if gum confirm --negative "No, change it" "Does this look right?"; then
break
else
keyboard_form
user_form
fi
done
echo
if gum confirm --negative "No, change it" "Does this look right?"; then
break
else
keyboard_form
user_form
fi
done
}
# STEP 3: DISK
@@ -347,11 +349,22 @@ wait_for_device() {
# Save the configurator answers the installer consumes: git identity and
# encryption choice as plain files, account details as archinstall credentials.
# OEM installs defer the user to first boot: no credentials, and on encrypted
# targets the orchestrator generates a throwaway LUKS passphrase itself.
write_user_files() {
echo "$full_name" >user_full_name.txt
echo "$email_address" >user_email_address.txt
printf '%s\n' "$encrypt_installation" >user_encrypt_installation.txt
if $oem_install; then
cat <<-_EOF_ >user_credentials.json
{
"users": []
}
_EOF_
return
fi
local password_escaped password_hash_escaped username_escaped
password_escaped=$(echo -n "$password" | jq -Rsa)
password_hash_escaped=$(echo -n "$password_hash" | jq -Rsa)
@@ -413,10 +426,10 @@ not_enough_space() {
return 1
}
run_partition_install() {
local dry=false
[[ "${1:-}" == "dry" ]] && dry=true
# Decide half of the free-space install: checks, free-space analysis, and the
# encryption confirm. Sets the globals run_partition_execute needs. Returns 1
# to fall back to the install-mode picker.
run_partition_decide() {
step "Checking for BitLocker on $disk"
local bl_part
bl_part=$(detect_bitlocker || true)
@@ -448,7 +461,8 @@ run_partition_install() {
# An unlabeled disk has no partition table for parted to scan, so we
# synthesize a whole-disk free region and mklabel gpt before mkpart.
local FREE_SPACE_INFO needs_mklabel=false pt_type disk_size_b
local FREE_SPACE_INFO pt_type disk_size_b
needs_mklabel=false
pt_type=$(lsblk -dno PTTYPE "$disk" 2>/dev/null)
if [[ -z "$pt_type" ]]; then
disk_size_b=$(lsblk -bdno SIZE "$disk" 2>/dev/null)
@@ -491,7 +505,6 @@ run_partition_install() {
local EFI_SIZE_B=$((2 * gib))
local MIN_INSTALL_B=$((32 * gib)) # Minimum total Omarchy allocation
local EFI_START_B EFI_END_B ROOT_START_B ROOT_END_B
EFI_START_B=$(( (FREE_START_B + ALIGN - 1) / ALIGN * ALIGN ))
EFI_END_B=$((EFI_START_B + EFI_SIZE_B))
ROOT_START_B=$(( (EFI_END_B + 1 + ALIGN - 1) / ALIGN * ALIGN ))
@@ -554,7 +567,6 @@ run_partition_install() {
esac
done
local esp_mount_in_target
if [[ "$encrypt_installation" == "true" ]]; then
esp_mount_in_target=/boot
else
@@ -563,12 +575,11 @@ run_partition_install() {
kernel_choice=$(detect_kernel)
local last_part efi_part_num root_part_num
local last_part
last_part=$(lsblk -n -o PARTN "$disk" 2>/dev/null | grep -E '^[0-9]+$' | sort -n | tail -1)
efi_part_num=$(( ${last_part:-0} + 1 ))
root_part_num=$(( efi_part_num + 1 ))
local efi_dev root_partition_device root_mapper
efi_dev=$(partition_path "$disk" "$efi_part_num")
root_partition_device=$(partition_path "$disk" "$root_part_num")
if [[ "$encrypt_installation" == "true" ]]; then
@@ -576,6 +587,14 @@ run_partition_install() {
else
root_mapper="$root_partition_device"
fi
}
# Execute half of the free-space install: partition, format, mount, and write
# the pre-mounted (protected) install configuration. Runs after the user step
# so LUKS can be formatted with the user's password.
run_partition_execute() {
local dry=false
[[ "${1:-}" == "dry" ]] && dry=true
if $dry; then
$needs_mklabel && say "[dry] would initialize GPT on $disk"
@@ -634,10 +653,6 @@ run_partition_install() {
mount "$efi_dev" /mnt"$esp_mount_in_target"
fi
clear
write_user_files
local luks_uuid_json="null" luks_uuid
if [[ "$encrypt_installation" == "true" ]]; then
if $dry; then
@@ -716,10 +731,6 @@ run_partition_install() {
"version": "3.0.9"
}
_EOF_
if $dry; then
print_dry_run_files
fi
}
disk_form() {
@@ -771,8 +782,7 @@ open_partition_tool() {
}
# If the disk has no partition table or no unallocated free space, the only
# viable path is a full-disk install. Skip the install-mode picker and go
# straight to confirmation.
# viable paths write the whole disk, so the free-space mode is not offered.
requires_full_disk_install() {
local pt_type free_space_b
pt_type=$(lsblk -dno PTTYPE "$disk" 2>/dev/null)
@@ -799,9 +809,12 @@ install_mode_form() {
# The free-space/protected path registers an EFI boot entry and is UEFI-only.
# Full-disk installs still support BIOS through the orchestrator's BIOS branch.
if [[ -d /sys/firmware/efi ]]; then
if [[ -d /sys/firmware/efi ]] && ! $full_disk_only; then
choices+=("Free space install (alongside existing data)")
fi
# OEM: install the whole system now, defer user setup to the machine's
# first boot — for machines built to be sold or handed on.
choices+=("OEM install (new owner creates their user on first boot)")
choices+=("Choose a different disk")
step "Let's select how to install Omarchy..."
@@ -846,30 +859,41 @@ confirm_disk_overwrite() {
done
}
# Decide what and how to install: sets install_target (full_disk|free_space),
# oem_install, and encrypt_installation. Nothing is written to disk yet — the
# user step (skipped for OEM) runs between this and the execution below.
select_installation() {
local full_disk_only
while true; do
if requires_full_disk_install; then
full_disk_only=true
install_mode="Full disk install"
else
full_disk_only=false
install_mode_form
fi
install_mode_form
case "$install_mode" in
"Full disk install")
oem_install=false
if confirm_disk_overwrite; then
install_target="full_disk"
return 0
fi
if $full_disk_only; then
disk_form
fi
;;
"Free space install (alongside existing data)")
if run_partition_install "$@"; then
exit 0
"OEM install"*)
oem_install=true
if confirm_disk_overwrite; then
install_target="full_disk"
return 0
fi
oem_install=false
;;
"Free space install"*)
oem_install=false
if run_partition_decide; then
install_target="free_space"
return 0
fi
;;
"Choose a different disk")
@@ -879,13 +903,41 @@ select_installation() {
done
}
# STEP 3: WHERE AND HOW TO INSTALL
oem_install=false
install_target="full_disk"
disk_form
select_installation "$@"
select_installation
# STEP 4: USER (OEM installs defer this to the machine's first boot)
if $oem_install; then
username=""
password=""
password_hash=""
full_name=""
email_address=""
hostname="omarchy"
timezone="UTC"
else
user_step
fi
clear
write_user_files
if [[ $install_target == "free_space" ]]; then
run_partition_execute "$@"
if [[ ${1:-} == "dry" ]]; then
print_dry_run_files
fi
exit 0
fi
# Setup partition layout
disk_size=$(lsblk -bdno SIZE "$disk")
mib=$((1024 * 1024))
@@ -902,14 +954,21 @@ main_partition_size=$((disk_size_in_mib - main_partition_start - gpt_backup_rese
kernel_choice=$(detect_kernel)
if [[ $encrypt_installation == true ]]; then
# OEM installs carry no password: the orchestrator generates a throwaway
# LUKS passphrase and stages it for the first-boot re-key.
if $oem_install; then
encryption_password_line=""
else
encryption_password_line=",
\"encryption_password\": $(echo -n "$password" | jq -Rsa)"
fi
disk_encryption_config=$(cat <<-_EOF_
,
"disk_encryption": {
"encryption_type": "luks",
"lvm_volumes": [],
"iter_time": 2000,
"partitions": [ "8c2c2b92-1070-455d-b76a-56263bab24aa" ],
"encryption_password": $(echo -n "$password" | jq -Rsa)
"partitions": [ "8c2c2b92-1070-455d-b76a-56263bab24aa" ]$encryption_password_line
}
_EOF_
)
@@ -927,6 +986,7 @@ cat <<-_EOF_ >user_configuration.json
"custom_commands": [],
"omarchy_install": {
"mode": "full_disk",
"oem": $oem_install,
"target_mount": "/mnt",
"boot": {
"esp_mount": "/boot",
@@ -11,6 +11,9 @@
# Anything less than the pair means this isn't an autoinstall drive, and the
# caller falls back to the wizard.
#
# An `oem` marker file stands in for user_credentials.json: OEM installs defer
# user creation to first boot, so imaging rigs ship no credentials at all.
#
# The optional argument prefixes every path the script touches; tests use it to
# run against a sandbox instead of the live system.
@@ -36,9 +39,9 @@ mkdir -p "$mnt"
mount -o ro "$device" "$mnt" || exit 1
loaded=1
if [[ -f $mnt/user_configuration.json && -f $mnt/user_credentials.json ]]; then
files=("$mnt"/user_configuration.json "$mnt"/user_credentials.json)
for file in user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey; do
if [[ -f $mnt/user_configuration.json ]] && [[ -f $mnt/user_credentials.json || -f $mnt/oem ]]; then
files=("$mnt"/user_configuration.json)
for file in user_credentials.json oem user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey; do
[[ -f $mnt/$file ]] && files+=("$mnt/$file")
done
if cp "${files[@]}" "$dest"/; then
@@ -321,11 +321,13 @@ install_progress() {
"Installing Arch + Omarchy") lo=65 hi=700 tau=150 pkg_signal=1 ;;
"Configuring hibernation") lo=700 hi=720 tau=10 ;;
"Configuring system") lo=720 hi=850 tau=120 ;;
"Finalizing Limine boot") lo=850 hi=950 tau=90 ;;
"Staging OEM state") lo=850 hi=855 tau=5 ;;
"Finalizing Limine boot") lo=855 hi=950 tau=90 ;;
"Finalizing user") lo=950 hi=978 tau=30 ;;
"Configuring login") lo=978 hi=988 tau=5 ;;
"Configuring DNS resolver") lo=988 hi=994 tau=5 ;;
"Validating boot setup") lo=994 hi=1000 tau=5 ;;
"Validating boot setup") lo=994 hi=998 tau=5 ;;
"Creating factory snapshot") lo=998 hi=1000 tau=5 ;;
*)
# Unknown names used to inherit a 1->99 sweep. Derive an even band from
# the published index instead; with no index, a bounded slice, so a
@@ -18,6 +18,7 @@ while [[ $# -gt 0 ]]; do
--encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;;
--authorized-keys-file) export OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE="$2"; shift 2 ;;
--tailscale-authkey-file) export OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE="$2"; shift 2 ;;
--oem-file) export OMARCHY_INSTALL_OEM_FILE="$2"; shift 2 ;;
*) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;;
esac
done
@@ -6,6 +6,7 @@ from __future__ import annotations
import json
import os
import secrets
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@@ -25,6 +26,7 @@ class InstallContext:
user_credentials: dict
arch_config_path: Path
omarchy_install: dict[str, Any]
oem: bool = False
target: Path = Path("/mnt")
omarchy_path: Path = Path("/usr/share/omarchy")
@@ -48,10 +50,37 @@ class InstallContext:
user_configuration = json.loads(config_path.read_text())
omarchy_install = user_configuration.get("omarchy_install") or _default_omarchy_install(user_configuration)
# OEM mode: the whole system installs but user creation is deferred to
# first boot. Selected by the configurator (omarchy_install.oem) or by
# an `oem` marker file on an autoinstall drive, which also replaces the
# user_credentials.json requirement.
oem_marker = _optional_path(os.environ.get("OMARCHY_INSTALL_OEM_FILE"))
oem = bool(omarchy_install.get("oem")) or oem_marker is not None
omarchy_install["oem"] = oem
if creds_path.exists():
user_credentials = json.loads(creds_path.read_text())
elif oem:
user_credentials = {"users": []}
else:
raise RuntimeError(f"credentials file missing: {creds_path}")
arch_configuration = dict(user_configuration)
arch_configuration.pop("omarchy_install", None)
state_dir = Path(os.environ.get("OMARCHY_INSTALL_STATE_DIR", "/run/omarchy-install"))
state_dir.mkdir(parents=True, exist_ok=True)
if oem:
# Encrypted OEM installs have no user password to protect LUKS
# with. Generate a throwaway passphrase (staged for first-boot
# re-key by the stage_oem_state phase) and hand it to archinstall
# through both places it may look: the disk_encryption block and
# the credentials file.
_inject_oem_encryption_password(arch_configuration, user_credentials)
creds_path = state_dir / "oem-user_credentials.json"
creds_path.write_text(json.dumps(user_credentials, indent=2) + "\n")
creds_path.chmod(0o600)
arch_config_path = state_dir / "archinstall-user_configuration.json"
arch_config_path.write_text(json.dumps(arch_configuration, indent=2) + "\n")
@@ -64,9 +93,10 @@ class InstallContext:
authorized_keys_path=_optional_path(os.environ.get("OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE")),
tailscale_authkey_path=_optional_path(os.environ.get("OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE")),
user_configuration=user_configuration,
user_credentials=json.loads(creds_path.read_text()),
user_credentials=user_credentials,
arch_config_path=arch_config_path,
omarchy_install=omarchy_install,
oem=oem,
state_dir=state_dir,
)
disk_config = user_configuration.get("disk_config", {})
@@ -77,7 +107,12 @@ class InstallContext:
@property
def username(self) -> str:
return self.user_credentials["users"][0]["username"]
users = self.user_credentials.get("users") or []
if users:
return users[0]["username"]
if self.oem:
return ""
raise RuntimeError("user_credentials.json contains no users")
@property
def mode(self) -> str:
@@ -91,6 +126,24 @@ class InstallContext:
return self.mode == "protected"
def _inject_oem_encryption_password(arch_configuration: dict, user_credentials: dict) -> None:
"""Ensure an encrypted OEM install has a LUKS passphrase archinstall can
use. Reuse one the input already carries (an autoinstall rig may supply
its own); otherwise generate a throwaway. Mutates the disk_encryption
block in place — arch_configuration shares it with user_configuration, so
the stage_oem_state phase can read the effective passphrase back."""
disk_encryption = (arch_configuration.get("disk_config") or {}).get("disk_encryption")
if disk_encryption is None:
return
password = disk_encryption.get("encryption_password") or user_credentials.get("encryption_password")
if not password:
password = secrets.token_urlsafe(24)
disk_encryption["encryption_password"] = password
user_credentials["encryption_password"] = password
def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]:
disk_config = user_configuration.get("disk_config", {})
mode = "protected" if disk_config.get("config_type") == "pre_mounted_config" else "full_disk"
@@ -34,6 +34,7 @@ def build_phases(ctx: InstallContext):
arch_install_system,
configure_hibernation,
run_system_finalizer,
stage_oem_state,
finalize_limine_boot,
run_chroot_finalizer,
configure_dns_resolver,
@@ -41,6 +42,7 @@ def build_phases(ctx: InstallContext):
configure_ssh_access,
configure_tailscale,
validate_boot,
create_factory_snapshot,
)
return [
@@ -49,6 +51,9 @@ def build_phases(ctx: InstallContext):
("Installing Arch + Omarchy", arch_install_system),
("Configuring hibernation", configure_hibernation),
("Configuring system", run_system_finalizer),
# Before finalize_limine_boot: the OEM cryptkey drop-in and keyfile
# must be in place for the final UKI build.
("Staging OEM state", stage_oem_state),
("Finalizing Limine boot", finalize_limine_boot),
("Finalizing user", run_chroot_finalizer),
("Configuring login", configure_login),
@@ -56,6 +61,7 @@ def build_phases(ctx: InstallContext):
("Configuring Tailscale", configure_tailscale),
("Configuring DNS resolver", configure_dns_resolver),
("Validating boot setup", validate_boot),
("Creating factory snapshot", create_factory_snapshot),
]
@@ -66,7 +72,8 @@ def main() -> int:
error(f"Configuration error: {e}")
return 2
info(f"Installing Omarchy for {ctx.username} → {ctx.target}")
who = ctx.username or "OEM (user created at first boot)"
info(f"Installing Omarchy for {who} → {ctx.target}")
from .phases_impl import (
boost_cpu_governor,
@@ -1136,16 +1136,140 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str
def run_system_finalizer(ctx: InstallContext) -> None:
if ctx.oem:
cmd = ["/usr/bin/omarchy-setup-system", "--oem", "--first-install"]
else:
cmd = ["/usr/bin/omarchy-setup-system", "--install-user", ctx.username, "--first-install"]
_mask_mkinitcpio_pacman_hooks(ctx, ctx.target, TARGET_DEFERRED_BOOT_HOOKS)
try:
_run_target_setup_command(
ctx,
["/usr/bin/omarchy-setup-system", "--install-user", ctx.username, "--first-install"],
)
_run_target_setup_command(ctx, cmd)
finally:
_unmask_mkinitcpio_pacman_hooks(ctx, ctx.target, TARGET_DEFERRED_BOOT_HOOKS)
# ─────────────────────────────────────────────────────────────────────────────
# stage_oem_state: produce the on-disk "OEM state" the runtime's first-boot
# setup (omarchy-oem-setup) and factory reset (omarchy-reset-computer) consume.
#
# Every install stashes the bundled Node tarball in /var/lib/omarchy/oem/
# so a later factory reset can finalize the new owner's user offline. OEM-mode
# installs additionally arm the first-boot setup service and, on encrypted
# targets, stage the throwaway LUKS passphrase: the keyfile embedded in the
# initramfs auto-unlocks boot during the OEM window, and first-boot setup
# re-keys the volume to the owner's password and removes it.
#
# Runs before finalize_limine_boot so the cryptkey cmdline drop-in and the
# keyfile land in the final UKI build.
# ─────────────────────────────────────────────────────────────────────────────
OEM_STATE_DIR = "var/lib/omarchy/oem"
OEM_KEYFILE = "etc/omarchy/oem.key"
NODE_PACKAGES_DIR = Path("/opt/packages")
def stage_oem_state(ctx: InstallContext) -> None:
oem_dir = ctx.target / OEM_STATE_DIR
oem_dir.mkdir(parents=True, exist_ok=True)
oem_dir.chmod(0o700)
_stage_node_tarball(ctx, oem_dir)
if not ctx.oem:
return
service_src = ctx.target / "usr/share/omarchy/install/oem/omarchy-oem-setup.service"
setup_bin = ctx.target / "usr/bin/omarchy-oem-setup"
if not service_src.exists() or not setup_bin.exists():
raise RuntimeError(
"OEM install requested, but the installed Omarchy runtime does not ship "
"first-boot setup (omarchy-oem-setup + install/oem/omarchy-oem-setup.service). "
"Update the runtime package this ISO bundles before installing in OEM mode."
)
info("› arming first-boot OEM setup")
(oem_dir / "pending").touch()
unit_dst = ctx.target / "etc/systemd/system/omarchy-oem-setup.service"
unit_dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(service_src, unit_dst)
wants_dir = ctx.target / "etc/systemd/system/multi-user.target.wants"
wants_dir.mkdir(parents=True, exist_ok=True)
link = wants_dir / "omarchy-oem-setup.service"
link.unlink(missing_ok=True)
link.symlink_to("/etc/systemd/system/omarchy-oem-setup.service")
if _oem_install_encrypted(ctx):
_stage_oem_luks_unlock(ctx, oem_dir)
def _stage_node_tarball(ctx: InstallContext, oem_dir) -> None:
tarballs = sorted(NODE_PACKAGES_DIR.glob("node-v*-linux-x64.tar.gz"))
if not tarballs:
if ctx.oem:
raise RuntimeError(
"no bundled Node tarball in /opt/packages — OEM first-boot setup "
"could not finalize the user offline"
)
info("› no bundled Node tarball to stash for factory reset; skipping")
return
packages_dir = oem_dir / "packages"
packages_dir.mkdir(parents=True, exist_ok=True)
target_tarball = packages_dir / tarballs[0].name
if not target_tarball.exists():
info("› stashing Node tarball for offline first-boot setup")
shutil.copy2(tarballs[0], target_tarball)
def _oem_install_encrypted(ctx: InstallContext) -> bool:
if _storage_intent(ctx).get("luks_uuid"):
return True
disk_encryption = (ctx.user_configuration.get("disk_config") or {}).get("disk_encryption")
if disk_encryption and disk_encryption.get("encryption_type", "luks") != "no_encryption":
return True
return ctx.encrypt
def _oem_encryption_password(ctx: InstallContext) -> str | None:
disk_encryption = (ctx.user_configuration.get("disk_config") or {}).get("disk_encryption") or {}
return disk_encryption.get("encryption_password") or ctx.user_credentials.get("encryption_password")
def _stage_oem_luks_unlock(ctx: InstallContext, oem_dir) -> None:
password = _oem_encryption_password(ctx)
if not password:
# Full-disk OEM installs get a generated passphrase injected by
# InstallContext; only a pre-mounted (rig-partitioned) LUKS target can
# land here, and it must hand over the passphrase it formatted with.
raise RuntimeError(
"OEM install on a pre-encrypted target requires the LUKS passphrase "
"in user_credentials.json (encryption_password) so first boot can re-key"
)
info("› staging LUKS auto-unlock for the OEM window")
# Byte-for-byte the slot passphrase: no trailing newline anywhere.
luks_key = oem_dir / "luks-key"
luks_key.write_text(password)
luks_key.chmod(0o600)
keyfile = ctx.target / OEM_KEYFILE
keyfile.parent.mkdir(parents=True, exist_ok=True)
keyfile.write_text(password)
keyfile.chmod(0o600)
cmdline_dropin = ctx.target / "etc/limine-entry-tool.d/99-omarchy-oem-unlock.conf"
cmdline_dropin.parent.mkdir(parents=True, exist_ok=True)
cmdline_dropin.write_text(
'KERNEL_CMDLINE[default]+=" cryptkey=rootfs:/etc/omarchy/oem.key"\n'
)
files_dropin = ctx.target / "etc/mkinitcpio.conf.d/99-omarchy-oem-key.conf"
files_dropin.parent.mkdir(parents=True, exist_ok=True)
files_dropin.write_text("FILES+=(/etc/omarchy/oem.key)\n")
def finalize_limine_boot(ctx: InstallContext) -> None:
"""Finalize Limine after target system setup has written all dynamic
boot drop-ins (hibernation, hardware quirks, protected-mode ESP settings).
@@ -1239,6 +1363,10 @@ def _limine_kernel_cmdline(config_text: str) -> str:
def run_chroot_finalizer(ctx: InstallContext) -> None:
if ctx.oem:
info("› OEM install: user finalization deferred to first boot")
return
_run_target_setup_command(
ctx,
["/usr/bin/omarchy-finalize-user", "--force", "--first-install"],
@@ -1285,29 +1413,32 @@ def configure_login(ctx: InstallContext) -> None:
)
autologin_conf = sddm_dir / "autologin.conf"
if ctx.encrypt:
if ctx.encrypt and not ctx.oem:
autologin_conf.write_text(
"[Autologin]\n"
f"User={ctx.username}\n"
"Session=omarchy.desktop\n"
)
else:
# OEM installs have no user yet; omarchy-oem-setup writes autologin
# and SDDM state at first boot once the owner exists.
autologin_conf.unlink(missing_ok=True)
state_dir = ctx.target / "var" / "lib" / "sddm"
state_dir.mkdir(parents=True, exist_ok=True)
(state_dir / "state.conf").write_text(
f"[Last]\nSession=omarchy.desktop\nUser={ctx.username}\n"
)
if not ctx.oem:
state_dir = ctx.target / "var" / "lib" / "sddm"
state_dir.mkdir(parents=True, exist_ok=True)
(state_dir / "state.conf").write_text(
f"[Last]\nSession=omarchy.desktop\nUser={ctx.username}\n"
)
subprocess.run(
["arch-chroot", str(ctx.target), "chown", "sddm:sddm",
"/var/lib/sddm", "/var/lib/sddm/state.conf"],
check=False, capture_output=True,
)
autologin = ctx.target / "etc" / "systemd" / "system" / "getty@tty1.service.d" / "autologin.conf"
autologin.unlink(missing_ok=True)
subprocess.run(
["arch-chroot", str(ctx.target), "chown", "sddm:sddm",
"/var/lib/sddm", "/var/lib/sddm/state.conf"],
check=False, capture_output=True,
)
subprocess.run(
["arch-chroot", str(ctx.target), "systemctl", "enable", "sddm.service"],
check=False, capture_output=True,
@@ -1326,24 +1457,36 @@ def configure_ssh_access(ctx: InstallContext) -> None:
return
keys = _authorized_keys(ctx.authorized_keys_path)
info(f"› installing {len(keys)} SSH key(s) for {ctx.username}")
ssh_dir = ctx.target / "home" / ctx.username / ".ssh"
ssh_dir.mkdir(parents=True, exist_ok=True)
ssh_dir.chmod(0o700)
if ctx.oem:
# No user to authorize yet. Stage the keys in OEM state for
# omarchy-oem-setup to install once first boot creates the owner, and
# still open the door (sshd + ufw) below.
info(f"› staging {len(keys)} SSH key(s) for the first-boot user")
oem_dir = ctx.target / OEM_STATE_DIR
oem_dir.mkdir(parents=True, exist_ok=True)
staged = oem_dir / "authorized_keys"
staged.write_text("".join(f"{key}\n" for key in keys))
staged.chmod(0o600)
else:
info(f"› installing {len(keys)} SSH key(s) for {ctx.username}")
authorized_keys = ssh_dir / "authorized_keys"
authorized_keys.write_text("".join(f"{key}\n" for key in keys))
authorized_keys.chmod(0o600)
ssh_dir = ctx.target / "home" / ctx.username / ".ssh"
ssh_dir.mkdir(parents=True, exist_ok=True)
ssh_dir.chmod(0o700)
# Ask the target for the uid rather than assuming the first user is 1000.
# Uncaptured so a failure shows up in the install log, and checked because
# a root-owned authorized_keys is one sshd refuses to read.
subprocess.run(
["arch-chroot", str(ctx.target), "chown", "-R",
f"{ctx.username}:{ctx.username}", f"/home/{ctx.username}/.ssh"],
check=True,
)
authorized_keys = ssh_dir / "authorized_keys"
authorized_keys.write_text("".join(f"{key}\n" for key in keys))
authorized_keys.chmod(0o600)
# Ask the target for the uid rather than assuming the first user is 1000.
# Uncaptured so a failure shows up in the install log, and checked because
# a root-owned authorized_keys is one sshd refuses to read.
subprocess.run(
["arch-chroot", str(ctx.target), "chown", "-R",
f"{ctx.username}:{ctx.username}", f"/home/{ctx.username}/.ssh"],
check=True,
)
info("› enabling sshd")
subprocess.run(
@@ -1550,6 +1693,34 @@ def validate_boot(ctx: InstallContext) -> None:
if ctx.is_protected:
_validate_pre_mounted_filesystems(ctx)
if ctx.oem:
_validate_oem_state(ctx)
def _validate_oem_state(ctx: InstallContext) -> None:
"""An OEM install that boots without a working first-boot setup is a
user-less brick; insist the armed state is complete before reboot."""
oem_dir = ctx.target / OEM_STATE_DIR
if not (oem_dir / "pending").exists():
raise RuntimeError(f"OEM install but {oem_dir / 'pending'} is missing")
link = ctx.target / "etc/systemd/system/multi-user.target.wants/omarchy-oem-setup.service"
if not link.is_symlink():
raise RuntimeError("OEM install but omarchy-oem-setup.service is not enabled")
if not list((oem_dir / "packages").glob("node-v*.tar.gz")):
raise RuntimeError(f"OEM install but no Node tarball staged in {oem_dir / 'packages'}")
if _oem_install_encrypted(ctx):
for required in (oem_dir / "luks-key", ctx.target / OEM_KEYFILE):
if not required.exists():
raise RuntimeError(f"encrypted OEM install but {required} is missing")
limine_conf_text = (
ctx.target / _boot_intent(ctx)["esp_mount"].lstrip("/") / "limine.conf"
).read_text()
if "cryptkey=rootfs:" not in limine_conf_text:
raise RuntimeError("encrypted OEM install but limine.conf has no cryptkey= for auto-unlock")
def _assert_boot_hooks_restored(ctx: InstallContext) -> None:
"""Never hand over a system whose UKI rebuild hook is still masked.
@@ -1607,6 +1778,62 @@ def _validate_pre_mounted_filesystems(ctx: InstallContext) -> None:
raise RuntimeError(f"{crypttab} missing LUKS UUID {storage['luks_uuid']}")
# ─────────────────────────────────────────────────────────────────────────────
# create_factory_snapshot: read-only snapshot of @ kept at the btrfs top level
# as @factory — outside snapper's .snapshots, so cleanup timers and the Limine
# snapshot menu never touch it. Zero bytes at creation; grows only with drift.
# Taken at the end of every install, it is what makes omarchy-reset-computer a
# true factory reset.
# ─────────────────────────────────────────────────────────────────────────────
def create_factory_snapshot(ctx: InstallContext) -> None:
fstype = _findmnt_value(ctx.target, "FSTYPE")
if fstype != "btrfs":
info(f"› target root is {fstype or 'unknown'}, not btrfs; skipping factory snapshot")
return
options = (_findmnt_value(ctx.target, "OPTIONS") or "").split(",")
if not any(opt in ("subvol=/@", "subvol=@") for opt in options):
info("› target root is not the @ subvolume; skipping factory snapshot")
return
device = (_findmnt_value(ctx.target, "SOURCE") or "").split("[")[0]
if not device:
raise RuntimeError(f"could not determine the btrfs device backing {ctx.target}")
top = ctx.state_dir / "factory-top"
top.mkdir(parents=True, exist_ok=True)
subprocess.run(["mount", "-o", "subvolid=5", device, str(top)], check=True)
try:
root_subvol = top / "@"
if not root_subvol.is_dir():
raise RuntimeError(f"no @ subvolume at the top level of {device}")
factory = top / "@factory"
if factory.exists():
subprocess.run(
["btrfs", "subvolume", "delete", str(factory)],
check=True, capture_output=True,
)
info("› snapshotting @ as @factory (read-only)")
subprocess.run(
["btrfs", "subvolume", "snapshot", "-r", str(root_subvol), str(factory)],
check=True,
)
finally:
subprocess.run(["umount", str(top)], check=False, capture_output=True)
def _findmnt_value(path: Path, column: str) -> str | None:
res = subprocess.run(
["findmnt", "-no", column, str(path)],
capture_output=True, text=True,
)
value = res.stdout.strip()
return value if res.returncode == 0 and value else None
CPU_SYSFS = Path("/sys/devices/system/cpu")