Compare commits

..
Author SHA1 Message Date
Ryan Hughes 9a604ef1e7 Add limine 2025-11-26 01:28:23 -05:00
Ryan Hughes 9debbec097 Tail both logs to get additional output 2025-11-16 22:05:04 -05:00
Ryan Hughes 9147ed0f54 Fix builds 2025-11-16 22:04:52 -05:00
Ryan Hughes 76699b4de0 Refactor work 2025-11-16 19:31:01 -05:00
70 changed files with 1133 additions and 23633 deletions

No files matched your search

+32 -26
View File
@@ -2,34 +2,40 @@ name: Nightly ISO Build
on:
schedule:
- cron: "1 0 * * *" # Daily at 00:01 UTC
workflow_dispatch: # Allow manual triggering
- cron: '1 0 * * *' # Daily at 00:01 UTC
workflow_dispatch: # Allow manual triggering
jobs:
build-iso:
runs-on: ubuntu-latest
container:
image: archlinux/archlinux:latest
options: --privileged
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Free Disk Space
uses: jlumbroso/free-disk-space@v1.3.1
- name: Build ISO
run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make
- name: Delete previously uploaded ISO artifact
uses: philips-labs/action-delete-artifacts@v1.0.0
with:
workflow: nightly-build.yml
continue-on-error: true
- name: Upload newly built ISO artifact
uses: actions/upload-artifact@v4
with:
name: omarchy-iso-${{ github.run_number }}
path: ./release/*.iso
retention-days: 1
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build ISO in Arch Linux container
run: |
docker run --rm \
--privileged \
-v "${{ github.workspace }}:/workspace" \
-w /workspace \
archlinux:latest \
bash -c "
pacman -Syu --noconfirm && \
chmod +x build_iso.sh && \
./build_iso.sh
"
- name: Upload ISO artifact
uses: actions/upload-artifact@v4
with:
name: omarchy-iso-${{ github.run_number }}
path: out/*.iso
retention-days: 30
-7
View File
@@ -1,9 +1,2 @@
release/**
vm-saves/**
# Python bytecode (orchestrator)
__pycache__/
*.pyc
# Acceptance test harness artifacts
test-runs/**
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Anton Hvornum
Copyright (c) 2025 Anton Hvornum
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+13 -97
View File
@@ -1,6 +1,6 @@
# Omarchy ISO
The Omarchy ISO is the only supported way to install Omarchy. It ships the Omarchy Configurator, installs Arch Linux, installs the Omarchy packages from the bundled mirror, runs target system setup in the chroot, creates the user, and runs `omarchy-setup-user` for that user.
The Omarchy ISO streamlines [the installation of Omarchy](https://learn.omacom.io/2/the-omarchy-manual/50/getting-started). It includes the Omarchy Configurator as a front-end to archinstall and automatically launches the [Omarchy Installer](https://github.com/basecamp/omarchy) after base arch has been setup.
## Downloading the latest ISO
@@ -8,116 +8,32 @@ See the ISO link on [omarchy.org](https://omarchy.org).
## Creating the ISO
Run `./bin/omarchy-iso-make`; output goes into `./release`. By default the ISO uses the Omarchy packages and tracks the `quattro` branch, from the stable mirror. Pass `--edge` to use `omarchy-dev` and `omarchy-settings-dev` from the edge mirror.
Run `./bin/omarchy-iso-make` and the output goes into `./release`. You can build from your local $OMARCHY_PATH for testing by using `--local-source` or from a checkout of the dev branch (instead of master) by using `--dev`.
For local development, build the ISO from sibling checkouts:
### Environment Variables
You can customize the repositories used during the build process by passing in variables:
- `OMARCHY_INSTALLER_REPO` - GitHub repository for the installer (default: `basecamp/omarchy`)
- `OMARCHY_INSTALLER_REF` - Git ref (branch/tag) for the installer (default: `master`)
Example usage:
```bash
./bin/omarchy-iso-make --local-source ../omarchy-installer ../omarchy-pkgs
OMARCHY_INSTALLER_REPO="myuser/omarchy-fork" OMARCHY_INSTALLER_REF="some-feature" ./bin/omarchy-iso-make
```
Despite the local folder name, the first argument is the Omarchy source checkout (runtime commands, configs, setup scripts, themes, shell, migrations). The installer itself lives in this ISO repo.
Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror.
## Autoinstall
The shipped ISO installs itself with no keyboard when it finds its configuration on a second drive. Attach a drive labeled `cidata` alongside the ISO and the installer copies the config off it and skips the configurator; with no such drive, nothing changes and the wizard runs as usual. No rebuild, no extra boot entry.
`cidata` is the cloud-init `NoCloud` label, so Proxmox, libvirt, and Packer already know how to attach one.
### Configuration files
These are the configurator's own output files, so the way to get a starting set is to run one interactive install and copy what it wrote into `/root`.
| File | Required | Purpose |
|------|----------|---------|
| `user_configuration.json` | Yes | archinstall config: disk, hostname, timezone, keyboard |
| `user_credentials.json` | Yes | Username and password hash |
| `user_full_name.txt` | No | Git full name |
| `user_email_address.txt` | No | Git email |
| `user_encrypt_installation.txt` | No | `true` when `user_configuration.json` carries a `disk_encryption` block; defaults to false |
| `authorized_keys` | No | SSH public keys in sshd's own format, one per line |
| `tailscale_authkey` | No | Tailscale auth key; the machine joins your tailnet on first boot |
Both required files must be present or the installer falls back to the configurator. Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`.
Encryption itself is configured by the `disk_encryption` block inside `user_configuration.json` — which carries the passphrase in plaintext, so treat a drive built from an encrypted install accordingly. The flag file must match it: it drives the encrypted install's SDDM autologin and the final boot validation, not the encryption.
`authorized_keys` is the same file sshd reads — copy your own or write one key per line:
```
ssh-ed25519 AAAA... you@host
```
When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An `authorized_keys` with no usable keys fails the install rather than producing a machine nobody can reach.
When `tailscale_authkey` is present (one key, blank lines and `#` comments ignored), the install adds the `tailscale` package from the ISO's bundled mirror — nothing is fetched from the network at install or boot — and stages the join for first boot: the key lands at `/etc/tailscale/authkey` (root-only), `tailscaled` is enabled, ufw allows traffic in on `tailscale0`, and a background unit runs `tailscale up` once the network is actually up, retrying until it succeeds without holding up the boot. After a successful join the key is deleted and the unit disables itself; until then both survive reboots, so a machine installed offline joins whenever it first gets connectivity. The node appears on the tailnet under the configured hostname. Use a reusable, pre-authorized (tagged) key so one drive image serves many machines — or an ephemeral key for disposable VMs.
### Building the drive
```bash
mkdir cidata
cp user_configuration.json user_credentials.json authorized_keys cidata/
genisoimage -output cidata.iso -volid cidata -joliet -rock cidata/
```
### Proxmox example
```bash
qm create 101 --name my-omarchy \
--bios ovmf --machine q35 --cpu host --cores 4 --memory 8192 \
--ostype l26 --scsihw virtio-scsi-single \
--efidisk0 local-lvm:0,efitype=4m,pre-enrolled-keys=0 \
--scsi0 local-lvm:40,discard=on,iothread=1 \
--net0 virtio,bridge=vmbr0 --vga virtio --serial0 socket \
--ide2 local:iso/omarchy.iso,media=cdrom \
--ide3 local:iso/cidata.iso,media=cdrom \
--boot order='scsi0;ide2'
qm start 101
```
Boot order is disk first: the empty disk falls through to the ISO on the first boot, and the installed system boots from disk afterwards. The machine reboots into Omarchy on its own when the install finishes.
Encrypted autoinstalls are not fully unattended — the LUKS passphrase prompt still needs someone at the first boot.
## Testing the ISO
Run `./bin/omarchy-iso-boot [release/omarchy.iso]`.
Run `./test/all` for the unit tests, which cover cidata autoinstall loading and the orchestrator's SSH access phase without needing a built ISO.
To exercise installation alongside existing Windows-style partitions, run
`./bin/omarchy-iso-test-windows-disk [release/omarchy.iso]`. It creates a
synthetic disk in `/tmp` with an existing ESP and data partition plus ample
unallocated space, then offers to start an interactive installation on it. The
fixture exercises Windows partition preservation but does not contain Windows.
## Acceptance testing the ISO
Run `./bin/omarchy-iso-test [release/omarchy.iso]` to install the ISO into a headless VM by driving the real interactive install flow — the harness reads each screen via QMP screendumps + OCR and answers with virtual keystrokes, so the configurator wizard, install dashboard, reboot prompt, and SDDM login are all exercised exactly as a user would. It then boots the installed system, sends real VM keyboard shortcuts for the primary shell and window-management actions, and runs the in-guest acceptance suite (`test/acceptance` in the omarchy repo). The suite checks session and service health, the complete core-package manifest, user defaults, representative applications, menus, panels, live weather, launchers, visual selectors, notifications, clipboard, and other interactive shell behavior.
Visual checkpoints are saved as `success-<step>.png` or `failure-<step>.png` alongside the serial and install logs in `test-runs/<iso>/runs/<timestamp>/`. Independent test files and applications continue after a failure so one broken surface does not hide the rest of the report. The harness then stops the VM and opens the ordered screenshots in `imv` for quick visual review.
The harness syncs the acceptance suite from `$OMARCHY_PATH` when it is available. The install phase produces a reusable base image, so iterating against another checkout is fast:
```bash
./bin/omarchy-iso-test release/omarchy.iso --install-only # once per ISO
./bin/omarchy-iso-test release/omarchy.iso --reuse-base \
--sync-omarchy ../omarchy # fast loop against local tests
```
Pass `--encrypt` to drive the encrypted install flow (including typing the LUKS passphrase at boot) instead of the unencrypted one. Pass `--no-preview` to collect the same visual artifacts without opening them in `imv` when the run finishes.
## Signing the ISO
Run `./bin/omarchy-iso-sign [release/omarchy.iso]`. The signing key is retrieved from the shared Omarchy vault with the 1Password CLI.
Run `./bin/omarchy-iso-sign [gpg-user] [release/omarchy.iso]`.
## Uploading the ISO
Run `./bin/omarchy-iso-upload [release/omarchy.iso]`. This requires rclone configuration (`rclone config`).
Run `./bin/omarchy-iso-upload [release/omarchy.iso]`. This requires you've configured rclone (use `rclone config`).
## Full release of the ISO
Run `./bin/omarchy-iso-release VERSION` to create, test, sign, and upload the ISO in one flow. Add `--rc` to release an RC build instead.
Run `./bin/omarchy-iso-release` to create, test, sign, and upload the ISO in one flow.
+1 -1
Submodule archiso updated: 424e78130d...69047bdc03.
+17 -139
View File
@@ -2,168 +2,46 @@
set -e
ISO=""
REUSE=""
SSH_HOST="${OMARCHY_VM_SSH_HOST:-127.0.0.1}"
SSH_PORT="${OMARCHY_VM_SSH_PORT:-2222}"
NETWORK="${OMARCHY_VM_NETWORK:-1}"
DISK="${OMARCHY_VM_DISK:-/tmp/omarchy-iso-boot.qcow2}"
OVMF_VARS="${OMARCHY_VM_OVMF_VARS:-/tmp/OVMF_VARS.4m.fd}"
EXTRA_QEMU_ARGS=()
ISO="$1"
REUSE="$2"
usage() {
cat <<USAGE
Usage: omarchy-iso-boot [options] [release/omarchy.iso] [reuse]
Options:
--reuse Reuse the existing VM disk
--ssh-port PORT Forward host PORT to guest port 22 (default: $SSH_PORT)
--ssh-host HOST Host address for SSH forwarding (default: $SSH_HOST)
--no-network Boot without a virtual NIC
--disk PATH VM disk path (default: $DISK)
--ovmf-vars PATH OVMF vars path (default: $OVMF_VARS)
-h, --help Show this help
-- Pass the remaining arguments directly to QEMU
SSH access after starting sshd in the guest:
ssh -p $SSH_PORT root@localhost
scp -P $SSH_PORT /path/to/system-manifest root@localhost:/tmp/
USAGE
}
while (( $# > 0 )); do
case "$1" in
--reuse|reuse)
REUSE="reuse"
shift
;;
--ssh-port)
SSH_PORT="${2:-}"
shift 2
;;
--ssh-port=*)
SSH_PORT="${1#*=}"
shift
;;
--ssh-host)
SSH_HOST="${2:-}"
shift 2
;;
--ssh-host=*)
SSH_HOST="${1#*=}"
shift
;;
--no-network|--no-net)
NETWORK=0
shift
;;
--disk)
DISK="${2:-}"
shift 2
;;
--disk=*)
DISK="${1#*=}"
shift
;;
--ovmf-vars)
OVMF_VARS="${2:-}"
shift 2
;;
--ovmf-vars=*)
OVMF_VARS="${1#*=}"
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
EXTRA_QEMU_ARGS+=("$@")
break
;;
*)
if [[ -z $ISO ]]; then
ISO="$1"
else
echo "Unexpected argument: $1" >&2
usage >&2
exit 1
fi
shift
;;
esac
done
if [[ -z $ISO && -t 0 ]]; then
if [ $# -eq 0 ]; then
ISO=$(ls -t release/*.iso 2>/dev/null | gum choose --header "Select ISO to boot")
if gum confirm --default=false "Reuse existing disk?"; then
REUSE="reuse"
fi
fi
if [[ -z $ISO ]]; then
usage
if [ -z "$ISO" ]; then
echo "Usage: omarchy-iso-boot [release/omarchy.iso] [reuse]"
echo " SSH available at: ssh -p 2222 root@localhost (after installing openssh)"
exit 1
fi
if [[ -z $DISK || -z $OVMF_VARS ]]; then
echo "Disk and OVMF vars paths must not be empty" >&2
exit 1
fi
omarchy-pkg-add qemu-full edk2-ovmf
if [[ -z ${WAYLAND_DISPLAY:-} && -z ${DISPLAY:-} ]]; then
while IFS='=' read -r name value; do
case "$name" in
WAYLAND_DISPLAY|DISPLAY|XAUTHORITY|XDG_SESSION_TYPE)
export "$name=$value"
;;
esac
done < <(systemctl --user show-environment 2>/dev/null || true)
fi
if [[ -z ${WAYLAND_DISPLAY:-} && -z ${DISPLAY:-} ]]; then
echo "Unable to find a graphical session for QEMU." >&2
echo "Run omarchy-iso-boot from the Omarchy desktop session." >&2
exit 1
if ! pacman -Q qemu-full >/dev/null || ! pacman -Q edk2-ovmf >/dev/null; then
sudo pacman -S --noconfirm --needed --quiet qemu-full edk2-ovmf
fi
DISK="/tmp/omarchy-iso-boot.qcow2"
if [[ ! -f $DISK || $REUSE != "reuse" ]]; then
qemu-img create -f qcow2 "$DISK" 40G
cp /usr/share/edk2/x64/OVMF_VARS.4m.fd "$OVMF_VARS"
fi
NET_ARGS=()
if [[ $NETWORK != "0" && $NETWORK != "false" && $NETWORK != "no" ]]; then
if [[ -n $SSH_PORT && $SSH_PORT != "0" ]]; then
NET_ARGS=(-netdev "user,id=net0,hostfwd=tcp:${SSH_HOST}:${SSH_PORT}-:22" -device virtio-net-pci,netdev=net0)
echo "SSH forwarding enabled: ssh -p $SSH_PORT root@localhost"
echo "SCP example: scp -P $SSH_PORT /path/to/system-manifest root@localhost:/tmp/"
else
NET_ARGS=(-netdev user,id=net0 -device virtio-net-pci,netdev=net0)
echo "Networking enabled without SSH port forwarding."
fi
else
echo "Networking disabled."
qemu-img create -f qcow2 "$DISK" 20G
cp /usr/share/edk2/x64/OVMF_VARS.4m.fd /tmp/OVMF_VARS.4m.fd
fi
# Boot from ISO before we've installed, then boot from drive after
qemu-system-x86_64 \
-cpu host -enable-kvm -machine q35,accel=kvm \
-smp "$(($(nproc) < 8 ? $(nproc) : 8))" \
-smp "$(nproc)" \
-m 8192 \
-drive if=pflash,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd \
-drive if=pflash,format=raw,file="$OVMF_VARS" \
-drive if=pflash,format=raw,file=/tmp/OVMF_VARS.4m.fd \
-drive file="$DISK",format=qcow2,if=none,id=drive0 \
-device virtio-blk-pci,drive=drive0,bootindex=1 \
-drive file="$ISO",media=cdrom,if=none,format=raw,id=cdrom0 \
-device ide-cd,drive=cdrom0,bootindex=2 \
-device virtio-vga-gl \
-display sdl,gl=on \
-display gtk,gl=on \
-usb -device usb-tablet \
-serial none \
"${NET_ARGS[@]}" \
-boot menu=on \
"${EXTRA_QEMU_ARGS[@]}"
-netdev user,id=net0,hostfwd=tcp::2222-:22 \
-device virtio-net-pci,netdev=net0 \
-boot menu=on
-16
View File
@@ -1,16 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ -z "${OMARCHY_PATH:-}" ]]; then
for candidate in "$repo_root/../omarchy-installer" "$repo_root/../omarchy" /usr/share/omarchy; do
if [[ -f "$candidate/logo.txt" ]]; then
export OMARCHY_PATH="$candidate"
break
fi
done
fi
# Do a dry-run of the configurator to show the flow without having to do a real installation
exec bash "$repo_root/configs/airootfs/root/configurator" dry "$@"
-142
View File
@@ -1,142 +0,0 @@
#!/usr/bin/env bash
# Preview the shipped Omarchy installer dashboard without building an ISO.
set -euo pipefail
# The dashboard drives the package phase from the target's pacman database, so
# fake one here; without it the preview falls back to its time curve and the bar
# barely moves, which reads as a bug.
PREVIEW_PACKAGES="${PREVIEW_PACKAGES:-925}"
write_simulation_state() {
local state_file="$1" started_at="$2" phase_started_at="$3" current_phase="$4"
local target="$5" index="$6" total="$7"
local finished_at="${8:-null}" temporary="${state_file}.tmp"
jq -n \
--argjson started_at "$started_at" \
--argjson phase_started_at "$phase_started_at" \
--arg current_phase "$current_phase" \
--arg target "$target" \
--argjson current_index "$index" \
--argjson total_phases "$total" \
--argjson finished_at "$finished_at" \
'{
started_at: $started_at,
phase_started_at: $phase_started_at,
current_phase: $current_phase,
target: $target,
current_index: $current_index,
total_phases: $total_phases
} + if $finished_at == null then {} else {finished_at: $finished_at} end' \
>"$temporary"
mv -f -- "$temporary" "$state_file"
}
# Fill the fake database over the phase's duration, so the bar is driven by the
# same count it uses on a real install.
simulate_packages() {
local db="$1" duration="$2" total="$3" steps=25 i n
for ((i = 1; i <= steps; i++)); do
n=$(( total * i / steps ))
while (( $(ls -1 "$db" 2>/dev/null | wc -l) < n )); do
mkdir -p "$db/preview-$(( $(ls -1 "$db" 2>/dev/null | wc -l) ))-1.0-1"
done
sleep "$(awk -v d="$duration" -v s="$steps" 'BEGIN { printf "%.3f", d / s }')"
done
}
simulate_install() {
local state_file="$1"
local started_at="$EPOCHREALTIME"
local phase phase_started_at finished_at duration i index total
local target db
target="$(dirname -- "$state_file")/target"
db="$target/var/lib/pacman/local"
mkdir -p "$db"
local -a phases=(
"Starting installation" 0.5
"Preparing live environment" 0.8
"Preparing install target" 0.3
"Installing Arch + Omarchy" 5.0
"Configuring hibernation" 0.4
"Configuring system" 1.2
"Finalizing Limine boot" 1.0
"Finalizing user" 1.0
"Configuring login" 0.6
"Configuring DNS resolver" 0.3
"Validating boot setup" 0.8
)
total=$(( ${#phases[@]} / 2 ))
for ((i = 0; i < ${#phases[@]}; i += 2)); do
phase="${phases[i]}"
duration="${phases[i + 1]}"
index=$(( i / 2 ))
phase_started_at="$EPOCHREALTIME"
write_simulation_state "$state_file" "$started_at" "$phase_started_at" \
"$phase" "$target" "$index" "$total"
if [[ $phase == "Installing Arch + Omarchy" ]]; then
simulate_packages "$db" "$duration" "$PREVIEW_PACKAGES"
else
sleep "$duration"
fi
done
finished_at="$EPOCHREALTIME"
write_simulation_state "$state_file" "$started_at" "$finished_at" \
"Installation complete" "$target" "$(( total - 1 ))" "$total" "$finished_at"
sleep 0.3
}
if [[ ${1:-} == "--simulate-install" ]]; then
(( $# == 2 )) || exit 2
simulate_install "$2"
exit 0
fi
if (( $# > 0 )); then
echo "Usage: $0" >&2
exit 2
fi
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
dashboard="$repo_root/configs/airootfs/usr/local/bin/omarchy-install-dashboard"
if [[ ! -x $dashboard ]]; then
echo "Installer dashboard is not executable: $dashboard" >&2
exit 1
fi
if [[ -z ${OMARCHY_PATH:-} ]]; then
for candidate in "$repo_root/../omarchy" /usr/share/omarchy; do
if [[ -f $candidate/logo.txt ]]; then
export OMARCHY_PATH="$candidate"
break
fi
done
fi
if [[ -z ${OMARCHY_PATH:-} || ! -f $OMARCHY_PATH/logo.txt ]]; then
echo "Unable to find Omarchy logo.txt; set OMARCHY_PATH to an Omarchy checkout." >&2
exit 1
fi
preview_dir="$(mktemp -d)"
cleanup() {
rm -rf "$preview_dir"
}
trap cleanup EXIT
log_file="$preview_dir/install.log"
state_file="$preview_dir/state.json"
touch "$log_file"
# Match what the simulated database will reach, so the preview shows the
# package-driven bar rather than the missing-signal fallback.
printf '%s\n' "$PREVIEW_PACKAGES" >"$preview_dir/expected-packages"
OMARCHY_UI_AUTO_REBOOT=no \
OMARCHY_EXPECTED_PACKAGES_FILE="$preview_dir/expected-packages" \
PREVIEW_PACKAGES="$PREVIEW_PACKAGES" \
"$dashboard" "$log_file" "$state_file" -- \
"$0" --simulate-install "$state_file"
+56 -97
View File
@@ -1,10 +1,10 @@
#!/bin/bash
# Abort if anything fails
set -e
# Parse command line arguments
NO_CACHE=""
LOCAL_OMARCHY_PATH=""
LOCAL_PKGS_PATH=""
while [[ $# -gt 0 ]]; do
case $1 in
--no-cache)
@@ -15,108 +15,39 @@ while [[ $# -gt 0 ]]; do
NO_BOOT_OFFER=1
shift
;;
--debug)
OMARCHY_INSTALL_DEBUG=1
shift
;;
--edge)
OMARCHY_ISO_REF=edge
OMARCHY_MIRROR=edge
shift
;;
--local-source)
LOCAL_OMARCHY_PATH="${2:-}"
LOCAL_PKGS_PATH="${3:-}"
if [[ -z $LOCAL_OMARCHY_PATH || -z $LOCAL_PKGS_PATH ]]; then
echo "Error: --local-source requires two args: <omarchy-checkout> <pkgs-checkout>" >&2
exit 1
fi
OMARCHY_MIRROR=edge
OMARCHY_ISO_REF=local
shift 3
LOCAL_SOURCE=1
shift
;;
--dev)
OMARCHY_ISO_REF=dev
OMARCHY_MIRROR=edge
shift
;;
--quattro)
OMARCHY_INSTALLER_REF=quattro
OMARCHY_MIRROR=edge
shift
;;
--rc)
OMARCHY_ISO_REF=rc
OMARCHY_MIRROR=rc
OMARCHY_INSTALLER_REF=dev
shift
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [--no-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--local-source <omarchy-checkout> <pkgs-checkout>]"
echo "Usage: $0 [--no-cache] [--no-boot-offer] [--local-source] [--dev]"
exit 1
;;
esac
done
if [[ -n $LOCAL_OMARCHY_PATH ]]; then
[[ -d $LOCAL_OMARCHY_PATH ]] || { echo "Error: Omarchy checkout not found at $LOCAL_OMARCHY_PATH" >&2; exit 1; }
[[ -d $LOCAL_PKGS_PATH ]] || { echo "Error: pkgs checkout not found at $LOCAL_PKGS_PATH" >&2; exit 1; }
LOCAL_OMARCHY_PATH=$(realpath "$LOCAL_OMARCHY_PATH")
LOCAL_PKGS_PATH=$(realpath "$LOCAL_PKGS_PATH")
fi
lint_file_permissions() {
local profiledef="configs/profiledef.sh"
local -A declared=()
local missing=()
while IFS= read -r path; do
[[ -n $path ]] && declared["$path"]=1
done < <(grep -oP '^\s*\["\K[^"]+(?="\])' "$profiledef" || true)
while IFS= read -r f; do
[[ -f $f ]] || continue
local rel="${f#configs/airootfs}"
local mode
mode=$(git ls-files --stage -- "$f" | awk '{print $1}')
if [[ $mode != 100755 && $(head -c 2 "$f") != "#!" ]]; then
continue
fi
if [[ -z ${declared[$rel]+x} ]]; then
missing+=("$rel")
fi
done < <(find configs/airootfs/usr/local/bin configs/airootfs/root -type f 2>/dev/null)
if (( ${#missing[@]} > 0 )); then
echo "ERROR: the following executable files are missing from $profiledef file_permissions:" >&2
printf ' %s\n' "${missing[@]}" >&2
echo "Add entries like [\"<path>\"]=\"0:0:755\" to profiledef.sh and retry." >&2
exit 1
fi
}
lint_file_permissions
sudo rm -rf /var/cache/pacman/pkg/*
# Ensure we have the submodule checked out
git submodule update --init --recursive --jobs=8
# Prepare build repo
BUILD_SCRIPT="builder/build-iso.sh"
BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
BUILD_RELEASE_PATH="$BUILD_ROOT/release"
mkdir -p "$BUILD_RELEASE_PATH"
OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-quattro}"
OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}"
OMARCHY_INSTALLER_REPO="${OMARCHY_INSTALLER_REPO:-basecamp/omarchy}"
OMARCHY_INSTALLER_REF="${OMARCHY_INSTALLER_REF:-master}"
DOCKER_ARGS=(
--rm
--privileged
-e "OMARCHY_ISO_REF=$OMARCHY_ISO_REF"
-e "OMARCHY_MIRROR=$OMARCHY_MIRROR"
-e "OMARCHY_INSTALL_DEBUG=${OMARCHY_INSTALL_DEBUG:-}"
-e "OMARCHY_INSTALLER_REPO=$OMARCHY_INSTALLER_REPO"
-e "OMARCHY_INSTALLER_REF=$OMARCHY_INSTALLER_REF"
-e "HOST_UID=$(id -u)"
-e "HOST_GID=$(id -g)"
-v "$BUILD_RELEASE_PATH/:/out/"
@@ -126,36 +57,64 @@ DOCKER_ARGS=(
-v "$BUILD_ROOT/configs:/configs:ro"
)
if [[ -n $LOCAL_OMARCHY_PATH ]]; then
DOCKER_ARGS+=(
-v "$LOCAL_OMARCHY_PATH:/omarchy-source:ro"
-v "$LOCAL_PKGS_PATH:/omarchy-pkgs:ro"
)
# Mount omarchy-pkgs if it exists (for local package building)
# Can be overridden with OMARCHY_PKGS_PATH environment variable
if [ -z "$OMARCHY_PKGS_PATH" ]; then
OMARCHY_PKGS_PATH="$BUILD_ROOT/../omarchy-pkgs"
fi
if [[ -d /var/cache/pacman/pkg ]]; then
if [ -d "$OMARCHY_PKGS_PATH" ]; then
echo "Mounting omarchy-pkgs from: $OMARCHY_PKGS_PATH"
OMARCHY_PKGS_PATH=$(realpath "$OMARCHY_PKGS_PATH")
DOCKER_ARGS+=(-v "$OMARCHY_PKGS_PATH:/omarchy-pkgs:ro")
else
echo "Note: omarchy-pkgs not found at $OMARCHY_PKGS_PATH (build will fail)"
fi
# Mount omarchy-installer for dev builds (optional)
# Can be overridden with OMARCHY_INSTALLER_PATH environment variable
# If not set, will auto-detect ../omarchy-installer for dev builds
if [ -n "$LOCAL_SOURCE" ]; then
if [ -z "$OMARCHY_INSTALLER_PATH" ]; then
OMARCHY_INSTALLER_PATH="$BUILD_ROOT/../omarchy-installer"
fi
if [ -d "$OMARCHY_INSTALLER_PATH" ]; then
echo "Mounting omarchy-installer from: $OMARCHY_INSTALLER_PATH (dev mode)"
OMARCHY_INSTALLER_PATH=$(realpath "$OMARCHY_INSTALLER_PATH")
DOCKER_ARGS+=(-v "$OMARCHY_INSTALLER_PATH:/omarchy-installer:ro")
DOCKER_ARGS+=(-e "OMARCHY_DEV_BUILD=1")
else
echo "Note: omarchy-installer not found at $OMARCHY_INSTALLER_PATH"
echo " Will use git clone from PKGBUILD"
fi
fi
# Use local pacman cache if you already have one on host to speed up repeat runs
if [ -d "/var/cache/pacman/pkg" ]; then
DOCKER_ARGS+=(-v "/var/cache/pacman/pkg:/var/cache/pacman/pkg")
fi
# Mount the build cache directory where packages are downloaded. Channels use
# different package snapshots and must never share an offline mirror cache.
# Mount the build cache directory where packages are downloaded
if [[ -z "$NO_CACHE" ]]; then
OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}/airootfs/var/cache/omarchy"
# using a dated dir on the host to refresh daily if building multiple times
BUILD_DATE=$(date +%Y-%m-%d)
OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${BUILD_DATE}/airootfs/var/cache/omarchy"
mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR"
# Mount to /var/cache/airootfs/var/cache/omarchy since that's where repo packages go in the ISO
DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy")
else
# The locally cached :latest image is a cache too — docker run never
# re-checks the registry for it, so refresh it when building cold.
DOCKER_ARGS+=(--pull=always)
fi
# Run the builder with assembled args
docker run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT
# Move as install ref
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*.iso | head -n1)
iso_ref="${latest_iso%.*}-$OMARCHY_ISO_REF.iso"
iso_ref="${latest_iso%.*}-$OMARCHY_INSTALLER_REF.iso"
mv -f "$latest_iso" "$iso_ref"
if [[ -z "${NO_BOOT_OFFER:-}" ]]; then
# Offer to boot the new build
if [[ -z "$NO_BOOT_OFFER" ]]; then
echo
gum confirm "Boot $iso_ref?" && "$BUILD_ROOT/bin/omarchy-iso-boot" "$iso_ref"
gum confirm "Boot $iso_ref?" && omarchy-iso-boot "$iso_ref"
fi
-2
View File
@@ -1,6 +1,4 @@
#!/bin/bash
omarchy-pkg-add rclone
mkdir -p ~/.config/rclone
access_key=$(op read "op://Shared/Cloudflare Buckets/iso.omarchy.org/access_key_id" --account Omarchy)
secret_access_key=$(op read "op://Shared/Cloudflare Buckets/iso.omarchy.org/secret_access_key" --account Omarchy)
+15 -46
View File
@@ -1,62 +1,31 @@
#!/bin/bash
MAKE_ARGS=()
ISO_REF=quattro
RELEASE_SUFFIX=""
while [[ $# -gt 0 ]]; do
case $1 in
--no-make)
MAKE_ARGS+=("$1")
shift
;;
--rc)
MAKE_ARGS+=("$1")
ISO_REF=rc
RELEASE_SUFFIX="-rc"
shift
;;
-*)
echo "Unknown option: $1"
echo "Usage: omarchy-iso-release [--no-make] [--rc] [version]"
exit 1
;;
*)
if [[ -n "${BUILD_VERSION-}" ]]; then
echo "Usage: omarchy-iso-release [--no-make] [--rc] [version]"
exit 1
fi
BUILD_VERSION="$1"
shift
;;
esac
done
if [[ -z "${BUILD_VERSION-}" ]]; then
echo "Usage: omarchy-iso-release [--no-make] [--rc] [version]"
if [ $# -eq 0 ]; then
echo "Usage: omarchy-iso-release [version]"
exit 1
fi
BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
BUILD_RELEASE_PATH="$BUILD_ROOT/release"
BULD_VERSION="$1"
# Make ISOs
if [[ ! " ${MAKE_ARGS[*]} " =~ " --no-make " ]]; then
echo -e "\e[32mMaking $ISO_REF ISO\e[0m"
omarchy-iso-make --no-cache --no-boot-offer "${MAKE_ARGS[@]}"
if [[ $1 != "--no-make" ]]; then
echo -e "\e[32mMaking master ISO\e[0m"
OMARCHY_INSTALLER_REF=master omarchy-iso-make --no-cache --no-boot-offer
else
echo -e "\e[31mSkipping remaking ISOs\e[0m"
fi
# Sign and upload ISO
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*x86_64-"$ISO_REF".iso | head -n1)
release_iso="$BUILD_RELEASE_PATH/omarchy-$BUILD_VERSION$RELEASE_SUFFIX.iso"
cp "$latest_iso" "$release_iso"
# Sign and upload master ISO
latest_master_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*x86_64-master.iso | head -n1)
release_master_iso="$BUILD_RELEASE_PATH/omarchy-$BULD_VERSION.iso"
cp "$latest_master_iso" "$release_master_iso"
echo -e "\e[32mSigning $ISO_REF ISO\e[0m"
omarchy-iso-sign "$release_iso"
echo -e "\e[32mSigning master ISO\e[0m"
omarchy-iso-sign "$release_master_iso"
iso_sha=$(sha256sum "$release_iso")
echo -e "\e[32mSHA256 for $ISO_REF ISO: $iso_sha\e[0m"
master_sha=$(sha256sum "$release_master_iso")
echo -e "\e[32mSHA256 for master ISO: $master_sha\e[0m"
omarchy-iso-upload "$release_iso"
omarchy-iso-upload "$release_master_iso"
+5 -39
View File
@@ -1,48 +1,14 @@
#!/bin/bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
if [ $# -eq 0 ]; then
echo "usage: omarchy-iso-sign [release/omarchy.iso]"
exit 1
fi
iso="$1"
key_ref="op://Shared/Omarchy GPG Private Key/omarchy-private.asc"
if [[ ! -f $iso ]]; then
echo "ISO not found: $iso" >&2
exit 1
fi
if ! command -v op >/dev/null; then
echo "1Password CLI is required to retrieve the signing key." >&2
exit 1
fi
if ! pacman -Q gnupg >/dev/null 2>&1; then
if ! pacman -Q gnupg >/dev/null; then
sudo pacman -S --noconfirm --needed --quiet gnupg
echo "You'll need to import key with: gpg --import path/to/signing-key.asc"
fi
gnupg_home="$(mktemp -d)"
chmod 700 "$gnupg_home"
trap 'rm -rf "$gnupg_home"' EXIT
op read "$key_ref" --account Omarchy |
gpg --batch --homedir "$gnupg_home" --import >/dev/null
signing_key="$(
gpg --batch --homedir "$gnupg_home" --with-colons --list-secret-keys |
awk -F: '$1 == "fpr" { print $10; exit }'
)"
if [[ -z $signing_key ]]; then
echo "No private signing key found in $key_ref" >&2
exit 1
fi
gpg --batch --yes \
--homedir "$gnupg_home" \
--local-user "$signing_key" \
--output "$iso.sig" \
--detach-sig "$iso"
gnupg_user_key=$(gpg --list-secret-keys --keyid-format=long | awk '/^[[:space:]]/{print $1; exit}')
gpg --local-user "$gnupg_user_key" --output $1.sig --detach-sig $1
-1114
View File
File diff suppressed because it is too large. Load diff
-295
View File
@@ -1,295 +0,0 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
TEST_DISK="${OMARCHY_ISO_TEST_WINDOWS_DISK:-/tmp/omarchy-iso-test-windows.qcow2}"
TEST_OVMF_VARS="${OMARCHY_ISO_TEST_WINDOWS_OVMF_VARS:-/tmp/OVMF_VARS.omarchy-iso-test-windows.fd}"
DISK_SIZE_GIB=96
MIN_FREE_GIB=64
RECREATE=0
ISO=""
ACTIVE_NBD=""
MOUNT_DIR=""
MOUNTED=0
TEMPORARY_DISK=""
usage() {
cat <<USAGE
Usage: omarchy-iso-test-windows-disk [--recreate] [release/omarchy.iso]
Creates or validates a synthetic Windows-style test disk with:
- a 512MiB EFI partition containing EFI/Microsoft
- an existing ext4 data partition with a preservation marker
- more than 64GiB of contiguous unallocated space for Omarchy
This exercises Windows partition detection and preservation; it is not a
bootable Windows installation.
Options:
--recreate Replace the existing test fixture
-h, --help Show this help
Paths can be overridden with:
OMARCHY_ISO_TEST_WINDOWS_DISK
OMARCHY_ISO_TEST_WINDOWS_OVMF_VARS
USAGE
}
while (( $# > 0 )); do
case "$1" in
--recreate)
RECREATE=1
shift
;;
-h|--help)
usage
exit 0
;;
-*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
*)
if [[ -n $ISO ]]; then
echo "Only one ISO may be specified." >&2
usage >&2
exit 2
fi
ISO="$1"
shift
;;
esac
done
cleanup_mount() {
if (( MOUNTED )); then
sudo umount "$MOUNT_DIR" >/dev/null 2>&1 || true
MOUNTED=0
fi
if [[ -n $MOUNT_DIR ]]; then
rmdir "$MOUNT_DIR" >/dev/null 2>&1 || true
MOUNT_DIR=""
fi
}
disconnect_disk() {
cleanup_mount
if [[ -n $ACTIVE_NBD ]]; then
sudo qemu-nbd --disconnect "$ACTIVE_NBD" >/dev/null 2>&1 || true
ACTIVE_NBD=""
fi
}
cleanup() {
disconnect_disk
[[ -z $TEMPORARY_DISK ]] || rm -f "$TEMPORARY_DISK"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
find_free_nbd() {
local device
for device in /sys/class/block/nbd*; do
[[ -e $device ]] || continue
[[ ! -e $device/pid ]] || continue
printf '/dev/%s\n' "${device##*/}"
return 0
done
return 1
}
connect_disk() {
local disk="$1" mode="${2:-read-write}"
local -a options=(--format=qcow2)
ACTIVE_NBD="$(find_free_nbd)" || {
echo "No unused NBD device is available." >&2
return 1
}
[[ $mode == read-only ]] && options+=(--read-only)
if ! sudo qemu-nbd "${options[@]}" --connect="$ACTIVE_NBD" "$disk"; then
ACTIVE_NBD=""
return 1
fi
if ! sudo udevadm settle; then
disconnect_disk
return 1
fi
}
mount_partition() {
local partition="$1"
MOUNT_DIR="$(mktemp -d)" || return 1
if ! sudo mount -o "${2:-defaults}" "$partition" "$MOUNT_DIR"; then
rmdir "$MOUNT_DIR" >/dev/null 2>&1 || true
MOUNT_DIR=""
return 1
fi
MOUNTED=1
}
largest_free_region() {
sudo parted -ms "$1" unit B print free </dev/null 2>/dev/null |
awk -F: '
$NF ~ /free/ {
size=$4
gsub(/B/, "", size)
if (size+0 > max+0) max=size
}
END { print max+0 }
'
}
validate_test_disk() {
local part_count pttype free_space_b min_free_b
local esp data valid=1
[[ -f $TEST_DISK ]] || return 1
qemu-img check -q "$TEST_DISK" >/dev/null 2>&1 || return 1
connect_disk "$TEST_DISK" read-only || return 1
esp="${ACTIVE_NBD}p1"
data="${ACTIVE_NBD}p2"
part_count=$(lsblk -nro TYPE "$ACTIVE_NBD" | awk '$1 == "part" { count++ } END { print count+0 }')
pttype=$(lsblk -dnro PTTYPE "$ACTIVE_NBD" 2>/dev/null || true)
free_space_b=$(largest_free_region "$ACTIVE_NBD")
min_free_b=$((MIN_FREE_GIB * 1024 * 1024 * 1024))
[[ $pttype == gpt ]] || valid=0
(( part_count == 2 )) || valid=0
(( free_space_b >= min_free_b )) || valid=0
[[ $(sudo blkid -s TYPE -o value "$esp" 2>/dev/null || true) == vfat ]] || valid=0
[[ $(sudo blkid -s LABEL -o value "$esp" 2>/dev/null || true) == WINDOWS_ESP ]] || valid=0
[[ $(sudo blkid -s TYPE -o value "$data" 2>/dev/null || true) == ext4 ]] || valid=0
[[ $(sudo blkid -s LABEL -o value "$data" 2>/dev/null || true) == WINDOWS_DATA ]] || valid=0
if (( valid )); then
mount_partition "$esp" ro || valid=0
if (( valid )); then
sudo test -d "$MOUNT_DIR/EFI/Microsoft" || valid=0
fi
cleanup_mount
fi
if (( valid )); then
mount_partition "$data" ro || valid=0
if (( valid )); then
sudo test -f "$MOUNT_DIR/OMARCHY-MUST-NOT-TOUCH.txt" || valid=0
fi
cleanup_mount
fi
disconnect_disk
(( valid ))
}
create_test_disk() {
local esp data
mkdir -p "$(dirname "$TEST_DISK")"
TEMPORARY_DISK="${TEST_DISK}.creating.$$"
rm -f "$TEMPORARY_DISK"
gum style --foreground 6 "Creating synthetic Windows test disk at $TEST_DISK"
qemu-img create -f qcow2 "$TEMPORARY_DISK" "${DISK_SIZE_GIB}G"
connect_disk "$TEMPORARY_DISK"
sudo parted --script "$ACTIVE_NBD" mklabel gpt
sudo parted --script "$ACTIVE_NBD" mkpart WINDOWS_ESP fat32 1MiB 513MiB
sudo parted --script "$ACTIVE_NBD" set 1 esp on
sudo parted --script "$ACTIVE_NBD" mkpart WINDOWS_DATA ext4 513MiB 20GiB
sudo partprobe "$ACTIVE_NBD"
sudo udevadm settle
esp="${ACTIVE_NBD}p1"
data="${ACTIVE_NBD}p2"
sudo mkfs.fat -F32 -n WINDOWS_ESP "$esp" >/dev/null
sudo mkfs.ext4 -F -L WINDOWS_DATA "$data" >/dev/null 2>&1
mount_partition "$esp"
sudo mkdir -p "$MOUNT_DIR/EFI/Microsoft/Boot"
printf '%s\n' 'Synthetic Windows ESP for Omarchy ISO testing.' |
sudo tee "$MOUNT_DIR/EFI/Microsoft/README.txt" >/dev/null
cleanup_mount
mount_partition "$data"
printf '%s\n' 'This existing partition must survive the Omarchy installation.' |
sudo tee "$MOUNT_DIR/OMARCHY-MUST-NOT-TOUCH.txt" >/dev/null
cleanup_mount
sync
disconnect_disk
mv -f "$TEMPORARY_DISK" "$TEST_DISK"
TEMPORARY_DISK=""
}
select_iso() {
local -a isos=()
if [[ -n $ISO ]]; then
[[ -f $ISO ]] || {
echo "ISO not found: $ISO" >&2
return 1
}
realpath "$ISO"
return 0
fi
mapfile -t isos < <(
find "$REPO_ROOT/release" -maxdepth 1 -type f -name '*.iso' -printf '%T@ %p\n' 2>/dev/null |
sort -nr |
cut -d' ' -f2-
)
(( ${#isos[@]} > 0 )) || {
echo "No ISO found under $REPO_ROOT/release." >&2
return 1
}
printf '%s\n' "${isos[@]}" | gum choose --header "Select ISO to install"
}
omarchy-pkg-add qemu-full edk2-ovmf parted dosfstools e2fsprogs
sudo modprobe nbd max_part=16
if [[ -f $TEST_DISK ]] && (( ! RECREATE )); then
gum style --foreground 6 "Verifying existing test disk at $TEST_DISK"
if validate_test_disk; then
gum style --foreground 2 "✓ Synthetic Windows test disk is ready"
elif gum confirm --default=false "The existing test disk is not a pristine fixture. Recreate it?"; then
create_test_disk
else
gum style "Left the existing disk untouched."
exit 0
fi
else
create_test_disk
fi
# The synthetic fixture has no real Windows bootloader, so reset OVMF state for
# every run. The ISO still sees EFI/Microsoft and must leave that ESP untouched.
mkdir -p "$(dirname "$TEST_OVMF_VARS")"
cp /usr/share/edk2/x64/OVMF_VARS.4m.fd "$TEST_OVMF_VARS"
gum style --border rounded --padding "0 1" --border-foreground 6 \
"Synthetic Windows disk ready" \
"Disk: $TEST_DISK" \
"Layout: 512MiB Windows ESP + existing data + 64GiB+ free"
if gum confirm "Start installation on this disk?"; then
ISO="$(select_iso)"
trap - EXIT INT TERM
exec "$SCRIPT_DIR/omarchy-iso-boot" \
--reuse \
--disk "$TEST_DISK" \
--ovmf-vars "$TEST_OVMF_VARS" \
"$ISO" \
-- -boot once=d,menu=on
fi
+15 -68
View File
@@ -4,8 +4,8 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VM_DIR="$(dirname "$SCRIPT_DIR")/vm-saves"
TMP_DISK="${OMARCHY_VM_DISK:-/tmp/omarchy-iso-boot.qcow2}"
TMP_OVMF="${OMARCHY_VM_OVMF_VARS:-/tmp/OVMF_VARS.4m.fd}"
TMP_DISK="/tmp/omarchy-iso-boot.qcow2"
TMP_OVMF="/tmp/OVMF_VARS.4m.fd"
mkdir -p "$VM_DIR"
@@ -18,7 +18,7 @@ save_vm() {
fi
if [[ ! -f "$TMP_DISK" || ! -f "$TMP_OVMF" ]]; then
echo "Error: No active VM found at $TMP_DISK / $TMP_OVMF"
echo "Error: No active VM found in /tmp"
echo "Boot a VM first with: omarchy-iso-boot [iso]"
exit 1
fi
@@ -53,44 +53,7 @@ list_vms() {
}
boot_vm() {
local name=""
local boot_args=()
local active_disk="$TMP_DISK"
local active_ovmf="$TMP_OVMF"
if [[ $# -gt 0 && ${1:-} != --* ]]; then
name="$1"
shift
fi
while (( $# > 0 )); do
case "$1" in
--disk)
active_disk="${2:-}"
boot_args+=("$1" "${2:-}")
shift 2
;;
--disk=*)
active_disk="${1#*=}"
boot_args+=("$1")
shift
;;
--ovmf-vars)
active_ovmf="${2:-}"
boot_args+=("$1" "${2:-}")
shift 2
;;
--ovmf-vars=*)
active_ovmf="${1#*=}"
boot_args+=("$1")
shift
;;
*)
boot_args+=("$1")
shift
;;
esac
done
local name="$1"
if [[ -z "$(ls -A "$VM_DIR" 2>/dev/null)" ]]; then
echo "No VM snapshots available"
@@ -110,34 +73,25 @@ boot_vm() {
exit 1
fi
if [[ -z $active_disk || -z $active_ovmf ]]; then
echo "Error: active disk and OVMF vars paths must not be empty"
exit 1
fi
if [[ -f $active_disk ]] && ! gum confirm "Override existing active VM disk at $active_disk?"; then
if [[ -f "$TMP_DISK" ]] && ! gum confirm "Override existing /tmp VM?"; then
exit 0
fi
echo "Copying VM '$name' to $active_disk..."
mkdir -p "$(dirname "$active_disk")" "$(dirname "$active_ovmf")"
cp "$vm_path/omarchy-iso-boot.qcow2" "$active_disk"
cp "$vm_path/OVMF_VARS.4m.fd" "$active_ovmf"
echo "Copying VM '$name' to /tmp..."
cp "$vm_path/omarchy-iso-boot.qcow2" "$TMP_DISK"
cp "$vm_path/OVMF_VARS.4m.fd" "$TMP_OVMF"
gum style --foreground 212 "✓ VM ready. Booting..."
exec omarchy-iso-boot "${boot_args[@]}" /dev/null reuse
exec omarchy-iso-boot /dev/null reuse
}
command="${1:-}"
[[ $# -gt 0 ]] && shift
case "$command" in
case "${1:-}" in
save)
save_vm "${1:-}"
save_vm "$2"
;;
boot)
boot_vm "$@"
boot_vm "$2"
;;
list)
list_vms
@@ -147,16 +101,9 @@ case "$command" in
"$(gum style --bold 'omarchy-vm') - VM snapshot manager" \
"" \
"$(gum style --foreground 245 'Commands:')" \
" save [name] Save current active VM" \
" boot [name] [boot-options] Boot saved VM (interactive if no name)" \
" list List available VMs" \
"" \
"$(gum style --foreground 245 'Boot options:')" \
" --ssh-port PORT Forward host PORT to guest SSH (default: 2222)" \
" --ssh-host HOST Host address for SSH forwarding (default: 127.0.0.1)" \
" --disk PATH Active disk path, useful for parallel VMs" \
" --ovmf-vars PATH Active OVMF vars path, useful for parallel VMs" \
" --no-network Boot without a virtual NIC" \
" save [name] Save current VM from /tmp" \
" boot [name] Boot saved VM (interactive if no name)" \
" list List available VMs" \
"" \
"$(gum style --foreground 245 'Current VMs:')"
list_vms 2>/dev/null || echo " None"
+143
View File
@@ -0,0 +1,143 @@
# Omarchy ISO Builder
## Overview
The ISO build system now integrates local package building for development workflows.
## Build Scripts
### build-iso.sh
Main ISO build script that:
1. Sets up Arch Linux build environment
2. Builds omarchy-settings package locally (if omarchy-pkgs mounted)
3. Installs omarchy-settings in the ISO
4. Downloads remaining packages to offline mirror
5. Assembles the final ISO image
### build-omarchy-packages.sh
Builds omarchy-settings package locally from source:
- Runs inside Docker container
- Builds as `nobody` user (makepkg requirement)
- Outputs package to `/tmp/omarchy-pkg-build/`
- Exports `OMARCHY_SETTINGS_PKG` variable for build-iso.sh
## Package Integration
### omarchy-settings
**Installed in ISO** - Provides EVERYTHING needed:
- User skeleton files (`/etc/skel/`)
- System configurations (`/etc/`)
- Plymouth boot theme
- Essential binaries (`omarchy-debug`, `omarchy-upload-log`)
- Install scripts (`/usr/share/omarchy/install/`)
- Default configs and migrations
**Size**: ~5-10MB
**Purpose**: Complete self-contained package for ISO - no git clone needed!
### omarchy (future)
**In offline mirror** - Full desktop environment:
- All omarchy-* binaries
- Install scripts and migrations
- Themes and assets
- Heavy dependencies (Hyprland, Waybar, etc.)
**Installed during**: Post-install phase in chroot
## Development Workflow
### Quick Build
```bash
# From omarchy-iso directory (uses default paths)
omarchy-iso-make-dev
# Or with custom paths
export OMARCHY_PKGS_PATH=/path/to/pkgs
export OMARCHY_INSTALLER_PATH=/path/to/installer
omarchy-iso-make-dev
```
This will:
1. Mount omarchy-pkgs into Docker container
2. Mount omarchy-installer (if OMARCHY_INSTALLER_PATH set)
3. Build omarchy-settings from local source
4. Install it in the ISO
5. Create bootable ISO with latest changes
See [ENV_VARS.md](../ENV_VARS.md) for all configuration options.
### Without Package Build
If `omarchy-pkgs` directory is not mounted:
- **Build will fail** with helpful error message
- The ISO build now requires omarchy-settings package
- No fallback to git clone (removed for simplicity)
- Set `OMARCHY_PKGS_PATH` to enable builds
## Directory Structure
```
omarchy-iso/
├── bin/
│ └── omarchy-iso-make # Main entry point (updated to mount omarchy-pkgs)
├── builder/
│ ├── build-iso.sh # Main ISO build (updated for package integration)
│ ├── build-omarchy-packages.sh # New: Local package builder
│ └── README.md # This file
├── configs/
│ ├── profiledef.sh # ISO profile (updated to remove manual file perms)
│ └── ...
└── release/
└── omarchy-*.iso # Built ISOs
```
## How It Works
1. **omarchy-iso-make** detects `../omarchy-pkgs` directory
2. Mounts it read-only into Docker container at `/omarchy-pkgs`
3. **build-iso.sh** checks for `/omarchy-pkgs` mount (REQUIRED!)
4. Sources **build-omarchy-packages.sh** to build package
5. Package is built with makepkg as `nobody` user
6. Built package added to `packages.x86_64` list
7. Copied to offline mirror
8. mkarchiso installs it during ISO assembly
9. ISO boots with omarchy-settings pre-installed
10. **Everything works from package** - no git clone, no manual copying!
## Benefits
### For Development
- ✅ No need to publish packages for testing
- ✅ Instant iteration on configs and binaries
- ✅ Test package integration in real ISO environment
- ✅ Single command builds everything
### For Production
- ✅ Same workflow works with published packages
- ✅ Fallback to manual copying if needed
- ✅ Reproducible builds
- ✅ Version-locked packages
## Troubleshooting
### Package build fails
```bash
# Check that omarchy-pkgs is mounted
docker run --rm -v "$PWD/../omarchy-pkgs:/omarchy-pkgs:ro" archlinux ls -la /omarchy-pkgs
```
### Manual file copying used instead of package
- Ensure omarchy-pkgs directory exists at `../omarchy-pkgs`
- Check Docker mount in omarchy-iso-make
- Look for "Mounting omarchy-pkgs" message during build
### Permission errors
- build-omarchy-packages.sh runs makepkg as `nobody` user
- Temporary sudo access granted for pacman operations
- Cleaned up after build
## Future Enhancements
1. Build full `omarchy` package locally
2. Remove git clone of omarchy-installer entirely
3. Use only packages for all file installation
4. Support building other omarchy-* packages
-1
View File
@@ -12,4 +12,3 @@ openssh
pipewire
snapper
sof-firmware
tailscale
+105 -310
View File
@@ -2,353 +2,148 @@
set -e
OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-quattro}"
OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}"
# Quattro, edge, dev, and local-source ISOs install the dev packages explicitly.
# Those package recipes track the quattro branch. This avoids relying on
# pacman's provides=omarchy resolution and shows the real package names being
# tested in the offline mirror and target install.
case "$OMARCHY_ISO_REF" in
quattro|edge|dev|local)
: "${OMARCHY_RUNTIME_PACKAGE:=omarchy-dev}"
: "${OMARCHY_SETTINGS_PACKAGE:=omarchy-settings-dev}"
;;
*)
: "${OMARCHY_RUNTIME_PACKAGE:=omarchy}"
: "${OMARCHY_SETTINGS_PACKAGE:=omarchy-settings}"
;;
esac
: "${OMARCHY_NVIM_PACKAGE:=omarchy-nvim}"
export OMARCHY_RUNTIME_PACKAGE OMARCHY_SETTINGS_PACKAGE OMARCHY_NVIM_PACKAGE
# Packages installed into the Arch container used to build the ISO.
# Note that these are packages installed to the Arch container used to build the ISO.
pacman-key --init
pacman --noconfirm -Sy archlinux-keyring
# Full upgrade, not just -Sy: docker never re-pulls :latest once it's cached,
# so this container can be months behind the mirror it installs from. A plain
# -Sy install is then a partial upgrade — new packages linked against a glibc
# the container doesn't have yet.
pacman --noconfirm -Syu archiso git sudo base-devel jq grub imagemagick neovim nodejs npm tree-sitter-cli
pacman --noconfirm -Sy archiso git sudo base-devel jq grub
# Pre-import the omarchy signing key (so pacman trusts our [omarchy] repo
# during the build without keyserver lookups).
pacman-key --add /builder/omarchy.gpg
pacman-key --lsign-key 40DFB630FF42BCFFB047046CF0134EE680CAC571
# omarchy-keyring is needed inside the offline mirror too.
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Sy omarchy-keyring
# Install omarchy-keyring for package verification during build
# The [omarchy] repo is defined in /configs/pacman-online.conf with SigLevel = Optional TrustAll
pacman --config /configs/pacman-online.conf --noconfirm -Sy omarchy-keyring
pacman-key --populate omarchy
# Append the [omarchy] repo to the container's /etc/pacman.conf so subsequent
# tools (notably makepkg in build-omarchy-packages.sh) can resolve omarchy-
# only build deps like limine-snapper-sync and limine-mkinitcpio-hook.
if ! grep -q '^\[omarchy\]' /etc/pacman.conf; then
awk '/^\[omarchy\]/,/^$/' /configs/pacman-online-${OMARCHY_MIRROR}.conf >> /etc/pacman.conf
fi
# Build locations
build_cache_dir=/var/cache
# Setup build locations
build_cache_dir="/var/cache"
offline_mirror_dir="$build_cache_dir/airootfs/var/cache/omarchy/mirror/offline"
mkdir -p "$build_cache_dir" "$offline_mirror_dir"
mkdir -p $build_cache_dir/
mkdir -p $offline_mirror_dir/
# Seed from the official Arch releng profile.
cp -r /archiso/configs/releng/* "$build_cache_dir/"
# We base our ISO on the official arch ISO (releng) config
cp -r /archiso/configs/releng/* $build_cache_dir/
rm "$build_cache_dir/airootfs/etc/motd"
# We rely on the global CDN; drop reflector.
rm -rf "$build_cache_dir/airootfs/etc/systemd/system/multi-user.target.wants/reflector.service"
# Avoid using reflector for mirror identification as we are relying on the global CDN
rm "$build_cache_dir/airootfs/etc/systemd/system/multi-user.target.wants/reflector.service"
rm -rf "$build_cache_dir/airootfs/etc/systemd/system/reflector.service.d"
rm -rf "$build_cache_dir/airootfs/etc/xdg/reflector"
# Bring in our archiso profile additions.
cp -r /configs/* "$build_cache_dir/"
mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso"
echo "$OMARCHY_MIRROR" > "$build_cache_dir/airootfs/root/omarchy_mirror"
echo "$OMARCHY_ISO_REF" > "$build_cache_dir/airootfs/root/omarchy_iso_ref"
cat > "$build_cache_dir/airootfs/usr/share/omarchy-iso/package-targets" <<EOF
OMARCHY_RUNTIME_PACKAGE=$OMARCHY_RUNTIME_PACKAGE
OMARCHY_SETTINGS_PACKAGE=$OMARCHY_SETTINGS_PACKAGE
OMARCHY_NVIM_PACKAGE=$OMARCHY_NVIM_PACKAGE
EOF
# Bring in our configs
cp -r /configs/* $build_cache_dir/
if [[ ${OMARCHY_INSTALL_DEBUG:-} == "1" ]]; then
touch "$build_cache_dir/airootfs/usr/share/omarchy-iso/install-debug"
{
echo "debug=1"
echo "built_at=$(date -Is)"
echo "ref=$OMARCHY_ISO_REF"
echo "mirror=$OMARCHY_MIRROR"
echo "runtime_package=$OMARCHY_RUNTIME_PACKAGE"
echo "settings_package=$OMARCHY_SETTINGS_PACKAGE"
echo "nvim_package=$OMARCHY_NVIM_PACKAGE"
if [[ -d /omarchy-source ]]; then
echo "omarchy_source=/omarchy-source"
git -c safe.directory=/omarchy-source -C /omarchy-source rev-parse HEAD 2>/dev/null | sed 's/^/omarchy_commit=/' || true
git -c safe.directory=/omarchy-source -C /omarchy-source status --short 2>/dev/null | sed 's/^/omarchy_status=/' || true
fi
if [[ -d /omarchy-pkgs ]]; then
echo "omarchy_pkgs_source=/omarchy-pkgs"
git -c safe.directory=/omarchy-pkgs -C /omarchy-pkgs rev-parse HEAD 2>/dev/null | sed 's/^/omarchy_pkgs_commit=/' || true
git -c safe.directory=/omarchy-pkgs -C /omarchy-pkgs status --short 2>/dev/null | sed 's/^/omarchy_pkgs_status=/' || true
fi
} > "$build_cache_dir/airootfs/usr/share/omarchy-iso/build-info"
# Build omarchy-settings package locally
# This package provides EVERYTHING needed for the ISO:
# - User configs (/etc/skel/)
# - System configs (/etc/)
# - Plymouth theme
# - Essential binaries (debug, upload-log)
# - Install scripts (/usr/share/omarchy/install/)
if [ -d "/omarchy-pkgs" ]; then
echo "========================================="
echo "Building omarchy-settings package locally"
echo "========================================="
source /builder/build-omarchy-packages.sh
# Add omarchy packages to ISO
# omarchy-settings provides configs and will be installed in ISO
# omarchy-installer provides install scripts and will be installed in ISO
echo "omarchy-settings" >> "$build_cache_dir/packages.x86_64"
echo "omarchy-installer" >> "$build_cache_dir/packages.x86_64"
# Copy built packages to offline mirror (for installation on target system)
cp "$OMARCHY_SETTINGS_PKG" "$offline_mirror_dir/"
cp "$OMARCHY_INSTALLER_PKG" "$offline_mirror_dir/"
cp "$OMARCHY_PKG" "$offline_mirror_dir/"
cp "$OMARCHY_LIMINE_PKG" "$offline_mirror_dir/"
echo "✓ omarchy-settings will be installed in ISO (configs, binaries)"
echo "✓ omarchy-installer will be installed in ISO (install scripts, helpers)"
echo "✓ omarchy-limine available in offline mirror for installation"
echo "✓ All omarchy packages available in offline mirror for installation"
echo "✓ No git clone needed - everything comes from packages!"
else
echo "ERROR: /omarchy-pkgs not mounted!"
echo "The ISO build now requires omarchy-settings package."
echo "Please set OMARCHY_PKGS_PATH or ensure ../omarchy-pkgs exists."
exit 1
fi
# When --local-source is in effect, build omarchy* from the mounted source
# trees and drop them in the offline mirror. Otherwise pacman -Syw below
# downloads the published versions from the omarchy network mirror.
if [[ -d /omarchy-source && -d /omarchy-pkgs ]]; then
bash /builder/build-omarchy-packages.sh "$offline_mirror_dir"
LOCAL_OMARCHY_BUILD=1
fi
# Node.js binary for offline mise install.
# Download and verify Node.js binary for offline installation
NODE_DIST_URL="https://nodejs.org/dist/latest"
# Get checksums and parse filename and SHA
NODE_SHASUMS=$(curl -fsSL "$NODE_DIST_URL/SHASUMS256.txt")
NODE_FILENAME=$(echo "$NODE_SHASUMS" | grep "linux-x64.tar.gz" | awk '{print $2}')
NODE_SHA=$(echo "$NODE_SHASUMS" | grep "linux-x64.tar.gz" | awk '{print $1}')
# Download the tarball
curl -fsSL "$NODE_DIST_URL/$NODE_FILENAME" -o "/tmp/$NODE_FILENAME"
echo "$NODE_SHA /tmp/$NODE_FILENAME" | sha256sum -c -
# Verify SHA256 checksum
echo "$NODE_SHA /tmp/$NODE_FILENAME" | sha256sum -c - || {
echo "ERROR: Node.js checksum verification failed!"
exit 1
}
# Copy to ISO
mkdir -p "$build_cache_dir/airootfs/opt/packages/"
cp "/tmp/$NODE_FILENAME" "$build_cache_dir/airootfs/opt/packages/"
# Packages installed into the live ISO environment itself (NOT the target system).
# The selected omarchy-settings package is needed here so its post_install hook
# drops Omarchy's plymouthd.conf into /etc/plymouth before mkarchiso builds the
# live initramfs.
arch_packages=(linux-t2 git gum jq openssl plymouth ttfx tzupdate omarchy-keyring "$OMARCHY_SETTINGS_PACKAGE" lvm2 cryptsetup parted)
printf '%s\n' "${arch_packages[@]}" >> "$build_cache_dir/packages.x86_64"
# Add our additional packages to packages.x86_64
arch_packages=(linux-t2 git gum jq openssl plymouth tzupdate omarchy-keyring python-terminaltexteffects)
printf '%s\n' "${arch_packages[@]}" >>"$build_cache_dir/packages.x86_64"
# The live ISO boots linux-t2 (see airootfs/etc/mkinitcpio.d/linux-t2.preset), so
# stock linux is a second kernel nobody boots: ~147MB of ISO, plus its own archiso
# initramfs, copied into both the ISO tree and the size-constrained FAT EFI image.
#
# It cannot just be deleted — releng's broadcom-wl hard-depends on it, and it is
# the only releng package that does, so pacman would drag the kernel straight back
# in. broadcom-wl is a prebuilt module for stock linux and cannot load on the
# kernel we boot, so it has done nothing since we started booting T2 anyway. The
# install is entirely offline and the live environment needs no Wi-Fi driver.
#
# Anchored so linux-t2 and linux-firmware are untouched.
sed -i -E '/^(linux|broadcom-wl)$/d' "$build_cache_dir/packages.x86_64"
# Build list of all the packages needed for the offline mirror
# Start with packages.x86_64 but exclude locally-built packages
all_packages=($(cat "$build_cache_dir/packages.x86_64" | grep -vE '^(omarchy|omarchy-settings|omarchy-installer|omarchy-limine)$'))
# Build the offline mirror: everything pacstrap might want during the target
# install. With --local-source, the omarchy* packages we just built are
# already in the mirror and we filter them out below. Without it, pacman -Syw
# pulls the published omarchy* from the network mirror like any other package.
if [[ -d /omarchy-source ]]; then
base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages)
setup_form=/omarchy-source/install/provisioning/setup-form.sh
# Add omarchy package lists from source, excluding locally-built packages
if [ -n "$OMARCHY_INSTALLER_SRC" ] && [ -d "$OMARCHY_INSTALLER_SRC" ]; then
# Filter out locally-built packages and known problematic packages
all_packages+=($(grep -v '^#' "$OMARCHY_INSTALLER_SRC/install/omarchy-base.packages" | grep -v '^$' | grep -vE '^(omarchy|omarchy-settings|omarchy-installer|omarchy-limine)$'))
all_packages+=($(grep -v '^#' "$OMARCHY_INSTALLER_SRC/install/omarchy-other.packages" | grep -v '^$' | grep -vE '^qt5-remoteobjects$'))
else
# Pull the same package lists out of the freshly-downloaded Omarchy runtime
# package so we don't need a local checkout in the non-local-source path.
bootstrap_cache_dir=/tmp/omarchy-pkg-bootstrap
rm -rf "$bootstrap_cache_dir" /tmp/offlinedb-bootstrap /tmp/omarchy-pkglists
mkdir -p "$bootstrap_cache_dir" /tmp/offlinedb-bootstrap
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw "$OMARCHY_RUNTIME_PACKAGE" --cachedir "$bootstrap_cache_dir" --dbpath /tmp/offlinedb-bootstrap >/dev/null
omarchy_pkg=$(find "$bootstrap_cache_dir" -maxdepth 1 -type f -name "$OMARCHY_RUNTIME_PACKAGE-*.pkg.tar.zst" | sort | head -1)
if [[ -z $omarchy_pkg ]]; then
echo "ERROR: downloaded package for $OMARCHY_RUNTIME_PACKAGE not found in $bootstrap_cache_dir" >&2
exit 1
fi
mkdir -p /tmp/omarchy-pkglists
bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/omarchy-base.packages usr/share/omarchy/install/omarchy-other.packages
base_pkg_lists=(/tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-base.packages /tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-other.packages)
# Extracted on its own, tolerating a miss: bsdtar exits non-zero for a member
# it can't find, so asking for this alongside the package lists would abort the
# build here (set -e) with a bare "Not found in archive" instead of the
# actionable error below.
bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/provisioning/setup-form.sh 2>/dev/null || true
setup_form=/tmp/omarchy-pkglists/usr/share/omarchy/install/provisioning/setup-form.sh
echo "WARNING: Could not find omarchy package lists, offline mirror may be incomplete"
fi
mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso"
cp "${base_pkg_lists[0]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages"
cp "${base_pkg_lists[1]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages"
all_packages+=($(grep -v '^#' /builder/archinstall.packages | grep -v '^$' | grep -vE '^(omarchy|omarchy-settings|omarchy-installer|omarchy-limine)$'))
# The configurator's setup form comes from the runtime this ISO bundles, so the
# installer and the first-boot setup that finishes a deferred install can never
# disagree. A runtime predating the split ships no such file, which would leave
# the configurator with no prompts at all.
if [[ ! -f $setup_form ]]; then
if [[ -d /omarchy-source ]]; then
echo "ERROR: the --local-source checkout ships no install/provisioning/setup-form.sh" >&2
remedy="Update the checkout to a revision carrying the shared setup form."
else
echo "ERROR: $OMARCHY_RUNTIME_PACKAGE does not ship install/provisioning/setup-form.sh" >&2
remedy="Publish a runtime carrying the shared setup form, or build with --local-source against a checkout that has it."
fi
echo " The configurator sources its prompts from that file, so this ISO" >&2
echo " would boot into an installer with no questions to ask." >&2
echo " $remedy" >&2
exit 1
fi
cp "$setup_form" "$build_cache_dir/airootfs/usr/share/omarchy-iso/setup-form.sh"
# Collect every package we want available in the offline mirror.
declare -a all_packages
mapfile -t all_packages < <(
{
cat "$build_cache_dir/packages.x86_64"
grep -hv '^#\|^$' "${base_pkg_lists[@]}"
grep -hv '^#\|^$' /builder/archinstall.packages
# Always include the selected Omarchy packages so the target install can
# find the runtime and companion packages in the offline mirror.
printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE"
} | sort -u
)
# With --local-source we already built these omarchy* packages directly into
# the mirror; strip them from the pacman -Syw list so it doesn't try to fetch
# the published versions on top.
if [[ -n ${LOCAL_OMARCHY_BUILD:-} ]]; then
mapfile -t all_packages < <(
printf '%s\n' "${all_packages[@]}" |
grep -Fxv \
-e "$OMARCHY_RUNTIME_PACKAGE" \
-e "$OMARCHY_SETTINGS_PACKAGE" \
-e "$OMARCHY_NVIM_PACKAGE" || true
)
fi
# Remove duplicates
all_packages=($(printf '%s\n' "${all_packages[@]}" | sort -u))
# Download all the packages to the offline mirror inside the ISO
# Use --needed to skip packages that are already in cache
# Use --noconfirm to avoid interactive prompts
mkdir -p /tmp/offlinedb
download_offline_packages() {
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw \
"${all_packages[@]}" --cachedir "$offline_mirror_dir/" --dbpath /tmp/offlinedb --needed
}
echo "Downloading ${#all_packages[@]} packages to offline mirror..."
pacman --config /configs/pacman-online.conf --noconfirm -Syw "${all_packages[@]}" --cachedir $offline_mirror_dir/ --dbpath /tmp/offlinedb --needed
# A repository may occasionally republish a package without changing its
# filename. Pacman detects that the persistent cached copy no longer matches
# the refreshed repository checksum and deletes it, but still fails the
# transaction. Retry once so the now-missing package is downloaded.
if ! download_offline_packages; then
echo "Offline package download failed; retrying after pacman cleaned invalid cached files..." >&2
download_offline_packages
fi
# Resolve the exact filenames chosen by the same synced package databases used
# for the download. Pruning by this transaction (rather than merely keeping the
# newest version of every cached package name) removes packages that have left
# the lists or dependency closure, such as an old Electron major version.
if ! resolved_package_files="$(
pacman --config "/configs/pacman-online-${OMARCHY_MIRROR}.conf" --noconfirm \
--dbpath /tmp/offlinedb -S --print --print-format '%f' "${all_packages[@]}"
)"; then
echo "ERROR: could not resolve the package files required by the offline mirror" >&2
exit 1
fi
mapfile -t required_package_files <<< "$resolved_package_files"
# The online transaction intentionally excludes packages built from the local
# checkouts. Add those exact artifacts back to the keep-set after verifying
# that the local build left exactly one file for each selected package name.
if [[ -n ${LOCAL_OMARCHY_BUILD:-} ]]; then
for local_package_name in \
"$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE"; do
local_package_file=""
for candidate in "$offline_mirror_dir/$local_package_name-"*.pkg.tar.*; do
[[ -f $candidate && $candidate != *.sig ]] || continue
read -r candidate_name _ < <(pacman -Qp "$candidate" 2>/dev/null) || continue
[[ $candidate_name == "$local_package_name" ]] || continue
if [[ -n $local_package_file ]]; then
echo "ERROR: multiple local builds found for $local_package_name" >&2
exit 1
fi
local_package_file="${candidate##*/}"
done
if [[ -z $local_package_file ]]; then
echo "ERROR: local build not found for $local_package_name" >&2
exit 1
fi
required_package_files+=("$local_package_file")
done
fi
printf '%s\n' "${required_package_files[@]}" |
bash /builder/prune-offline-mirror.sh "$offline_mirror_dir"
# Rebuild the offline repo db from scratch so size/checksum/depends entries
# always reflect only the package files selected for this build.
rm -f "$offline_mirror_dir"/offline.db* "$offline_mirror_dir"/offline.files*
# Build repository database for the offline mirror
# First, create the database from all downloaded packages
echo "Building offline repository database..."
repo-add "$offline_mirror_dir/offline.db.tar.gz" "$offline_mirror_dir/"*.pkg.tar.zst
# mkarchiso expects the mirror at /var/cache/omarchy/mirror/offline inside the
# container (the airootfs path); symlink rather than duplicate.
# Note: Our locally built packages (omarchy, omarchy-settings) were copied earlier
# and are now included in the repository database
# Create a symlink to the offline mirror instead of duplicating it.
# mkarchiso needs packages at /var/cache/omarchy/mirror/offline in the container,
# but they're actually in $build_cache_dir/airootfs/var/cache/omarchy/mirror/offline
mkdir -p /var/cache/omarchy/mirror
ln -sf "$offline_mirror_dir" /var/cache/omarchy/mirror/offline
ln -s "$offline_mirror_dir" "/var/cache/omarchy/mirror/offline"
# Denominator for the install dashboard's progress bar. Resolving the mirror's
# own package lists against the mirror we just indexed, with an empty local db,
# is the question pacstrap asks at install time — same resolver, same repo, same
# lists — so no hand-kept constant can drift.
#
# It over-counts by ~1 in 925: archinstall.packages lists both amd-ucode and
# intel-ucode because the mirror must contain either. phases.py records expected
# and actual in the timing JSON, so growing drift shows up in acceptance runs.
# The early-bootstrap set is already inside this closure, so restating it would
# only add a second list to drift.
resolve_expected_packages() {
local resolve_root=/tmp/omarchy-expected-packages
local resolved
local -a targets
# Copy the pacman.conf to the ISO's /etc directory so the live environment uses our
# same config when booted
cp $build_cache_dir/pacman.conf "$build_cache_dir/airootfs/etc/pacman.conf"
rm -rf "$resolve_root"
mkdir -p "$resolve_root/var/lib/pacman"
# Create missing device-mapper udev rules file to silence mkinitcpio warning
# This file was removed in recent device-mapper versions but mkinitcpio still references it
mkdir -p /usr/lib/initcpio/udev
touch /usr/lib/initcpio/udev/11-dm-initramfs.rules
mapfile -t targets < <(
{
grep -hv '^#\|^$' /builder/archinstall.packages
# Read the shipped copy, which is what _runtime_package_list reads at
# install time, not the build-time source it came from.
grep -hv '^#\|^$' \
"$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages"
printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" \
"$OMARCHY_NVIM_PACKAGE"
} | sort -u
)
# Finally, we assemble the entire ISO
mkarchiso -v -w "$build_cache_dir/work/" -o "/out/" "$build_cache_dir/"
pacman --config "$build_cache_dir/pacman-offline.conf" \
--root "$resolve_root" --dbpath "$resolve_root/var/lib/pacman" \
--noconfirm -Sy >/dev/null || return 1
# Capture before counting: no pipefail here, so a pacman failure inside a
# pipeline would become a plausible partial count, which never trips the
# dashboard's fallback.
resolved="$(pacman --config "$build_cache_dir/pacman-offline.conf" \
--root "$resolve_root" --dbpath "$resolve_root/var/lib/pacman" \
--noconfirm -S --print --print-format '%n' "${targets[@]}")" || return 1
printf '%s\n' "$resolved" | sort -u | grep -c .
}
# Worth failing the build over: -S --print only aborts when a target is missing
# from the offline repo, which would fail pacstrap the same way. A count that
# merely looks wrong is not — the dashboard falls back without the file.
if ! expected_packages="$(resolve_expected_packages)"; then
echo "ERROR: could not resolve the target package count from the offline mirror." >&2
echo " pacman -S --print aborts the whole transaction if any single target" >&2
echo " is missing, so this almost certainly means pacstrap would fail the" >&2
echo " same way at install time." >&2
exit 1
fi
if (( expected_packages < 600 || expected_packages > 2000 )); then
echo "WARNING: resolved target package count $expected_packages is outside the" >&2
echo " expected 600-2000 range; shipping no denominator so the install" >&2
echo " dashboard falls back to its time-based curve." >&2
else
printf '%s\n' "$expected_packages" \
>"$build_cache_dir/airootfs/usr/share/omarchy-iso/expected-packages"
echo "Target install resolves to $expected_packages packages."
fi
# Live ISO uses the same offline pacman.conf.
cp "$build_cache_dir/pacman-offline.conf" "$build_cache_dir/airootfs/etc/pacman.conf"
# Build the ISO.
mkarchiso -v -w "$build_cache_dir/work/" -o /out/ "$build_cache_dir/"
# Match host UID/GID on output.
if [[ -n $HOST_UID && -n $HOST_GID ]]; then
chown -R "$HOST_UID:$HOST_GID" /out/
# Fix ownership of output files to match host user
if [ -n "$HOST_UID" ] && [ -n "$HOST_GID" ]; then
chown -R "$HOST_UID:$HOST_GID" /out/
fi
+199 -65
View File
@@ -1,85 +1,219 @@
#!/bin/bash
# Build Omarchy packages from mounted source (/omarchy-source + /omarchy-pkgs)
# and place the resulting .pkg.tar.zst files in the offline mirror.
# Build omarchy packages locally for ISO integration
# This runs inside the Docker container during ISO build
set -e
offline_mirror_dir="$1"
if [[ -z $offline_mirror_dir ]]; then
echo "Usage: build-omarchy-packages.sh <offline-mirror-dir>" >&2
exit 1
echo "========================================="
echo "Building omarchy-settings package locally"
echo "========================================="
# Packages will be built in /tmp/omarchy-pkg-build
BUILD_DIR="/tmp/omarchy-pkg-build"
# Clean old builds to avoid checksum issues
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR"
# Create a writable build directory
WORK_DIR="/tmp/omarchy-pkg-work"
# Clean old work directory
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR"
# Copy package files to writable location
echo "Setting up build directory..."
cp -r /omarchy-pkgs/pkgbuilds/omarchy-settings "$WORK_DIR/"
cd "$WORK_DIR/omarchy-settings"
# Set PKGDEST to control where built packages go
export PKGDEST="$BUILD_DIR"
# Check if we should use dev build (local source)
if [ -n "$OMARCHY_DEV_BUILD" ] && [ -d "/omarchy-installer" ]; then
echo "========================================="
echo "DEV MODE: Using locally mounted source"
echo "========================================="
# Source the dev PKGBUILD patch
source /builder/templates/dev-pkgbuild-patch.sh
convert_to_dev_pkgbuild "PKGBUILD"
BUILD_FILE="PKGBUILD.dev"
BUILD_FLAGS="--noconfirm --skippgpcheck --skipchecksums -f"
echo "✓ Will build from local source (no git clone)"
else
echo "========================================="
echo "PRODUCTION MODE: Using git source"
echo "========================================="
BUILD_FILE="PKGBUILD"
BUILD_FLAGS="--noconfirm --skippgpcheck"
echo "✓ Will git clone from repository"
fi
if [[ ! -d /omarchy-source ]]; then
echo "ERROR: /omarchy-source not mounted (pass --local-source or set OMARCHY_SOURCE_PATH)" >&2
exit 1
fi
if [[ ! -d /omarchy-pkgs ]]; then
echo "ERROR: /omarchy-pkgs not mounted (set OMARCHY_PKGS_PATH or place ../omarchy-pkgs)" >&2
exit 1
fi
work_dir=/tmp/omarchy-pkg-build
rm -rf "$work_dir"
mkdir -p "$work_dir"
# Sync package databases (needed for dependency resolution)
echo "Syncing package databases..."
pacman -Sy --noconfirm
# Build the package
# Note: The container runs as root, but makepkg refuses to run as root
# Create a fresh build user (nobody account is expired in base image)
if ! id builder &>/dev/null; then
useradd -m -s /bin/bash builder
fi
echo 'builder ALL=(ALL) NOPASSWD: /usr/bin/pacman' > /etc/sudoers.d/99-omarchy-pkg-builder
chmod 440 /etc/sudoers.d/99-omarchy-pkg-builder
chown builder:builder "$work_dir"
pacman -Sy --noconfirm
# Give builder ownership
chown -R builder:builder "$WORK_DIR/omarchy-settings"
chown -R builder:builder "$BUILD_DIR"
: "${OMARCHY_RUNTIME_PACKAGE:=omarchy-dev}"
: "${OMARCHY_SETTINGS_PACKAGE:=omarchy-settings-dev}"
: "${OMARCHY_NVIM_PACKAGE:=omarchy-nvim}"
# Allow builder to run pacman without password (needed for dependency installation)
echo "builder ALL=(ALL) NOPASSWD: /usr/bin/pacman" > /etc/sudoers.d/99-builder-pacman
chmod 440 /etc/sudoers.d/99-builder-pacman
packages=(
"$OMARCHY_SETTINGS_PACKAGE"
"$OMARCHY_RUNTIME_PACKAGE"
"$OMARCHY_NVIM_PACKAGE"
)
# Build the package as builder user (makepkg will install dependencies via sudo)
su builder -c "cd $WORK_DIR/omarchy-settings && makepkg -p $BUILD_FILE $BUILD_FLAGS -s"
# Local-source packages must replace every cached build of the same package,
# even when the checkout's generated pkgver sorts below a published build.
# Otherwise the later generic cache pruning can silently keep edge instead.
for pkg in "${packages[@]}"; do
rm -f "$offline_mirror_dir/$pkg-"*.pkg.tar.*
done
# Find the built package
BUILT_PACKAGE=$(ls -t "$BUILD_DIR"/omarchy-settings-*.pkg.tar.zst 2>/dev/null | head -n1)
for pkg in "${packages[@]}"; do
echo "----------------------------------------"
echo "Building $pkg"
echo "----------------------------------------"
pkg_work="$work_dir/$pkg"
if [[ ! -d "/omarchy-pkgs/pkgbuilds/$pkg" ]]; then
echo "ERROR: package source not found: /omarchy-pkgs/pkgbuilds/$pkg" >&2
if [ -z "$BUILT_PACKAGE" ]; then
echo "ERROR: Failed to build omarchy-settings package"
exit 1
fi
cp -a "/omarchy-pkgs/pkgbuilds/$pkg" "$pkg_work"
chown -R builder:builder "$pkg_work"
fi
su builder -c "
cd '$pkg_work' &&
PKGDEST='$work_dir' \
OMARCHY_SRC=/omarchy-source \
makepkg --noconfirm --skippgpcheck --skipchecksums --nodeps -f
"
done
echo "✓ Successfully built: $(basename $BUILT_PACKAGE)"
echo "Package location: $BUILT_PACKAGE"
mkdir -p "$offline_mirror_dir"
for package_file in "$work_dir"/*.pkg.tar.zst; do
destination="$offline_mirror_dir/$(basename "$package_file")"
# Export the path for use by build-iso.sh
export OMARCHY_SETTINGS_PKG="$BUILT_PACKAGE"
# A cached signature belongs to the previously downloaded or locally built
# package. Keeping it beside a newly built package makes pacman reject the
# otherwise valid local-source build.
rm -f "$destination" "$destination.sig"
mv "$package_file" "$destination"
done
# Export the source directory for accessing package lists before package is installed
# Check both possible locations (dev mode uses /omarchy-installer, prod uses src/)
if [ -d "/omarchy-installer" ]; then
export OMARCHY_INSTALLER_SRC="/omarchy-installer"
echo "✓ Package source available at: $OMARCHY_INSTALLER_SRC (mounted)"
elif [ -d "$WORK_DIR/omarchy-settings/src/omarchy-installer" ]; then
export OMARCHY_INSTALLER_SRC="$WORK_DIR/omarchy-settings/src/omarchy-installer"
echo "✓ Package source available at: $OMARCHY_INSTALLER_SRC (from build)"
fi
echo
echo "Built Omarchy packages, placed in $offline_mirror_dir:"
ls "$offline_mirror_dir"/omarchy*.pkg.tar.zst | sed 's|^| |'
echo "✓ Package contains everything needed for ISO (configs, package lists, binaries, theme)"
# Now build the omarchy-installer package
echo ""
echo "========================================="
echo "Building omarchy-installer package"
echo "========================================="
# Copy omarchy-installer package files to writable location
cp -r /omarchy-pkgs/pkgbuilds/omarchy-installer "$WORK_DIR/"
cd "$WORK_DIR/omarchy-installer"
# Check if we should use dev build
if [ -n "$OMARCHY_DEV_BUILD" ] && [ -d "/omarchy-installer" ]; then
echo "DEV MODE: Using locally mounted source"
# Generate dev PKGBUILD
source /builder/templates/dev-pkgbuild-patch.sh
convert_to_dev_pkgbuild "PKGBUILD"
INSTALLER_BUILD_FILE="PKGBUILD.dev"
INSTALLER_BUILD_FLAGS="--noconfirm --skippgpcheck --skipchecksums -f"
else
echo "PRODUCTION MODE: Using git source"
INSTALLER_BUILD_FILE="PKGBUILD"
INSTALLER_BUILD_FLAGS="--noconfirm --skippgpcheck"
fi
# Give builder ownership
chown -R builder:builder "$WORK_DIR/omarchy-installer"
# Build the omarchy-installer package
su builder -c "cd $WORK_DIR/omarchy-installer && makepkg -p $INSTALLER_BUILD_FILE $INSTALLER_BUILD_FLAGS -d"
# Find the built package
OMARCHY_INSTALLER_PKG=$(ls -t "$BUILD_DIR"/omarchy-installer-*.pkg.tar.zst 2>/dev/null | head -n1)
if [ -z "$OMARCHY_INSTALLER_PKG" ]; then
echo "ERROR: Failed to build omarchy-installer package"
exit 1
fi
echo "✓ Successfully built: $(basename $OMARCHY_INSTALLER_PKG)"
echo "Package location: $OMARCHY_INSTALLER_PKG"
# Now build the full omarchy package for offline mirror
echo ""
echo "========================================="
echo "Building omarchy meta-package"
echo "========================================="
# Copy omarchy package files to writable location
cp -r /omarchy-pkgs/pkgbuilds/omarchy "$WORK_DIR/"
cd "$WORK_DIR/omarchy"
# Check if we should use dev build
if [ -n "$OMARCHY_DEV_BUILD" ] && [ -d "/omarchy-installer" ]; then
echo "DEV MODE: Using locally mounted source"
# Generate dev PKGBUILD
source /builder/templates/dev-pkgbuild-patch.sh
convert_to_dev_pkgbuild "PKGBUILD"
OMARCHY_BUILD_FILE="PKGBUILD.dev"
OMARCHY_BUILD_FLAGS="--noconfirm --skippgpcheck --skipchecksums -f"
else
echo "PRODUCTION MODE: Using git source"
OMARCHY_BUILD_FILE="PKGBUILD"
OMARCHY_BUILD_FLAGS="--noconfirm --skippgpcheck"
fi
# Give builder ownership
chown -R builder:builder "$WORK_DIR/omarchy"
# Build the omarchy package
# Note: Skip dependency checks (-d) since we're just packaging files, not running them
# The dependencies will be resolved when the package is installed in the actual system
su builder -c "cd $WORK_DIR/omarchy && makepkg -p $OMARCHY_BUILD_FILE $OMARCHY_BUILD_FLAGS -d"
# Find the built package
OMARCHY_PKG=$(ls -t "$BUILD_DIR"/omarchy-*.pkg.tar.zst 2>/dev/null | head -n1)
if [ -z "$OMARCHY_PKG" ]; then
echo "ERROR: Failed to build omarchy package"
exit 1
fi
echo "✓ Successfully built: $(basename $OMARCHY_PKG)"
echo "Package location: $OMARCHY_PKG"
# Now build the omarchy-limine package
echo ""
echo "========================================="
echo "Building omarchy-limine package"
echo "========================================="
# Copy omarchy-limine package files to writable location
cp -r /omarchy-pkgs/pkgbuilds/omarchy-limine "$WORK_DIR/"
cd "$WORK_DIR/omarchy-limine"
# Give builder ownership
chown -R builder:builder "$WORK_DIR/omarchy-limine"
# Build the omarchy-limine package (no source needed, just config files)
su builder -c "cd $WORK_DIR/omarchy-limine && makepkg --noconfirm --skippgpcheck -d"
# Find the built package
OMARCHY_LIMINE_PKG=$(ls -t "$BUILD_DIR"/omarchy-limine-*.pkg.tar.zst 2>/dev/null | head -n1)
if [ -z "$OMARCHY_LIMINE_PKG" ]; then
echo "ERROR: Failed to build omarchy-limine package"
exit 1
fi
echo "✓ Successfully built: $(basename $OMARCHY_LIMINE_PKG)"
echo "Package location: $OMARCHY_LIMINE_PKG"
# Export for use by build-iso.sh
export OMARCHY_PKG="$OMARCHY_PKG"
export OMARCHY_INSTALLER_PKG="$OMARCHY_INSTALLER_PKG"
export OMARCHY_LIMINE_PKG="$OMARCHY_LIMINE_PKG"
-13
View File
@@ -1,13 +0,0 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEaLAsIhYJKwYBBAHaRw8BAQdAYmKmrWMlfpdIPh3QzvEMzzxdNd/kqDl/Bj8H
mCavKji0Gk9tYXJjaHkgPHBrZ3NAb21hcmNoeS5vcmc+iJAEExYKADgWIQRA37Yw
/0K8/7BHBGzwE07mgMrFcQUCaLAsIgIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIX
gAAKCRDwE07mgMrFcRF2AQCjjvjgju/kowkN69nenqqRnE+v2MrHmWh2wU0ugIt9
3gD/bXGugWc5J1vqoT+5aCnJAtfRRmHEfsrAiFgjduB+VA24OARosCwiEgorBgEE
AZdVAQUBAQdAJoU73WdgxE/pEYK5ofyRvQDs1IFRdfJHV9j4Jm+tuDUDAQgHiHgE
GBYKACAWIQRA37Yw/0K8/7BHBGzwE07mgMrFcQUCaLAsIgIbDAAKCRDwE07mgMrF
cYXsAQC5eBEGK0xKsGwtnDDVeNAapx32dpvLgJ94W4DtIpdLlwEA85cSsTiBMuGy
6kwYmHzqw7oFx3VYGXdMNl0SMKzvJA4=
=G7wU
-----END PGP PUBLIC KEY BLOCK-----
-67
View File
@@ -1,67 +0,0 @@
#!/bin/bash
set -euo pipefail
offline_mirror_dir="${1:-}"
if [[ -z $offline_mirror_dir ]]; then
echo "Usage: prune-offline-mirror.sh <offline-mirror-dir>" >&2
exit 1
fi
if [[ ! -d $offline_mirror_dir ]]; then
echo "ERROR: offline mirror directory not found: $offline_mirror_dir" >&2
exit 1
fi
# Read the exact package filenames selected by pacman on stdin. Validate the
# complete selection before deleting anything: an empty/partial resolution must
# never turn a warm-cache build into a broken mirror.
declare -A required=()
while IFS= read -r filename; do
[[ -n $filename ]] || continue
if [[ $filename == */* || $filename != *.pkg.tar.* || $filename == *.sig ]]; then
echo "ERROR: invalid required package filename: $filename" >&2
exit 1
fi
required["$filename"]=1
done
if (( ${#required[@]} == 0 )); then
echo "ERROR: refusing to prune the offline mirror with an empty package selection" >&2
exit 1
fi
missing=()
for filename in "${!required[@]}"; do
[[ -f "$offline_mirror_dir/$filename" ]] || missing+=("$filename")
done
if (( ${#missing[@]} > 0 )); then
echo "ERROR: refusing to prune; selected packages are missing from the mirror:" >&2
printf ' %s\n' "${missing[@]}" >&2
exit 1
fi
stale=()
while IFS= read -r -d '' package_file; do
filename="${package_file##*/}"
[[ -n ${required[$filename]+x} ]] || stale+=("$package_file")
done < <(
find "$offline_mirror_dir" -maxdepth 1 -type f \
-name '*.pkg.tar.*' ! -name '*.sig' -print0
)
if (( ${#stale[@]} > 0 )); then
echo "Pruning package archives not selected for the offline mirror:"
for package_file in "${stale[@]}"; do
echo " $(basename "$package_file")"
rm -f -- "$package_file" "$package_file.sig"
done
fi
# A failed or interrupted download can leave a signature without its archive.
# It cannot be indexed and only wastes space in the ISO.
while IFS= read -r -d '' signature_file; do
[[ -f ${signature_file%.sig} ]] || rm -f -- "$signature_file"
done < <(
find "$offline_mirror_dir" -maxdepth 1 -type f \
-name '*.pkg.tar.*.sig' -print0
)
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# Patch to convert production PKGBUILD to dev PKGBUILD
# This script modifies PKGBUILD to use local source instead of git clone
convert_to_dev_pkgbuild() {
local pkgbuild_path="$1"
local dev_pkgbuild_path="${pkgbuild_path}.dev"
echo "Creating dev PKGBUILD from production version..."
# Copy production PKGBUILD
cp "$pkgbuild_path" "$dev_pkgbuild_path"
# Remove git source (replace with empty source array)
sed -i '/^source=/c\source=()' "$dev_pkgbuild_path"
sed -i '/^sha256sums=/c\sha256sums=()' "$dev_pkgbuild_path"
# Replace cd "omarchy-installer" with cd "/omarchy-installer" to use mounted directory directly
sed -i 's|cd "omarchy-installer"|cd "/omarchy-installer"|g' "$dev_pkgbuild_path"
echo "✓ Dev PKGBUILD created: $dev_pkgbuild_path"
echo "✓ Will use /omarchy-installer directly (no git clone, no symlinks)"
}
@@ -1,18 +0,0 @@
# mkinitcpio preset file for the 'linux-t2' package on archiso
#
# The filename is the pkgbase, and that is load-bearing. Kernel packages ship
# nothing in /boot — they only install usr/lib/modules/<kver>/vmlinuz — so
# mkinitcpio's alpm hook sources /etc/mkinitcpio.d/<pkgbase>.preset and copies
# THAT package's kernel to whatever ALL_kver names (is_kernelcopy in
# /usr/share/libalpm/scripts/mkinitcpio). Named linux.preset, this pointed the
# stock kernel at the T2 filenames, so the ISO booted stock Arch while every
# boot entry said linux-t2 and T2 Macs got no keyboard or trackpad.
#
# releng ships its own linux.preset for the stock kernel; leave it alone.
PRESETS=('archiso')
ALL_kver='/boot/vmlinuz-linux-t2'
archiso_config='/etc/mkinitcpio.conf.d/archiso.conf'
archiso_image="/boot/initramfs-linux-t2.img"
@@ -0,0 +1,9 @@
# mkinitcpio preset file for the 'linux' package on archiso
PRESETS=('archiso')
ALL_kver='/boot/vmlinuz-linux-t2'
archiso_config='/etc/mkinitcpio.conf.d/archiso.conf'
archiso_image="/boot/initramfs-linux-t2.img"
@@ -1,3 +0,0 @@
# linux-t2 ships for every machine, and on a pre-T2 Mac its applesmc oopses
# registering the keyboard-backlight LED, wedging udev. Nothing here needs the SMC.
blacklist applesmc
@@ -1,14 +0,0 @@
# Fix Panther Lake audio: blacklist SOF modules that conflict with HDA drivers
# (prevents boot issues on Dell XPS 2026 and similar Panther Lake systems)
blacklist snd_sof_pci_intel_ptl
blacklist snd_sof_pci_intel_lnl
blacklist snd_sof_pci_intel_mtl
blacklist snd_sof_intel_hda_generic
blacklist snd_sof_intel_hda_common
blacklist snd_sof_intel_hda
blacklist snd_sof_pci
blacklist snd_sof
blacklist soundwire_intel
blacklist snd_soc_cs35l56_sdw
blacklist snd_soc_cs35l56
blacklist snd_soc_skl_hda_dsp
@@ -1,21 +0,0 @@
# remove from airootfs!
#
# Mirror omarchy-settings' /etc/skel shell defaults into /root so the live ISO
# console has the same bashrc, starship prompt, and aliases as an installed
# Omarchy system. Fires during airootfs pacstrap (when omarchy-settings is
# installed); the "remove from airootfs" magic string causes
# zzzz99-remove-custom-hooks-from-airootfs.hook to delete this file before
# the squashfs is built, so no runtime hook remains.
[Trigger]
Operation = Install
Operation = Upgrade
Type = Package
Target = omarchy-settings
[Action]
Description = Mirroring /etc/skel shell defaults into /root for the live ISO...
When = PostTransaction
Depends = coreutils
Depends = omarchy-settings
Exec = /bin/sh -c 'install -d -m 0700 /root/.config && for f in .bashrc .bash_profile; do [ -f "/etc/skel/$f" ] && install -m 0644 "/etc/skel/$f" "/root/$f"; done; [ -f /etc/skel/.config/starship.toml ] && install -m 0644 /etc/skel/.config/starship.toml /root/.config/starship.toml; true'
+1 -1
View File
@@ -1,3 +1,3 @@
[Daemon]
Theme=omarchy
ShowDelay=0
ShowDelay=0
+173 -123
View File
@@ -1,131 +1,181 @@
#!/usr/bin/env bash
#
# Live ISO entry point on tty1: set up the live VT, run the configurator
# wizard, then hand off to the Python install orchestrator. Mirrors the
# stream/env contract from the previously-working installer:
# - stdout teed to /var/log/omarchy-install.log (CSI-stripped) AND to tty
# - stderr direct to /dev/tty so gum (which draws its TUI on stderr)
# renders correctly
# - CLICOLOR_FORCE/FORCE_COLOR so gum emits ANSI even with stdout piped
# - COLUMNS/LINES so gum picks up real terminal size
set -euo pipefail
[[ $(tty) == /dev/tty1 ]] || exit 0
export OMARCHY_MIRROR="$(cat /root/omarchy_mirror)"
if [[ -f /root/omarchy_iso_ref ]]; then
export OMARCHY_ISO_REF="$(cat /root/omarchy_iso_ref)"
fi
if [[ -f /usr/share/omarchy-iso/package-targets ]]; then
# shellcheck disable=SC1091
source /usr/share/omarchy-iso/package-targets
export OMARCHY_RUNTIME_PACKAGE OMARCHY_SETTINGS_PACKAGE OMARCHY_NVIM_PACKAGE
fi
export OMARCHY_PATH=/usr/share/omarchy
export OMARCHY_INSTALL=$OMARCHY_PATH/install
export OMARCHY_INSTALL_LOG_FILE=/var/log/omarchy-install.log
if [[ -f /usr/share/omarchy-iso/install-debug ]]; then
export OMARCHY_INSTALL_DEBUG=1
fi
# Tokyo Night palette so the live VT matches the installed look.
set_tokyo_night_colors() {
echo -en "\e]P01a1b26"; echo -en "\e]P1f7768e"; echo -en "\e]P29ece6a"
echo -en "\e]P3e0af68"; echo -en "\e]P47aa2f7"; echo -en "\e]P5bb9af7"
echo -en "\e]P67dcfff"; echo -en "\e]P7a9b1d6"; echo -en "\e]P8414868"
echo -en "\e]P9f7768e"; echo -en "\e]PA9ece6a"; echo -en "\e]PBe0af68"
echo -en "\e]PC7aa2f7"; echo -en "\e]PDbb9af7"; echo -en "\e]PE7dcfff"
echo -en "\e]PFc0caf5"
echo -en "\033[0m"
clear
}
set_tokyo_night_colors
mkdir -p /var/log
touch "$OMARCHY_INSTALL_LOG_FILE"
export COLUMNS=$(tput cols)
export LINES=$(tput lines)
exec > >(tee >(sed -u 's/\x1b\[[0-9;?]*[A-Za-z]//g' >>"$OMARCHY_INSTALL_LOG_FILE") 2>/dev/null) 2>/dev/tty
export CLICOLOR_FORCE=1
export FORCE_COLOR=1
if [[ ${OMARCHY_INSTALL_DEBUG:-} == "1" ]]; then
echo "=== Omarchy ISO debug build ==="
[[ -f /usr/share/omarchy-iso/build-info ]] && cat /usr/share/omarchy-iso/build-info
pacman -Q omarchy-settings omarchy-keyring 2>/dev/null || true
echo "================================"
fi
# Warm the page cache for the bundled packages while the user works through the
# wizard. The install reads ~3GB out of the offline mirror, and pacman consumes
# it at only ~33MB/s, so on media slower than that the install is read-bound and
# every byte cached here is a byte it never waits for. On faster media this costs
# nothing but otherwise-idle bandwidth: the medium is untouched while the user
# types, and the target disk it writes to later is a different device.
#
# Clean page cache only, so the kernel reclaims it under pressure instead of
# OOMing, and a budget so small machines never evict what was just warmed.
# Set OMARCHY_NO_PREFETCH=1 to A/B the same ISO with this disabled.
warm_offline_mirror() {
local mirror=/var/cache/omarchy/mirror/offline
local budget_kb spent_kb=0 size_kb path
[[ ${OMARCHY_NO_PREFETCH:-} == 1 ]] && return 0
[[ -d $mirror ]] || return 0
budget_kb=$(($(awk '/^MemAvailable:/ { print $2 }' /proc/meminfo) / 2))
((budget_kb > 262144)) || return 0
# Largest first: the install reads most of the mirror, so when the budget
# cannot cover all of it this still front-loads the bytes that dominate.
while read -r size_kb path; do
((spent_kb + size_kb > budget_kb)) && continue
cat -- "$path" >/dev/null 2>&1 || true
spent_kb=$((spent_kb + size_kb))
done < <(du -k "$mirror"/*.pkg.tar.zst 2>/dev/null | sort -rn)
use_omarchy_helpers() {
# Use omarchy-settings package location (installed in ISO)
export OMARCHY_PATH="/usr/share/omarchy"
export OMARCHY_INSTALL="/usr/share/omarchy/install"
export OMARCHY_INSTALL_LOG_FILE="/var/log/archinstall/install.log"
# Load presentation helpers (clear_logo, etc.) and logging helpers (start_log_output, etc.)
source /usr/share/omarchy/install/helpers/all.sh
}
warm_offline_mirror &
warm_pid=$!
trap 'kill "$warm_pid" 2>/dev/null' EXIT
cd /root
# Autoinstall: a cidata drive carrying the configurator's own output files
# stands in for the wizard. omarchy-cidata-load copies them into /root and
# everything downstream runs the ordinary path against ordinary inputs.
if /usr/local/bin/omarchy-cidata-load; then
echo "Autoinstall configuration found on cidata drive; skipping the configurator."
export OMARCHY_UI_INTERACTIVE=no
else
run_configurator() {
set_tokyo_night_colors
./configurator
fi
export OMARCHY_USER="$(jq -r '.username' user_info.json)"
export OMARCHY_USER_PASSWORD_HASH="$(jq -r '.password_hash' user_info.json)"
export OMARCHY_USER_NAME="$(cat user_full_name.txt)"
export OMARCHY_USER_EMAIL="$(cat user_email_address.txt)"
}
# Deferred-provisioning installs skip the celebration/reboot prompt and reboot on
# their own — the owner completes setup at first boot. Signalled by the config's
# defer_provisioning flag (interactive) or the defer-provisioning marker (cidata).
# Parse the flag with jq (the same JSON semantics the orchestrator uses) rather
# than a line regex, so a reformatted config can't read as a direct install.
if [[ -f /root/defer-provisioning ]] ||
[[ "$(jq -r '.omarchy_install.defer_provisioning // false' /root/user_configuration.json 2>/dev/null)" == "true" ]]; then
export OMARCHY_UI_DEFER_PROVISIONING=yes
fi
install_omarchy() {
clear_logo
gum style --foreground 3 --padding "1 0 0 $PADDING_LEFT" "Installing..."
echo
# The foreground dashboard is now the sole visible install UI owner. It starts
# the actual installer as a non-interactive child, logs child output, waits for
# completion, then renders the final installed-time/reboot prompt itself.
export OMARCHY_DASHBOARD_TTY="$(tty)"
rm -f /run/omarchy-install/state.json
/usr/local/bin/omarchy-install-dashboard \
"$OMARCHY_INSTALL_LOG_FILE" \
/run/omarchy-install/state.json \
-- \
/usr/local/bin/omarchy-iso-install \
--config /root/user_configuration.json \
--creds /root/user_credentials.json \
--full-name-file /root/user_full_name.txt \
--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 \
--defer-provisioning-file /root/defer-provisioning
# Copy user info to /tmp on the ISO side
# omarchy-install will copy these into the chroot at the right time
cp user_full_name.txt /tmp/omarchy-user-name.txt 2>/dev/null || true
cp user_email_address.txt /tmp/omarchy-user-email.txt 2>/dev/null || true
# Check if advanced disk mode (partition TUI)
EDIT_PARTITIONS_FLAG=""
if [[ -f edit_partitions_flag.txt ]]; then
EDIT_PARTITIONS_FLAG=$(cat edit_partitions_flag.txt | tr -d '\n' | xargs)
fi
# ADVANCED MODE: Run disk configuration TUI before installation
if [[ -n "$EDIT_PARTITIONS_FLAG" ]]; then
clear_logo
gum style --foreground 3 --padding "1 0 0 $PADDING_LEFT" "Configuring disk layout..."
echo
# Run omarchy-disk-config to show partition TUI and validate
# This updates user_configuration.json in place
/usr/share/omarchy/bin/omarchy-disk-config \
--config user_configuration.json \
--creds user_credentials.json
disk_config_exit_code=$?
if [[ $disk_config_exit_code -ne 0 ]]; then
echo "ERROR: Disk configuration failed or was cancelled"
exit $disk_config_exit_code
fi
clear_logo
gum style --foreground 3 --padding "1 0 0 $PADDING_LEFT" "Disk configuration complete!"
echo
sleep 1
fi
# Run installation with the configuration (possibly updated by disk-config)
# Create backup log file for raw output (with ANSI codes stripped)
touch /var/log/omarchy-install.log
# Ensure archinstall log directory exists
sudo mkdir -p /var/log/archinstall
# Prepare chroot log location (will be created by chroot script when it runs)
sudo mkdir -p /mnt/var/log
CHROOT_LOG="/mnt/var/log/omarchy-install-chroot.log"
# Start tailing BOTH logs with tail -F (follows multiple files, waits for files to appear)
# This will automatically pick up the chroot log when it's created
start_log_output "/var/log/omarchy-install.log" "$CHROOT_LOG"
# Run omarchy-install and capture output
# archinstall writes to /var/log/archinstall/install.log internally
# We also capture stdout/stderr to our backup log (stripping ANSI codes for clean viewing)
/usr/share/omarchy/bin/omarchy-install \
--config user_configuration.json \
--creds user_credentials.json \
2>&1 | sed -u 's/\x1b\[[0-9;]*[a-zA-Z]//g' | tee -a /var/log/omarchy-install.log >/dev/null
install_exit_code=$?
# Check if omarchy-install failed
if [[ $install_exit_code -ne 0 ]]; then
stop_log_output
echo
echo "ERROR: Installation failed (exit code $install_exit_code)"
echo "Check /var/log/omarchy-install.log and /mnt/var/log/omarchy-install-chroot.log for details"
exit $install_exit_code
fi
# Installation succeeded - stop log output BEFORE doing anything else
stop_log_output
# Copy and merge logs to installed system for user access (silent)
{
sudo cp /var/log/omarchy-install.log /mnt/var/log/omarchy-install.log 2>/dev/null || true
sudo cp /var/log/archinstall/install.log /mnt/var/log/archinstall-install.log 2>/dev/null || true
# Create a merged log with all phases
{
echo "========================================"
echo "Omarchy Installation - Complete Log"
echo "Generated: $(date '+%Y-%m-%d %H:%M:%S')"
echo "========================================"
echo
echo "=== Phase 1: Main Installation (ISO) ==="
cat /var/log/omarchy-install.log 2>/dev/null || echo "(No main install log found)"
echo
echo "=== Phase 2: Chroot Configuration ==="
cat "$CHROOT_LOG" 2>/dev/null || echo "(No chroot log found)"
echo
echo "========================================"
echo "Installation Complete"
echo "========================================"
} | sudo tee /mnt/var/log/omarchy-install-full.log >/dev/null
} >/dev/null 2>&1
# Show completion screen
clear
echo
tte -i /usr/share/omarchy/logo.txt --canvas-width 0 --anchor-text c --frame-rate 920 laseretch
echo
# Display installation time if available
if [[ -f /mnt/tmp/omarchy-install-time.txt ]]; then
TOTAL_TIME=$(cat /mnt/tmp/omarchy-install-time.txt)
echo "Installed in $TOTAL_TIME" | tte --canvas-width 0 --anchor-text c --frame-rate 640 print
else
echo "Finished installing" | tte --canvas-width 0 --anchor-text c --frame-rate 640 print
fi
echo
# Prompt for reboot (centered)
BUTTON_WIDTH=12 # "Reboot Now" + padding
BUTTON_PADDING=$(( (TERM_WIDTH - BUTTON_WIDTH) / 2 ))
if gum confirm --padding "0 0 0 $BUTTON_PADDING" --show-help=false --default --affirmative "Reboot Now" --negative "" ""; then
clear
reboot
fi
}
# Set Tokyo Night color scheme for the terminal
set_tokyo_night_colors() {
if [[ $(tty) == "/dev/tty"* ]]; then
# Tokyo Night color palette
echo -en "\e]P01a1b26" # black (background)
echo -en "\e]P1f7768e" # red
echo -en "\e]P29ece6a" # green
echo -en "\e]P3e0af68" # yellow
echo -en "\e]P47aa2f7" # blue
echo -en "\e]P5bb9af7" # magenta
echo -en "\e]P67dcfff" # cyan
echo -en "\e]P7a9b1d6" # white
echo -en "\e]P8414868" # bright black
echo -en "\e]P9f7768e" # bright red
echo -en "\e]PA9ece6a" # bright green
echo -en "\e]PBe0af68" # bright yellow
echo -en "\e]PC7aa2f7" # bright blue
echo -en "\e]PDbb9af7" # bright magenta
echo -en "\e]PE7dcfff" # bright cyan
echo -en "\e]PFc0caf5" # bright white (foreground)
# Set default foreground and background
echo -en "\033[0m"
clear
fi
}
if [[ $(tty) == "/dev/tty1" ]]; then
use_omarchy_helpers
run_configurator
install_omarchy
fi
File diff suppressed because it is too large. Load diff
@@ -1,70 +0,0 @@
#!/bin/bash
#
# Autoinstall: a drive labeled cidata (the cloud-init NoCloud label, so Proxmox,
# libvirt and Packer all know how to attach one) carrying the configurator's own
# output files stands in for the wizard. Copy those files into /root and
# everything downstream runs the ordinary path against ordinary inputs.
#
# Exits 0 when an autoinstall drive was loaded and the configurator can be
# skipped. The required pair is the same one the orchestrator treats as
# mandatory; the rest are optional there too, so don't be stricter here.
# Anything less than the pair means this isn't an autoinstall drive, and the
# caller falls back to the wizard.
#
# A `defer-provisioning` marker file stands in for user_credentials.json: deferred-provisioning
# installs defer user creation to first boot, so imaging rigs ship no credentials.
#
# The optional argument prefixes every path the script touches; tests use it to
# run against a sandbox instead of the live system.
set -uo pipefail
root=${1:-}
by_label=$root/dev/disk/by-label
dest=$root/root
mnt=$root/run/cidata
# The by-label symlinks come from udev, and on a fast boot this can run before
# device enumeration has finished. Wait for the queue to drain instead of
# racing it and silently dropping a headless install into the wizard.
udevadm settle 2>/dev/null || true
device=""
for label in cidata CIDATA; do
[[ -e $by_label/$label ]] && { device=$by_label/$label; break; }
done
[[ -n $device ]] || exit 1
mkdir -p "$mnt"
mount -o ro "$device" "$mnt" || exit 1
# Clear any inputs a previous load in this live session left in /root before
# copying the current drive: a stale `defer-provisioning` marker or credentials file would
# otherwise force a fresh drive back into deferred provisioning or hand it an old
# LUKS passphrase. The interactive wizard writes these same names, but a
# cidata load always precedes it, so nothing of the user's is discarded here.
optional_inputs=(user_credentials.json defer-provisioning user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey)
for file in user_configuration.json "${optional_inputs[@]}"; do
rm -f "$dest/$file"
done
loaded=1
if [[ -f $mnt/user_configuration.json ]] && [[ -f $mnt/user_credentials.json || -f $mnt/defer-provisioning ]]; then
files=("$mnt"/user_configuration.json)
for file in "${optional_inputs[@]}"; do
[[ -f $mnt/$file ]] && files+=("$mnt/$file")
done
if cp "${files[@]}" "$dest"/; then
loaded=0
else
# A partial copy must not leave files behind: the wizard cleans up only
# what it writes itself, so a half-copied authorized_keys would leak into
# the interactive install that follows.
for file in "${files[@]}"; do
rm -f "$dest/${file##*/}"
done
fi
fi
umount "$mnt"
exit $loaded
@@ -1,831 +0,0 @@
#!/usr/bin/env bash
# Foreground Omarchy ISO install UI supervisor.
#
# This process is the only owner of the visible install UI. It launches the
# actual installer as a non-interactive child, sends child output to the support
# log, renders progress while the child runs, then renders the final installed
# time + reboot prompt itself. There is deliberately no background dashboard.
set -euo pipefail
usage() {
echo "Usage: $0 <log-file> <state-file> -- <installer command...>" >&2
exit 2
}
(( $# >= 4 )) || usage
LOG_FILE="$1"
STATE_FILE="$2"
shift 2
[[ ${1:-} == "--" ]] || usage
shift
(( $# > 0 )) || usage
INSTALL_CMD=("$@")
TTY_PATH="${OMARCHY_DASHBOARD_TTY:-/dev/tty}"
LOGO_PATH="${OMARCHY_PATH:-/usr/share/omarchy}/logo.txt"
# Autoinstall has no one at the keyboard, and every prompt this dashboard owns
# reads from the TTY, so each would hang forever. Suppress them here rather than
# in the scripts they live in: this process is the only owner of the visible UI.
interactive() { [[ ${OMARCHY_UI_INTERACTIVE:-} != "no" ]]; }
CSI=$'\033['
RESET="${CSI}0m"
DIM="${CSI}2m"
HIDE_CURSOR="${CSI}?25l"
SHOW_CURSOR="${CSI}?25h"
CLEAR="${CSI}2J${CSI}H"
CLEAR_LINE="${CSI}2K"
CLEAR_TO_END="${CSI}J"
GREEN="${CSI}32m"
WHITE="${CSI}37m"
RED="${CSI}31m"
DARK="${CSI}90m"
# The dashboard owns the final reboot prompt now, so keep the gum confirm
# styling that used to come from install/helpers/presentation.sh.
export GUM_CONFIRM_PROMPT_FOREGROUND="${GUM_CONFIRM_PROMPT_FOREGROUND:-6}"
export GUM_CONFIRM_SELECTED_FOREGROUND="${GUM_CONFIRM_SELECTED_FOREGROUND:-0}"
export GUM_CONFIRM_SELECTED_BACKGROUND="${GUM_CONFIRM_SELECTED_BACKGROUND:-2}"
export GUM_CONFIRM_UNSELECTED_FOREGROUND="${GUM_CONFIRM_UNSELECTED_FOREGROUND:-7}"
export GUM_CONFIRM_UNSELECTED_BACKGROUND="${GUM_CONFIRM_UNSELECTED_BACKGROUND:-0}"
child_pid=""
child_pgid=""
tips=(
"Super + Space opens the Omarchy menu for apps, settings, and more"
"Super + K shows all the key bindings"
"Super is the Windows or command key on your keyboard"
"Use Xournal++ to sign PDFs"
"Share files with phones and laptops using LocalSend"
"Turn any website into an app with Install > Web App in the menu"
"Super + Return opens a terminal, Super + Shift + Return the browser"
"Edit images with Pinta, videos with Kdenlive, docs with LibreOffice"
"Super + Ctrl + Print grabs text off the screen with OCR"
"Print takes a screenshot, Alt + Print records the screen"
"Set a reminder with Super + Ctrl + R"
"Switch themes from Style > Theme in the Omarchy menu"
"Double-click the menu bar to make it transparent"
"Run a full Windows VM via Install > Windows in the menu"
"Super + Ctrl + V opens the clipboard manager"
"Super + 1 through 0 switches workspaces, add Shift to bring the window"
"Super + Print picks a color from anywhere on screen"
"Keep the system fresh with Update in the Omarchy menu"
)
cleanup() {
printf '%s%s' "$RESET" "$SHOW_CURSOR" >"$TTY_PATH" 2>/dev/null || true
}
dashboard_log() {
printf '[dashboard] %s\n' "$*" >>"$LOG_FILE" 2>/dev/null || true
}
stop_child() {
[[ -n ${child_pid:-} ]] || return 0
if kill -0 "$child_pid" 2>/dev/null; then
if [[ -n ${child_pgid:-} ]]; then
kill -- -"$child_pgid" 2>/dev/null || true
fi
kill "$child_pid" 2>/dev/null || true
for _ in {1..20}; do
kill -0 "$child_pid" 2>/dev/null || break
sleep 0.05
done
if kill -0 "$child_pid" 2>/dev/null; then
[[ -n ${child_pgid:-} ]] && kill -9 -- -"$child_pgid" 2>/dev/null || true
kill -9 "$child_pid" 2>/dev/null || true
fi
fi
wait "$child_pid" 2>/dev/null || true
}
interrupt() {
local code="$1"
stop_child
cleanup
exit "$code"
}
trap cleanup EXIT
trap 'interrupt 130' INT
trap 'interrupt 143' TERM
term_size() { stty size 2>/dev/null <"$TTY_PATH" || printf '24 80\n'; }
term_cols() { local size; size="$(term_size)"; printf '%s\n' "${size#* }"; }
repeat() {
local char="$1" count="$2" out="" i
for ((i=0; i<count; i++)); do out+="$char"; done
printf '%s' "$out"
}
logo_width() {
if [[ -f $LOGO_PATH ]]; then
# The logo is drawn in box-drawing glyphs, three bytes each in UTF-8, so
# awk's `length` returns 197 rather than 81 unless the locale happens to be
# UTF-8 — and a width wider than the console makes left_padding clamp to
# zero, which puts the logo flush left. Count bytes deliberately (LC_ALL=C)
# and drop the continuation bytes instead, so the answer is columns in every
# locale rather than only in the ones that have a charset.
LC_ALL=C awk '
{ gsub(/[\200-\277]/, ""); if (length > max) max = length }
END { print max+0 }
' "$LOGO_PATH"
else
printf '81\n'
fi
}
logo_height() {
if [[ -f $LOGO_PATH ]]; then
wc -l <"$LOGO_PATH"
else
printf '1\n'
fi
}
LOGO_WIDTH="$(logo_width)"
LOGO_HEIGHT="$(logo_height)"
(( LOGO_WIDTH > 0 )) || LOGO_WIDTH=81
(( LOGO_HEIGHT > 0 )) || LOGO_HEIGHT=1
CONTENT_WIDTH="$LOGO_WIDTH"
# Rows the progress screen occupies: logo, blank, title, blank, bar, blank, tip.
PROGRESS_HEIGHT=$((LOGO_HEIGHT + 6))
TOP_ROW=1
DYNAMIC_ROW=$((LOGO_HEIGHT + 3))
LAST_SIZE=""
LAST_PM=10
# The finish screen mirrors the greeter's frame — logo, blank, one line, gap,
# one button — so the last thing the installer shows matches the first. It runs
# one row taller: `gum confirm` always paints prompt/blank/button and clears all
# three, so the button cannot sit closer than two rows under a title we drew.
FINISH_HEIGHT=$((LOGO_HEIGHT + 5))
FINISH_TOP_ROW=2
# ── Install progress ─────────────────────────────────────────────────────────
#
# Position = max(floor, work), both non-decreasing, in per-mille of the bar.
#
# floor lo + (span-1) * t / (t+tau), on time since this process saw the
# phase. Asymptotic, so it approaches the band end without reaching it.
# tau is a shape constant, not a duration prediction.
# work packages installed / PKG_TOTAL, for the package phase only (~85% of
# the install). libalpm makes one directory per package under
# <target>/var/lib/pacman/local; the denominator comes from
# build-iso.sh, so there is no second package list to drift.
#
# Per-mille because a whole-percent asymptote over a 2-point band has two
# reachable values and stops moving. No give-up predicate: two discontinuous
# curves behind a monotonic clamp freeze. With no signal the floor alone still
# finishes the band.
EXPECTED_PACKAGES_FILE="${OMARCHY_EXPECTED_PACKAGES_FILE:-/usr/share/omarchy-iso/expected-packages}"
PKG_TOTAL=0
if [[ -r $EXPECTED_PACKAGES_FILE ]]; then
read -r PKG_TOTAL _ <"$EXPECTED_PACKAGES_FILE" 2>/dev/null || PKG_TOTAL=0
fi
[[ $PKG_TOTAL =~ ^[0-9]+$ ]] || PKG_TOTAL=0
# A wrong denominator is worse than none: it never triggers the fallback.
(( PKG_TOTAL < 300 || PKG_TOTAL > 5000 )) && PKG_TOTAL=0
PKG_BONUS_MAX_PM=100 # cap on how far the bar may run ahead of the count
PKG_TAIL_PM=40 # band held back so a stalled bar has somewhere to go
PKG_FLOOR_TAU=900 # with a work signal, time is only a backstop until the
# first package, so slow it until it cannot outrun data
POS=10 # position, per-mille, monotonic
PROGRESS_PM=10
NOW=0 # monotonic seconds, refreshed once per frame
DASH_T0=0
PHASE_NAME="" # last phase this process observed
PHASE_T0=0 # NOW when it was first observed
PHASE_LO=10 # POS on entry, for an unknown phase with no index
PKG_DB_DIR=""
PKG_COUNT=0
PKG_BASE=-1 # package count when the phase started
PKG_SEEN=0 # running max of packages since PKG_BASE
PKG_LAST_CHANGE=0
PKG_BONUS=0
# Monotonic clock: tzupdate and systemd-timesyncd can step the wall clock
# mid-install, which would jump every time-driven band to its ceiling.
set_now() {
local up rest
if read -r up rest </proc/uptime 2>/dev/null && [[ $up == [0-9]* ]]; then
NOW=${up%%.*}
else
NOW=$EPOCHSECONDS
fi
return 0
}
left_padding() {
local width="${1:-$CONTENT_WIDTH}" cols pad
cols="$(term_cols)"
pad=$(( (cols - width) / 2 ))
(( pad < 0 )) && pad=0
printf '%*s' "$pad" ''
}
visible_len() {
local text="$1"
text="$(printf '%b' "$text" | sed -E $'s/\x1b\\[[0-9;?]*[A-Za-z]//g')"
printf '%s' "${#text}"
}
blank_line() { printf '\r%s\n' "$CLEAR_LINE"; }
line_at() {
local width="${1:-$CONTENT_WIDTH}" indent="${2:-0}"
shift 2
printf '\r%s%s%*s' "$CLEAR_LINE" "$(left_padding "$width")" "$indent" ''
printf '%b' "$*"
}
center() {
local text="$1" width="${2:-$CONTENT_WIDTH}" len inner_pad
len="$(visible_len "$text")"
inner_pad=$(( (width - len) / 2 ))
(( inner_pad < 0 )) && inner_pad=0
printf '\r%s%s%*s%b%s\n' "$CLEAR_LINE" "$(left_padding "$width")" "$inner_pad" '' "$text" "$RESET"
}
render_logo() {
local pad
pad="$(left_padding "$LOGO_WIDTH")"
if [[ -f $LOGO_PATH ]]; then
while IFS= read -r line; do
printf '\r%s%s%b%s%b\n' "$CLEAR_LINE" "$pad" "$GREEN" "$line" "$RESET"
done <"$LOGO_PATH"
else
center "${GREEN}Omarchy${RESET}" "$CONTENT_WIDTH"
fi
}
progress_bar() {
local pm="$1" width="${2:-34}" filled empty
(( pm < 0 )) && pm=0
(( pm > 1000 )) && pm=1000
filled=$(( pm * width / 1000 ))
empty=$(( width - filled ))
printf '%s%s%s%s%s' "$WHITE" "$(repeat █ "$filled")" "$DARK" "$(repeat ░ "$empty")" "$RESET"
}
# Packages libalpm has finished installing. local/ holds one directory per
# package and nothing else in it is a directory, so */ is exact. One glob, no
# forks: this runs twice a second while the disk is saturated.
count_packages() {
local -a entries
PKG_COUNT=0
[[ -n $PKG_DB_DIR ]] || return 0
entries=("$PKG_DB_DIR"/*/)
# Unmatched globs expand to the pattern; nullglob stays off script-wide.
if (( ${#entries[@]} == 1 )) && [[ ! -d ${entries[0]} ]]; then
return 0
fi
PKG_COUNT=${#entries[@]}
return 0
}
# Sets PROGRESS_PM. Never call in a command substitution: POS, the phase timer
# and the package counters must survive between frames. Print nothing — output
# here would land on the TTY between the cursor escape and the bar.
install_progress() {
local phase finished index total target
local lo hi tau span t pos work gap n pkg_signal=0
local -a state
PROGRESS_PM=$POS
[[ -f $STATE_FILE ]] || return 0
mapfile -t state < <(jq -r '
(.current_phase // "Starting installation"),
((.finished_at // 0) | tonumber? // 0 | floor),
((.current_index // -1) | tonumber? // -1 | floor),
((.total_phases // 0) | tonumber? // 0 | floor),
(.target // "/mnt")
' "$STATE_FILE" 2>/dev/null)
(( ${#state[@]} == 5 )) || return 0
phase="${state[0]}" finished="${state[1]}"
index="${state[2]}" total="${state[3]}" target="${state[4]}"
[[ -n $target ]] || target="/mnt"
if (( finished > 0 )) || [[ $phase == "Installation complete" ]]; then
POS=1000
PROGRESS_PM=1000
return 0
fi
# Transitions are observed here rather than read from the state file, which
# keeps wall clock out of the dashboard entirely.
if [[ $phase != "$PHASE_NAME" ]]; then
PHASE_NAME="$phase"
PHASE_T0=$NOW
PHASE_LO=$POS
PKG_BASE=-1
PKG_SEEN=0
PKG_BONUS=0
PKG_LAST_CHANGE=$NOW
fi
# Bands in per-mille, plus tau. The tail bands are wide relative to what they
# cost on NVMe: run_system_finalizer can DKMS-build a driver and
# finalize_limine_boot rebuilds every UKI, so they vary most on real hardware.
# Over-allotting costs a step at a boundary; under-allotting costs a stall.
case "$phase" in
"Starting installation") lo=10 hi=25 tau=5 ;;
"Preparing live environment") lo=25 hi=50 tau=25 ;;
"Preparing install target") lo=50 hi=65 tau=20 ;;
"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 ;;
"Staging provisioning") 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=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
# renamed phase degrades to one cell rather than a runaway.
if (( total >= 2 && index >= 0 && index < total )); then
lo=$(( 10 + 990 * index / total ))
hi=$(( 10 + 990 * (index + 1) / total ))
else
lo=$PHASE_LO
hi=$(( PHASE_LO + 30 ))
fi
tau=60
;;
esac
(( pkg_signal && PKG_TOTAL > 0 )) || pkg_signal=0
(( pkg_signal )) && tau=$PKG_FLOOR_TAU
t=$(( NOW - PHASE_T0 ))
(( t < 0 )) && t=0
span=$(( hi - lo ))
(( span < 2 )) && span=2
(( tau < 1 )) && tau=1
pos=$(( lo + (span - 1) * t / (t + tau) ))
if (( pkg_signal )); then
PKG_DB_DIR="$target/var/lib/pacman/local"
count_packages
# Baseline on first sight: a target that already holds packages would
# otherwise pin the bar at the band ceiling for the whole phase.
(( PKG_BASE < 0 )) && PKG_BASE=$PKG_COUNT
n=$(( PKG_COUNT - PKG_BASE ))
(( n < 0 )) && n=0
# Running max: a reinstalled package briefly drops its version-stamped dir.
if (( n > PKG_SEEN )); then
PKG_SEEN=$n
PKG_LAST_CHANGE=$NOW
fi
gap=$(( NOW - PKG_LAST_CHANGE ))
(( gap < 0 )) && gap=0
(( gap > PKG_BONUS_MAX_PM )) && gap=$PKG_BONUS_MAX_PM
(( gap > PKG_BONUS )) && PKG_BONUS=$gap
work=$(( lo + (span - 1 - PKG_TAIL_PM) * PKG_SEEN / PKG_TOTAL + PKG_BONUS ))
(( work > pos )) && pos=$work
fi
(( pos > hi - 1 )) && pos=$(( hi - 1 ))
(( pos > POS )) && POS=$pos
(( POS > 1000 )) && POS=1000
PROGRESS_PM=$POS
return 0
}
# One tip per 8 seconds, in order. On elapsed time, not a frame counter: a frame
# is 0.5s of sleep plus a render, which stretches under pacstrap I/O — on the
# machine where the tips, not the bar, are carrying liveness.
current_tip() {
printf '%s' "${tips[$(( (NOW - DASH_T0) / 8 % ${#tips[@]} ))]}"
}
# Vertically center a block of the given height, echoing the row it starts on.
# Both screens here center the way the configurator's greeter centers its
# welcome, so the install opens, runs, and closes on the same composition
# rather than stacking each frame from row one.
centered_top_row() {
local height="$1" rows top
rows="$(term_size)"
rows="${rows%% *}"
[[ $rows =~ ^[0-9]+$ ]] || rows=24
top=$(( (rows - height) / 2 ))
(( top < 0 )) && top=0
printf '%d' "$(( top + 1 ))"
}
measure_progress_layout() {
LAST_SIZE="$(term_size)"
TOP_ROW="$(centered_top_row "$PROGRESS_HEIGHT")"
DYNAMIC_ROW=$(( TOP_ROW + LOGO_HEIGHT + 1 ))
}
render_static() {
measure_progress_layout
printf '%s%s%d;1H' "$CLEAR" "$CSI" "$TOP_ROW"
render_logo
blank_line
}
render_dynamic() {
local pm
# The console can still resize under us (the framebuffer settles late in VMs).
# Horizontal padding is recomputed every frame anyway, but the logo is drawn
# once, so redraw the static half at the new center rather than leave the
# centered layout stranded against a stale geometry.
[[ "$(term_size)" == "$LAST_SIZE" ]] || render_static
set_now
install_progress
pm=$PROGRESS_PM
[[ $pm =~ ^[0-9]+$ ]] || pm=$LAST_PM
(( pm > 1000 )) && pm=1000
# POS is monotone by construction; this is a guard, not the mechanism.
(( pm < LAST_PM )) && pm=$LAST_PM
LAST_PM=$pm
printf '%s%d;1H' "$CSI" "$DYNAMIC_ROW"
center "Installing Omarchy" "$CONTENT_WIDTH"
blank_line
line_at "$CONTENT_WIDTH" $(( (CONTENT_WIDTH - 34) / 2 )) ""
progress_bar "$pm" 34
printf '\n'
blank_line
center "${DIM}Tip:${RESET} ${GREEN}$(current_tip)${RESET}" "$CONTENT_WIDTH"
printf '%s' "$CLEAR_TO_END"
}
install_duration() {
local path line duration
if [[ -f $STATE_FILE ]]; then
duration="$(jq -r '
((.started_at // 0) | tonumber? // 0) as $started
| ((.finished_at // 0) | tonumber? // 0) as $finished
| if $started <= 0 or $finished <= 0 or $finished < $started then empty
else (($finished - $started) | round) as $secs
| if $secs >= 3600 then "\($secs / 3600 | floor)h \($secs % 3600 / 60 | floor)m \($secs % 60)s"
else "\($secs / 60 | floor)m \($secs % 60)s"
end
end
' "$STATE_FILE" 2>/dev/null || true)"
[[ -n $duration ]] && { printf '%s' "$duration"; return 0; }
fi
for path in /mnt/var/log/omarchy-install.log "$LOG_FILE"; do
[[ -f $path ]] || continue
for prefix in Total Omarchy; do
line="$(grep "^$prefix:" "$path" 2>/dev/null | tail -n 1 || true)"
[[ -n $line ]] || continue
duration="${line#*:}"
duration="${duration#${duration%%[![:space:]]*}}"
[[ -n $duration ]] && { printf '%s' "$duration"; return 0; }
done
done
return 0
}
render_finish() {
local duration effect_canvas_width
duration="$(install_duration || true)"
duration="${duration:-Complete}"
# ttfx centers its text two columns right of plain centering, so hand it a
# canvas two columns narrower and the animated logo lands exactly on the
# static one left_padding already drew. Otherwise the logo jumps a column
# sideways when the effect starts and again when it ends.
effect_canvas_width="$(term_cols)"
(( effect_canvas_width > 2 )) && effect_canvas_width=$((effect_canvas_width - 2))
# Set before anything that can fail, so reboot_prompt still lands on the
# button row even if the logo or the effect below never renders.
FINISH_TOP_ROW="$(centered_top_row "$FINISH_HEIGHT")"
{
printf '%s%s%s%d;1H' "$SHOW_CURSOR" "$CLEAR" "$CSI" "$FINISH_TOP_ROW"
render_logo
blank_line
center "Installed Omarchy in ${duration}" "$CONTENT_WIDTH"
blank_line
} >"$TTY_PATH"
if [[ -f $LOGO_PATH ]]; then
# Reuse mode restores a saved cursor before moving up over its input. Save
# this anchor so the effect redraws the same logo rows shown above.
printf '%s%d;1H\0337' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT))" >"$TTY_PATH"
# Laseretch runs ~4s at 260fps, so the 8s cap is a stuck-process guard rather
# than a trim. --xterm-colors keeps the whole effect in the 256-index palette
# the way the greeter does, since the console reduces indexed codes well and
# truecolor ones badly, and it settles on indexed 2 — the green gum already
# drew the logo in above, so the send-off ends on the frame it started from
# instead of recoloring it. The laser and sparks keep their own hot palette.
timeout 8s ttfx -i "$LOGO_PATH" \
--canvas-width "$effect_canvas_width" \
--anchor-text c \
--frame-rate 260 \
--reuse-canvas \
--xterm-colors \
laseretch \
--final-gradient-stops 2 \
>"$TTY_PATH" 2>/dev/null || dashboard_log "logo effect skipped (ttfx exited $?)"
printf '%s%d;1H' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 5))" >"$TTY_PATH"
fi
}
reboot_prompt() {
local prompt_pad
# Nobody to confirm; the OMARCHY_UI_AUTO_REBOOT check at the call site still
# applies, so setting both stops on the finish screen without a keyboard.
interactive || return 0
# gum paints its prompt row at the cursor and the button two rows under it, so
# anchor on the row right below the title to put the button as close to it as
# gum allows. FINISH_HEIGHT budgets for the same three rows.
printf '%s%d;1H%s' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 2))" "$CLEAR_LINE" >"$TTY_PATH"
prompt_pad=$(( ( $(term_cols) - 18 ) / 2 ))
(( prompt_pad < 0 )) && prompt_pad=0
gum confirm \
--padding "0 0 0 $prompt_pad" \
--show-help=false \
--default \
--affirmative "Reboot Now" \
--negative "" \
"" <"$TTY_PATH" >"$TTY_PATH" 2>&1
}
truncate_for_width() {
local text="$1" width="$2"
if (( ${#text} > width )); then
printf '%s…' "${text:0:$((width - 1))}"
else
printf '%s' "$text"
fi
}
render_failure_log_tail() {
local rows lines width line tail_log label
rows=$(term_size)
rows=${rows%% *}
lines=$((rows - LOGO_HEIGHT - 18))
(( lines < 6 )) && lines=6
(( lines > 14 )) && lines=14
width=$((CONTENT_WIDTH - 4))
(( width < 40 )) && width=40
tail_log="${OMARCHY_FAILURE_TAIL_LOG:-}"
if [[ -z $tail_log ]]; then
if [[ -f /mnt/var/log/omarchy-install.log ]]; then
tail_log=/mnt/var/log/omarchy-install.log
else
tail_log="$LOG_FILE"
fi
fi
[[ -f $tail_log ]] || return 0
label="Last log lines:"
[[ $tail_log == /mnt/* ]] && label="Last target log lines:"
line_at "$CONTENT_WIDTH" 2 "${DIM}${label}${RESET}"
printf '\n'
tail -n "$lines" "$tail_log" 2>/dev/null | while IFS= read -r line; do
line_at "$CONTENT_WIDTH" 2 "$(truncate_for_width "$line" "$width")"
printf '\n'
done
}
find_log_uploader() {
local candidate
for candidate in omarchy-upload-log /usr/local/bin/omarchy-upload-log /usr/bin/omarchy-upload-log /mnt/usr/bin/omarchy-upload-log; do
if [[ $candidate == */* ]]; then
[[ -x $candidate ]] && { printf '%s' "$candidate"; return 0; }
elif command -v "$candidate" >/dev/null 2>&1; then
command -v "$candidate"
return 0
fi
done
return 1
}
prompt_enter() {
printf '\nPress Enter to continue…' >"$TTY_PATH"
IFS= read -r _ <"$TTY_PATH" || true
}
upload_failure_log() {
local uploader
printf '%s%s' "$SHOW_CURSOR" "$CLEAR" >"$TTY_PATH" 2>/dev/null || true
if uploader=$(find_log_uploader); then
"$uploader" install >"$TTY_PATH" 2>&1 || true
else
{
echo "No log uploader is available in this environment."
echo "Install log: $LOG_FILE"
[[ -f /mnt/var/log/omarchy-install.log ]] && echo "Target log: /mnt/var/log/omarchy-install.log"
} >"$TTY_PATH"
fi
prompt_enter
}
view_failure_log() {
printf '%s%s' "$SHOW_CURSOR" "$CLEAR" >"$TTY_PATH" 2>/dev/null || true
if command -v less >/dev/null 2>&1 && [[ -f $LOG_FILE ]]; then
less "$LOG_FILE" <"$TTY_PATH" >"$TTY_PATH" 2>&1 || true
else
tail -n 200 "$LOG_FILE" >"$TTY_PATH" 2>&1 || true
prompt_enter
fi
}
render_failure() {
local status="$1" state_summary="${2:-}" line
printf '%s%s' "$SHOW_CURSOR" "$CLEAR"
blank_line
render_logo
blank_line
center "${RED}Omarchy installation stopped${RESET}" "$CONTENT_WIDTH"
center "Installer exited with status $status" "$CONTENT_WIDTH"
if [[ -n $state_summary ]]; then
blank_line
while IFS= read -r line; do
[[ -n $line ]] || continue
center "${DIM}$(truncate_for_width "$line" "$CONTENT_WIDTH")${RESET}" "$CONTENT_WIDTH"
done <<<"$state_summary"
fi
blank_line
render_failure_log_tail
blank_line
center "${DIM}Get help at https://omarchy.org/discord${RESET}" "$CONTENT_WIDTH"
blank_line
}
failure_menu() {
local choice uploader
[[ ${OMARCHY_UI_FAILURE_ACTION:-} == "exit" ]] && return 0
# The failure screen and log tail have already rendered; exit with the
# installer's status instead of blocking on a menu nobody can answer.
interactive || return 0
while true; do
uploader=""
uploader=$(find_log_uploader || true)
if [[ -n $uploader ]]; then
choice=$(gum choose \
--height 6 \
--header "What would you like to do?" \
"Upload log for support" \
"View full log" \
"Drop to shell" \
"Reboot" \
"Power off" \
<"$TTY_PATH" 2>"$TTY_PATH") || choice="Drop to shell"
else
choice=$(gum choose \
--height 5 \
--header "What would you like to do?" \
"View full log" \
"Drop to shell" \
"Reboot" \
"Power off" \
<"$TTY_PATH" 2>"$TTY_PATH") || choice="Drop to shell"
fi
case "$choice" in
"Upload log for support")
upload_failure_log
render_failure "${failure_status:-1}" "${failure_summary:-}" >"$TTY_PATH" 2>/dev/null || true
;;
"View full log")
view_failure_log
render_failure "${failure_status:-1}" "${failure_summary:-}" >"$TTY_PATH" 2>/dev/null || true
;;
"Reboot")
reboot 2>/dev/null || systemctl reboot 2>/dev/null || true
;;
"Power off")
poweroff 2>/dev/null || systemctl poweroff 2>/dev/null || true
;;
"Drop to shell"|"")
return 0
;;
esac
done
}
sanitize_child_output() {
# Archinstall is only a library subsystem in Omarchy's installer. Its own
# post-install success/reboot banner can be buffered until process exit and
# appear after an Omarchy failure, which makes the unified log contradictory.
# Keep that third-party banner out of Omarchy's support log; the raw
# archinstall log is still uploaded separately by omarchy-upload-log.
sed -u \
-e $'s/\x1b\\[[0-9;?]*[A-Za-z]//g' \
-e '/^Activating systemd-timesyncd for time synchronization using Arch Linux and ntp.org NTP servers$/d' \
-e '/^Enabling service systemd-timesyncd$/d' \
-e '/^Setting password for root$/d' \
-e '/^Updating \/mnt\/etc\/fstab$/d' \
-e '/^Syncing the system\.\.\.$/d' \
-e '/^Installation completed without any errors\.$/d' \
-e '/^Log files temporarily available at \/var\/log\/archinstall\.$/d' \
-e '/^You may reboot when ready\.$/d'
}
launch_child() {
mkdir -p "$(dirname "$LOG_FILE")"
# Child is non-interactive. All visible UI belongs to this supervisor.
PYTHONUNBUFFERED=1 setsid "${INSTALL_CMD[@]}" </dev/null > >(sanitize_child_output >>"$LOG_FILE") 2>&1 &
child_pid=$!
child_pgid=$child_pid
}
[[ -e $TTY_PATH ]] || exit 2
printf '%s' "$HIDE_CURSOR" >"$TTY_PATH"
render_static >"$TTY_PATH"
set_now
DASH_T0=$NOW
PHASE_T0=$NOW
PKG_LAST_CHANGE=$NOW
launch_child
while jobs -pr | grep -qx "$child_pid"; do
render_dynamic >"$TTY_PATH" 2>/dev/null || true
sleep 0.5
done
set +e
wait "$child_pid"
status=$?
set -e
child_pid=""
child_pgid=""
dashboard_log "installer child exited with status $status"
if (( status == 0 )); then
# finished_at lands microseconds before the child exits, so the 0.5s poll
# rarely renders a full bar before the finish screen replaces it.
render_dynamic >"$TTY_PATH" 2>/dev/null || true
sleep 0.4
fi
if (( status != 0 )); then
failure_status="$status"
failure_summary=""
if [[ -f $STATE_FILE ]]; then
failure_summary="$(jq -r '
(.current_phase // empty | "last installer phase: \(.)"),
((.phases // []) | map(select(.status == "failed")) | last // empty
| "failed phase: \(.name): \(.error)")
' "$STATE_FILE" 2>/dev/null || true)"
if [[ -n $failure_summary ]]; then
while IFS= read -r line; do
[[ -n $line ]] && dashboard_log "$line"
done <<<"$failure_summary"
fi
fi
render_failure "$failure_status" "$failure_summary" >"$TTY_PATH" 2>/dev/null || true
failure_menu
exit "$failure_status"
fi
# Deferred-provisioning installs finish silently and reboot on their own — the
# owner completes setup (and sees the send-off) at first boot, so the "Installed
# Omarchy in ..." celebration and the Reboot Now prompt belong to a direct
# install only.
if [[ ${OMARCHY_UI_DEFER_PROVISIONING:-} == "yes" ]]; then
printf '%s' "$CLEAR" >"$TTY_PATH" 2>/dev/null || true
[[ ${OMARCHY_UI_AUTO_REBOOT:-} == "no" ]] && exit 0
reboot 2>/dev/null || systemctl reboot 2>/dev/null || true
exit 0
fi
set +e
render_finish
finish_render_status=$?
set -e
if (( finish_render_status != 0 )); then
dashboard_log "finish screen render failed with status $finish_render_status; using fallback"
{
printf '%s%s' "$SHOW_CURSOR" "$CLEAR"
printf '\nFinished installing\n\n'
} >"$TTY_PATH" 2>/dev/null || true
fi
if reboot_prompt; then
if [[ ${OMARCHY_UI_AUTO_REBOOT:-} == "no" ]]; then
exit 0
fi
printf '%s' "$CLEAR" >"$TTY_PATH" 2>/dev/null || true
reboot 2>/dev/null || systemctl reboot 2>/dev/null || true
fi
exit 0
@@ -1,47 +0,0 @@
#!/bin/bash
#
# Clean up any holders / mounts / LVM / crypt devices on the install disk
# before archinstall starts wiping. Idempotent — called once per install attempt
# from the orchestrator's cleanup_disk phase.
#
# Ported verbatim from .automated_script.sh's cleanup_install_disk(). Kept in
# bash because lsblk / findmnt / lvm / cryptsetup are all easier to drive
# here than from Python.
set -uo pipefail
if [[ ${1:-} == --protected ]]; then
target="${2:-}"
[[ -n $target ]] || { echo "Usage: $0 --protected <target>" >&2; exit 1; }
findmnt -R "$target" >/dev/null 2>&1 && umount -R "$target" || true
exit 0
fi
disk="${1:-}"
[[ -n $disk && -b $disk ]] || { echo "Need a block device argument; got '$disk'" >&2; exit 1; }
echo "Cleaning up existing holders on install disk: $disk"
findmnt -R /mnt >/dev/null && umount -R /mnt || true
while read -r dev; do
[[ -b $dev ]] || continue
swapoff "$dev" 2>/dev/null || true
while read -r target; do
[[ -n $target ]] && umount "$target" 2>/dev/null || true
done < <(findmnt -rn -S "$dev" -o TARGET 2>/dev/null || true)
done < <(lsblk -rnpo PATH "$disk")
while read -r dev type; do
[[ $type == disk || $type == part || $type == crypt ]] || continue
while read -r vg; do
[[ -n $vg ]] && vgchange -an "$vg" 2>/dev/null || true
done < <(pvs --noheadings -o vg_name "$dev" 2>/dev/null | awk '{$1=$1; print}' | sort -u)
done < <(lsblk -rnpo PATH,TYPE "$disk")
while read -r dev type; do
[[ $type == crypt ]] && cryptsetup close "$dev" 2>/dev/null || true
done < <(lsblk -rnpo PATH,TYPE "$disk")
blockdev --flushbufs "$disk" 2>/dev/null || true
partprobe "$disk" 2>/dev/null || true
udevadm settle || true
@@ -1,27 +0,0 @@
#!/bin/bash
#
# Live-ISO entry point for the Omarchy install orchestrator. Invoked from
# /root/.automated_script.sh after the configurator wizard has produced its
# user_*.json / user_*.txt files. The outer shim owns install logging.
set -eEo pipefail
# Parse our args; convert to env vars before exec'ing Python so archinstall's
# own argparse (which runs at module import time inside the orchestrator)
# doesn't choke on flags it doesn't recognize.
while [[ $# -gt 0 ]]; do
case $1 in
--config) export OMARCHY_INSTALL_CONFIG="$2"; shift 2 ;;
--creds) export OMARCHY_INSTALL_CREDS="$2"; shift 2 ;;
--full-name-file) export OMARCHY_INSTALL_FULL_NAME_FILE="$2"; shift 2 ;;
--email-file) export OMARCHY_INSTALL_EMAIL_FILE="$2"; shift 2 ;;
--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 ;;
--defer-provisioning-file) export OMARCHY_INSTALL_DEFER_PROVISIONING_FILE="$2"; shift 2 ;;
*) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;;
esac
done
cd /usr/share/omarchy-iso
exec python -m orchestrator.main
Whitespace-only changes.
@@ -1,246 +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.4 (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:
# guided.py:84-85 SKIPS this for DiskLayoutType.Pre_mount — pre-mounted
# configs do their own mounting before the Installer context opens.
if disk_config.config_type != DiskLayoutType.Pre_mount:
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.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 installs Omarchy's Limine files directly instead of invoking
archinstall's bootloader helper, so EFI paths and efibootmgr labels are ours
from the start.
"""
from __future__ import annotations
import importlib
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.disk.filesystem import FilesystemHandler
from archinstall.lib.disk.utils import get_parent_device_path, get_unique_path_for_device, udev_sync
from archinstall.lib.hardware import SysInfo
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
from .ui import info
def load_arch_config(config_path: Path, creds_path: Path) -> ArchConfigHandler:
"""Build an ArchConfigHandler from on-disk JSON.
archinstall's ArchConfigHandler reads --config / --creds from sys.argv
via argparse at construction time (lib/args.py:_parse_args). It does
NOT consult env vars for these paths. We can't pass them on our real
argv because the wrapper strips them before exec'ing Python (so other
archinstall arg-parsing code doesn't choke on our flags), so we hand
archinstall a synthetic argv just for this call.
"""
import sys
saved_argv = sys.argv
sys.argv = [
saved_argv[0] if saved_argv else "omarchy-iso-install",
"--config", str(config_path),
"--creds", str(creds_path),
]
try:
return ArchConfigHandler()
finally:
sys.argv = saved_argv
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.
parted's partition-table commit races udev: the disk wipe and the first
BLKPG partition registration trigger probes that briefly hold the disk
open, and the kernel then refuses to register the remaining partitions
(_ped.IOException: "Partition(s) ... have been written, but we have been
unable to inform the kernel"). The table is written correctly when that
happens — only the kernel's view is stale — so settle udev and redo the
operations. The wipe/partition/format sequence is idempotent."""
if not arch_config.disk_config:
raise RuntimeError("disk_config missing from arch config")
handler = FilesystemHandler(arch_config.disk_config)
# archinstall's perform_filesystem_operations defaults to a ~4.5s
# "5...4...3...2...1" countdown (FilesystemHandler._final_warning) before it
# wipes the disk. That warning is meant for its interactive TUI; in our
# orchestrated install it renders nowhere and just sleeps. Suppress it when
# the running archinstall still takes the flag — newer releases dropped both
# the countdown and the parameter, so only pass it when it's accepted.
fs_kwargs = (
{"show_countdown": False}
if _method_accepts(handler.perform_filesystem_operations, "show_countdown")
else {}
)
attempts = 3
for attempt in range(1, attempts + 1):
udev_sync()
try:
handler.perform_filesystem_operations(**fs_kwargs)
return
except Exception as exc:
if attempt == attempts or "unable to inform the kernel" not in str(exc):
raise
info(f"› partition commit lost a udev race (attempt {attempt}/{attempts}); retrying")
@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(
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 bootloader_enabled(arch_config: ArchConfig) -> bool:
bl = arch_config.bootloader_config
return bool(bl and bl.bootloader != Bootloader.NO_BOOTLOADER)
def is_limine(arch_config: ArchConfig) -> bool:
bl = arch_config.bootloader_config
return bool(bl and bl.bootloader == Bootloader.Limine)
def has_uefi() -> bool:
return SysInfo.has_uefi()
def parent_device_path(dev_path: Path) -> Path:
return get_parent_device_path(dev_path)
def unique_device_path(dev_path: Path) -> Path | None:
return get_unique_path_for_device(dev_path)
def _application_handler():
"""Return archinstall's application handler across archinstall versions."""
module = importlib.import_module("archinstall.lib.applications.application_handler")
handler = getattr(module, "application_handler", None)
if handler is not None and callable(getattr(handler, "install_applications", None)):
return handler
handler_class = getattr(module, "ApplicationHandler", None)
if handler_class is None:
raise RuntimeError("archinstall application handler is unavailable")
try:
handler = handler_class()
except TypeError as exc:
raise RuntimeError("archinstall ApplicationHandler cannot be constructed") from exc
if not callable(getattr(handler, "install_applications", None)):
raise RuntimeError("archinstall ApplicationHandler has no install_applications method")
return handler
def _method_accepts(method, name: str) -> bool:
"""Return whether a bound archinstall method accepts a named argument.
Do not use inspect.signature here: Python 3.14 may evaluate archinstall's
lazy annotations, and some archinstall releases annotate with names that are
only imported under TYPE_CHECKING.
"""
fn = getattr(method, "__func__", method)
code = getattr(fn, "__code__", None)
if code is None:
return True
positional = code.co_varnames[:code.co_argcount]
kwonly = code.co_varnames[code.co_argcount:code.co_argcount + code.co_kwonlyargcount]
return name in (*positional, *kwonly)
def _method_accepts_users(method) -> bool:
return _method_accepts(method, "users")
def install_applications(installer: Installer, arch_config: ArchConfig) -> None:
"""Install archinstall application selections such as PipeWire audio.
The configurator still writes archinstall's audio_config. We own phase
ordering now, but should not silently drop archinstall's hardware-aware
application installers (SOF/ALSA firmware detection, PipeWire packages,
Bluetooth selections, etc.).
"""
app_config = arch_config.app_config
if not app_config:
return
users = arch_config.auth_config.users if arch_config.auth_config else None
handler = _application_handler()
install_applications_method = handler.install_applications
if _method_accepts_users(install_applications_method):
install_applications_method(installer, app_config, users)
else:
install_applications_method(installer, app_config)
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)
@@ -1,210 +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
import os
import secrets
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
authorized_keys_path: Path | None
tailscale_authkey_path: Path | None
user_configuration: dict
user_credentials: dict
arch_config_path: Path
omarchy_install: dict[str, Any]
defer_provisioning: bool = False
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_env(cls) -> "InstallContext":
config_str = os.environ.get("OMARCHY_INSTALL_CONFIG")
creds_str = os.environ.get("OMARCHY_INSTALL_CREDS")
if not config_str or not creds_str:
raise RuntimeError("OMARCHY_INSTALL_CONFIG and OMARCHY_INSTALL_CREDS must be set")
config_path = Path(config_str)
creds_path = Path(creds_str)
user_configuration = json.loads(config_path.read_text())
omarchy_install = user_configuration.get("omarchy_install") or _default_omarchy_install(user_configuration)
# Deferred provisioning: the whole system installs but user creation is deferred to
# first boot. Selected by the configurator (omarchy_install.defer_provisioning) or by
# an `defer-provisioning` marker file on an autoinstall drive, which also replaces the
# user_credentials.json requirement.
defer_provisioning_marker = _optional_path(os.environ.get("OMARCHY_INSTALL_DEFER_PROVISIONING_FILE"))
defer_provisioning = bool(omarchy_install.get("defer_provisioning")) or defer_provisioning_marker is not None
omarchy_install["defer_provisioning"] = defer_provisioning
if creds_path.exists():
user_credentials = json.loads(creds_path.read_text())
elif defer_provisioning:
user_credentials = {"users": []}
else:
raise RuntimeError(f"credentials file missing: {creds_path}")
if defer_provisioning:
# Deferred provisioning promises no account until first boot. A supplied
# credentials file (a rig handing over its LUKS passphrase) must
# not smuggle in users or a root password.
encryption_password = user_credentials.get("encryption_password")
user_credentials = {"users": []}
if encryption_password:
user_credentials["encryption_password"] = encryption_password
arch_configuration = dict(user_configuration)
arch_configuration.pop("omarchy_install", None)
if defer_provisioning:
# The userless invariant covers the archinstall config too: a
# combined/legacy config could carry account authentication that
# archinstall would still act on (users, a root password). Strip
# every such field so no account is created before first boot.
_strip_account_fields(arch_configuration)
state_dir = Path(os.environ.get("OMARCHY_INSTALL_STATE_DIR", "/run/omarchy-install"))
state_dir.mkdir(parents=True, exist_ok=True)
if defer_provisioning:
# Encrypted deferred-provisioning installs have no user password to protect LUKS
# with. Generate a throwaway passphrase (staged for first-boot
# re-key by the stage_provisioning_state phase) and hand it to archinstall
# through both places it may look: the disk_encryption block and
# the credentials file.
_inject_provisioning_encryption_password(arch_configuration, user_credentials)
creds_path = state_dir / "provisioning-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")
ctx = cls(
config_path=config_path,
creds_path=creds_path,
full_name=_read_text(os.environ.get("OMARCHY_INSTALL_FULL_NAME_FILE")),
email=_read_text(os.environ.get("OMARCHY_INSTALL_EMAIL_FILE")),
encrypt=_read_text(os.environ.get("OMARCHY_INSTALL_ENCRYPT_FILE")).lower() in ("true", "yes", "1"),
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=user_credentials,
arch_config_path=arch_config_path,
omarchy_install=omarchy_install,
defer_provisioning=defer_provisioning,
state_dir=state_dir,
)
disk_config = user_configuration.get("disk_config", {})
target_mount = omarchy_install.get("target_mount") or disk_config.get("mountpoint")
if target_mount:
ctx.target = Path(target_mount)
return ctx
@property
def username(self) -> str:
users = self.user_credentials.get("users") or []
if users:
return users[0]["username"]
if self.defer_provisioning:
return ""
raise RuntimeError("user_credentials.json contains no users")
@property
def mode(self) -> str:
if mode := self.omarchy_install.get("mode"):
return mode
cfg_type = self.user_configuration.get("disk_config", {}).get("config_type")
return "protected" if cfg_type == "pre_mounted_config" else "full_disk"
@property
def is_protected(self) -> bool:
return self.mode == "protected"
def _strip_account_fields(arch_configuration: dict) -> None:
"""Remove every field archinstall reads to create users or set a root
password, so it cannot ship a hidden provisioning account.
Encryption material lives elsewhere (disk_config.disk_encryption) and is
untouched."""
for key in ("!users", "!root-password", "root_enc_password", "users"):
arch_configuration.pop(key, None)
auth = arch_configuration.get("auth_config")
if isinstance(auth, dict):
for key in ("users", "root_enc_password"):
auth.pop(key, None)
def _inject_provisioning_encryption_password(arch_configuration: dict, user_credentials: dict) -> None:
"""Ensure an encrypted deferred-provisioning 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_provisioning_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"
return {
"mode": mode,
"target_mount": disk_config.get("mountpoint") or "/mnt",
"boot": {
"esp_mount": "/boot",
"esp_path": "/EFI/limine",
"efi_binary": "limine_x64.efi",
"enable_fallback": mode == "full_disk",
},
"storage": {},
}
def _optional_path(path: str | None) -> Path | None:
"""A path the caller always passes but that need not exist. The live ISO
passes --authorized-keys-file on every install; only autoinstall drives put
a file there."""
if not path:
return None
p = Path(path)
return p if p.exists() else None
def _read_text(path: str | None) -> str:
if not path:
return ""
p = Path(path)
if not p.exists():
return ""
return p.read_text().strip()
@@ -1,63 +0,0 @@
"""Offline target keyboard configuration.
archinstall's set_keyboard_language boots the installed system in a
systemd-nspawn container just to run localectl. systemd-firstboot --root
produces the part of that output Omarchy actually consumes — KEYMAP for the
console plus the XKB* settings in vconsole.conf that omarchy's
detect-keyboard-layout.sh copies into Hyprland's kb_layout — without booting
anything. The Xorg 00-keyboard.conf that localectl also writes is not
generated: nothing on an Omarchy system reads it.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
def configure_keyboard(target: Path, language: str) -> bool:
"""Write the console keymap into a mounted target without booting it.
Returns False for layouts localectl doesn't know, matching archinstall:
warn and keep the default. Every layout the configurator offers is known;
the guard is for the kb_layout an autoinstall drive can name freely.
"""
if not language.strip():
return True
result = subprocess.run(
["localectl", "--no-pager", "list-keymaps"],
check=False,
text=True,
capture_output=True,
)
if result.returncode != 0:
detail = result.stderr.strip() or "localectl returned an error"
raise RuntimeError(f"Unable to list keyboard layouts: {detail}")
if language.lower() not in {layout.lower() for layout in result.stdout.splitlines()}:
return False
# systemd-firstboot --force rewrites vconsole.conf wholesale, dropping the
# FONT= line archinstall's set_vconsole wrote earlier.
vconsole_path = target / "etc" / "vconsole.conf"
font = None
if vconsole_path.exists():
font = next(
(line for line in vconsole_path.read_text().splitlines() if line.startswith("FONT=")),
None,
)
result = subprocess.run(
["systemd-firstboot", f"--root={target}", f"--keymap={language}", "--force"],
check=False,
text=True,
capture_output=True,
)
if result.returncode != 0:
detail = result.stderr.strip() or "systemd-firstboot returned an error"
raise RuntimeError(f"Unable to configure keyboard layout {language}: {detail}")
if font:
vconsole_path.write_text(vconsole_path.read_text() + font + "\n")
return True
@@ -1,111 +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).
The live-ISO wrapper consumes CLI args and passes configuration paths via
OMARCHY_INSTALL_* environment variables before Python starts. This keeps
archinstall's import-time CLI parsing from seeing Omarchy-specific flags.
"""
from __future__ import annotations
import sys
from .context import InstallContext
from .phases import PhaseError, run
from .ui import error, info
def build_phases(ctx: InstallContext):
"""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.
Full-disk and protected installs use the same phase sequence. The
configurator only changes the JSON input: full-disk asks archinstall to
create/mount the layout, while protected provides an already-mounted target
and the partition details Omarchy needs for boot/fstab generation.
"""
from .phases_impl import (
prepare_live,
prepare_install_target,
arch_install_system,
configure_hibernation,
run_system_finalizer,
stage_provisioning_state,
finalize_limine_boot,
run_chroot_finalizer,
configure_dns_resolver,
configure_login,
configure_ssh_access,
configure_tailscale,
validate_boot,
create_factory_snapshot,
)
return [
("Preparing live environment", prepare_live),
("Preparing install target", prepare_install_target),
("Installing Arch + Omarchy", arch_install_system),
("Configuring hibernation", configure_hibernation),
("Configuring system", run_system_finalizer),
# Before finalize_limine_boot: the deferred-provisioning cryptkey drop-in and keyfile
# must be in place for the final UKI build.
("Staging provisioning", stage_provisioning_state),
("Finalizing Limine boot", finalize_limine_boot),
("Finalizing user", run_chroot_finalizer),
("Configuring login", configure_login),
("Configuring SSH access", configure_ssh_access),
("Configuring Tailscale", configure_tailscale),
("Configuring DNS resolver", configure_dns_resolver),
("Validating boot setup", validate_boot),
("Creating factory snapshot", create_factory_snapshot),
]
def main() -> int:
try:
ctx = InstallContext.from_env()
except RuntimeError as e:
error(f"Configuration error: {e}")
return 2
who = ctx.username or "deferred provisioning (user created at first boot)"
info(f"Installing Omarchy for {who} → {ctx.target}")
from .phases_impl import (
boost_cpu_governor,
cleanup_bind_mounts,
cleanup_protected_state,
cleanup_target_hook_masks,
restore_cpu_governors,
)
governors = boost_cpu_governor()
success = False
try:
try:
run(ctx, build_phases(ctx))
success = True
except PhaseError:
error("Installation halted.")
return 1
except KeyboardInterrupt:
error("Installation interrupted.")
return 130
info("Installation complete.")
return 0
finally:
restore_cpu_governors(governors)
cleanup_bind_mounts(ctx)
cleanup_target_hook_masks(ctx)
if not success:
cleanup_protected_state(ctx)
if __name__ == "__main__":
sys.exit(main())
@@ -1,105 +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 os
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(),
# The dashboard counts packages under <target>/var/lib/pacman/local;
# publish the path rather than have the UI assume /mnt.
"target": str(ctx.target),
"total_phases": len(phases),
"current_index": 0,
"current_phase": "Starting installation",
"phases": [],
}
_write_state(state_path, state)
for index, (name, fn) in enumerate(phases):
state["current_index"] = index
state["current_phase"] = name
state["phase_started_at"] = time.time()
_write_state(state_path, state)
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["current_index"] = max(len(phases) - 1, 0)
state["current_phase"] = "Installation complete"
state["finished_at"] = time.time()
# Expected vs actual for the bar's denominator, so drift is visible in
# acceptance runs rather than only by watching a bar creep.
state["installed_packages"] = _installed_package_count(ctx.target)
state["expected_packages"] = _expected_package_count()
_write_state(state_path, state)
timing_path = ctx.target / "var" / "log" / "omarchy-install-timing.json"
timing_path.parent.mkdir(parents=True, exist_ok=True)
_write_state(timing_path, state)
def _installed_package_count(target: Path) -> int:
"""Packages libalpm installed into the target — one directory each under
local/, which is what the dashboard counts live."""
local_db = target / "var" / "lib" / "pacman" / "local"
try:
with os.scandir(local_db) as entries:
return sum(1 for entry in entries if entry.is_dir())
except OSError:
return 0
def _expected_package_count() -> int:
path = Path("/usr/share/omarchy-iso/expected-packages")
try:
return int(path.read_text().split()[0])
except (OSError, ValueError, IndexError):
return 0
def _write_state(path: Path, state: dict) -> None:
# Dashboard polls this file while phases update it. Write atomically so the
# reader never observes a truncated/partial JSON document and resets UI.
tmp = path.with_name(f".{path.name}.tmp")
tmp.write_text(json.dumps(state, indent=2, default=str))
tmp.replace(path)
File diff suppressed because it is too large. Load diff
@@ -1,28 +0,0 @@
"""Orchestrator progress lines.
These land in the install log, not on a screen: omarchy-install-dashboard owns
the visible UI, captures the orchestrator's stdout into the support log, and
strips CSI sequences on the way in. The colouring was therefore never seen by
anyone, while gum charged a Go process start per line for it. Plain writes keep
the same indented shape at no cost.
Flush every line: pacstrap and arch-chroot inherit this fd, so buffering here
would reorder our lines against theirs in the log.
"""
from __future__ import annotations
import sys
def _emit(text: str) -> None:
sys.stdout.write(f"\n {text}\n")
sys.stdout.flush()
def info(text: str) -> None:
_emit(text)
def error(text: str) -> None:
_emit(text)
@@ -2,4 +2,4 @@ title Omarchy (x86_64, UEFI)
sort-key 01
linux /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
initrd /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0 initramfs_async=0
options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash
+5 -2
View File
@@ -54,13 +54,13 @@ timeout_style=hidden
menuentry "Omarchy (%ARCH%, ${archiso_platform})" --class arch --class gnu-linux --class gnu --class os --id 'archlinux' {
set gfxpayload=keep
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0 initramfs_async=0
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
}
menuentry "Omarchy with speakup screen reader (%ARCH%, ${archiso_platform})" --hotkey s --class arch --class gnu-linux --class gnu --class os --id 'archlinux-accessibility' {
set gfxpayload=keep
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% accessibility=on xe.enable_panel_replay=0 initramfs_async=0
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% accessibility=on
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
}
@@ -104,3 +104,6 @@ menuentry 'System restart' --class reboot --class restart {
}
# GRUB init tune for accessibility
play 600 988 1 1319 4
+2 -2
View File
@@ -30,13 +30,13 @@ timeout_style=hidden
menuentry "Omarchy (%ARCH%, ${archiso_platform})" --class arch --class gnu-linux --class gnu --class os --id 'archlinux' {
set gfxpayload=keep
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" quiet splash xe.enable_panel_replay=0 initramfs_async=0
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" quiet splash
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
}
menuentry "Omarchy with speakup screen reader (%ARCH%, ${archiso_platform})" --hotkey s --class arch --class gnu-linux --class gnu --class os --id 'archlinux-accessibility' {
set gfxpayload=keep
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" accessibility=on xe.enable_panel_replay=0 initramfs_async=0
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" accessibility=on
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
}
-25
View File
@@ -1,25 +0,0 @@
#
# /etc/pacman.conf
#
# See the pacman.conf(5) manpage for option and repository directives
[options]
HoldPkg = pacman glibc
Architecture = auto
NoProgressBar
ParallelDownloads = 5
# By default, pacman accepts packages signed by keys that its local keyring
# trusts (see pacman-key and its man page), as well as unsigned packages.
SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional
# No signature checks for the offline mirror: it ships inside the ISO, whose
# integrity is already covered by the airootfs sha512 and the ISO signature.
# "Optional TrustAll" is NOT a bypass — Optional verifies whenever a .sig file
# exists (they all do), and pacstrap verifies against the LIVE keyring (pacman
# -r relocates the root but not GpgDir). That made installs depend on archiso's
# slow boot-time pacman-init.service having finished, which stalled or failed
# pacstrap on real hardware with "required key missing from keyring".
[offline]
SigLevel = Never
Server = file:///var/cache/omarchy/mirror/offline/
-35
View File
@@ -1,35 +0,0 @@
#
# /etc/pacman.conf
#
# See the pacman.conf(5) manpage for option and repository directives
[options]
HoldPkg = pacman glibc
Architecture = auto
ParallelDownloads = 5
# By default, pacman accepts packages signed by keys that its local keyring
# trusts (see pacman-key and its man page), as well as unsigned packages.
SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional
[core]
Server = https://mirror.omarchy.org/$repo/os/$arch
Include = /etc/pacman.d/mirrorlist
[extra]
Server = https://mirror.omarchy.org/$repo/os/$arch
Include = /etc/pacman.d/mirrorlist
[multilib]
Server = https://mirror.omarchy.org/$repo/os/$arch
Include = /etc/pacman.d/mirrorlist
[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/edge/$arch
[arch-mact2]
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
SigLevel = Never
-35
View File
@@ -1,35 +0,0 @@
#
# /etc/pacman.conf
#
# See the pacman.conf(5) manpage for option and repository directives
[options]
HoldPkg = pacman glibc
Architecture = auto
ParallelDownloads = 5
# By default, pacman accepts packages signed by keys that its local keyring
# trusts (see pacman-key and its man page), as well as unsigned packages.
SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional
[core]
Server = https://rc-mirror.omarchy.org/$repo/os/$arch
Include = /etc/pacman.d/mirrorlist
[extra]
Server = https://rc-mirror.omarchy.org/$repo/os/$arch
Include = /etc/pacman.d/mirrorlist
[multilib]
Server = https://rc-mirror.omarchy.org/$repo/os/$arch
Include = /etc/pacman.d/mirrorlist
[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/edge/$arch
[arch-mact2]
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
SigLevel = Never
@@ -27,9 +27,8 @@ Include = /etc/pacman.d/mirrorlist
[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/stable/$arch
Server = https://pkgs.omarchy.org/$arch
[arch-mact2]
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
SigLevel = Never
+18
View File
@@ -0,0 +1,18 @@
#
# /etc/pacman.conf
#
# See the pacman.conf(5) manpage for option and repository directives
[options]
HoldPkg = pacman glibc
Architecture = auto
NoProgressBar
ParallelDownloads = 5
# By default, pacman accepts packages signed by keys that its local keyring
# trusts (see pacman-key and its man page), as well as unsigned packages.
SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional
[offline]
SigLevel = Optional TrustAll
Server = file:///var/cache/omarchy/mirror/offline/
+3 -23
View File
@@ -10,36 +10,16 @@ install_dir="arch"
buildmodes=('iso')
bootmodes=('bios.syslinux' 'uefi.grub')
arch="x86_64"
pacman_conf="pacman-offline.conf"
pacman_conf="pacman.conf"
airootfs_image_type="squashfs"
# Package archives in the offline mirror are already zstd-compressed. Storing
# them in an outer stream saves little space but makes pacman decompress the
# outer layer while hashing and extracting every package during installation.
#
# Everything else in the live root is zstd rather than xz. Squashfs decompresses
# on the page-fault path through a single stream (CONFIG_SQUASHFS_DECOMP_SINGLE),
# where xz manages ~100MB/s against zstd's ~900MB/s, and the live root is read
# cold on every boot: kernel, plymouth, systemd, python, archinstall, gum. The
# whole ISO grows well under a percent for it, and dropping the x86 BCJ filter
# also removes one of the blockers listed in plans/aarch64-support.md.
airootfs_image_tool_options=(
'-comp' 'zstd'
'-Xcompression-level' '19'
'-b' '1M'
'-action' 'uncompressed@subpathname(var/cache/omarchy/mirror/offline)'
)
airootfs_image_tool_options=('-comp' 'xz' '-Xbcj' 'x86' '-b' '1M' '-Xdict-size' '1M')
bootstrap_tarball_compression=('zstd' '-c' '-T0' '--auto-threads=logical' '--long' '-19')
file_permissions=(
["/etc/shadow"]="0:0:400"
["/root"]="0:0:750"
["/root/.automated_script.sh"]="0:0:755"
["/root/.gnupg"]="0:0:700"
["/root/configurator"]="0:0:755"
["/usr/local/bin/choose-mirror"]="0:0:755"
["/usr/local/bin/omarchy-cidata-load"]="0:0:755"
["/usr/local/bin/omarchy-iso-cleanup-disk"]="0:0:755"
["/usr/local/bin/omarchy-install-dashboard"]="0:0:755"
["/usr/local/bin/omarchy-iso-install"]="0:0:755"
["/usr/local/bin/omarchy-upload-log"]="0:0:755"
["/root/configurator"]="0:0:755"
["/var/cache/omarchy/mirror/offline/"]="0:0:775"
)
+3 -3
View File
@@ -6,7 +6,7 @@ ENDTEXT
MENU LABEL Omarchy install medium (x86_64, NBD)
LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% archiso_nbd_srv=${pxeserver} cms_verify=y initramfs_async=0
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% archiso_nbd_srv=${pxeserver} cms_verify=y
SYSAPPEND 3
LABEL arch64_nfs
@@ -17,7 +17,7 @@ ENDTEXT
MENU LABEL Omarchy install medium (x86_64, NFS)
LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
APPEND archisobasedir=%INSTALL_DIR% archiso_nfs_srv=${pxeserver}:/run/archiso/bootmnt cms_verify=y initramfs_async=0
APPEND archisobasedir=%INSTALL_DIR% archiso_nfs_srv=${pxeserver}:/run/archiso/bootmnt cms_verify=y
SYSAPPEND 3
LABEL arch64_http
@@ -28,5 +28,5 @@ ENDTEXT
MENU LABEL Omarchy install medium (x86_64, HTTP)
LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
APPEND archisobasedir=%INSTALL_DIR% archiso_http_srv=http://${pxeserver}/ cms_verify=y initramfs_async=0
APPEND archisobasedir=%INSTALL_DIR% archiso_http_srv=http://${pxeserver}/ cms_verify=y
SYSAPPEND 3
+2 -2
View File
@@ -6,7 +6,7 @@ ENDTEXT
MENU LABEL Omarchy install medium (x86_64, BIOS)
LINUX /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
INITRD /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0 initramfs_async=0
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash
# Accessibility boot option
LABEL arch64speech
@@ -17,4 +17,4 @@ ENDTEXT
MENU LABEL Omarchy install medium (x86_64, BIOS) with ^speech
LINUX /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
INITRD /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% accessibility=on xe.enable_panel_replay=0 initramfs_async=0
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% accessibility=on
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-178
View File
@@ -1,178 +0,0 @@
# Plan: aarch64 (Generic UEFI ARM64) build for omarchy-iso
## Context
omarchy-iso currently produces a single x86_64 Arch Linux live ISO. The build is x86-baked from top to bottom — profile arch, package list filename, microcode, kernel choice (`linux-t2`), bootloader configs (BIOS syslinux + UEFI GRUB), squashfs BCJ filter, Node.js download URL, QEMU smoke-test, release filename, and CI runner.
Target: a parallel **generic UEFI aarch64** ISO — boots on Ampere servers, AWS Graviton VMs, Snapdragon X laptops, ARM dev kits. Anything that exposes vanilla UEFI + ACPI. Apple Silicon (Asahi) and SBCs (U-Boot/rpi-firmware) are out of scope.
**Assumption:** cross-repo dependencies are someone else's problem to land first. This plan only covers omarchy-iso. The hard prerequisites are listed up front so it's clear what blocks the first green build.
---
## Hard prerequisites (NOT in this plan, but block any green build)
These exist outside omarchy-iso. Flagged for visibility — without them, nothing here boots:
1. **aarch64 base packages must exist somewhere we can pacman from.** Vanilla Arch (`geo.mirror.pkgbuild.com`) is x86_64-only — there is no `core/os/aarch64`. The realistic source is **Arch Linux ARM** (`mirror.archlinuxarm.org`) for `core`/`extra`/`alarm`/`aur`. Decision required: target Arch Linux ARM as the aarch64 base distribution.
2. **`pkgs.omarchy.org/{stable,edge}/aarch64/`** must serve a real repo. Probed today, both return 404. omarchy-pkgs already has multi-arch build support per its README, so this is a publish step, not a port.
3. **Omarchy ISO architecture guard** must allow supported architectures or scope any x86_64-only checks behind an explicit guard.
4. **archinstall + Limine** must work end-to-end on aarch64. archinstall supports it; Limine supports aarch64 UEFI. Worth a manual verification before committing to this bootloader path on ARM.
---
## Approach
Add an `--arch aarch64` flag to `bin/omarchy-iso-make`. Branch on it everywhere x86_64 is currently assumed. Don't fork the profile directory — overlay arch-specific files at build time so the two architectures share one source of truth.
Output artifact naming already uses no arch suffix until release-time renaming, so two parallel builds coexist cleanly.
---
## Concrete changes
### 1. Build entrypoint — `bin/omarchy-iso-make`
- Add `--arch x86_64|aarch64` flag (default `x86_64` for backward compat).
- Pass `OMARCHY_ARCH` env var into the container.
- Switch the docker image / platform per arch:
- x86_64: `archlinux/archlinux:latest` (unchanged)
- aarch64: `archlinuxarm/archlinuxarm:latest` (or `menci/archlinuxarm` — pick whichever publishes a recent multi-arch image), with `--platform linux/arm64`. Native arm64 host preferred; QEMU emulation works but is slow.
- Don't change pacman cache mount on the host: pacman keeps per-arch subdirs, so the cache is safe to share.
### 2. Profile — `configs/profiledef.sh`
Change `arch` and the squashfs BCJ filter at runtime. Either:
- Keep one `profiledef.sh` and have `builder/build-iso.sh` `sed` the arch line + BCJ filter (`x86` → `arm`) before `mkarchiso` runs, **or**
- Source `OMARCHY_ARCH` directly inside `profiledef.sh` (mkarchiso sources this file with bash, so env access is fine).
`bootmodes` also needs to drop `bios.syslinux` for aarch64 (BIOS boot is x86-only) — `bootmodes=('uefi.grub')` on ARM.
### 3. Build script — `builder/build-iso.sh`
- **Line 56–57**: Node.js URL grep. Branch on `OMARCHY_ARCH`:
- `x86_64` → `linux-x64.tar.gz`
- `aarch64` → `linux-arm64.tar.gz`
- **Line 73**: package additions. Drop `linux-t2` for aarch64 (T2-only x86 kernel) — use plain `linux`. Write to `packages.${OMARCHY_ARCH}` instead of hardcoded `packages.x86_64`.
- **Line 77**: read same `packages.${OMARCHY_ARCH}` for offline mirror enumeration.
- **archiso releng overlay** (`cp -r /archiso/configs/releng/*`): the upstream `releng` profile ships only `packages.x86_64`. For aarch64, copy `packages.x86_64` to `packages.aarch64` and prune obviously x86-only entries (`memtest86+`, `intel-ucode`, `amd-ucode`, `edk2-shell` x64 binary, `syslinux`). Land this as a small fixup step in `build-iso.sh` rather than a full fork of `releng`.
### 4. archinstall package list — `builder/archinstall.packages`
Drop microcode for aarch64 — `intel-ucode` and `amd-ucode` don't exist for ARM. Either:
- Two lists (`archinstall.packages.x86_64`, `archinstall.packages.aarch64`), or
- One list with sentinel comments and a filter step in `build-iso.sh:80` that drops microcode lines when `OMARCHY_ARCH=aarch64`.
The second is less duplication for one-line drift.
### 5. Bootloader configs
aarch64 has no BIOS — only UEFI. Drop the syslinux path entirely on ARM.
- **Skip on aarch64**: `configs/syslinux/*` (BIOS syslinux), `configs/grub/*` references to `shellx64.efi`/`shellia32.efi` (lines 81–85 of `grub.cfg`).
- **`configs/efiboot/loader/loader.conf`**: change `default 01-archiso-x86_64-linux.conf` to a per-arch entry. Either two loader.conf files (selected by build-iso.sh) or rename the entry generically (`01-archiso-linux.conf`) and keep one file.
- **`configs/efiboot/loader/entries/01-archiso-x86_64-linux.conf`**: produce an aarch64 sibling (`01-archiso-aarch64-linux.conf`) at build time — same template, drop microcode initrd lines, swap title. The kernel/initrd paths use `%ARCH%` already in `grub.cfg` but are hardcoded `x86_64` in this file (line 3–4).
- **`configs/grub/grub.cfg` and `loopback.cfg`**: remove or guard the x86_64 conditionals. The `%ARCH%` placeholder gets substituted by mkarchiso from `profiledef.sh:arch`, so the kernel/initrd paths flip automatically. Manually-written `grub_cpu == 'x86_64'` blocks (lines 34–39, 68, 81–85) should drop on aarch64 builds — easiest done with a build-time sed for the aarch64 path, or by extracting them into a separate `grub-x86_64.cfg.fragment` only included on x86 builds.
The cleanest cut: keep one `grub.cfg` shared, strip the x86 shell/memtest fragments at build time when `OMARCHY_ARCH=aarch64`. Don't fork the file.
### 6. mkinitcpio preset — `configs/airootfs/etc/mkinitcpio.d/linux-t2.preset`
Names `vmlinuz-linux-t2` and `initramfs-linux-t2.img`. The filename is the pkgbase and the alpm hook keys off it, so aarch64 does not rename this file — it simply does not ship it, and releng's own `linux.preset` covers the stock kernel. Drop `linux-t2` from `arch_packages` on aarch64 and the boot entries point at `vmlinuz-linux` / `initramfs-linux.img`.
### 7. Configurator — `configs/airootfs/root/configurator`
Lines 320–325:
```bash
if lspci -nn 2>/dev/null | grep -q "106b:180[12]"; then
kernel_choice="linux-t2"
else
kernel_choice="linux"
fi
```
T2 detection is harmless on aarch64 (`lspci` returns no match), so `kernel_choice` falls through to `linux`. **No change strictly required**, but clearer to wrap the lspci probe in `[[ $(uname -m) == "x86_64" ]]` so the intent reads correctly. Cheap.
The archinstall JSON's `mirror_config.custom_servers` (lines 422–424) points at `mirror.omarchy.org`, `mirror.rackspace.com/archlinux`, `geo.mirror.pkgbuild.com` — none of these serve aarch64. For aarch64 the list must be `mirror.archlinuxarm.org` and friends. Branch the JSON template on `OMARCHY_ARCH` (or `uname -m` at runtime, since this file runs on the live ISO).
### 8. pacman configs — `configs/pacman-online-{stable,rc,edge,offline}.conf`
The `$arch` placeholder is pacman-resolved (matches `Architecture = auto`), so `https://stable-mirror.omarchy.org/$repo/os/$arch` adapts automatically — **as long as the mirror serves aarch64**. The omarchy mirror is the bottleneck (prerequisite #2). The `arch-mact2` repo is x86-only by definition; gate it behind arch on aarch64 builds (drop the section).
### 9. Smoke-test scripts — `bin/omarchy-iso-boot`, `bin/omarchy-vm`
Detect arch from the ISO filename (or accept a flag) and switch:
- x86_64: `qemu-system-x86_64`, `/usr/share/edk2/x64/OVMF_CODE.4m.fd` + `OVMF_VARS.4m.fd` (unchanged).
- aarch64: `qemu-system-aarch64`, `-machine virt -cpu max`, `/usr/share/edk2/aarch64/QEMU_CODE.fd` + `QEMU_VARS.fd` (Arch package: `edk2-armvirt`).
Both scripts hardcode the OVMF path; small, mechanical fix.
### 10. Release script — `bin/omarchy-iso-release:52`
```bash
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*${OMARCHY_ARCH}-"$ISO_REF".iso | head -n1)
```
Hardcoded `x86_64` glob. Take an arch arg, or do `*${OMARCHY_ARCH}-${ISO_REF}.iso`. The ISO filename already contains arch (set by archiso from `profiledef.sh`), so this just needs the glob parameterized.
### 11. CI — `.github/workflows/nightly-build.yml`
Add a matrix:
```yaml
strategy:
matrix:
arch: [x86_64, aarch64]
include:
- arch: x86_64
runner: ubuntu-latest
- arch: aarch64
runner: ubuntu-24.04-arm # GitHub-hosted ARM runner, GA
```
Pass `--arch ${{ matrix.arch }}` to the build. Upload artifacts named per-arch.
If GitHub-hosted ARM runners aren't available on this org's plan, fall back to QEMU emulation on `ubuntu-latest` (slow, ~3-4× build time) or self-hosted.
---
## Files to modify (summary)
Code:
- `bin/omarchy-iso-make` — add `--arch` flag, branch docker image/platform
- `bin/omarchy-iso-boot` — branch QEMU binary + OVMF path
- `bin/omarchy-vm` — same as iso-boot
- `bin/omarchy-iso-release` — parameterize ISO glob
- `builder/build-iso.sh` — main branching: Node.js URL, package list filename, releng overlay fixups, kernel package
- `builder/archinstall.packages` — filter microcode on aarch64 (sentinel comments + filter step)
- `configs/profiledef.sh` — env-driven `arch` + BCJ filter + bootmodes
- `configs/efiboot/loader/loader.conf` — per-arch default entry
- `configs/efiboot/loader/entries/01-archiso-x86_64-linux.conf` — generate aarch64 sibling at build time
- `configs/grub/grub.cfg`, `loopback.cfg` — strip x86-only fragments on aarch64
- `configs/airootfs/etc/mkinitcpio.d/linux-t2.preset` — omit on aarch64 (drop linux-t2 there; releng's linux.preset covers the stock kernel)
- `configs/airootfs/root/configurator` — branch archinstall JSON mirror list on `uname -m`; optionally guard lspci T2 probe
- `.github/workflows/nightly-build.yml` — matrix x86_64/aarch64
No new files unless we go the dual-list route on archinstall.packages or efiboot loader entries; preferred path is build-time templating to keep one source of truth.
---
## Verification
**Local (host is x86_64):**
1. `bin/omarchy-iso-make --arch x86_64` — must produce a byte-similar ISO to today's nightly (smoke test for regressions in the refactor).
2. `bin/omarchy-iso-make --arch aarch64` — succeeds (slow under QEMU binfmt; expect 30-60 min).
3. `bin/omarchy-iso-boot release/omarchy-*-aarch64-*.iso` — boots to the configurator under `qemu-system-aarch64 -machine virt`. Walk through the picker, confirm archinstall lays down a working system, reboot into the installed system.
4. Live-iso shell: `pacman -Sy && pacman -Si linux` returns an aarch64 package from `mirror.archlinuxarm.org`.
**On real hardware (post green QEMU run):**
- AWS Graviton VM (`c7g.medium`, dd ISO to a volume, attach as boot) — fastest cloud check, no hardware needed.
- One physical ARM64 UEFI box if available (Ampere dev kit, Snapdragon X laptop, etc.) — the long pole.
**CI:**
- Both matrix legs go green on a manual `workflow_dispatch` before merging.
- Nightly produces both ISOs as artifacts.
---
## Open risks (call out before implementation)
1. **mkarchiso on aarch64 is less-trodden ground.** It accepts `arch="aarch64"`, but the upstream Arch project doesn't dogfood it. Expect to file/patch around small bugs in archiso's helper scripts. Keep the archiso submodule pin tight so a regression doesn't surprise nightly.
2. **Limine + LUKS + Btrfs + Snapper on aarch64** — every one of these works individually on ARM, but the combination is what omarchy ships. Worth one manual end-to-end pass before declaring done.
3. **Apple T2 / linux-t2 / `arch-mact2` repo** are silently dropped on aarch64; users mistakenly trying to install the aarch64 ISO on a T2 Mac will get an obviously-wrong result. The configurator could refuse to install when arch mismatch is detected, but that's outside this plan.
-187
View File
@@ -1,187 +0,0 @@
# Autoinstall Plan
## Goal
Let the standard Omarchy ISO install itself with no keyboard present, driven by config files handed to the VM on a second drive. This makes Omarchy usable as a Packer/Proxmox base image for disposable dev environments: create VM, boot, walk away, SSH in.
## Product Requirements
- No ISO rebuild. The shipped ISO autoinstalls when config is present and runs the configurator when it is not.
- No new menu entry, no new boot parameter, no separate ISO flavor.
- Config format is exactly what the configurator already writes — no second schema to maintain.
- The installed machine is reachable over SSH with a key supplied at install time.
- Interactive installs behave identically to today.
---
## Chosen Architecture
### Trigger: config presence, not a mode flag
The installer looks for a drive labeled `cidata` at startup. If it is there and carries the configurator's output files, those files are copied into `/root` and `./configurator` is skipped — the rest of the install runs the ordinary path against ordinary inputs. If it is not there, nothing changes.
This is Ryan Hughes' suggestion from PR #72 and it is what keeps the diff small: the install pipeline can be rewritten freely as long as it keeps reading its five files out of `/root`, and autoinstall follows along for free.
`cidata` is the cloud-init `NoCloud` label, so Proxmox, libvirt, and Packer all already know how to attach one.
### What the ISO already does for us
One thing that would otherwise need building is already true on a stock install: `NetworkManager.service` is enabled and active with DHCP by default, so autoinstall needs no `systemd-networkd` unit — one would contend with NetworkManager.
Remote access needs three things, all verified against a real install rather than inferred:
- `authorized_keys` for the user.
- `systemctl enable sshd.service`. Omarchy ships `openssh` but leaves the service disabled.
- `ufw allow ssh`. `install/config/firewall.sh` opens LocalSend (53317) and docker DNS and nothing else, and ufw runs default-deny incoming.
Still no loosening of `PasswordAuthentication` — key auth only.
Do not take `manifests/fresh-4-semantic.json` as the authority on any of this. It claims `sshd.service: enabled` and carries an `allow tcp 22` ufw rule; a real install of this ISO has neither. It is a snapshot of one machine's state, not a contract about stock behavior, and trusting it cost two test cycles.
### Non-interactivity belongs to the dashboard
`omarchy-install-dashboard` owns every prompt that survives to the end of an install: the reboot confirm and, on failure, the action menu. Both are `gum` calls reading from the TTY, and both hang forever on a headless VM. Autoinstall therefore sets one env var that the dashboard reads, rather than patching prompts out of scripts after the fact — which is what PR #72 did to `finished.sh`, and what everyone correctly disliked.
---
## Non-Goals
- No config generator, validator, or schema doc. The configurator is the source of truth for the format; users copy its output.
- No secret management. Whoever builds the `cidata` drive owns the password hash and the public key on it.
- No PXE, no network-fetched config, no cloud-init compatibility beyond reusing the drive label.
- No changes to disk selection, encryption, or any install phase behavior.
---
## Repo Implementation Plan
## 1) Detect the cidata drive and skip the configurator
### Files
- `configs/airootfs/usr/local/bin/omarchy-cidata-load`
- `configs/airootfs/root/.automated_script.sh`
- `configs/profiledef.sh`
### Changes
- Add `omarchy-cidata-load` alongside the other install commands in `/usr/local/bin` (with the matching `file_permissions` entry in `profiledef.sh`). It exits 0 when an autoinstall drive was loaded:
- `udevadm settle` first: the by-label symlinks come from udev, and probing before enumeration finishes would silently drop a headless install into the wizard.
- Look for `/dev/disk/by-label/cidata` then `/dev/disk/by-label/CIDATA`; exit non-zero if neither exists.
- Mount read-only on a temp mountpoint; exit non-zero if the mount fails.
- Require both `user_configuration.json` and `user_credentials.json` on the drive — this is the same pair `InstallContext.from_env` treats as mandatory. Missing either means the drive is not an autoinstall drive; unmount and exit non-zero.
- Copy the required pair plus, when present, `user_full_name.txt`, `user_email_address.txt`, `user_encrypt_installation.txt`, and `authorized_keys` into `/root` with a single `cp`, and exit non-zero if the copy fails — a "loaded" drive with missing inputs would fail the install later with a worse error.
- Unmount before exiting. Leave nothing mounted for the install to trip over.
- The script takes an optional path prefix so the unit tests can point it at a sandbox; production callers pass nothing.
- Replace the bare `./configurator` call in `.automated_script.sh` with: run `omarchy-cidata-load`; on success export `OMARCHY_UI_INTERACTIVE=no`, on failure run `./configurator`.
- Pass `--authorized-keys-file /root/authorized_keys` to `omarchy-iso-install` in the dashboard invocation, alongside the existing five flags. Always pass it; the orchestrator no-ops when the file is absent, which keeps the interactive path on the identical command line.
### Notes
- The three text files are optional by construction: `_read_text` in `context.py` returns `""` for a missing path and `encrypt` falls back to false. Do not add stricter checks here than the orchestrator itself applies.
- `omarchy-cidata-load` must not run before `warm_offline_mirror` is backgrounded — the mirror warm should keep its head start.
### Acceptance criteria
- ISO with no `cidata` drive attached: configurator runs, byte-identical UX to today.
- ISO with a `cidata` drive carrying the five files: configurator never appears; install starts within seconds of boot.
- ISO with a drive labeled `cidata` that is empty or carries only one of the two required files: falls back to the configurator rather than failing the boot.
- Nothing remains mounted from `/dev/disk/by-label/cidata` once the install begins.
---
## 2) Non-interactive dashboard
### File
- `configs/airootfs/usr/local/bin/omarchy-install-dashboard`
### Changes
- Read `OMARCHY_UI_INTERACTIVE` once near the other env reads at the top.
- In `reboot_prompt()` (`:474`): when non-interactive, return 0 immediately instead of calling `gum confirm`. The existing `OMARCHY_UI_AUTO_REBOOT=no` check at `:737` stays in front of the actual `reboot`, so a debug run can set both and stop on the finish screen without a keyboard.
- In `failure_menu()` (`:597`): treat non-interactive as equivalent to the existing `OMARCHY_UI_FAILURE_ACTION=exit` early return (`:600`), so a failed autoinstall exits with the installer's status instead of blocking on `gum choose` forever. The failure screen and log tail still render, and the serial console still captures them.
### Why here and not in the caller
The dashboard is the only process that owns the visible UI. Suppressing its prompts anywhere else means editing scripts it invokes — the `finished.sh` `sed` from PR #72 — which breaks the moment those scripts move.
### Acceptance criteria
- Autoinstall run reaches the finish screen and reboots with no input.
- Autoinstall run whose install fails renders the failure screen, exits non-zero, and does not hang.
- Interactive run still prompts for reboot and still offers the failure menu.
- `OMARCHY_UI_INTERACTIVE=no` plus `OMARCHY_UI_AUTO_REBOOT=no` stops at the finish screen without prompting.
---
## 3) SSH keys as an orchestrator phase
### Files
- `configs/airootfs/usr/local/bin/omarchy-iso-install`
- `configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py`
- `configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py`
- `configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py`
### Changes
- `omarchy-iso-install`: add `--authorized-keys-file` to the arg loop, exporting `OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE`. Same shape as the five existing flags, ahead of the `unknown arg` catch-all (`:19`).
- `context.py`: add an `authorized_keys_path: Path | None` field populated from that variable in `from_env`, `None` when unset or when the file does not exist.
- `phases_impl.py`: add `configure_ssh_access(ctx)`:
- Return immediately when `ctx.authorized_keys_path` is `None`.
- Read the file as sshd's own authorized_keys format, dropping blank lines and # comments; write the keys one per line to `<target>/home/<user>/.ssh/authorized_keys`.
- `.ssh` at `0700`, `authorized_keys` at `0600`, both owned by the user via `arch-chroot <target> chown -R <user>:<user>`. Do not hardcode uid 1000 as PR #72 did — ask the target for it.
- `arch-chroot <target> systemctl enable sshd.service`. Works cleanly in the chroot.
- `arch-chroot <target> ufw allow ssh`. This one exits **non-zero** in a chroot (`ERROR: problem running`) because ufw cannot reach netfilter, but it writes the rule to `/etc/ufw/user.rules` before failing, and that file is what `ufw.service` loads on first boot. So ignore the exit status and assert the rule landed in the file instead. No ufw-presence check: ufw is part of Omarchy's default package set, and the rule assertion already fails loudly if it is somehow missing.
- An `authorized_keys` with no usable keys must fail the phase loudly. A machine that installs "successfully" and is then unreachable is worse than one that stops with an error on screen.
- `main.py`: register `("Configuring SSH access", configure_ssh_access)` in `build_phases` after `configure_login` (`:52`) and before `validate_boot`.
### Model to follow
`configure_login` (`phases_impl.py:1251`) — direct writes against `ctx.target`, `arch-chroot` reserved for ownership and `systemctl`. Match its structure.
### Acceptance criteria
- Install with `authorized_keys`: `ssh <user>@<vm>` works with the corresponding private key on first boot, no password.
- Install without `authorized_keys`: phase runs, does nothing, install unaffected; `~/.ssh` is not created.
- `authorized_keys` is `0600` and owned by the install user, not root.
- Port 22 is open in ufw on the installed system. Test this by connecting, not by reading the phase log — the rule is written from a chroot that cannot verify it.
- Password SSH auth is left at the distro default — autoinstall does not enable it.
- Interactive installs are unaffected: the flag is passed but the file never exists.
---
## 4) Documentation
### File
- `README.md`
### Changes
- New "Autoinstall" section covering: what the `cidata` drive is, the file table, how to produce the drive, and a Proxmox `qm create` example with disk-first boot order so the empty disk falls through to the ISO on the first boot and boots the installed system afterward.
- Show `openssl passwd -6` for the credential hash and the `authorized_keys` format.
- State plainly that the config files are the configurator's own output — the documented way to get a starting `user_configuration.json` is to run one interactive install and copy what it wrote.
- Do not repeat PR #72's networking claims. NetworkManager comes enabled with DHCP on a stock install; sshd ships disabled, which is why the SSH phase exists.
---
## 5) Validation
### Unit tests
`./test/all` runs everything under `test/`: `cidata-load-test.sh` exercises `omarchy-cidata-load` against a sandbox with `mount`/`umount`/`udevadm` stubbed, and `test_configure_ssh_access.py` runs the orchestrator phase against a temp target with `subprocess.run` recorded and ufw's write-the-rule-then-fail chroot behavior simulated. The Python tests run through `unittest` discovery alongside the repo's existing `test_*.py` suites.
### Manual matrix
1. Build the ISO from this branch.
2. Interactive control run: boot with no `cidata` drive, install normally, confirm nothing changed.
3. Autoinstall run on Proxmox: attach ISO + `cidata` drive, boot, no input; confirm the configurator never renders, the install completes, the machine reboots on its own, and SSH with the key works against the booted system.
4. Encrypted autoinstall: `user_encrypt_installation.txt` set true; confirm the LUKS prompt appears on boot (this is the one autoinstall case that still needs a human at first boot — document it).
5. Full Packer cycle: build, destroy, rebuild from the same `cidata` drive; confirm reproducibility.
### Negative tests
- Drive labeled `cidata` with no files → configurator runs.
- Drive with `user_configuration.json` only → configurator runs.
- An `authorized_keys` with no usable keys → install stops in the remote-access phase with a visible error, does not reboot into an unreachable machine.
- Install failure under autoinstall (e.g. a disk target that does not exist) → failure screen renders, process exits non-zero, no hang.
### Regression surface
The interactive path shares every line of this change except the `omarchy-cidata-load` branch, so run the standard acceptance harness (`./bin/omarchy-iso-test`) on the built ISO before proposing the PR.
---
## Relationship to PR #72
PR #72 targeted the pre-`quattro` tree and cannot be rebased: `use_omarchy_helpers`, `run_configurator`, `install_arch`, `install_omarchy`, and `chroot_bash` no longer exist. This plan reimplements the same feature against the current tree and drops three things that PR deliberately flagged as questionable:
- The `finished.sh` `sed` — replaced by section 2. The dashboard renders the reboot prompt now.
- The `systemd-networkd` DHCP unit — unnecessary; NetworkManager is already enabled and would contend with it.
- The `PasswordAuthentication yes` rewrite — unnecessary and a security downgrade.
`load_cidata` is the one piece that carries over close to intact. PR #72's `ufw allow ssh` also carries over: it was dropped from the first draft of this plan on the reasoning that an enabled sshd is a reachable sshd, and a test install proved otherwise. That PR was right to include it.
-395
View File
@@ -1,395 +0,0 @@
# Consumer Secure Boot Plan: Microsoft Shim + MOK + UKI
## Goal
Support Omarchy installs on consumer laptops that ship with UEFI Secure Boot enabled and trust the Microsoft UEFI CA, without requiring users to disable Secure Boot.
This plan targets normal consumer hardware, especially Windows dual-boot machines. OEM-controlled hardware with Omarchy-owned firmware keys is a separate path.
## Core Architecture
Use a Microsoft-signed first-stage shim, then boot only signed Omarchy-controlled artifacts.
```text
Firmware Secure Boot
-> Microsoft-signed Omarchy shim
-> MokManager when key enrollment is pending
-> signed second-stage boot manager
-> signed Unified Kernel Image (UKI)
-> encrypted Omarchy root
```
For the installed OS, use machine-local Machine Owner Key (MOK) signing for UKIs generated during install and later kernel updates.
## Why MOK For Installed Systems
Omarchy's installed UKI is machine-specific because it includes or depends on install-time data such as:
- Kernel command line for encrypted root.
- Initramfs generated for the installed system.
- Kernel package version selected at install or update time.
We must not ship Omarchy's private release signing key on the ISO or installed system. Therefore:
1. The official ISO is signed by Omarchy release infrastructure.
2. During install, generate a machine-local Secure Boot keypair.
3. Enroll the public certificate through MokManager.
4. Sign installed UKIs and update-generated UKIs with the local private key.
5. Store the local private key only on the encrypted installed root, never on the ESP.
This preserves offline tamper resistance for `/boot` while avoiding any central private key on user machines.
## Scope Constraints
- UEFI only.
- Microsoft UEFI CA trust path only for consumer machines.
- No custom firmware key enrollment for v1 consumer installs.
- No TPM auto-unlock for Omarchy root in v1.
- No attempt to modify Windows BitLocker protectors or TPM ownership.
- Secure Boot mode uses signed UKIs only; no unsigned external kernel, initramfs, or command line files.
- Secure Boot mode may use a different boot manager than the current Limine path until Limine has a proven verified-boot story.
## Bootloader Decision
Signing Limine as an EFI binary is not sufficient if Limine can then boot unsigned kernels, initrds, or configs. For v1 Secure Boot support, use a boot path with a small, enforceable verification surface:
```text
shim -> systemd-boot -> signed UKI
```
Policy:
- Keep Limine as the normal non-Secure-Boot bootloader unless we later prove Limine enforces signed payloads correctly.
- Use systemd-boot only for Secure Boot installs if that is the fastest reliable path.
- Boot only UKIs with embedded command line and initramfs.
- Disable or avoid unsigned boot entry editing in Secure Boot mode.
If later validation proves Limine can enforce signed payloads behind shim, this plan can swap the second stage while keeping the shim, MOK, UKI, and signing architecture.
## Required Artifacts
### Omarchy Shim Artifacts
These are release artifacts, not generated on normal developer machines:
- `shimx64.efi`, signed by Microsoft UEFI CA.
- `mmx64.efi` / MokManager artifact compatible with the shim.
- Optional `fbx64.efi` fallback artifact if needed by the selected shim packaging.
- Shim SBAT metadata owned by Omarchy.
- Omarchy public release certificate embedded in shim.
Do not depend on another distribution's shim as a permanent product strategy. Omarchy needs its own shim submission, SBAT identity, revocation path, and release process.
### Omarchy Signing Material
Release infrastructure owns:
- Omarchy Secure Boot release private key, stored offline or in an HSM-backed CI secret store.
- Omarchy Secure Boot release public certificate, embedded in shim and shipped for verification.
Installed systems own:
- Machine-local MOK private key, stored on encrypted root with `0600` permissions.
- Machine-local MOK public certificate, enrolled through MokManager.
Private keys must never be committed to this repo.
## Build-Time Flow
Add a Secure Boot-capable ISO build mode.
```text
bin/omarchy-iso-make --secure-boot
```
Build behavior:
1. Add Microsoft-signed Omarchy shim artifacts to the ISO UEFI boot path.
2. Build the live environment boot payload as a UKI.
3. Sign the live UKI with the Omarchy release key in official builds.
4. Sign any second-stage EFI binary with the Omarchy release key.
5. Assert that every Secure Boot UEFI entry uses the signed shim path.
6. Assert that no unsigned kernel/initramfs path is offered in Secure Boot UEFI mode.
Local developer builds should support a dev-signing mode for QEMU validation:
```text
OMARCHY_SECURE_BOOT_SIGNING_MODE=dev
```
Dev mode creates local test keys and is only expected to boot in QEMU firmware enrolled with those test keys. It must not be presented as Microsoft Secure Boot compatible.
## Installed-System Flow
When the live installer detects Secure Boot enabled:
1. Show a Secure Boot explanation before disk mutation.
2. If Windows is detected, warn the user to suspend BitLocker and have the recovery key available.
3. Install Omarchy's shim to its own ESP directory, for example `EFI/Omarchy`.
4. Do not modify `EFI/Microsoft`.
5. Generate a machine-local MOK keypair under the installed encrypted root.
6. Build an installed UKI with embedded kernel, initramfs, command line, and OS release data.
7. Sign the installed UKI with the machine-local MOK private key.
8. Sign the selected second-stage boot manager with the machine-local MOK private key, unless it is release-signed and expected to remain static.
9. Schedule public certificate enrollment with `mokutil --import`.
10. Tell the user they must complete MokManager enrollment on the next reboot.
11. Preserve current firmware boot order by default on dual-boot machines.
Expected first reboot sequence after install:
```text
Firmware -> Omarchy shim -> MokManager -> user enrolls Omarchy machine key -> reboot -> Omarchy shim -> signed boot manager -> signed UKI -> LUKS unlock
```
## Kernel Update Flow
Secure Boot installs need deterministic UKI regeneration and signing after kernel or initramfs changes.
Add a target-system hook that:
1. Regenerates the initramfs.
2. Rebuilds the UKI.
3. Signs the UKI with the machine-local MOK private key.
4. Writes it atomically to the ESP.
5. Keeps at least one previously booted signed UKI as fallback.
Failure policy:
- If signing fails, do not replace the currently bootable UKI.
- If ESP space is low, keep the current UKI and fail loudly.
- If the local signing key is missing, block the update from producing an unsigned boot artifact.
## File Changes
### `bin/omarchy-iso-make`
Add flags:
```text
--secure-boot build Secure Boot-capable ISO flavor
--secure-boot-dev build with local test keys for QEMU only
```
Pass mode and signing configuration into the Docker build environment.
### `builder/build-iso.sh`
Add Secure Boot build support:
- Copy shim artifacts into the UEFI boot path.
- Build live UKI.
- Sign live UKI and second-stage EFI artifact.
- Fail official Secure Boot builds if release signing material is unavailable.
- Write `/root/omarchy_secure_boot_mode` into the live environment.
- Assert that Secure Boot boot entries do not point at unsigned payloads.
### `builder/secure-boot/`
Add non-secret Secure Boot build inputs:
- Public release certificate.
- SBAT template.
- Artifact manifest with expected shim/MokManager checksums.
- Developer test-key generation script for QEMU only.
Do not store private keys in this directory.
### `configs/airootfs/root/configurator`
Add Secure Boot detection and UX:
- Detect UEFI Secure Boot state through efivars and/or `mokutil --sb-state`.
- Detect Windows through ESP vendor paths and partition metadata.
- Warn about BitLocker recovery risk when Windows is present.
- Explain one-time MokManager enrollment.
- Write `secure_boot.sh` with simple shell state for `.automated_script.sh`.
Example state:
```bash
SECURE_BOOT_ENABLED=true
SECURE_BOOT_INSTALL=true
SECURE_BOOT_BOOTLOADER=systemd-boot
SECURE_BOOT_UKI=true
SECURE_BOOT_MOK_COMMON_NAME="Omarchy Machine Owner Key"
```
### `configs/airootfs/root/.automated_script.sh`
Add Secure Boot install support:
- Source `secure_boot.sh` after configurator runs.
- Install the Secure Boot boot path when `SECURE_BOOT_INSTALL=true`.
- Generate machine-local MOK keypair in the installed encrypted root.
- Build and sign UKI.
- Schedule MOK enrollment with `mokutil --import`.
- Install kernel-update hooks for UKI rebuild/signing.
- Preserve dual-boot firmware boot order unless explicitly promoted.
### Target System Files
Add installed files similar to:
```text
/etc/omarchy/secure-boot.conf
/etc/kernel/cmdline
/etc/pacman.d/hooks/90-omarchy-uki.hook
/usr/local/sbin/omarchy-build-uki
/usr/local/sbin/omarchy-sign-uki
/boot/EFI/Omarchy/shimx64.efi
/boot/EFI/Omarchy/mmx64.efi
/boot/EFI/Omarchy/systemd-bootx64.efi
/boot/EFI/Linux/omarchy-linux.efi
/boot/EFI/Linux/omarchy-linux-fallback.efi
```
Exact paths can change during implementation, but signed boot artifacts must stay under Omarchy-owned ESP directories.
## Package Requirements
Live ISO and installed Secure Boot path need tools for:
- Building UKIs.
- Signing PE/COFF EFI binaries.
- Importing MOK certificates.
- Inspecting Secure Boot state.
Candidate packages:
```text
sbsigntools
efitools
mokutil
openssl
systemd
```
Validate Arch package availability before implementation. Do not assume an official Arch `shim-signed` package exists.
## Dual-Boot Policy
On Windows dual-boot machines:
- Never modify Windows boot files.
- Never clear or change TPM state.
- Never change BitLocker protectors.
- Preserve existing firmware default boot order by default.
- Warn that adding a boot entry or changing ESP contents can trigger BitLocker recovery.
- Recommend suspending BitLocker from Windows before install and resuming it after both OSes boot.
If the user chooses to make Omarchy first in boot order, capture the old `BootOrder` and provide rollback guidance.
## Security Model
Protected against:
- Offline replacement of unsigned kernels on the ESP.
- Offline modification of initramfs or kernel command line when UKI signature verification is enforced.
- Accidental boot of unsigned Omarchy boot payloads in Secure Boot mode.
Not protected against in v1:
- A compromised running root user signing malicious future UKIs with the local MOK key.
- Evil-maid attacks that trick the user into enrolling a malicious MOK.
- Attacks requiring TPM-measured boot or remote attestation.
Mitigations:
- Store local private key only on encrypted root.
- Make MokManager enrollment instructions explicit and brand-specific.
- Display expected certificate fingerprint before reboot.
- Keep release signing key outside shipped media.
- Keep SBAT metadata current for revocation response.
## QEMU Validation
Use QEMU with OVMF Secure Boot enabled and test keys enrolled.
Test matrix:
1. Secure Boot ISO boots with test shim/test db in OVMF.
2. Unsigned live UKI fails to boot.
3. Tampered live UKI fails to boot.
4. Secure Boot install completes to empty disk.
5. First installed boot enters MokManager enrollment flow.
6. After MOK enrollment, installed Omarchy boots and prompts for LUKS unlock.
7. Tampered installed UKI fails to boot.
8. Kernel update regenerates and signs a new UKI.
9. Failed signing leaves previous UKI bootable.
10. Dual-boot disk with Windows-style ESP preserves `EFI/Microsoft` and boot order.
QEMU cannot fully prove Microsoft production trust. Hardware validation is required with firmware that trusts Microsoft UEFI CA and has Secure Boot enabled.
## Hardware Validation
Minimum hardware tests:
1. Microsoft Secure Boot enabled laptop boots official Secure Boot ISO.
2. Windows dual-boot install preserves Windows boot.
3. BitLocker-suspended install avoids recovery prompt after resuming BitLocker.
4. BitLocker-active install warning is visible before any disk mutation.
5. Installed Omarchy boots only after MOK enrollment.
6. Secure Boot remains enabled after install.
7. Kernel update boots with newly signed UKI.
8. Manually tampered UKI is rejected.
9. Firmware boot order is preserved unless user opted to promote Omarchy.
## Rollout Plan
### Phase 1: Research And Decisions
- Confirm shim submission requirements and timeline.
- Confirm selected second-stage boot manager.
- Confirm UKI generation mechanism on Arch.
- Confirm package availability for signing and MOK workflows.
- Decide local MOK key storage and update-hook policy.
### Phase 2: Dev Secure Boot In QEMU
- Add dev keys and QEMU OVMF Secure Boot harness.
- Build a dev-signed live UKI.
- Boot ISO in QEMU with test keys.
- Prove unsigned and tampered payload rejection.
### Phase 3: Installed UKI + MOK Flow
- Generate local MOK key during install.
- Build and sign installed UKI.
- Schedule MokManager enrollment.
- Boot installed system in QEMU after enrollment.
- Add kernel update hook and fallback UKI handling.
### Phase 4: Official Shim And Release Signing
- Build Omarchy shim with SBAT metadata and embedded release cert.
- Submit shim artifacts for Microsoft signing.
- Integrate signed shim artifacts into official build pipeline.
- Add release signing with HSM/offline key handling.
### Phase 5: Consumer Dual-Boot UX
- Add Secure Boot and BitLocker warnings to configurator.
- Preserve Windows boot order by default.
- Add MOK enrollment instructions and certificate fingerprint display.
- Validate on real Windows hardware.
## Acceptance Criteria
- Official Secure Boot ISO boots on Microsoft Secure Boot hardware without disabling Secure Boot.
- Secure Boot install does not ship or expose Omarchy release private keys.
- Installed Omarchy boots through shim after MOK enrollment.
- Installed Omarchy root remains LUKS-encrypted and passphrase-unlocked.
- Kernel updates produce signed UKIs automatically.
- Tampered UKIs fail to boot.
- Windows ESP contents are preserved.
- Windows boot order is preserved by default.
- Secure Boot mode never offers an unsigned kernel/initramfs boot entry.
## Open Questions
- Whether v1 should use systemd-boot, GRUB, or a verified Limine configuration as the second stage.
- Whether the local MOK private key should be encrypted with a user-supplied passphrase or stored root-only on encrypted root.
- How to support signed out-of-tree kernel modules, especially proprietary GPU drivers or DKMS packages.
- How snapshot booting should work when each bootable kernel must be a signed UKI.
- Whether official builds should always be Secure Boot-capable or ship a separate Secure Boot ISO flavor.
- What user-facing recovery path should exist if MOK enrollment is skipped or fails.
-158
View File
@@ -1,158 +0,0 @@
# Dual-Boot Installation Support for Omarchy ISO
## Context
Currently, the Omarchy installer wipes the entire selected disk (`"wipe": true`) and creates two partitions: a 2GB FAT32 ESP and a LUKS-encrypted Btrfs root. This means users must dedicate an entire disk to Omarchy. Adding dual-boot support lets users install alongside an existing OS (typically Windows) while keeping the Omarchy partition encrypted.
## Core Approach: `pre_mounted_config`
**Do our own partitioning, then hand off to archinstall for package installation only.**
archinstall's `manual_partitioning` mode has a long history of bugs with existing partitions and LUKS (archinstall issues #998, #2072, #2444). Instead, we:
1. Detect existing partitions and free space ourselves (with `sgdisk`/`parted`)
2. Create the LUKS partition and Btrfs subvolumes ourselves (with `cryptsetup`/`mkfs.btrfs`)
3. Mount everything at `/mnt/archinstall`
4. Tell archinstall to use `"config_type": "pre_mounted_config"` — it installs packages into our pre-mounted tree and does zero disk operations
5. Handle encryption config (crypttab, mkinitcpio hooks, bootloader cmdline) in post-install
The existing full-disk path stays completely unchanged. Dual-boot is a parallel code path.
## Implementation Guardrails
- **Run target changes in chroot** — all target-system commands (`mkinitcpio -P`, `limine-scan`, bootloader config updates) must run via `arch-chroot "$INSTALL_ROOT" ...`
- **Edit target files under `$INSTALL_ROOT` only** — write `mkinitcpio` hook config, `limine.conf`, `crypttab`, and related files in `$INSTALL_ROOT/etc` and `$INSTALL_ROOT/boot` (never in the live environment root)
- **Settle kernel partition state** — after creating the new partition, run `partprobe "$DISK"` and `udevadm settle` before `cryptsetup` to avoid device-node races
- **Deterministic fstab** — always generate `/etc/fstab` for dual-boot with overwrite semantics (e.g., `genfstab -U "$INSTALL_ROOT" > "$INSTALL_ROOT/etc/fstab"`) to avoid duplicate entries on retries
- **Hook compatibility** — when updating mkinitcpio hooks, add `encrypt` for busybox initramfs or `sd-encrypt` for systemd initramfs; never assume one universally
- **Safe rollback on failure** — on any dual-boot setup failure: unmount mounts, close LUKS mapping, and surface recovery guidance; optionally delete the just-created partition only if it was created in this run
## Scope Constraints
- **UEFI/GPT only** — dual-boot on MBR/BIOS is too fragile; those users get full-disk only
- **No auto-shrinking** — users must free up space from their existing OS first (e.g., Windows Disk Management). Too risky to shrink partitions automatically
- **Minimum 20GB free space** required for Omarchy
## User Flow
```
Select disk → Detect layout →
If disk has existing OS + ESP + ≥20GB free space:
"How would you like to install?"
→ "Install alongside Windows (use 85GB free space)"
→ "Erase entire disk"
If disk is empty or no free space:
Existing "Confirm overwriting" flow (unchanged)
```
## File Changes
### 1. `configs/airootfs/root/configurator`
**Add after disk selection (line 233), before config generation:**
- `detect_disk_layout()` — examines selected disk for:
- GPT partition table (required for dual-boot)
- Existing ESP partition (via `lsblk` PARTTYPE matching EFI GUID)
- Free unallocated space (via `sgdisk -F`/`-E` or `parted print free`)
- OS detection for installer UX warnings/prompts (not boot correctness): mount ESP read-only, check common vendor paths like `EFI/Microsoft`, `EFI/systemd`, `EFI/ubuntu`, `EFI/fedora`, `EFI/GRUB`
- `install_mode_form()` — shows gum choose with dual-boot vs erase options when conditions are met. Sets `DUAL_BOOT=true/false`.
**Generate different JSON when `DUAL_BOOT=true`:**
```json
{
"disk_config": {
"config_type": "pre_mounted_config",
"mountpoint": "/mnt/archinstall"
}
}
```
No `device_modifications`, no `disk_encryption`, no `btrfs_options` — we handle all of that ourselves. Everything else (hostname, kernels, packages, etc.) stays the same.
**Write `install_mode.sh`** alongside the other config files:
```bash
DUAL_BOOT=true
DISK=/dev/nvme0n1
ESP_PARTITION=/dev/nvme0n1p1
```
### 2. `configs/airootfs/root/.automated_script.sh`
**Source `install_mode.sh`** in `run_configurator()` if it exists.
**Add `setup_dual_boot_partitions()`** — runs before archinstall when `DUAL_BOOT=true`:
1. Select the largest contiguous free region and run `sgdisk` to create new partition there (type 8309 = Linux LUKS)
2. `partprobe "$DISK"` + `udevadm settle` so new partition node is present
3. `cryptsetup luksFormat` + `luksOpen` with user's password
4. `mkfs.btrfs` on `/dev/mapper/omarchy_root`
5. Create Btrfs subvolumes: `@`, `@home`, `@log`, `@pkg`
6. Mount subvolumes at `/mnt/archinstall` with `compress=zstd`
7. Mount existing ESP (untouched) at `/mnt/archinstall/boot`
8. Generate target `/etc/fstab` from mounted filesystems before post-install steps
**Add `configure_dual_boot_post_install()`** — runs after archinstall:
1. Write `/etc/crypttab` entry using LUKS UUID/PARTUUID (stable identifier), e.g. `omarchy_root UUID=<luks-uuid> none luks,discard`
2. Update mkinitcpio hooks before `filesystems`: `encrypt` (busybox) or `sd-encrypt` (systemd)
3. Regenerate initramfs with `arch-chroot "$INSTALL_ROOT" mkinitcpio -P`
4. Configure Limine kernel cmdline to match initramfs mode:
- busybox `encrypt`: `cryptdevice=UUID=<uuid>:omarchy_root root=/dev/mapper/omarchy_root`
- systemd `sd-encrypt`: `rd.luks.name=<uuid>=omarchy_root root=/dev/mapper/omarchy_root` (or equivalent `rd.luks.uuid=` form)
5. Run `arch-chroot "$INSTALL_ROOT" limine-scan` to detect other OS boot entries (Windows, etc.)
6. Preserve current firmware default boot order unless user explicitly chooses to make Limine first (capture and restore `BootOrder` if `efibootmgr` side effects reorder entries)
**Use `$INSTALL_ROOT` variable** — defaults to `/mnt` for full-disk, `/mnt/archinstall` for dual-boot. Post-install steps (offline mirror bind-mount, sudoers, omarchy copy) reference this instead of hardcoded `/mnt`.
### 3. `builder/build-iso.sh` + `builder/archinstall.packages`
Add `parted` to packages available in the live ISO (needed for free space detection).
## Bootloader Coexistence
Limine installs to `ESP/EFI/arch-limine/` and registers via `efibootmgr`. This sits alongside:
- Windows Boot Manager at `ESP/EFI/Microsoft/Boot/bootmgfw.efi`
- Any other bootloader in its own ESP subdirectory
`limine-scan` auto-detects other UEFI boot entries and adds them to `limine.conf`. Users can choose OS at boot.
**Boot order policy**: creating a new EFI entry must not silently change the current default boot target. Keep existing firmware boot order by default and only promote Limine when user opts in.
Implementation note: when registering Limine with `efibootmgr`, do not pass reorder flags by default; if firmware still changes order, explicitly restore prior `BootOrder` unless the user opted to promote Limine.
**ESP size note**: Windows typically creates a 100-260MB ESP. This is enough for Limine + kernel + initramfs, but Snapper boot snapshots (which copy kernels to ESP) will be limited or disabled.
**ESP free-space threshold**:
- If ESP has `<100MB` free at install time: show a strong warning (do not block install)
- If ESP has `<64MB` free: hide dual-boot option and require full-disk install (or user frees space first)
**BitLocker warning**: If Windows is detected, show a warning about having the BitLocker recovery key ready, since modifying ESP/UEFI entries can trigger recovery.
## Edge Cases
| Scenario | Behavior |
|----------|----------|
| Disk is empty | Full-disk install only (current behavior) |
| Existing OS but no free space | Full-disk install only, with note to free space first |
| Free space < 20GB | Dual-boot option not shown |
| MBR/BIOS disk | Dual-boot option not shown |
| ESP < 100MB free after mount | Warn user; continue allowed |
| ESP < 64MB free after mount | Dual-boot option not shown |
| Multiple free regions | Use largest contiguous region |
| Partitioning/setup failure mid-run | Cleanup mounts/LUKS and abort safely |
## Verification
1. **Regression**: Full-disk install in VM with empty disk — must work identically to today
2. **Dual-boot with Windows**: VM with Windows installed + 40GB free space — detect Windows, install alongside, both OSes boot
3. **Dual-boot with Linux**: VM with another Linux + free space — similar test
4. **No free space**: VM with fully partitioned disk — dual-boot option should not appear
5. **Encryption**: After dual-boot install, verify LUKS unlock prompt at boot and encrypted root
6. **Initramfs mode**: Validate both busybox and systemd hook paths boot correctly (`encrypt` vs `sd-encrypt`)
7. **Boot order**: Verify existing default UEFI entry remains unchanged unless user opted to promote Limine
-163
View File
@@ -1,163 +0,0 @@
# OEM Install + Factory Reset Plan
## Goal
An install mode that installs the entire system but defers user account setup to
first boot. On first power-on the machine asks for the user, creates it, and
lands in the already-installed system — the classic OEM out-of-box experience.
The same machinery doubles as a "reset computer" option for wiping a machine
before selling it.
## Core idea: "OEM state" as a first-class system state
Define one canonical state — *fully installed, no user, setup pending* — and
make both paths produce it:
- **ISO OEM mode** ends the install in OEM state instead of creating the user.
- **Reset** returns a running machine to OEM state.
OEM state is: a fully installed system plus `/var/lib/omarchy/oem/` containing:
| File | Purpose |
|------|---------|
| `pending` | Flag that arms the first-boot setup service |
| `packages/node-v*.tar.gz` | Node tarball copied off the ISO's `/opt/packages` (~30MB) so `omarchy-finalize-user` works offline at first boot |
| `groups` | Deferred `usermod -aG` groups written by system setup (see below) |
| `luks-key` | Throwaway LUKS passphrase for encrypted installs, staged for auto-unlock until first-boot re-key |
## Why the seam is nearly clean already
Everything in the orchestrator through `finalize_limine_boot` is
user-independent except five touch points:
1. `installer.create_users` — useradd + password (root gets the same password).
2. Three `usermod -aG` calls inside `omarchy-setup-system`'s scripts
(`config/docker.sh`, `hardware/input-group.sh`, `hardware/apple/fix-t2.sh` —
docker/input/video). This is the *only* reason `omarchy-setup-system`
requires the user to exist (its getent check).
3. `run_chroot_finalizer` → `omarchy-finalize-user` as the user. Already
offline-safe: `/etc/skel` does the heavy seeding at useradd time,
`omarchy-mise-install` writes lazy wrappers, and the only external artifact
is the bundled Node tarball from `/opt/packages` (`install/user/mise.sh`).
4. `configure_login` — SDDM `state.conf` and the encrypted-install autologin.
5. Encryption — the user's password *is* the LUKS passphrase.
Nothing in hardware setup, Limine, hibernation, snapper, or package
installation cares who the user is.
## First boot: `omarchy-oem-setup.service`
Ships in the omarchy runtime package (so reset can arm it without the ISO):
- `ConditionPathExists=/var/lib/omarchy/oem/pending`
- `Before=display-manager.service`, conflicts `getty@tty1`, TUI on tty1.
- Runs the user form extracted from the configurator (Step 2: username,
password, full name, email). The configurator is bash+gum, so the form moves
into a runtime-shipped `omarchy-oem-setup` script that reuses the exact ISO
look (logo, Tokyo Night VT palette).
Then it does what the ISO would have done:
1. `useradd` with wheel + the groups from `/var/lib/omarchy/oem/groups`,
set user + root password.
2. `omarchy-finalize-user` as the user, with a setup context that points at the
stashed Node tarball (a sibling of the existing `iso-chroot` context).
3. The `configure_login` writes: SDDM `state.conf`, autologin for encrypted
installs.
4. If `luks-key` exists: `cryptsetup luksChangeKey` to the user's password,
delete the stash, remove the auto-unlock keyfile, rebuild initramfs.
5. Git identity from the form (the `OMARCHY_USER_NAME`/`OMARCHY_USER_EMAIL`
path in `install/user/git.sh`).
6. Remove `/var/lib/omarchy/oem/`, hand off to SDDM.
The user lands in their session exactly like a normal install.
## Encryption: keep it, Pop!_OS-style
Do not force OEM installs unencrypted. Install encrypted with a generated
throwaway passphrase, stage a keyfile so boot auto-unlocks during the OEM
window, and have first-boot setup re-key to the user's chosen password
(step 4 above). Limine and mkinitcpio don't care what the passphrase is. This
preserves encrypted-by-default without the machine builder ever knowing the end
user's password.
## ISO-side changes (this repo)
- **Configurator**: an OEM choice in the install-mode step that skips Step 2
(user), plus a cidata `oem` marker file that replaces the
`user_credentials.json` requirement — that's what actual OEM imaging rigs
will use via autoinstall.
- **Orchestrator**: `oem: true` in the `omarchy_install` JSON. Swaps the three
user phases (`create_users` inside `arch_install_system`,
`run_chroot_finalizer`, the user parts of `configure_login`) for a
"stage OEM state" phase that writes `/var/lib/omarchy/oem/` and enables
`omarchy-oem-setup.service` in the target. `InstallContext.username` needs an
OEM-safe fallback (creds are absent).
- **Factory snapshot**: at the end of *every* install (not just OEM), take a
read-only snapshot of `@` kept at the 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. This is what makes
reset a true factory reset.
## Runtime-side changes (omarchy repo)
- `omarchy-oem-setup` + `omarchy-oem-setup.service` (the OOBE above).
- `omarchy-system-factory-reset` (below).
- Relax `omarchy-setup-system`: when the install user doesn't exist yet, the
three `usermod -aG` scripts append to `/var/lib/omarchy/oem/groups` instead
of failing the getent check.
## Reset: `omarchy-system-factory-reset`
Behind a very explicit confirmation (typed, not y/n):
1. Point the default subvolume at a writable snapshot of `@factory`; stage a
next-boot wipe unit.
2. On reboot: recreate `@home` (subvolume delete/create — instant, no rm -rf),
clear `@log`, delete snapper snapshots, reset machine-id, SSH host keys,
NetworkManager connections, Tailscale state, SDDM state; run `fstrim`.
3. Re-arm OEM state: `pending` flag, `groups`, Node tarball (kept from install
or re-fetched into `@factory` at install time), and on encrypted machines a
throwaway LUKS re-key — the buyer never needs the seller's passphrase.
Next boot is the same OOBE as a fresh OEM install. User-installed packages and
/etc drift are gone, which is what "wipe and sell" actually promises.
Machines installed before `@factory` existed get a degraded reset — keep the
current system, wipe users/state only — with that caveat surfaced in the
confirmation.
### Honest limitations (document these)
- On unencrypted disks this is deletion, not forensic erasure. `fstrim` helps
on SSDs; offer `cryptsetup reencrypt` or `blkdiscard` + reinstall for the
paranoid.
- `luksChangeKey` changes the passphrase but not the volume key; freed extents
remain readable to someone with raw-device access and the new passphrase.
Same remedy as above.
- A reset machine boots with day-one packages until it updates — same as a
fresh install.
## Sequencing
The OOBE lives in the omarchy runtime package, but the ISO's OEM phase depends
on it existing. So:
1. Ship the runtime side first (`omarchy-oem-setup`, service, reset command,
`omarchy-setup-system` relaxation).
2. ISO OEM mode then asserts the service file exists in the target before
offering the mode.
3. Factory snapshot can land with either half; reset requires both.
## Validation
- VM: OEM install (encrypted + unencrypted) → reboot → OOBE → session; verify
LUKS passphrase is the user's, throwaway key gone, groups applied,
`omarchy-finalize-user` state marker present.
- VM: normal install → use system → `omarchy-system-factory-reset` → OOBE as new
user; verify no trace of prior user (home, NM connections, tailscale,
machine-id, host keys, snapper snapshots).
- Autoinstall: cidata drive with `oem` marker and no credentials → unattended
OEM install.
- Negative: OEM ISO built against a runtime package lacking the service fails
the install with a clear error, not a user-less boot loop.
-643
View File
@@ -1,643 +0,0 @@
# Protected Partition Install Plan: Dual-Boot + Restore
## Goal
Implement one shared non-destructive install path that supports both:
- Dual-boot installs alongside an existing OS.
- OEM/restore installs from a permanent internal restore partition set.
Beyond install: every standalone Omarchy machine — whether OEM-provisioned or installed from the consumer ISO onto an empty disk — exposes a "factory reset" command that durably stages the next boot into the on-disk restore partition set. The installed-OS command leaves the machine as though it had just been prepared to boot from restore; the restore environment owns the destructive wipe/reinstall flow. Dual-boot installs do not get factory reset (no room for a restore partition set).
The current full-disk install path remains unchanged until the protected-partition path is proven. The protected path does its own partitioning, encryption, filesystems, mounts, and boot configuration, then hands a prepared mount tree to archinstall with `pre_mounted_config`.
## Core Architecture
Use a single installer engine with multiple partition policies.
```text
full_disk existing archinstall default_layout + wipe:true path
dual_boot protect existing OS partitions, reuse existing ESP, add Omarchy LUKS root
restore_install protect restore partition set, create/recreate installed ESP + Omarchy LUKS root
```
The key rule: protected modes must never pass the whole disk to archinstall with `wipe: true`.
## Why This Combines Both Features
Dual-boot and restore installs are the same technical problem:
1. Detect disk layout.
2. Identify protected partitions.
3. Create or format only explicitly writable Omarchy partitions.
4. Set up encrypted root manually.
5. Mount a complete install tree manually.
6. Run archinstall only as a package/base-system installer.
7. Configure encryption, fstab, initramfs, and bootloader after archinstall.
Only the partition policy differs.
## Scope Constraints
- UEFI/GPT only for `dual_boot` and `restore_install` v1.
- Existing BIOS/syslinux ISO behavior stays available for normal full-disk installs.
- No automatic shrinking of existing partitions.
- Dual-boot requires pre-existing free space.
- Restore install requires a detected Omarchy restore partition set.
- Installed Omarchy root is encrypted with LUKS.
- Restore partitions are not encrypted unless a later product requirement demands it.
- Restore payload partitions are normally left unmounted in the installed OS and mounted read-only by the restore environment. A separate restore state partition is the only restore-owned partition mounted read-write for reset triggers and recovery logs.
- Swap matches the current full-disk install: zram for memory pressure, plus a Btrfs swapfile in a dedicated `NODATACOW` `/swap` subvolume sized to RAM for hibernation. The subvolume, swapfile, `resume` mkinitcpio hook, and `resume=<dev> resume_offset=<offset>` Limine cmdline drop-in are created post-install by Omarchy's existing `omarchy-hibernation-setup`. Protected modes must not break this: the `encrypt`/`sd-encrypt` hook must precede `resume` in mkinitcpio so the swapfile is reachable after LUKS unlock, and Limine must honor `/etc/limine-entry-tool.d/*.conf` drop-ins so the post-install hook can append resume parameters. No swap partition.
## Partition Policies
### Full Disk
Current behavior:
```text
p1 installed ESP FAT32 2G
p2 installed root LUKS rest of disk
```
Implementation stays on archinstall `default_layout` with `wipe: true` for now.
Phase 7 replaces this with a layout that mirrors `restore_install`:
```text
p1 restore ESP FAT32 protected after install
p2 restore payload ext4 protected after install, read-only payload
p3 restore state ext4 protected after install, writable trigger/log state
p4 installed ESP FAT32 writable
p5 installed root LUKS writable
```
Rationale: factory reset requires an on-disk restore partition set. Making this the default for standalone installs gives every consumer machine the same self-recovery capability OEM-provisioned machines get. The legacy two-partition layout becomes opt-in (e.g. `--no-restore`) for users who explicitly want the disk space back.
### Dual Boot
Example target disk before install:
```text
p1 existing ESP FAT32 protected, reused as /efi
p2 Windows MSR MSR protected
p3 Windows NTFS protected
free space writable region
```
After install:
```text
p1 existing ESP FAT32 protected, mounted as /efi during install
p2 Windows MSR MSR protected
p3 Windows NTFS protected
p4 Omarchy root LUKS new encrypted Btrfs root
```
Policy:
- Protect all existing partitions.
- Reuse the existing ESP only for Omarchy boot files under `EFI/Omarchy`. Do not write top-level `vmlinuz-*`, `initramfs-*`, or vendor-looking paths on a shared ESP.
- Mount the shared ESP at `/efi`, keep `/boot` on the encrypted root, and install an Omarchy-owned kernel/initramfs sync hook that copies boot artifacts into `/efi/EFI/Omarchy` after kernel or initramfs updates.
- Refuse to install if the existing ESP is below 200 MB total or has less than 150 MB free. Warn below 300 MB total or below 200 MB free. These thresholds size for one Omarchy kernel + initramfs + Limine alongside a typical vendor loader; revisit if the Omarchy initramfs grows materially. Free space must be measured from the mounted ESP, not inferred from partition size.
- Create one new LUKS partition in the largest single contiguous free region of at least 40 GB. Refuse otherwise. Do not stitch across multiple non-contiguous regions.
- Preserve current firmware boot order unless the user explicitly opts to make Omarchy first.
- Abort with a clear error if any Windows partition is detected as BitLocker/FVE, including suspended BitLocker. Do not proceed past detection. Adding a new partition to a disk with a BitLocker-protected volume changes the firmware/boot environment enough that Windows can demand the recovery key on next boot — and many users do not have it. The error message must instruct the user to turn off BitLocker from inside Windows and wait for decryption to complete, then reboot into the Omarchy installer and retry. Do not offer an "I have my recovery key, continue anyway" override in v1; users who really want it can fully disable BitLocker themselves.
- Warn if non-BitLocker Windows is detected, since boot order changes can still surprise users.
### Restore Install
Recommended factory disk image layout:
```text
p1 restore ESP FAT32 protected, boots restore environment
p2 restore payload ext4 protected, contains live installer/offline payload
p3 restore state ext4 protected from formatting, writable reset trigger/log state
p4 installed ESP FAT32 writable, created/recreated by installer
p5 installed root LUKS writable, created/recreated by installer
```
Policy:
- Protect restore ESP, restore payload, and restore state partitions from delete/format operations. Only the restore state partition is mounted read-write, and only for trigger files and logs.
- On first boot, create installed ESP and installed root in remaining disk space.
- On reinstall, format the installed Omarchy partitions in place by default. Only delete and recreate them when their geometry no longer matches policy (e.g. the disk was resized). Format-in-place preserves PARTUUIDs, keeps NVRAM entries valid, and avoids touching the partition table.
- Set installed Omarchy first in boot order after successful install.
- Keep restore boot available as firmware fallback and as a boot menu entry.
- Require `copytoram=y` before mutating the internal disk from restore mode.
Use separate restore and installed ESPs for v1. A shared ESP saves space but makes restore easier to break during normal bootloader maintenance or reinstall.
## Restore Image Provisioning
Create a factory artifact separate from the normal consumer ISO:
```text
omarchy-restore.img.zst
```
Factory provisioning writes this image to the internal disk once:
```bash
zstd -dc omarchy-restore.img.zst | dd of=/dev/nvme0n1 bs=16M status=progress conv=fsync
```
The restore image should contain:
- A restore ESP with `EFI/BOOT/BOOTX64.EFI` fallback boot path.
- A restore payload partition labeled `OMARCHY_RESTORE`.
- A small restore state partition labeled `OMARCHY_STATE` for reset triggers and recovery logs.
- The archiso live environment payload, offline package mirror, configurator, and installer scripts.
- A boot entry that passes `omarchy.mode=restore copytoram=y`.
- Boot configuration that locates the restore payload by stable label/UUID, not by transient disk name.
- A signed manifest covering the archiso payload and offline mirror. The verification public key must be shipped outside the mutable restore payload, such as in the restore boot environment and installed reset tool package.
The factory flasher must verify the integrity of `omarchy-restore.img.zst` before writing. The on-disk restore environment must verify its signed payload manifest at boot before mutating the internal disk. A plain checksum stored on the same writable disk is not sufficient. Without a signed manifest, a tampered or corrupted restore image silently destroys the user's data the next time they invoke "factory reset."
On first restore boot:
1. Bind the candidate restore disk to the actual boot source (`/run/archiso/bootmnt`, kernel command line, or equivalent). Do not select an arbitrary disk just because it has matching labels; cloned restore images can have duplicate labels and UUIDs.
2. Move the backup GPT header to the real end of disk with `sgdisk -e` if the factory image was written to a larger disk. This relocates the header only — existing partitions are not grown. The newly-available space at the end of the disk is what installed ESP and installed root consume.
3. On first boot only, randomize the GPT disk GUID, partition GUIDs/PARTUUIDs, and filesystem UUIDs for restore-owned partitions before generating any NVRAM entries, fstab entries, or installer state. Labels may remain stable; UUIDs must not stay cloned across machines.
4. Detect and protect restore partitions by the newly-randomized PARTUUIDs.
5. Create installed ESP and installed root in remaining space.
6. Continue through the shared protected install path.
## Factory Reset
Goal: an installed Omarchy machine that has a restore partition set (any standalone install — `restore_install` today, `full_disk` after Phase 7) exposes a user-facing factory reset command that prepares the device to boot from internal restore without external media. After the next restore boot, the restore environment returns the device to a fresh-install state.
### User Flow
1. User runs `omarchy-factory-reset` from a terminal with sudo. (A GUI entry under Settings is deferred to a follow-up.)
2. The command prints which partitions will be destroyed (installed ESP, installed root) and which will be preserved (restore ESP, restore payload, restore state), by stable identifier, and asks the user to type a confirmation phrase. The typed-phrase gate is intentional friction; no `--yes` shortcut in v1.
3. On confirmation, the command writes a trigger file to the restore state partition, verifies or recreates the restore boot entry, and sets `BootNext` via `efibootmgr` to that restore entry.
4. The command exits only after the trigger file is fsynced, the restore state partition is unmounted, and `BootNext` is confirmed. At that point the installed-OS side is complete: the machine has been left as though it had just been prepared to boot from the restore partition.
5. The user reboots when ready. A later GUI may offer a "reboot now" action, but rebooting is a convenience after the staged state is durable, not part of the safety-critical mutation.
6. The restore environment detects the trigger on boot, verifies the signed restore payload manifest, writes a reset-in-progress marker to restore state, and temporarily sets persistent `BootOrder` to restore first before mutating installed partitions.
7. The restore environment runs the existing `restore_install` reinstall path — format installed ESP, LUKS-format installed root, run the standard install flow — non-interactively, then deletes the trigger and reset-in-progress marker.
8. On install completion, the restore environment restores persistent `BootOrder` to installed Omarchy first, restore second, sets `BootNext` to the installed Omarchy entry, and reboots.
9. The user lands in a fresh installed Omarchy. Normal persistent `BootOrder` (Omarchy first, restore second) has been restored.
User data on installed ESP and installed root is destroyed. Restore ESP, restore payload, and restore state are protected from destructive operations throughout. The restore payload is the source of truth for the rebuild; restore state contains only trigger and recovery state.
### Trigger Mechanism
Use a flag file on the restore state partition rather than NVRAM variables or the restore payload. NVRAM is fragile across firmware updates, and the restore payload should stay immutable/read-only so signed manifest verification remains meaningful.
- Path: `<restore-state-mount>/.omarchy-factory-reset`
- Contents: timestamp, hostname at trigger time, optional reason. Plain text, one `key=value` per line.
- The installed-OS command mounts the restore state partition by `OMARCHY_STATE` label, writes the flag, fsync, and unmounts. It may mount the restore payload read-only only long enough to verify the signed manifest before triggering.
- The restore environment checks for the flag at boot. If present, it runs the reset flow non-interactively and deletes the flag on success. If absent, restore boot behaves normally (manual install/repair menu).
- The flag must be deleted before reboot. Otherwise the machine resets itself in a loop.
### Boot Order
`efibootmgr --bootnext <restore-entry>` is one-shot — it takes effect on the next boot only, then NVRAM reverts to the existing `BootOrder`. This is what we want before the destructive phase: if the reset is interrupted before restore boot, the next normal boot still tries the installed Omarchy first.
After the restore environment accepts the trigger and before it wipes installed partitions, it must temporarily set persistent `BootOrder` to restore first, installed Omarchy second. This prevents a power loss during reinstall from leaving firmware stuck on a half-written installed ESP. On successful reinstall, the restore environment deletes the reset-in-progress marker, restores persistent `BootOrder` to installed Omarchy first and restore second, then sets `BootNext` to the installed Omarchy entry to land the user in their fresh OS.
If the installed OS cannot write and confirm `BootNext`, the staging command must remove any trigger it wrote and abort; a manual reinstall can still proceed through the firmware boot menu, but `omarchy-factory-reset` has not successfully prepared the machine. If the restore environment cannot set temporary restore-first `BootOrder` for an unattended reset, abort before destructive operations; manual reinstall can still proceed with an explicit warning.
### Safety
The factory reset command is a staging tool, not the destructive installer. It must:
- Require root and refuse to run otherwise.
- Refuse to run if no complete restore partition set is detected (restore ESP, restore payload, restore state), e.g. on a dual-boot machine or a `--no-restore` full-disk install.
- Refuse to run if the signed restore payload manifest check fails — a corrupt restore is not recoverable from inside the running OS, and triggering a reset would brick the machine.
- Verify the restore boot entry exists in NVRAM before triggering; if not, repopulate it from `EFI/BOOT/BOOTX64.EFI` fallback or abort with a message.
- Confirm `BootNext` points to the restore boot entry before exiting, so the system is actually prepared to boot restore.
- Print a final pre-confirmation list of partitions that will be destroyed, by stable identifier, so the user sees exactly what they are committing to.
The restore environment, when running due to the trigger flag, must still apply every guard from the Safety Model. The trigger is permission to run the reinstall flow, not permission to skip safety checks.
### File Changes for Factory Reset
- Ship `omarchy-factory-reset` on the installed system. Source location depends on Omarchy's package boundary — either as part of the Omarchy package on the offline mirror, or copied from the live ISO into the installed system during install. Pick one consistent approach during implementation.
- Restore state must be mountable read-write from the installed OS by an admin via `OMARCHY_STATE`. Restore payload should only be mounted read-only for manifest verification via `OMARCHY_RESTORE`.
## Safety Model
Protected installs must use stable identifiers, not only `/dev/nvme0n1pX` names.
Track:
```text
protected partition PARTUUIDs
restore state PARTUUID
writable partition PARTUUIDs
selected disk ID
install mode
install root
```
Every destructive operation must call a guard first:
```text
assert_writable_partition <partition>
assert_not_protected <partition>
```
Guarded operations include:
- `sgdisk --delete`
- `sgdisk --new`
- `wipefs`
- `mkfs.fat`
- `mkfs.btrfs`
- `cryptsetup luksFormat`
- partition table rewrites
In restore mode, also assert:
- `/proc/cmdline` contains `copytoram=y`.
- The restore payload is not mounted read-write from the target disk at mutation time.
- Restore ESP, restore payload, and restore state partitions are detected and marked protected from delete/format operations.
- The installed target partitions do not overlap protected partition ranges.
### Power Loss Recovery
A torn install must be recoverable on next boot rather than leaving the disk in a state that needs manual intervention.
- After partition table changes but before LUKS format: the new partition exists but holds no recognizable filesystem or LUKS header. On the next install boot, treat any writable partition without a valid header as discardable and re-run the format step.
- After LUKS format but before archinstall completes: the LUKS header exists but no working OS is installed. On the next boot from restore or ISO, offer to wipe and retry rather than attempting to resume.
- During archinstall: same as above. Resuming partway through a torn pacstrap is more brittle than restarting it.
In dual-boot mode, "wipe and retry" applies only to the writable Omarchy partition. Protected partitions must be re-verified by PARTUUID before any retry runs — power loss does not unlock protected ranges.
## Installer State Contract
The configurator writes `install_mode.sh` for `.automated_script.sh` to source.
Example dual-boot state:
```bash
INSTALL_MODE=dual_boot
INSTALL_ROOT=/mnt/archinstall
DISK=/dev/nvme0n1
BOOT_PARTITION=/dev/nvme0n1p1
ROOT_PARTITION=/dev/nvme0n1p4
PROTECTED_PARTUUIDS="1111-2222 3333-4444 5555-6666"
WRITABLE_PARTUUIDS="7777-8888"
PROMOTE_OMARCHY_BOOT=false
```
Example restore state:
```bash
INSTALL_MODE=restore_install
INSTALL_ROOT=/mnt/archinstall
DISK=/dev/nvme0n1
RESTORE_ESP_PARTITION=/dev/nvme0n1p1
RESTORE_PAYLOAD_PARTITION=/dev/nvme0n1p2
RESTORE_STATE_PARTITION=/dev/nvme0n1p3
INSTALLED_ESP_PARTITION=/dev/nvme0n1p4
ROOT_PARTITION=/dev/nvme0n1p5
PROTECTED_PARTUUIDS="1111-2222 3333-4444 5555-6666"
WRITABLE_PARTUUIDS="7777-8888 9999-aaaa"
PROMOTE_OMARCHY_BOOT=true
```
Keep this file simple shell, generated only by our configurator, and quote values safely.
Restore mode uses `INSTALLED_ESP_PARTITION` (distinct from `RESTORE_ESP_PARTITION`) because two ESPs are present. It also tracks `RESTORE_STATE_PARTITION` separately because that partition is writable state but protected from delete/format operations. Dual-boot mode keeps `BOOT_PARTITION` since only one ESP exists and is unambiguous.
## Archinstall Config
Protected modes generate this disk config and explicitly disable archinstall bootloader writes:
```json
{
"bootloader": null,
"disk_config": {
"config_type": "pre_mounted_config",
"mountpoint": "/mnt/archinstall"
}
}
```
Do not include `device_modifications`, `disk_encryption`, or `btrfs_options` in protected modes. The installer handles those before and after archinstall. If archinstall does not accept `"bootloader": null`, Phase 2a must identify the supported equivalent; protected mode must not proceed while archinstall can write boot files or NVRAM entries independently.
Full-disk mode keeps the existing generated `default_layout` config.
**Verify before Phase 2b.** Under `pre_mounted_config`, archinstall still tends to run `genfstab`, write a bootloader, and may write `/etc/crypttab`. The plan currently assumes archinstall acts only as a base-system installer in protected mode; this needs to be confirmed on a throwaway VM. Once confirmed, decide for each step (fstab, crypttab, bootloader, initramfs, kernel cmdline) whether the manual steps below run *instead of* archinstall's behavior, *after* archinstall has had its turn, or with archinstall's step disabled in config. The bootloader outcome is a hard gate: protected mode must have a confirmed way to prevent archinstall from writing Limine files or NVRAM entries. Do not start writing the manual pre/post steps until this is resolved — the unknown sits underneath every downstream step.
## File Changes
### `configs/airootfs/root/configurator`
Add shared detection and policy selection:
- `detect_disk_layout` finds GPT state, partitions, partition ranges, filesystem labels, ESPs, ESP free space, and free regions.
- `detect_existing_os` mounts ESP read-only and checks known vendor paths like `EFI/Microsoft`, `EFI/ubuntu`, `EFI/fedora`, `EFI/GRUB`, and `EFI/systemd`.
- `detect_bitlocker` checks every Windows/basic-data candidate partition on the selected disk for BitLocker. Use `blkid -o value -s TYPE`; BitLocker volumes report `BitLocker` rather than `ntfs`. As a backup, read 8 bytes at offset 3 of the partition — BitLocker volumes contain `-FVE-FS-` instead of `NTFS `. If any partition reports BitLocker, the dual-boot path aborts before any disk mutation with the message described in the dual-boot policy.
- `detect_restore_layout` finds `OMARCHY_RESTORE` and `OMARCHY_STATE` partitions and validates the restore ESP/payload/state set.
- `find_free_regions` finds unallocated GPT space using `sgdisk` or `parted`. Selection rule: pick the largest single contiguous region of at least 40 GB. Do not concatenate non-contiguous regions.
- `install_mode_form` chooses between full disk, dual boot, and restore install based on context.
User flow:
```text
Normal ISO boot:
select disk
detect layout
if existing OS/install is detected:
show detected installs and disk layout summary
always offer "Exclusive install (erase disk)" using the existing full-disk `wipe: true` path
if existing ESP + enough free space + guardrails pass:
also offer "Install alongside"
else:
show why "Install alongside" is unavailable (for example: no ESP, no suitable free space, BitLocker/FVE, ESP too small)
else:
keep current erase-disk flow
Restore boot:
detect restore disk automatically
show first-install or reinstall warning
protect restore ESP/payload/state partitions
create/recreate installed partitions only
```
Generated files:
- `user_credentials.json`
- `user_configuration.json`
- `user_full_name.txt`
- `user_email_address.txt`
- `install_mode.sh`
### `configs/airootfs/root/.automated_script.sh`
Add shared protected install functions:
- Source `install_mode.sh` after configurator runs.
- Set `INSTALL_ROOT=/mnt/archinstall` for all modes, including full-disk. A single mount root avoids subtle bugs in code touched by both paths and removes one branch from every post-install step.
- Before changing the mount root, grep the live ISO and post-install scripts for hardcoded `/mnt` references and produce an explicit list of call sites. Update each one consciously rather than via a global rename.
- Add `setup_protected_partitions` before archinstall.
- Add `configure_protected_post_install` after archinstall.
- Add cleanup trap for mounts, LUKS mappings, and temporary directories.
Protected pre-install steps:
1. Verify mode-specific guardrails.
2. Create writable partitions if needed.
3. Run `partprobe "$DISK"` and `udevadm settle`.
4. Format installed ESP when policy requires it.
5. `cryptsetup luksFormat` the root partition.
6. `cryptsetup open` root as `omarchy_root`.
7. `mkfs.btrfs /dev/mapper/omarchy_root`.
8. Create Btrfs subvolumes `@`, `@home`, `@log`, and `@pkg`.
9. Mount subvolumes at `$INSTALL_ROOT` with `compress=zstd`.
10. Mount ESP at `$INSTALL_ROOT/efi`; keep `$INSTALL_ROOT/boot` as a normal directory on encrypted root.
11. Generate deterministic fstab with overwrite semantics.
Protected post-install steps:
1. Configure root unlock for the initramfs hook actually in use. Root unlock is driven by initramfs + kernel cmdline, not by `/etc/crypttab`:
- `encrypt` hook: kernel cmdline `cryptdevice=UUID=<luks-uuid>:omarchy_root root=/dev/mapper/omarchy_root`. No crypttab entry needed for root.
- `sd-encrypt` hook: write `/etc/crypttab.initramfs` (not `/etc/crypttab`) with the LUKS UUID, and pass `rd.luks.name=<luks-uuid>=omarchy_root root=/dev/mapper/omarchy_root` on the kernel cmdline.
2. Update mkinitcpio hooks for the initramfs mode actually present.
3. Regenerate initramfs in chroot with `mkinitcpio -P`.
4. Install an Omarchy-owned kernel/initramfs sync hook that copies boot artifacts from `/boot` to `/efi/EFI/Omarchy` after kernel or initramfs updates.
5. Run the initial boot artifact sync before writing Limine config.
6. Configure Limine kernel cmdline for encrypted root per the hook chosen above, pointing at namespaced artifacts under `EFI/Omarchy`.
7. Install or update Limine on the selected installed ESP.
8. Run `limine-scan` in chroot when appropriate.
9. Apply mode-specific boot order policy.
10. Leave swapfile creation, `resume` hook, and `resume=` cmdline injection to Omarchy's existing `omarchy-hibernation-setup` post-install hook. Verify `/etc/limine-entry-tool.d/` drop-ins are honored by the Limine config layout written here so that hook works unchanged.
### `builder/archinstall.packages`
Ensure the live environment has the tools needed for protected installs:
- `parted`
- `gptfdisk`
- `btrfs-progs`
- `cryptsetup`
- `dosfstools`
- `efibootmgr`
- `mtools`
`efibootmgr` already exists in this file, but keep it explicit in the protected-install dependency checklist.
### `builder/build-iso.sh`
Add build-time mode support:
- Persist `/root/omarchy_mode` as `normal`, `dual_boot_capable`, or `restore`.
- For restore builds, inject `copytoram=y omarchy.mode=restore` into restore boot entries.
- For restore builds, assert boot entries contain `copytoram=y`.
- For normal ISO builds, keep existing behavior except adding dual-boot capability.
### `bin/omarchy-iso-make`
Add optional build flags:
```text
--restore-image build omarchy-restore.img.zst factory artifact
```
Keep the existing ISO output path unchanged unless a restore image is explicitly requested.
## Bootloader Policy
### Default Boot Target
For any standalone install (full-disk or restore_install, with or without a restore partition set), installed Omarchy is the default boot target. The persistent `BootOrder` after every successful install or factory reset is:
1. Installed Omarchy
2. Restore (when a restore partition set exists)
3. Firmware fallback
The user should never have to pick Omarchy from a boot menu under normal operation. Restore is reachable but never default. Boot-menu entries inside Limine for restore/reinstall are convenience; they do not replace this NVRAM ordering.
`BootNext` is used only for one-shot transitions (factory reset trigger, post-reset return to installed Omarchy) and must not be used to make Omarchy "stickily" default — that's the job of `BootOrder`. The only temporary exception is an in-progress factory reset: after the restore environment accepts the trigger and before it mutates installed partitions, restore becomes the persistent first boot target until reinstall succeeds.
For dual-boot, the previous OS's firmware order is preserved unless the user explicitly opts to make Omarchy first. Dual-boot is the only mode where Omarchy may not be the firmware default.
### Dual Boot
- Install Omarchy boot files under `EFI/Omarchy` on the shared ESP.
- Do not overwrite existing vendor directories.
- Preserve firmware default boot order unless user opts in.
- Run `limine-scan` so Windows/Linux entries are discoverable from Omarchy's menu. Verify on a real Windows + Linux dual-boot before depending on this — Limine's auto-detection is less battle-tested than GRUB's. If it does not pick them up cleanly, fall back to the firmware boot menu and document the limitation rather than hand-writing entries.
- BitLocker is handled at detection time by aborting the install (see Dual Boot policy). The bootloader path therefore only ever sees Windows volumes with BitLocker fully off/decrypted.
### Restore Install
- Installed Omarchy gets its own ESP and boot entry.
- Restore ESP keeps `EFI/BOOT/BOOTX64.EFI` fallback path.
- After successful install, write `BootOrder` as installed Omarchy first, restore second. Do not reorder on every boot — only after install or factory reset.
- Add a restore/reinstall entry to the installed Limine menu if it can be done cleanly.
- If NVRAM writes fail, rely on fallback boot paths and show a warning. The user can still recover via firmware boot menu.
## Rollout Plan
### Phase 1: Refactor Without Behavior Change
- Introduce `INSTALL_MODE=full_disk`.
- Introduce `$INSTALL_ROOT` and update post-install paths.
- Keep existing full-disk JSON and archinstall behavior.
- Add tests or dry-run checks for config generation.
### Phase 2a: Archinstall pre_mounted_config Spike
Single-purpose spike on a throwaway VM. No production code changes.
- Drive archinstall with a hand-crafted mount tree and `pre_mounted_config`.
- Record exactly what archinstall does: fstab generation, crypttab writes, bootloader install, initramfs regeneration, kernel cmdline writes.
- Prove the protected-mode config disables archinstall bootloader and NVRAM writes, or document the supported replacement setting. This is a release blocker for Phase 2b.
- Decide whether the manual fstab/Limine/initramfs steps in this plan run before, after, or instead of archinstall's own steps.
- Update this plan with the answers before Phase 2b begins. The downstream design depends on this; do not skip.
### Phase 2b: Shared Protected Install Engine
- Add `pre_mounted_config` generation.
- Add manual LUKS, Btrfs, subvolume, mount, fstab, and cleanup logic.
- Add encrypted boot post-install configuration.
- Add namespaced ESP boot artifact sync under `EFI/Omarchy`.
- Keep it behind internal mode switches until validated.
### Phase 3: Dual-Boot UI and Policy
- Add disk layout detection.
- When another install is detected, show an install-mode choice instead of assuming dual boot: "Install alongside" when eligible, and "Exclusive install (erase disk)" using the existing full-disk `wipe: true` path.
- If another install is detected but dual boot is not eligible, explain why and still offer the exclusive install path.
- Add Windows/BitLocker warnings.
- Validate alongside Windows and Linux.
### Phase 4: Restore Install Policy
- Add restore layout detection.
- Add restore partition set protection.
- Add first-install and reinstall flows.
- Validate that restore ESP, payload, and state survive installation and later reinstall.
### Phase 5: Restore Factory Image
- Build `omarchy-restore.img.zst`.
- Add factory flashing instructions or a dedicated factory flasher ISO/PXE workflow.
- Validate first boot from internal disk with no external media.
- Sign `omarchy-restore.img.zst` and the restore payload manifest. Verify in the factory flasher and at restore boot before any disk mutation.
- Add first-boot UUID randomization for cloned restore images before generating installed boot entries or state.
- Decide on Secure Boot before this phase ships. Shipping with SB disabled is a non-starter for some buyers, and BitLocker (when present on a dual-boot machine restored later) reseals against firmware state. Either implement a signed shim/Limine path, or commit to "SB off" with a documented rationale and a way for the user to flip it on later if they have keys.
### Phase 6: Factory Reset
- Implement `omarchy-factory-reset` as the installed-OS staging command: write and fsync the trigger flag, verify/recreate the restore boot entry, set and confirm `BootNext`, then leave the machine ready to boot restore.
- Wire the restore environment to detect the trigger flag and run the reinstall flow non-interactively.
- Set `BootNext` for one-shot transitions and temporary restore-first persistent `BootOrder` during the destructive reset window.
- Validate on `restore_install` machines first (the flow already has a restore partition set).
### Phase 7: Restore Partition for Standalone Installs
- Extend the standalone full-disk install path to create restore ESP + restore payload + restore state + installed ESP + installed root, mirroring the `restore_install` layout.
- Populate restore payload at install time from the live ISO offline mirror.
- Add `--no-restore` opt-out for users who explicitly want the legacy two-partition layout.
- After this phase, every consumer install gets factory reset by default.
## Acceptance Criteria
- Full-disk install behavior remains unchanged through Phase 6. Phase 7 changes the default layout to include a restore partition set; legacy layout remains available via `--no-restore`.
- Dual-boot install preserves existing OS partitions.
- Dual-boot install creates encrypted Omarchy root in free space.
- When another install is detected, the installer offers the current exclusive full-disk wipe path and only offers dual boot when guardrails pass.
- Dual-boot install writes Omarchy boot artifacts only under `EFI/Omarchy` on a shared ESP and leaves existing vendor paths untouched.
- Dual-boot install aborts cleanly when BitLocker/FVE is detected, including suspended BitLocker, before any disk mutation, with an actionable message.
- Restore install preserves restore ESP, restore payload, and restore state partitions.
- Restore install creates or recreates only installed Omarchy partitions.
- Protected modes never generate archinstall config with `wipe: true`.
- Protected modes prevent archinstall from independently writing boot files or NVRAM entries.
- Protected modes abort before destructive operations if a target partition is protected.
- Restore payload integrity is verified with a signed manifest before any restore-triggered disk mutation.
- Installed Omarchy boots and requires LUKS unlock.
- Installed Omarchy is the default firmware boot target for any standalone install (full-disk or restore_install).
- Restore boot remains available after first install but is never the default.
- Reinstall from restore works without external media.
- `omarchy-factory-reset` from the installed OS leaves the machine staged exactly like a freshly prepared restore-boot device: trigger flag durable on restore state, restore boot entry present, and `BootNext` pointing at restore. No installed partitions are destroyed before the next restore boot.
- A subsequent restore boot from that staged state wipes installed partitions, reinstalls a fresh Omarchy, and lands the user back in the installed OS without external media. Restore ESP, payload, and state partitions survive intact.
- Factory reset refuses to run when no complete restore partition set is present (e.g. dual-boot, `--no-restore` install).
## Validation Matrix
### Regression
1. Full-disk UEFI install on empty disk.
2. Current normal ISO boot menu and default install behavior.
3. Existing offline package installation flow.
### Dual Boot
1. Windows + free space + existing ESP (BitLocker off).
2. Windows + BitLocker enabled — installer must abort before any disk mutation with a clear "turn off BitLocker and wait for decryption" message.
3. Windows + BitLocker suspended — installer must still abort, because v1 treats any BitLocker/FVE signature as unsafe.
4. Linux distro + free space + existing ESP.
5. GPT disk with no free space.
6. ESP below total-size warning threshold.
7. ESP below total-size minimum threshold.
8. ESP large enough by total size but below free-space minimum.
9. Multiple free regions; choose largest suitable region.
10. Firmware boot order preservation.
### Restore
1. Factory image written to larger virtual disk.
2. First boot from internal restore ESP.
3. First install randomizes cloned disk/partition/filesystem UUIDs before creating installed boot entries.
4. First install creates installed ESP/root and preserves restore ESP, payload, and state.
5. Installed OS boots with encrypted root.
6. Installed Omarchy is the firmware default; restore is reachable but not default.
7. Restore boot still works after installed OS exists.
8. Reinstall formats only the installed Omarchy partitions in place; PARTUUIDs unchanged.
9. Restore mode without `copytoram=y` aborts before disk mutation.
10. Missing restore payload or restore state aborts before disk mutation.
11. NVRAM boot entry write failure still leaves fallback boot path usable.
### Factory Reset
1. `omarchy-factory-reset` from installed OS leaves the system staged for restore boot: trigger flag durable on restore state, restore boot entry present, and `BootNext` confirmed.
2. No installed partitions are destroyed before the staged system actually boots into restore.
3. Booting into restore from the staged state reinstalls and returns to a fresh installed Omarchy as the default-booted OS.
4. Trigger flag is deleted before the post-reset reboot. Machine does not loop.
5. Reset interrupted between trigger and restore boot: next normal boot still goes to installed Omarchy (because `BootNext` is one-shot).
6. Reset interrupted during reinstall (power loss): persistent temporary restore-first `BootOrder` boots restore again and re-runs the flow from the trigger flag/reset-in-progress state.
7. Reset refused when no complete restore partition set is detected.
8. Reset refused when signed restore payload verification fails.
9. Reset refused when running without root.
10. Reset refused when restore boot entry is missing from NVRAM and cannot be repopulated from fallback.
### Negative Safety Tests
1. Attempt to format protected restore ESP.
2. Attempt to format protected restore payload.
3. Attempt to format protected restore state.
4. Attempt to format existing Windows partition in dual-boot mode.
5. Partition overlap with protected range.
6. Power loss before partition creation.
7. Power loss after partition creation but before archinstall.
8. Power loss during archinstall.
## Open Questions
- Restore payload size: full offline mirror versus smaller network-capable restore image. Affects whether the consumer ISO's restore-partition default in Phase 7 is feasible at typical disk sizes.
- Restore update strategy after devices ship.
- Whether restore should also expose diagnostics / log upload alongside factory reset.
- Whether factory reset should preserve `/home` user data optionally, or always wipe. v1 always wipes (simpler, matches user expectation of "factory reset"); a "keep my files" variant could land later.
Resolved:
- User-facing reset staging command: yes, `omarchy-factory-reset` (Phase 6).
- Secure Boot: promoted to Phase 5 decision gate.
- Btrfs subvolume layout: protected modes use the current full-disk layout, `@`, `@home`, `@log`, and `@pkg`. The `/swap` subvolume for hibernation is created post-install by `omarchy-hibernation-setup` and is not predeclared at install time.
## Definition of Done
- One shared protected-partition implementation supports both dual-boot and restore install policies.
- Full-disk installs remain stable through Phase 6 and gain a default restore partition set in Phase 7.
- Dual-boot users can install alongside existing OSes without data loss.
- OEM devices can boot internal restore on first power-on, install Omarchy, and retain restore for future reinstall.
- All destructive operations are guarded by protected partition checks.
- Installed Omarchy is the firmware default boot target on every standalone install.
- `omarchy-factory-reset` stages any standalone install to boot internal restore without external media; the restore-triggered reinstall returns the user to a fresh installed Omarchy.
- VM and hardware validation matrices pass.
-124
View File
@@ -1,124 +0,0 @@
# Tailscale Autoinstall Plan
## Goal
Let an autoinstall drive carry a Tailscale auth key so the installed machine
joins the tailnet on its own. Combined with `authorized_keys`, this completes
the disposable-VM story: create VM, boot, walk away, `ssh user@machine` over
the tailnet from anywhere — no LAN discovery, no port forwarding.
## Product Requirements
- Same trigger philosophy as the rest of autoinstall: presence of a
`tailscale_authkey` file on the `cidata` drive, no new mode flag. No file,
no change — byte-identical behavior to today.
- The join happens when the network is available, not when the installer runs.
- No package installs from the network on first boot.
- The auth key does not persist on the installed system after a successful join.
---
## Chosen Architecture
### The package is bundled, not fetched
The `tailscale` package rides in the offline mirror (~10MB on the ISO) and is
installed during the ordinary package phase — but only when the auth key file
is present, so a stock install stays stock.
The alternative — installing from the network on first boot — was rejected
outright. A bare `pacman -Sy tailscale` on a fresh machine is a partial
upgrade waiting to corrupt something, and the "correct" version, `pacman -Syu
tailscale`, means an unattended full system upgrade racing the user's first
login (potentially swapping the kernel out on a machine that has booted once).
It would also fight Omarchy's own update flow and make the join depend on
mirror availability. The offline mirror is a coherent snapshot; installing
from it keeps the target consistent and the install deterministic and
offline-capable.
### The join is a first-boot unit, not an install step
`tailscale up` needs a running tailscaled, and there is no systemd in the
chroot. So the installer only stages: the key at `/etc/tailscale/authkey`
(root, `0600`), `tailscaled.service` enabled, and an
`omarchy-tailscale-join.service` that runs `tailscale up --auth-key
file:/etc/tailscale/authkey` on first boot.
"When the network is available" lives in the unit, not the installer:
`After=network-online.target` for ordering, plus an in-unit retry loop
(`until tailscale up …; do sleep 15; done`), because network-online can be
reached before there is real connectivity.
The unit is `Type=simple`, not oneshot, and that is load-bearing: target
units implicitly gain `After=` for their `Wants=`, so a oneshot wanted by
`multi-user.target` holds the whole boot — SDDM included — hostage until the
join finishes or times out, and once timed out it never retries within that
boot. (Caught by review.) Type=simple counts as started the moment it forks:
boot proceeds normally while the join retries in the background for as long
as the boot lasts.
Cleanup is sequenced inside the script, after `tailscale up` succeeds — the
key is removed and the unit disables itself, so the key never outlives the
join and the unit never runs again. On a boot that never gets connectivity
both survive (`ConditionPathExists` gates re-runs on the key), so a machine
installed offline joins on the first boot that can.
The machine appears on the tailnet under the hostname from
`user_configuration.json` — nothing extra to configure.
### Firewall
`ufw allow in on tailscale0`, the same write-the-rule-then-ignore-the-chroot-
exit-code dance as `configure_ssh_access` (ufw cannot reach netfilter in a
chroot but records the rule in `user.rules`, which is what first boot loads).
Without it the node joins and is then unreachable over the tailnet — worse
than not joining.
### Secrets
Same stance the autoinstall plan takes for the LUKS passphrase: whoever
builds the `cidata` drive owns the key on it. Docs recommend a reusable,
pre-authorized (tagged) key so one drive image serves N machines, and note
that ephemeral keys fit disposable VMs. No validation of the key beyond
"exactly one non-comment line" — Tailscale key formats are theirs to change.
---
## Implementation
- `builder/archinstall.packages`: add `tailscale` so it lands in the offline
mirror on every build.
- `omarchy-cidata-load`: add `tailscale_authkey` to the optional copy list.
- `.automated_script.sh`: pass `--tailscale-authkey-file /root/tailscale_authkey`
(always; the orchestrator no-ops when the file is absent, like
`--authorized-keys-file`).
- `omarchy-iso-install`: parse the flag into
`OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE`.
- `context.py`: `tailscale_authkey_path: Path | None` via `_optional_path`.
- `phases_impl.py`:
- `arch_install_system`: when the key is present, add `tailscale` to the
additional packages while the offline mirror is still bind-mounted.
- `configure_tailscale(ctx)`: no-op when the path is `None`; otherwise
stage key + unit, enable services, open ufw. Fails loudly on an empty or
ambiguous key file and when the tailscale binary is missing from the
target (an ISO built before the package was bundled).
- `main.py`: register `("Configuring Tailscale", configure_tailscale)` after
`configure_ssh_access`.
- `README.md`: file-table row + a Tailscale paragraph in the Autoinstall
section.
- Tests: `tailscale_authkey` cases in `cidata-load-test.sh`;
`test_configure_tailscale.py` mirroring the SSH phase tests.
## Acceptance criteria
- Drive with `tailscale_authkey`: machine appears on the tailnet under its
configured hostname after first boot with network; `/etc/tailscale/authkey`
is gone; `omarchy-tailscale-join.service` is disabled.
- First boot without network: no failure surfaced to the user; the join
happens on the first boot that has connectivity.
- Drive without the file: no tailscale package on the target, no unit, no
ufw rule — indistinguishable from today.
- Empty or multi-line key file: install stops in the Tailscale phase with a
visible error.
- Interactive installs: unaffected; the flag is passed but the file never
exists.
View File
Whitespace-only changes.
-13
View File
@@ -1,13 +0,0 @@
#!/bin/bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
for test in "$ROOT"/test/*-test.sh; do
printf '==> %s\n' "${test#$ROOT/}"
bash "$test"
done
printf '==> test/test_*.py\n'
python3 -m unittest discover -t "$ROOT" -s "$ROOT/test"
-224
View File
@@ -1,224 +0,0 @@
#!/bin/bash
#
# Unit tests for omarchy-cidata-load. The script takes a path prefix, so every
# case runs against a throwaway sandbox with mount/umount/udevadm stubbed out:
# "mounting" copies the fake drive's contents into the mountpoint, and every
# stub logs its invocation so the cases can assert what was (not) called.
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
CIDATA_LOAD="$ROOT/configs/airootfs/usr/local/bin/omarchy-cidata-load"
pass() {
printf 'ok - %s\n' "$1"
}
fail() {
local description="$1"
local detail="${2:-}"
[[ -n $detail ]] && printf '%s\n' "$detail" >&2
printf 'not ok - %s\n' "$description" >&2
exit 1
}
work=$(mktemp -d)
trap 'chmod -R u+w "$work"; rm -rf "$work"' EXIT
stub_dir="$work/stubs"
mkdir -p "$stub_dir"
cat >"$stub_dir/udevadm" <<'STUB'
#!/bin/bash
printf 'udevadm %s\n' "$*" >>"$TEST_LOG"
STUB
cat >"$stub_dir/mount" <<'STUB'
#!/bin/bash
printf 'mount %s\n' "$*" >>"$TEST_LOG"
[[ ${MOUNT_FAIL:-} == 1 ]] && exit 32
device=$3 mountpoint=$4
cp -a "$(readlink -f "$device")"/. "$mountpoint"/
STUB
cat >"$stub_dir/umount" <<'STUB'
#!/bin/bash
printf 'umount %s\n' "$*" >>"$TEST_LOG"
STUB
chmod +x "$stub_dir"/*
new_sandbox() {
sandbox=$(mktemp -d "$work/sandbox.XXXXXX")
mkdir -p "$sandbox/dev/disk/by-label" "$sandbox/root" "$sandbox/media"
export TEST_LOG="$sandbox/calls.log"
: >"$TEST_LOG"
}
attach_drive() {
ln -s "$sandbox/media" "$sandbox/dev/disk/by-label/$1"
}
run_load() {
PATH="$stub_dir:$PATH" "$CIDATA_LOAD" "$sandbox"
}
write_required_pair() {
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json"
echo '{"users": []}' >"$sandbox/media/user_credentials.json"
}
# No cidata drive attached: fall back to the wizard without mounting anything.
new_sandbox
! run_load || fail "no drive exits non-zero"
! grep -q '^mount ' "$TEST_LOG" || fail "no drive mounts nothing"
pass "no drive falls back to the wizard"
# The probe must wait for udev to finish enumerating before concluding there
# is no drive.
grep -q '^udevadm settle$' "$TEST_LOG" || fail "probe settles udev first"
pass "probe settles udev first"
# A drive with the full file set: everything lands in /root and the drive is
# unmounted afterwards.
new_sandbox
attach_drive cidata
write_required_pair
echo "Jeff" >"$sandbox/media/user_full_name.txt"
echo "jeff@example.com" >"$sandbox/media/user_email_address.txt"
echo "false" >"$sandbox/media/user_encrypt_installation.txt"
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
echo 'tskey-auth-kFAKEKEY' >"$sandbox/media/tailscale_authkey"
run_load || fail "full file set loads"
for file in user_configuration.json user_credentials.json user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey; do
[[ -f $sandbox/root/$file ]] || fail "full file set copies $file"
done
grep -q '^umount ' "$TEST_LOG" || fail "full file set unmounts the drive"
pass "full file set loads, copies everything, and unmounts"
# The uppercase label variant some tools produce works too.
new_sandbox
attach_drive CIDATA
write_required_pair
run_load || fail "uppercase CIDATA label loads"
pass "uppercase CIDATA label loads"
# The required pair alone is a valid autoinstall drive; the optional files
# stay optional.
new_sandbox
attach_drive cidata
write_required_pair
run_load || fail "required pair alone loads"
[[ ! -e $sandbox/root/authorized_keys ]] || fail "required pair alone copies no optional files"
pass "required pair alone loads without optional files"
# Optional files are copied individually when present.
new_sandbox
attach_drive cidata
write_required_pair
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
run_load || fail "required pair plus authorized_keys loads"
[[ -f $sandbox/root/authorized_keys ]] || fail "authorized_keys is copied when present"
[[ ! -e $sandbox/root/user_full_name.txt ]] || fail "absent optional files are not copied"
[[ ! -e $sandbox/root/tailscale_authkey ]] || fail "absent tailscale_authkey is not copied"
pass "present optional files are copied, absent ones skipped"
# A defer-provisioning marker replaces user_credentials.json: deferred-provisioning installs
# creation to first boot, so imaging rigs ship no credentials at all.
new_sandbox
attach_drive cidata
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json"
: >"$sandbox/media/defer-provisioning"
run_load || fail "config plus defer-provisioning marker loads"
[[ -f $sandbox/root/defer-provisioning ]] || fail "defer-provisioning marker is copied"
[[ ! -e $sandbox/root/user_credentials.json ]] || fail "defer-provisioning drive copies no credentials"
pass "defer-provisioning marker stands in for credentials"
# The defer-provisioning marker and credentials can coexist (rig supplies its own LUKS
# passphrase in the credentials file); both are copied.
new_sandbox
attach_drive cidata
write_required_pair
: >"$sandbox/media/defer-provisioning"
run_load || fail "defer-provisioning marker plus credentials loads"
[[ -f $sandbox/root/defer-provisioning && -f $sandbox/root/user_credentials.json ]] || fail "both defer-provisioning marker and credentials are copied"
pass "defer-provisioning marker plus credentials copies both"
# An defer-provisioning marker without the configuration is still not an autoinstall drive.
new_sandbox
attach_drive cidata
: >"$sandbox/media/defer-provisioning"
! run_load || fail "defer-provisioning marker alone is not an autoinstall drive"
[[ ! -e $sandbox/root/defer-provisioning ]] || fail "defer-provisioning marker alone copies nothing"
pass "defer-provisioning marker alone falls back to the wizard"
# Stale deferred-provisioning inputs from a previous load in the same session are cleared before
# the current drive is copied: a normal drive must not inherit an old defer-provisioning
# marker or credentials.
new_sandbox
attach_drive cidata
write_required_pair
: >"$sandbox/root/defer-provisioning" # leftover from a prior deferred-provisioning load
echo 'old-keys' >"$sandbox/root/authorized_keys"
run_load || fail "normal drive after a stale defer-provisioning load loads"
[[ ! -e $sandbox/root/defer-provisioning ]] || fail "stale defer-provisioning marker is cleared"
[[ ! -e $sandbox/root/authorized_keys ]] || fail "stale optional inputs are cleared"
pass "stale deferred-provisioning inputs are cleared before loading a normal drive"
# A drive that isn't an autoinstall drive at all still clears stale inputs so
# the wizard that follows doesn't inherit them.
new_sandbox
attach_drive cidata
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json" # half a pair
: >"$sandbox/root/defer-provisioning"
! run_load || fail "half-pair drive still falls back"
[[ ! -e $sandbox/root/defer-provisioning ]] || fail "stale defer-provisioning marker cleared even on fallback"
pass "stale inputs are cleared even when falling back to the wizard"
# Half the required pair is not an autoinstall drive: unmount and fall back.
new_sandbox
attach_drive cidata
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json"
! run_load || fail "half the required pair exits non-zero"
[[ ! -e $sandbox/root/user_configuration.json ]] || fail "half the required pair copies nothing"
grep -q '^umount ' "$TEST_LOG" || fail "half the required pair still unmounts"
pass "half the required pair falls back and unmounts"
# An empty drive labeled cidata is not an autoinstall drive either.
new_sandbox
attach_drive cidata
! run_load || fail "empty drive exits non-zero"
grep -q '^umount ' "$TEST_LOG" || fail "empty drive still unmounts"
pass "empty drive falls back and unmounts"
# A drive that will not mount falls back rather than failing the boot.
new_sandbox
attach_drive cidata
write_required_pair
! MOUNT_FAIL=1 run_load || fail "mount failure exits non-zero"
! grep -q '^umount ' "$TEST_LOG" || fail "mount failure has nothing to unmount"
pass "mount failure falls back to the wizard"
# A copy failure must not report a loaded drive: the install would start with
# missing inputs. Fall back and let the wizard produce them instead.
new_sandbox
attach_drive cidata
write_required_pair
chmod 555 "$sandbox/root"
! run_load 2>/dev/null || fail "copy failure exits non-zero"
grep -q '^umount ' "$TEST_LOG" || fail "copy failure still unmounts"
pass "copy failure falls back and unmounts"
# A partial copy must clean up after itself: the wizard removes only what it
# writes, so anything the loader left behind would leak into the interactive
# install that follows.
new_sandbox
attach_drive cidata
write_required_pair
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
mkdir "$sandbox/root/user_credentials.json"
! run_load 2>/dev/null || fail "partial copy exits non-zero"
[[ ! -e $sandbox/root/user_configuration.json ]] || fail "partial copy removes what it copied"
[[ ! -e $sandbox/root/authorized_keys ]] || fail "partial copy leaves no authorized_keys behind"
pass "partial copy cleans up what it copied"
-153
View File
@@ -1,153 +0,0 @@
"""Unit tests for the orchestrator's configure_ssh_access phase.
The phase touches the target only through the filesystem and arch-chroot, so
the tests run it against a temp directory with subprocess.run recorded and the
ufw side effect (writing user.rules) simulated by the fake.
"""
import stat
import sys
import tempfile
import types
import unittest
from pathlib import Path
from subprocess import CompletedProcess
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "configs/airootfs/usr/share/omarchy-iso"))
# phases_impl imports the archinstall adapter at module scope, which pulls in
# the archinstall library that only exists on the live ISO. configure_ssh_access
# never touches it, so stub the adapter out before the import.
sys.modules["orchestrator.archinstall_adapter"] = types.ModuleType("orchestrator.archinstall_adapter")
from orchestrator import phases_impl # noqa: E402
UFW_RULE = "-A ufw-user-input -p tcp --dport 22 -j ACCEPT\n"
class ConfigureSshAccessTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.target = Path(self.tmp.name)
self.calls = []
info_patch = mock.patch.object(phases_impl, "info")
info_patch.start()
self.addCleanup(info_patch.stop)
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=self.fake_run)
run_patch.start()
self.addCleanup(run_patch.stop)
self.ufw_writes_rule = True
def fake_run(self, cmd, **kwargs):
self.calls.append(cmd)
if cmd[2] == "ufw":
if self.ufw_writes_rule:
rules = self.target / "etc" / "ufw" / "user.rules"
rules.parent.mkdir(parents=True, exist_ok=True)
rules.write_text(UFW_RULE)
# ufw cannot reach netfilter inside a chroot and exits non-zero
# even when it recorded the rule; the phase must not trust this.
return CompletedProcess(cmd, 1)
return CompletedProcess(cmd, 0)
def ctx(self, authorized_keys=None):
authorized_keys_path = None
if authorized_keys is not None:
authorized_keys_path = self.target / "authorized_keys"
authorized_keys_path.write_text(authorized_keys)
return types.SimpleNamespace(target=self.target, username="jeff", authorized_keys_path=authorized_keys_path, defer_provisioning=False)
def configure(self, **kwargs):
phases_impl.configure_ssh_access(self.ctx(**kwargs))
def authorized_keys(self):
return self.target / "home" / "jeff" / ".ssh" / "authorized_keys"
def chrooted(self, program):
return [cmd for cmd in self.calls if cmd[:2] == ["arch-chroot", str(self.target)] and cmd[2] == program]
def test_no_authorized_keys_is_a_no_op(self):
self.configure()
self.assertEqual(self.calls, [])
self.assertFalse((self.target / "home" / "jeff" / ".ssh").exists())
def test_installs_keys_one_per_line(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\nssh-rsa BBBB jeff@work\n")
self.assertEqual(self.authorized_keys().read_text(), "ssh-ed25519 AAAA jeff@host\nssh-rsa BBBB jeff@work\n")
def test_drops_blank_lines_and_comments(self):
self.configure(authorized_keys="# work laptop\n\n ssh-ed25519 AAAA jeff@host \n")
self.assertEqual(self.authorized_keys().read_text(), "ssh-ed25519 AAAA jeff@host\n")
def test_keys_with_options_pass_through(self):
key = 'command="/usr/bin/true",no-pty ssh-ed25519 AAAA jeff@host'
self.configure(authorized_keys=f"{key}\n")
self.assertEqual(self.authorized_keys().read_text(), f"{key}\n")
def test_ssh_dir_is_private(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
mode = stat.S_IMODE((self.target / "home" / "jeff" / ".ssh").stat().st_mode)
self.assertEqual(mode, 0o700)
def test_authorized_keys_is_private(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
self.assertEqual(stat.S_IMODE(self.authorized_keys().stat().st_mode), 0o600)
def test_chowns_ssh_dir_to_the_user(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
self.assertEqual(self.chrooted("chown"), [
["arch-chroot", str(self.target), "chown", "-R", "jeff:jeff", "/home/jeff/.ssh"],
])
def test_enables_sshd(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
self.assertEqual(self.chrooted("systemctl"), [
["arch-chroot", str(self.target), "systemctl", "enable", "sshd.service"],
])
def test_allows_ssh_through_ufw_despite_chroot_exit_status(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
self.assertEqual(self.chrooted("ufw"), [
["arch-chroot", str(self.target), "ufw", "allow", "ssh"],
])
def test_fails_when_ufw_does_not_record_the_rule(self):
self.ufw_writes_rule = False
with self.assertRaisesRegex(RuntimeError, "allow rule for port 22"):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
def test_empty_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"):
self.configure(authorized_keys="")
def test_blank_only_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"):
self.configure(authorized_keys="\n \n")
def test_comment_only_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"):
self.configure(authorized_keys="# no keys here\n")
class OptionalPathTest(unittest.TestCase):
def test_unset_and_missing_paths_are_none(self):
from orchestrator.context import _optional_path
self.assertIsNone(_optional_path(None))
self.assertIsNone(_optional_path(""))
self.assertIsNone(_optional_path("/does/not/exist/authorized_keys"))
def test_existing_path_is_returned(self):
from orchestrator.context import _optional_path
with tempfile.NamedTemporaryFile() as f:
self.assertEqual(_optional_path(f.name), Path(f.name))
if __name__ == "__main__":
unittest.main()
-164
View File
@@ -1,164 +0,0 @@
"""Unit tests for the orchestrator's configure_tailscale phase.
Same harness as the SSH access tests: the phase touches the target only
through the filesystem and arch-chroot, so it runs against a temp directory
with subprocess.run recorded and the ufw side effect (writing user.rules)
simulated by the fake.
"""
import stat
import sys
import tempfile
import types
import unittest
from pathlib import Path
from subprocess import CompletedProcess
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "configs/airootfs/usr/share/omarchy-iso"))
# phases_impl imports the archinstall adapter at module scope, which pulls in
# the archinstall library that only exists on the live ISO. configure_tailscale
# never touches it, so stub the adapter out before the import.
sys.modules["orchestrator.archinstall_adapter"] = types.ModuleType("orchestrator.archinstall_adapter")
from orchestrator import phases_impl # noqa: E402
UFW_RULE = "-A ufw-user-input -i tailscale0 -j ACCEPT\n"
class ConfigureTailscaleTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.target = Path(self.tmp.name)
self.calls = []
info_patch = mock.patch.object(phases_impl, "info")
info_patch.start()
self.addCleanup(info_patch.stop)
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=self.fake_run)
run_patch.start()
self.addCleanup(run_patch.stop)
self.ufw_writes_rule = True
def fake_run(self, cmd, **kwargs):
self.calls.append(cmd)
if cmd[2] == "ufw":
if self.ufw_writes_rule:
rules = self.target / "etc" / "ufw" / "user.rules"
rules.parent.mkdir(parents=True, exist_ok=True)
rules.write_text(UFW_RULE)
# ufw cannot reach netfilter inside a chroot and exits non-zero
# even when it recorded the rule; the phase must not trust this.
return CompletedProcess(cmd, 1)
return CompletedProcess(cmd, 0)
def ctx(self, authkey=None, tailscale_installed=True):
authkey_path = None
if authkey is not None:
authkey_path = self.target / "tailscale_authkey"
authkey_path.write_text(authkey)
if tailscale_installed:
binary = self.target / "usr" / "bin" / "tailscale"
binary.parent.mkdir(parents=True, exist_ok=True)
binary.touch()
return types.SimpleNamespace(target=self.target, tailscale_authkey_path=authkey_path)
def configure(self, **kwargs):
phases_impl.configure_tailscale(self.ctx(**kwargs))
def staged_key(self):
return self.target / "etc" / "tailscale" / "authkey"
def unit(self):
return self.target / "etc" / "systemd" / "system" / "omarchy-tailscale-join.service"
def chrooted(self, program):
return [cmd for cmd in self.calls if cmd[:2] == ["arch-chroot", str(self.target)] and cmd[2] == program]
def test_no_authkey_is_a_no_op(self):
self.configure()
self.assertEqual(self.calls, [])
self.assertFalse((self.target / "etc" / "tailscale").exists())
def test_stages_the_key(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertEqual(self.staged_key().read_text(), "tskey-auth-kFAKEKEY\n")
def test_drops_blank_lines_and_comments(self):
self.configure(authkey="# reusable, tagged\n\n tskey-auth-kFAKEKEY \n")
self.assertEqual(self.staged_key().read_text(), "tskey-auth-kFAKEKEY\n")
def test_key_dir_and_file_are_root_private(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertEqual(stat.S_IMODE((self.target / "etc" / "tailscale").stat().st_mode), 0o700)
self.assertEqual(stat.S_IMODE(self.staged_key().stat().st_mode), 0o600)
def test_installs_the_join_unit(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
text = self.unit().read_text()
self.assertIn("ConditionPathExists=/etc/tailscale/authkey", text)
# The key must not outlive a successful join, the unit must not run
# again after one, and cleanup must be sequenced after the join
# succeeds -- inside the script, not in ExecStartPost.
self.assertIn(
"ExecStart=/usr/bin/sh -c 'until tailscale up --auth-key file:/etc/tailscale/authkey;"
" do sleep 15; done; rm -f /etc/tailscale/authkey;"
" systemctl disable omarchy-tailscale-join.service'",
text,
)
def test_join_unit_does_not_hold_up_boot(self):
# A Type=oneshot wanted by multi-user.target holds the whole boot
# (SDDM included) hostage until the join finishes or times out --
# target units implicitly gain After= for their Wants=.
self.configure(authkey="tskey-auth-kFAKEKEY\n")
text = self.unit().read_text()
self.assertIn("Type=simple", text)
self.assertNotIn("Type=oneshot", text)
self.assertNotIn("TimeoutStartSec", text)
def test_unit_avoids_systemd_variable_expansion(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertNotIn("$", self.unit().read_text())
def test_enables_tailscaled_and_the_join(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertEqual(self.chrooted("systemctl"), [
["arch-chroot", str(self.target), "systemctl", "enable",
"tailscaled.service", "omarchy-tailscale-join.service"],
])
def test_allows_tailnet_traffic_through_ufw_despite_chroot_exit_status(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertEqual(self.chrooted("ufw"), [
["arch-chroot", str(self.target), "ufw", "allow", "in", "on", "tailscale0"],
])
def test_fails_when_ufw_does_not_record_the_rule(self):
self.ufw_writes_rule = False
with self.assertRaisesRegex(RuntimeError, "allow rule for tailscale0"):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
def test_fails_when_tailscale_is_not_on_the_target(self):
with self.assertRaisesRegex(RuntimeError, "not installed on the target"):
self.configure(authkey="tskey-auth-kFAKEKEY\n", tailscale_installed=False)
def test_empty_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no auth key"):
self.configure(authkey="")
def test_comment_only_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no auth key"):
self.configure(authkey="# no key here\n")
def test_multiple_keys_fail_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "expected exactly one"):
self.configure(authkey="tskey-auth-kONE\ntskey-auth-kTWO\n")
if __name__ == "__main__":
unittest.main()
-86
View File
@@ -1,86 +0,0 @@
#!/usr/bin/python
import importlib.util
import re
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MODULE_PATH = ROOT / "configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py"
SPEC = importlib.util.spec_from_file_location("installer_keyboard", MODULE_PATH)
KEYBOARD = importlib.util.module_from_spec(SPEC)
assert SPEC.loader
SPEC.loader.exec_module(KEYBOARD)
# The layout list lives in the Omarchy runtime now, shared verbatim with the
# first-boot owner setup; build-iso.sh vendors it onto the ISO. Read it from
# wherever this checkout can see a runtime, and skip the coverage test rather
# than fail when none is around (a bare CI checkout of just this repo).
SETUP_FORM_CANDIDATES = (
Path("/omarchy-source/install/provisioning/setup-form.sh"),
ROOT.parent / "omarchy/install/provisioning/setup-form.sh",
Path("/usr/share/omarchy/install/provisioning/setup-form.sh"),
)
def supported_keymaps():
for candidate in SETUP_FORM_CANDIDATES:
if not candidate.is_file():
continue
block = re.search(
r"OMARCHY_KEYBOARD_LAYOUTS=\$'(.*?)'\n", candidate.read_text(), re.DOTALL
)
assert block, f"no layout list in {candidate}"
return [line.split("|", 1)[1] for line in block.group(1).splitlines()]
return None
SUPPORTED_KEYMAPS = supported_keymaps()
class KeyboardConfigurationTest(unittest.TestCase):
def target(self, directory: str) -> Path:
target = Path(directory)
(target / "etc").mkdir()
(target / "etc/vconsole.conf").write_text(
"KEYMAP=us\nFONT=default8x16\n"
)
return target
def test_writes_keymap_and_xkb_settings_and_preserves_font(self):
# XKBLAYOUT is load-bearing: omarchy's detect-keyboard-layout.sh copies
# it into Hyprland's kb_layout on the installed system.
with tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
self.assertTrue(KEYBOARD.configure_keyboard(target, "us"))
lines = (target / "etc/vconsole.conf").read_text().splitlines()
self.assertIn("KEYMAP=us", lines)
self.assertIn("XKBLAYOUT=us", lines)
self.assertEqual(lines.count("FONT=default8x16"), 1)
def test_all_configurator_keymaps_are_known_to_localectl(self):
if SUPPORTED_KEYMAPS is None:
self.skipTest("no Omarchy runtime checkout to read the layout list from")
for keymap in SUPPORTED_KEYMAPS:
with self.subTest(keymap=keymap), tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
self.assertTrue(KEYBOARD.configure_keyboard(target, keymap))
lines = (target / "etc/vconsole.conf").read_text().splitlines()
self.assertIn(f"KEYMAP={keymap}", lines)
self.assertEqual(lines.count("FONT=default8x16"), 1)
def test_unknown_and_empty_keymaps_match_archinstall_behavior(self):
for keymap, expected in (("definitely-not-a-keymap", False), ("", True)):
with self.subTest(keymap=keymap), tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
self.assertEqual(KEYBOARD.configure_keyboard(target, keymap), expected)
self.assertEqual(
(target / "etc/vconsole.conf").read_text(),
"KEYMAP=us\nFONT=default8x16\n",
)
if __name__ == "__main__":
unittest.main()
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/python
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PRUNER = ROOT / "builder/prune-offline-mirror.sh"
class OfflineMirrorPruningTest(unittest.TestCase):
def run_pruner(self, mirror: Path, required: list[str]):
return subprocess.run(
["bash", str(PRUNER), str(mirror)],
input="".join(f"{filename}\n" for filename in required),
capture_output=True,
text=True,
)
def add_file(self, mirror: Path, filename: str):
(mirror / filename).write_bytes(b"package")
def test_keeps_only_exactly_selected_package_archives(self):
with tempfile.TemporaryDirectory() as directory:
mirror = Path(directory)
keep = "keep-2.0-1-x86_64.pkg.tar.zst"
other = "other-1.0-1-any.pkg.tar.zst"
stale_version = "keep-1.0-1-x86_64.pkg.tar.zst"
removed_package = "removed-1.0-1-x86_64.pkg.tar.zst"
orphan_signature = "interrupted-1.0-1-x86_64.pkg.tar.zst.sig"
for filename in (keep, other, stale_version, removed_package):
self.add_file(mirror, filename)
for filename in (keep, stale_version, removed_package):
self.add_file(mirror, f"{filename}.sig")
self.add_file(mirror, orphan_signature)
self.add_file(mirror, "offline.db.tar.gz")
result = self.run_pruner(mirror, [keep, other])
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
{path.name for path in mirror.iterdir()},
{keep, f"{keep}.sig", other, "offline.db.tar.gz"},
)
self.assertIn(stale_version, result.stdout)
self.assertIn(removed_package, result.stdout)
def test_missing_selection_aborts_before_deleting_anything(self):
with tempfile.TemporaryDirectory() as directory:
mirror = Path(directory)
cached = "cached-1.0-1-x86_64.pkg.tar.zst"
self.add_file(mirror, cached)
result = self.run_pruner(
mirror, ["missing-1.0-1-x86_64.pkg.tar.zst"]
)
self.assertNotEqual(result.returncode, 0)
self.assertTrue((mirror / cached).exists())
self.assertIn("selected packages are missing", result.stderr)
def test_empty_selection_aborts_before_deleting_anything(self):
with tempfile.TemporaryDirectory() as directory:
mirror = Path(directory)
cached = "cached-1.0-1-x86_64.pkg.tar.zst"
self.add_file(mirror, cached)
result = self.run_pruner(mirror, [])
self.assertNotEqual(result.returncode, 0)
self.assertTrue((mirror / cached).exists())
self.assertIn("empty package selection", result.stderr)
if __name__ == "__main__":
unittest.main()
-440
View File
@@ -1,440 +0,0 @@
"""Unit tests for deferred-provisioning install support in the orchestrator.
Covers the InstallContext deferred-provisioning plumbing (marker file, credential-less input,
throwaway LUKS passphrase injection) and the stage_provisioning_state /
create_factory_snapshot / configure_login phases against a temp target with
subprocess mocked out.
"""
import json
import os
import sys
import tempfile
import types
import unittest
from pathlib import Path
from subprocess import CompletedProcess
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "configs/airootfs/usr/share/omarchy-iso"))
sys.modules.setdefault(
"orchestrator.archinstall_adapter", types.ModuleType("orchestrator.archinstall_adapter")
)
from orchestrator import phases_impl # noqa: E402
from orchestrator.context import InstallContext # noqa: E402
class ContextDeferProvisioningTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.dir = Path(self.tmp.name)
self.state_dir = self.dir / "state"
self.env = {
"OMARCHY_INSTALL_CONFIG": str(self.dir / "user_configuration.json"),
"OMARCHY_INSTALL_CREDS": str(self.dir / "user_credentials.json"),
"OMARCHY_INSTALL_STATE_DIR": str(self.state_dir),
}
def write_config(self, config):
(self.dir / "user_configuration.json").write_text(json.dumps(config))
def write_creds(self, creds):
(self.dir / "user_credentials.json").write_text(json.dumps(creds))
def from_env(self, **extra_env):
env = {**self.env, **extra_env}
with mock.patch.dict(os.environ, env, clear=False):
for key in ("OMARCHY_INSTALL_DEFER_PROVISIONING_FILE",):
if key not in env:
os.environ.pop(key, None)
return InstallContext.from_env()
def base_config(self, defer_provisioning=None, disk_encryption=None):
config = {
"disk_config": {"config_type": "default_layout"},
"omarchy_install": {"mode": "full_disk", "target_mount": "/mnt"},
}
if defer_provisioning is not None:
config["omarchy_install"]["defer_provisioning"] = defer_provisioning
if disk_encryption is not None:
config["disk_config"]["disk_encryption"] = disk_encryption
return config
def test_defer_provisioning_flag_from_config(self):
self.write_config(self.base_config(defer_provisioning=True))
ctx = self.from_env()
self.assertTrue(ctx.defer_provisioning)
self.assertEqual(ctx.user_credentials, {"users": []})
self.assertEqual(ctx.username, "")
def test_marker_file_arms_deferred_provisioning_without_credentials(self):
self.write_config(self.base_config())
marker = self.dir / "defer_provisioning"
marker.touch()
ctx = self.from_env(OMARCHY_INSTALL_DEFER_PROVISIONING_FILE=str(marker))
self.assertTrue(ctx.defer_provisioning)
self.assertTrue(ctx.omarchy_install["defer_provisioning"])
def test_absent_marker_file_is_not_deferred_provisioning(self):
self.write_config(self.base_config())
self.write_creds({"users": [{"username": "jeff"}]})
ctx = self.from_env(OMARCHY_INSTALL_DEFER_PROVISIONING_FILE=str(self.dir / "missing-defer_provisioning"))
self.assertFalse(ctx.defer_provisioning)
self.assertEqual(ctx.username, "jeff")
def test_missing_credentials_without_defer_provisioning_raises(self):
self.write_config(self.base_config())
with self.assertRaisesRegex(RuntimeError, "credentials file missing"):
self.from_env()
def test_deferred_provisioning_generates_throwaway_luks_passphrase(self):
self.write_config(self.base_config(defer_provisioning=True, disk_encryption={
"encryption_type": "luks", "partitions": ["x"],
}))
ctx = self.from_env()
password = ctx.user_configuration["disk_config"]["disk_encryption"]["encryption_password"]
self.assertTrue(password)
self.assertEqual(ctx.user_credentials["encryption_password"], password)
# archinstall reads both files from disk; the injected passphrase must
# be present in each.
arch_config = json.loads(ctx.arch_config_path.read_text())
self.assertEqual(
arch_config["disk_config"]["disk_encryption"]["encryption_password"], password
)
synthesized = json.loads(ctx.creds_path.read_text())
self.assertEqual(synthesized["encryption_password"], password)
def test_deferred_provisioning_reuses_rig_supplied_passphrase(self):
self.write_config(self.base_config(defer_provisioning=True, disk_encryption={
"encryption_type": "luks", "partitions": ["x"],
}))
self.write_creds({"users": [], "encryption_password": "rig-secret"})
ctx = self.from_env()
self.assertEqual(
ctx.user_configuration["disk_config"]["disk_encryption"]["encryption_password"],
"rig-secret",
)
def test_deferred_provisioning_discards_account_material(self):
# A rig credentials file must not smuggle in users or a root password.
self.write_config(self.base_config(defer_provisioning=True))
self.write_creds({
"users": [{"username": "backdoor", "enc_password": "x", "sudo": True}],
"root_enc_password": "x",
"encryption_password": "rig-secret",
})
ctx = self.from_env()
self.assertEqual(ctx.user_credentials["users"], [])
self.assertNotIn("root_enc_password", ctx.user_credentials)
self.assertEqual(ctx.user_credentials["encryption_password"], "rig-secret")
synthesized = json.loads(ctx.creds_path.read_text())
self.assertEqual(synthesized["users"], [])
self.assertNotIn("root_enc_password", synthesized)
def test_defer_provisioning_strips_account_fields(self):
config = self.base_config(defer_provisioning=True)
config["!users"] = [{"username": "rig"}]
config["!root-password"] = "hunter2"
config["root_enc_password"] = "x"
config["auth_config"] = {"users": [{"username": "rig"}], "root_enc_password": "x"}
self.write_config(config)
ctx = self.from_env()
arch_config = json.loads(ctx.arch_config_path.read_text())
for key in ("!users", "!root-password", "root_enc_password", "users"):
self.assertNotIn(key, arch_config)
self.assertEqual(arch_config["auth_config"], {})
def test_deferred_provisioning_unencrypted_touches_nothing(self):
self.write_config(self.base_config(defer_provisioning=True))
ctx = self.from_env()
self.assertNotIn("encryption_password", ctx.user_credentials)
def make_ctx(target, **overrides):
defaults = dict(
target=target,
defer_provisioning=True,
encrypt=False,
username="",
omarchy_install={"mode": "full_disk", "defer_provisioning": True, "storage": {}},
user_configuration={"disk_config": {}},
user_credentials={"users": []},
state_dir=target / "state",
authorized_keys_path=None,
)
defaults.update(overrides)
return types.SimpleNamespace(**defaults)
class StageProvisioningStateTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.target = Path(self.tmp.name) / "mnt"
self.target.mkdir()
info_patch = mock.patch.object(phases_impl, "info")
info_patch.start()
self.addCleanup(info_patch.stop)
# A fake bundled Node tarball on the "live ISO".
self.packages = Path(self.tmp.name) / "opt-packages"
self.packages.mkdir()
(self.packages / "node-v24.0.0-linux-x64.tar.gz").write_bytes(b"node")
node_patch = mock.patch.object(phases_impl, "NODE_PACKAGES_DIR", self.packages)
node_patch.start()
self.addCleanup(node_patch.stop)
def install_runtime_provisioning_support(self):
service = self.target / "usr/share/omarchy/install/provisioning/omarchy-provision-owner.service"
service.parent.mkdir(parents=True, exist_ok=True)
service.write_text("[Unit]\n")
setup_bin = self.target / "usr/bin/omarchy-provision-owner"
setup_bin.parent.mkdir(parents=True, exist_ok=True)
setup_bin.write_text("#!/bin/bash\n")
def provisioning_dir(self):
return self.target / "var/lib/omarchy/provisioning"
def test_normal_install_stages_only_the_node_tarball(self):
ctx = make_ctx(self.target, defer_provisioning=False)
phases_impl.stage_provisioning_state(ctx)
self.assertTrue((self.provisioning_dir() / "packages/node-v24.0.0-linux-x64.tar.gz").exists())
self.assertFalse((self.provisioning_dir() / "pending").exists())
self.assertFalse((self.target / "etc/systemd/system/omarchy-provision-owner.service").exists())
def test_deferred_provisioning_against_runtime_without_support_fails_clearly(self):
ctx = make_ctx(self.target)
with self.assertRaisesRegex(RuntimeError, "does not ship"):
phases_impl.stage_provisioning_state(ctx)
def test_deferred_provisioning_without_node_tarball_fails(self):
for tarball in self.packages.glob("*.tar.gz"):
tarball.unlink()
self.install_runtime_provisioning_support()
ctx = make_ctx(self.target)
with self.assertRaisesRegex(RuntimeError, "Node tarball"):
phases_impl.stage_provisioning_state(ctx)
def test_missing_node_tarball_fails_normal_installs_too(self):
# The stash is what makes a later factory reset work offline.
for tarball in self.packages.glob("*.tar.gz"):
tarball.unlink()
ctx = make_ctx(self.target, defer_provisioning=False)
with self.assertRaisesRegex(RuntimeError, "Node tarball"):
phases_impl.stage_provisioning_state(ctx)
def test_deferred_provisioning_arms_first_boot_setup(self):
self.install_runtime_provisioning_support()
ctx = make_ctx(self.target)
phases_impl.stage_provisioning_state(ctx)
self.assertTrue((self.provisioning_dir() / "pending").exists())
self.assertTrue((self.target / "etc/systemd/system/omarchy-provision-owner.service").exists())
link = self.target / "etc/systemd/system/multi-user.target.wants/omarchy-provision-owner.service"
self.assertTrue(link.is_symlink())
self.assertEqual(
os.readlink(link), "/etc/systemd/system/omarchy-provision-owner.service"
)
# Unencrypted: no LUKS staging.
self.assertFalse((self.provisioning_dir() / "luks-key").exists())
def test_deferred_provisioning_encrypted_stages_luks_auto_unlock(self):
self.install_runtime_provisioning_support()
ctx = make_ctx(
self.target,
user_configuration={"disk_config": {"disk_encryption": {
"encryption_type": "luks",
"encryption_password": "throwaway-secret",
}}},
)
phases_impl.stage_provisioning_state(ctx)
# Byte-for-byte the slot passphrase — no trailing newline.
self.assertEqual((self.provisioning_dir() / "luks-key").read_text(), "throwaway-secret")
keyfile = self.target / "etc/omarchy/provisioning.key"
self.assertEqual(keyfile.read_text(), "throwaway-secret")
self.assertEqual(keyfile.stat().st_mode & 0o777, 0o600)
cmdline = (self.target / "etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf").read_text()
self.assertIn("cryptkey=rootfs:/etc/omarchy/provisioning.key", cmdline)
files = (self.target / "etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf").read_text()
self.assertIn("FILES+=(/etc/omarchy/provisioning.key)", files)
def test_deferred_provisioning_pre_encrypted_without_passphrase_fails(self):
self.install_runtime_provisioning_support()
ctx = make_ctx(
self.target,
omarchy_install={"mode": "protected", "defer_provisioning": True, "storage": {"luks_uuid": "abc"}},
)
with self.assertRaisesRegex(RuntimeError, "passphrase"):
phases_impl.stage_provisioning_state(ctx)
class ConfigureLoginDeferProvisioningTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.target = Path(self.tmp.name)
self.calls = []
run_patch = mock.patch.object(
phases_impl.subprocess, "run",
side_effect=lambda cmd, **kw: (self.calls.append(cmd), CompletedProcess(cmd, 0))[1],
)
run_patch.start()
self.addCleanup(run_patch.stop)
def test_deferred_provisioning_login_leaves_user_state_to_first_boot(self):
ctx = make_ctx(self.target, encrypt=True)
phases_impl.configure_login(ctx)
self.assertTrue((self.target / "etc/sddm.conf.d/99-omarchy-login.conf").exists())
self.assertFalse((self.target / "etc/sddm.conf.d/autologin.conf").exists())
self.assertFalse((self.target / "var/lib/sddm/state.conf").exists())
self.assertTrue(any("sddm.service" in cmd for cmd in self.calls))
def test_normal_encrypted_login_still_autologs_in(self):
ctx = make_ctx(self.target, defer_provisioning=False, encrypt=True, username="jeff")
phases_impl.configure_login(ctx)
autologin = (self.target / "etc/sddm.conf.d/autologin.conf").read_text()
self.assertIn("User=jeff", autologin)
state = (self.target / "var/lib/sddm/state.conf").read_text()
self.assertIn("User=jeff", state)
class ConfigureSshAccessDeferProvisioningTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.target = Path(self.tmp.name) / "mnt"
self.target.mkdir()
info_patch = mock.patch.object(phases_impl, "info")
info_patch.start()
self.addCleanup(info_patch.stop)
def fake_run(cmd, **kwargs):
self.calls.append(cmd)
if "ufw" in cmd:
rules = self.target / "etc/ufw/user.rules"
rules.parent.mkdir(parents=True, exist_ok=True)
rules.write_text("-A ufw-user-input -p tcp --dport 22 -j ACCEPT\n")
return CompletedProcess(cmd, 0)
self.calls = []
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=fake_run)
run_patch.start()
self.addCleanup(run_patch.stop)
def test_deferred_provisioning_stages_keys_for_first_boot(self):
keys = Path(self.tmp.name) / "authorized_keys"
keys.write_text("ssh-ed25519 AAAA rig@host\n")
ctx = make_ctx(self.target, authorized_keys_path=keys)
phases_impl.configure_ssh_access(ctx)
staged = self.target / "var/lib/omarchy/provisioning/authorized_keys"
self.assertEqual(staged.read_text(), "ssh-ed25519 AAAA rig@host\n")
self.assertEqual(staged.stat().st_mode & 0o777, 0o600)
# No user yet — nothing under /home, no chown.
self.assertFalse((self.target / "home").exists())
self.assertFalse(any("chown" in cmd for cmd in self.calls))
# The door is still opened for first boot.
self.assertTrue(any("sshd.service" in cmd for cmd in self.calls))
class CreateFactorySnapshotTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.target = Path(self.tmp.name) / "mnt"
self.target.mkdir()
self.state_dir = Path(self.tmp.name) / "state"
self.state_dir.mkdir()
info_patch = mock.patch.object(phases_impl, "info")
info_patch.start()
self.addCleanup(info_patch.stop)
self.findmnt = {
"FSTYPE": "btrfs",
"OPTIONS": "rw,noatime,compress=zstd:3,subvol=/@",
"SOURCE": "/dev/mapper/omarchy_root[/@]",
}
self.calls = []
def fake_run(cmd, **kwargs):
self.calls.append(cmd)
if cmd[0] == "findmnt":
return CompletedProcess(cmd, 0, stdout=self.findmnt[cmd[2]] + "\n", stderr="")
if cmd[0] == "mount":
# Simulate the top level containing @.
(Path(cmd[-1]) / "@").mkdir(parents=True, exist_ok=True)
return CompletedProcess(cmd, 0, stdout="", stderr="")
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=fake_run)
run_patch.start()
self.addCleanup(run_patch.stop)
def ctx(self):
return make_ctx(self.target, defer_provisioning=False, state_dir=self.state_dir)
def test_snapshots_root_as_factory(self):
phases_impl.create_factory_snapshot(self.ctx())
top = str(self.state_dir / "factory-top")
self.assertIn(["mount", "-o", "subvolid=5", "/dev/mapper/omarchy_root", top], self.calls)
self.assertIn(
["btrfs", "subvolume", "snapshot", f"{top}/@", f"{top}/@factory"],
self.calls,
)
self.assertIn(
["btrfs", "property", "set", "-ts", f"{top}/@factory", "ro", "true"],
self.calls,
)
self.assertIn(["umount", top], self.calls)
def test_factory_snapshot_scrubs_provisioning_credentials(self):
# Simulate the snapshot carrying deployment credentials; the scrub
# must drop them before the snapshot goes read-only.
top = self.state_dir / "factory-top"
factory = top / "@factory"
secrets = [
factory / "var/lib/omarchy/provisioning/authorized_keys",
factory / "etc/tailscale/authkey",
factory / "etc/omarchy/provisioning.key",
]
keep = factory / "var/lib/omarchy/provisioning/groups"
for path in [*secrets, keep]:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("secret")
phases_impl.create_factory_snapshot(self.ctx())
for path in secrets:
self.assertFalse(path.exists(), path)
self.assertTrue(keep.exists())
def test_non_btrfs_target_skips(self):
self.findmnt["FSTYPE"] = "ext4"
phases_impl.create_factory_snapshot(self.ctx())
self.assertFalse(any(cmd[0] == "mount" for cmd in self.calls))
def test_non_subvol_root_skips(self):
self.findmnt["OPTIONS"] = "rw,noatime"
phases_impl.create_factory_snapshot(self.ctx())
self.assertFalse(any(cmd[0] == "mount" for cmd in self.calls))
if __name__ == "__main__":
unittest.main()