Source the setup form from the Omarchy runtime (#104)

The keyboard layout list, the account and hostname validation rules, and the
gum prompts that ask for them existed here and again in the runtime's
first-boot owner setup, with nothing keeping the copies honest. They had
already drifted: dropping two layouts from this list moved English (US) from
the top of one gum choose page to the bottom of the previous one, leaving the
preselected default pinned under a screenful of layouts.

The form now lives in the runtime at install/provisioning/setup-form.sh, and
build-iso.sh vendors it out of the very runtime package this ISO bundles —
the same two-branch machinery already used for omarchy-base.packages, so a
--local-source build takes it from the mounted checkout and everything else
extracts it from the downloaded package. An ISO therefore ships the form
belonging to the runtime it installs.

That file is extracted on its own rather than alongside the package lists:
bsdtar exits non-zero for a member it cannot find, so bundling the request
would abort the build under set -e with a bare "Not found in archive" instead
of the actionable error about publishing a runtime that carries the form.

Cancel handling is unified with first-boot setup, which is what made the
prompts shareable. Every prompt reports 0 (answered), 1 (Esc), or 130
(Ctrl+C). Esc now unwinds to the start of the form instead of ending the
install — it previously hit the same `|| abort` as Ctrl+C. Ctrl+C keeps both
of its hidden affordances: arming deferred provisioning on the first keyboard
screen, and toggling unencrypted on the disk screens.

test_keyboard.py reads the layout list from a runtime checkout now, and skips
its localectl coverage rather than failing when there is none to read.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier HanssonandClaude Opus 5 authored and GitHub committed 2026-08-10 11:20:48 +02:00
1 parent 6b2f8a6ac7
commit 8858ac2544
5 files changed
+116 -140

No files matched your search

+5 -5
View File
@@ -711,17 +711,17 @@ drive_provision_owner() {
log "Driving the deferred-provisioning first-boot setup"
# Deferred provisioning defers the keyboard step to first boot; it comes before the user form.
# Pick English (UK) (one row above the preselected English (US)) so the
# verifier can prove the deferred step applied a non-default layout — UK
# leaves every letter identical to US, so the typed password (sent as raw
# keycodes) still matches the literal one the harness pipes to sudo.
# Pick English (UK) (one row below the preselected English (US), which leads
# the list) so the verifier can prove the deferred step applied a non-default
# layout — UK leaves every letter identical to US, so the typed password (sent
# as raw keycodes) still matches the literal one the harness pipes to sudo.
# A greeter (animated logo + tagline) opens first boot; Return begins.
wait_for_screen "Opinionated" 600
capture_console "success-provision-00-greeter"
press ret
wait_for_screen "keyboard layout" 120
press up
press down
capture_console "success-provision-01-keyboard-layout-uk"
press ret
+21
View File
@@ -139,6 +139,7 @@ sed -i -E '/^(linux|broadcom-wl)$/d' "$build_cache_dir/packages.x86_64"
# 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
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.
@@ -154,12 +155,32 @@ else
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
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"
# 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
echo "ERROR: $OMARCHY_RUNTIME_PACKAGE does not ship install/provisioning/setup-form.sh" >&2
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 " Publish a runtime carrying the shared setup form, or build with" >&2
echo " --local-source against a checkout that has it." >&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 < <(
+57 -119
View File
@@ -9,6 +9,21 @@ OMARCHY_SETTINGS_PACKAGE="${OMARCHY_SETTINGS_PACKAGE:-omarchy-settings}"
LOGO_PATH="$OMARCHY_PATH/logo.txt"
# The setup form — keyboard, account, hostname, and timezone questions, plus the
# rules their answers are checked against — shared verbatim with the first-boot
# owner setup that finishes a deferred install. build-iso.sh vendors it out of
# the runtime package this ISO bundles, so the two can never offer different
# layouts or accept different usernames. On the ISO that vendored copy is what
# loads; a dry run outside the ISO falls back to the omarchy checkout
# bin/omarchy-iso-configurator already points OMARCHY_PATH at.
SETUP_FORM="${SETUP_FORM:-/usr/share/omarchy-iso/setup-form.sh}"
[[ -r $SETUP_FORM ]] || SETUP_FORM="$OMARCHY_PATH/install/provisioning/setup-form.sh"
if [[ ! -r $SETUP_FORM ]]; then
echo "ERROR: shared setup form not found at $SETUP_FORM" >&2
exit 1
fi
source "$SETUP_FORM"
LOGO_WIDTH=$(awk '{ if (length > max) max = length } END { print max+0 }' "$LOGO_PATH")
export GUM_CONFIRM_PROMPT_FOREGROUND="6"
@@ -177,76 +192,32 @@ keyboard_form() {
# screen is the hidden entry into deferred provisioning; $1 is "true" only for that first
# invocation, so the review-loop re-edit can't flip a user install into it.
local allow_defer_provisioning="${1:-false}"
clear_logo
echo
say "Let's setup your machine..."
if $allow_defer_provisioning; then
say --foreground 8 "Press Ctrl+C to prepare this machine for another owner."
fi
echo
local status
keyboards=$'Azerbaijani|azerty
Belarusian|by
Belgian|be-latin1
Bulgarian|bg-cp1251
Croatian|croat
Czech|cz
Danish|dk-latin1
Dutch|nl
English (UK)|uk
English (US)|us
English (US, Dvorak)|dvorak
English (US, Colemak)|colemak
Estonian|et
Finnish|fi
French|fr
French (Canada)|cf
French (Switzerland)|fr_CH
Georgian|ge
German|de
German (Switzerland)|de_CH-latin1
Greek|gr
Hebrew|il
Hungarian|hu
Icelandic|is-latin1
Irish|ie
Italian|it
Japanese|jp106
Kazakh|kazakh
Kyrgyz|kyrgyz
Lao|la-latin1
Latvian|lv
Lithuanian|lt
Macedonian|mk-utf
Norwegian|no-latin1
Polish|pl
Portuguese|pt-latin1
Portuguese (Brazil)|br-abnt2
Romanian|ro
Russian|ru
Serbian|sr-latin
Slovak|sk-qwertz
Slovenian|slovene
Spanish|es
Spanish (Latin American)|la-latin1
Swedish|sv-latin1
Tajik|tj_alt-UTF8
Turkish|trq
Ukrainian|ua'
choice=$(printf '%s\n' "$keyboards" | cut -d'|' -f1 | gum choose --height 10 --selected "English (US)" --header "Select keyboard layout")
local status=$?
if $allow_defer_provisioning && (( status == 130 )); then
if confirm_prepare_for_another_owner; then
defer_provisioning=true
return 0
while true; do
clear_logo
echo
say "Let's setup your machine..."
if $allow_defer_provisioning; then
say --foreground 8 "Press Ctrl+C to prepare this machine for another owner."
fi
keyboard_form true # declined: back to the keyboard picker
return
fi
(( status == 0 )) || abort
echo
keyboard=$(printf '%s\n' "$keyboards" | awk -F'|' -v c="$choice" '$1==c{print $2; exit}')
omarchy_prompt_keyboard && status=0 || status=$?
((status == 0)) && break
# Esc means "back", and nothing precedes the first screen, so re-ask.
((status == OMARCHY_FORM_BACK)) && continue
if $allow_defer_provisioning && ((status == OMARCHY_FORM_SIGNAL)); then
if confirm_prepare_for_another_owner; then
defer_provisioning=true
return 0
fi
continue # declined: back to the keyboard picker
fi
abort
done
# Only attempt to load keyboard layout if we're on a real console
# loadkeys only works on Linux virtual consoles (tty), not in terminal emulators
@@ -275,66 +246,34 @@ confirm_prepare_for_another_owner() {
user_form() {
step "Let's setup your user account..."
while true; do
username=$(gum input --placeholder "Alphanumeric without spaces (like dhh)" --prompt.foreground="#845DF9" --prompt "Username> ") || abort
if [[ "$username" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then
if [[ "$username" =~ ^(root|bin|daemon|mail|ftp|http|nobody|dbus|systemd-coredump|systemd-network|systemd-oom|systemd-journal-remote|systemd-resolve|systemd-timesync|tss|uuidd|alpm|git|avahi|cups|lp|_talkd|polkitd|rtkit|qemu|brltty|gluster|rpc|libvirt-qemu|pcscd|nvidia-persistenced|sddm)$ ]] ; then
notice "Username is reserved for system" 1
else
break
fi
else
notice "Username must be alphanumeric with no spaces" 1
fi
done
while true; do
password=$(gum input --placeholder "Used for user + root, and disk encryption when enabled" --prompt.foreground="#845DF9" --password --prompt "Password> ") || abort
password_confirmation=$(gum input --placeholder "Must match the password you just typed" --prompt.foreground="#845DF9" --password --prompt "Confirm> ") || abort
if [[ -n "$password" && "$password" == "$password_confirmation" ]]; then
break
elif [[ -z "$password" ]]; then
notice "Your password can't be blank!" 1
else
notice "Passwords didn't match!" 1
fi
done
# Each prompt reports 0 (set), OMARCHY_FORM_BACK (Esc), or OMARCHY_FORM_SIGNAL
# (Ctrl+C). Both non-zero cases unwind to user_step, which decides what they
# mean here; the form itself never aborts.
omarchy_prompt_username || return $?
omarchy_prompt_password || return $?
# Hash the password using yescrypt
password_hash=$(printf '%s' "$password" | openssl passwd -6 -stdin)
full_name=$(gum input --placeholder "Used for git authentication (hit return to skip)" --prompt.foreground="#845DF9" --prompt "Full name> ")
email_address=$(gum input --placeholder "Used for git authentication (hit return to skip)" --prompt.foreground="#845DF9" --prompt "Email address> ")
while true; do
hostname=$(gum input --placeholder "Letters, digits, and dashes (or return for 'omarchy')" --prompt.foreground="#845DF9" --prompt "Hostname> ")
if [[ "$hostname" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]]; then
break
elif [[ -z $hostname ]]; then
hostname="omarchy"
break
else
notice "Hostname must be 1-63 letters, digits, or dashes, and cannot start or end with a dash" 1
fi
done
# Pick timezone
geo_guessed_timezone=$(tzupdate -p 2>/dev/null)
if [[ -n $geo_guessed_timezone ]]; then
timezone=$(timedatectl list-timezones | gum choose --height 10 --selected "$geo_guessed_timezone" --header "Timezone") || abort
else
timezone=$(timedatectl list-timezones | gum filter --height 10 --header "Timezone") || abort
fi
omarchy_prompt_identity || return $?
omarchy_prompt_hostname || return $?
omarchy_prompt_timezone || return $?
}
user_step() {
user_form
local status
while true; do
user_form && status=0 || status=$?
if ((status != 0)); then
# Esc unwinds to the start of the form. Ctrl+C has no side channel in the
# user step (only the keyboard and disk screens give it a meaning), so it
# keeps its long-standing behaviour of ending the install.
((status == OMARCHY_FORM_BACK)) || abort
keyboard_form
continue
fi
# Add manual padding since gum table -p doesn't respect padding
echo -e "Field,Value
Username,$username
@@ -351,7 +290,6 @@ Keyboard,$keyboard" |
break
else
keyboard_form
user_form
fi
done
}
@@ -18,8 +18,9 @@ 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 (the configurator offers
two, ba and khmer), matching archinstall: warn and keep the default.
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
+30 -14
View File
@@ -14,14 +14,30 @@ KEYBOARD = importlib.util.module_from_spec(SPEC)
assert SPEC.loader
SPEC.loader.exec_module(KEYBOARD)
CONFIGURATOR = (ROOT / "configs/airootfs/root/configurator").read_text()
KEYBOARD_BLOCK = re.search(r"keyboards=\$'(.*?)'\n choice=", CONFIGURATOR, re.DOTALL)
assert KEYBOARD_BLOCK
SUPPORTED_KEYMAPS = [
line.split("|", 1)[1]
for line in KEYBOARD_BLOCK.group(1).splitlines()
]
UNSUPPORTED_KEYMAPS = {"ba", "khmer"}
# 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):
@@ -45,15 +61,15 @@ class KeyboardConfigurationTest(unittest.TestCase):
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)
configured = KEYBOARD.configure_keyboard(target, keymap)
self.assertEqual(configured, keymap not in UNSUPPORTED_KEYMAPS)
if configured:
lines = (target / "etc/vconsole.conf").read_text().splitlines()
self.assertIn(f"KEYMAP={keymap}", lines)
self.assertEqual(lines.count("FONT=default8x16"), 1)
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)):