From 8858ac2544259a2b9447df3c2fbe4cdb133f31eb Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 11:20:48 +0200 Subject: [PATCH] Source the setup form from the Omarchy runtime (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bin/omarchy-iso-test | 10 +- builder/build-iso.sh | 21 +++ configs/airootfs/root/configurator | 176 ++++++------------ .../omarchy-iso/orchestrator/keyboard.py | 5 +- test/test_keyboard.py | 44 +++-- 5 files changed, 116 insertions(+), 140 deletions(-) diff --git a/bin/omarchy-iso-test b/bin/omarchy-iso-test index 3e670a8..281cf56 100755 --- a/bin/omarchy-iso-test +++ b/bin/omarchy-iso-test @@ -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 diff --git a/builder/build-iso.sh b/builder/build-iso.sh index ae7719b..6f94ff4 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -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 < <( diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 3a1ca9e..ec94f53 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -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 } diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py index 3286241..48f9431 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py @@ -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 diff --git a/test/test_keyboard.py b/test/test_keyboard.py index a0103b4..c8b2abf 100644 --- a/test/test_keyboard.py +++ b/test/test_keyboard.py @@ -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)):