From 3aadc36bff3c8e3990e771350a426c132d4ec508 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Sun, 26 Apr 2026 22:17:00 +0100 Subject: [PATCH] fix(installer): implement robust step-based navigation and fix multi-line sed error - Implement a step-based state machine in main loop to support 'Back' navigation via Esc. - Refactor all prompts to use safe exit-code capture (rc -eq 130/1) and handle 'not submitted' output. - Add input flushing after Esc events to prevent cascading backtrack signals. - Add short-circuit checks to every wizard stage for reliable skip-forward behavior. - Fix sed error when generating multi-disk configurations by escaping newlines in additional_disks. - Add explicit 'Set a hostname' message to the hostname prompt. - Convert unsafe short-circuit lists to safe if statements to prevent set -e crashes. --- installer/install.sh | 399 +++++++++++++++++++++++++++++++------------ 1 file changed, 293 insertions(+), 106 deletions(-) diff --git a/installer/install.sh b/installer/install.sh index 0fb79d0..01b2346 100755 --- a/installer/install.sh +++ b/installer/install.sh @@ -115,9 +115,28 @@ load_state() { # Helper to run commands via nix run nrun() { - local pkg="$1" + local cmd="$1" shift - nix run --extra-experimental-features "nix-command flakes" "nixpkgs#$pkg" -- "$@" + if command -v "$cmd" >/dev/null 2>&1; then + "$cmd" "$@" + else + nix run --extra-experimental-features "nix-command flakes" "nixpkgs#$cmd" -- "$@" + fi +} + +clear_step_state() { + case "$1" in + select_disk) TARGET_DRIVE="" ;; + get_luks_passphrase) LUKS_PASSWORD="" ;; + configure_user) USERNAME=""; HOSTNAME=""; USER_PASSWORD="" ;; + select_keymap_locale) KEYMAP_LAYOUT=""; KEYMAP_VARIANT=""; LOCALE="" ;; + select_timezone) TIMEZONE="" ;; + select_hardware) HARDWARE_MODULES=""; NOMARCHY_HW_OPTS="" ;; + confirm_form_factor) FORM_FACTOR="" ;; + configure_impermanence) ENABLE_IMPERMANENCE="" ;; + select_profiles) SELECTED_PROFILES="" ;; + select_nomarchy_rev) NOMARCHY_REV="" ;; + esac } header() { @@ -201,7 +220,7 @@ check_environment() { while ! ping -c 1 -W 2 1.1.1.1 &>/dev/null; do error "No internet connection" local choice - choice=$(gum choose "Open Network Manager (nmtui)" "Retry" "Exit") + choice=$(gum choose "Open Network Manager (nmtui)" "Retry" "Exit") || exit 1 case "$choice" in *nmtui*) nmtui ;; *Exit*) exit 1 ;; @@ -218,8 +237,8 @@ select_disk() { section "Disk Selection" if [[ -n "$TARGET_DRIVE" ]]; then - success "Resumed: $TARGET_DRIVE" - return + success "Selected: $TARGET_DRIVE" + return 0 fi # Build a richer drive table than the bare `NAME SIZE` lsblk default. @@ -230,7 +249,7 @@ select_disk() { | grep -vE '^(/dev/(loop|ram|zram|sr))') while IFS= read -r line; do - [[ -z "$line" ]] && continue + if [[ -z "$line" ]]; then continue; fi # NAME and SIZE are reliably whitespace-free; ROTA/TRAN are short # tokens; VENDOR/MODEL/SERIAL can carry internal spaces. Pull the # first four fields off the front, treat the rest as the @@ -254,9 +273,9 @@ select_disk() { vendor=$(lsblk -d -n -o VENDOR "$dev" 2>/dev/null | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//') model=$(lsblk -d -n -o MODEL "$dev" 2>/dev/null | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//') serial=$(lsblk -d -n -o SERIAL "$dev" 2>/dev/null | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//') - [[ -z "$vendor" ]] && vendor="--" - [[ -z "$model" ]] && model="--" - [[ -z "$serial" ]] && serial="--" + if [[ -z "$vendor" ]]; then vendor="--"; fi + if [[ -z "$model" ]]; then model="--"; fi + if [[ -z "$serial" ]]; then serial="--"; fi # Tab-separated for column -t -s, then collapse internal whitespace # in MODEL so multi-space brand strings don't break alignment. @@ -281,20 +300,25 @@ select_disk() { # gum choose gets the same aligned rows so the picker reads like the table. local picker picker=$(printf '%s' "$rows" | column -t -s $'\t') - local choice - choice=$(printf '%s\n' "$picker" | gum choose --no-limit --header "Select target drive(s) - Use Space to select multiple for BTRFS RAID/Single") + local choice rc=0 + choice=$(printf '%s\n' "$picker" | nrun gum choose --no-limit --header "Select target drive(s) - Use Space to select multiple for BTRFS RAID/Single") || rc=$? + if [[ $rc -eq 130 || $rc -eq 1 || "$choice" == "not submitted" ]]; then return 130; fi + if [[ $rc -ne 0 ]]; then exit $rc; fi TARGET_DRIVE=$(awk '{print $1}' <<<"$choice" | xargs) if [[ -z "$TARGET_DRIVE" ]]; then error "No drive selected" - exit 1 + return 130 fi if [[ "$DRY_RUN" != "true" ]]; then echo "" - gum style --foreground 9 --bold "⚠ WARNING: All data on $TARGET_DRIVE will be DESTROYED!" + nrun gum style --foreground 9 --bold "⚠ WARNING: All data on $TARGET_DRIVE will be DESTROYED!" echo "" - if ! gum confirm "Are you sure you want to use $TARGET_DRIVE?"; then + rc=0 + nrun gum confirm "Are you sure you want to use $TARGET_DRIVE?" || rc=$? + if [[ $rc -ne 0 ]]; then + if [[ $rc -eq 130 || $rc -eq 1 ]]; then return 130; fi error "Aborted" exit 1 fi @@ -318,7 +342,7 @@ get_luks_passphrase() { # Already set this session (review-edit loop iterated). Don't re-prompt; # password isn't persisted across runs but is held in memory until # execute_installation unsets it. - [[ -n "${LUKS_PASSWORD:-}" ]] && return + if [[ -n "${LUKS_PASSWORD:-}" ]]; then return; fi section "Disk Encryption" @@ -326,12 +350,19 @@ get_luks_passphrase() { info "Enter a strong passphrase (you'll need this at every boot)." echo "" - local pass1 pass2 + local pass1 pass2 rc=0 while true; do - pass1=$(gum input --password --placeholder "Enter LUKS passphrase") - [[ -z "$pass1" ]] && continue + rc=0 + pass1=$(nrun gum input --password --placeholder "Enter LUKS passphrase") || rc=$? + if [[ $rc -eq 130 || $rc -eq 1 || "$pass1" == "not submitted" ]]; then return 130; fi + if [[ $rc -ne 0 ]]; then exit $rc; fi - pass2=$(gum input --password --placeholder "Confirm passphrase") + if [[ -z "$pass1" ]]; then continue; fi + + rc=0 + pass2=$(nrun gum input --password --placeholder "Confirm passphrase") || rc=$? + if [[ $rc -eq 130 || $rc -eq 1 || "$pass2" == "not submitted" ]]; then return 130; fi + if [[ $rc -ne 0 ]]; then exit $rc; fi if [[ "$pass1" == "$pass2" ]]; then LUKS_PASSWORD="$pass1" @@ -351,9 +382,24 @@ get_luks_passphrase() { configure_user() { section "User Configuration" + if [[ -n "$USERNAME" && -n "$HOSTNAME" ]]; then + # Password check skipped in dry run or if already set + if [[ "$DRY_RUN" == "true" ]] || [[ -n "$USER_PASSWORD" ]]; then + success "User $USERNAME @ $HOSTNAME configured" + return 0 + fi + fi + + local rc=0 if [[ -z "$USERNAME" ]]; then - USERNAME=$(nrun gum input --placeholder "Enter username (lowercase, no spaces)") + rc=0 + USERNAME=$(nrun gum input --placeholder "Enter username (lowercase, no spaces)") || rc=$? + if [[ $rc -eq 130 || $rc -eq 1 || "$USERNAME" == "not submitted" ]]; then return 130; fi + if [[ $rc -ne 0 ]]; then exit $rc; fi + if [[ -z "$USERNAME" ]] || [[ ! "$USERNAME" =~ ^[a-z][a-z0-9_-]*$ ]]; then + # If they just hit Enter/Esc and we got here, they might want to go back + if [[ -z "$USERNAME" ]]; then return 130; fi error "Invalid username" exit 1 fi @@ -361,8 +407,14 @@ configure_user() { success "Username: $USERNAME" if [[ -z "$HOSTNAME" ]]; then - HOSTNAME=$(nrun gum input --value "nomarchy" --placeholder "Hostname for this machine") + info "Set a hostname for this machine" + rc=0 + HOSTNAME=$(nrun gum input --value "nomarchy" --placeholder "Hostname for this machine") || rc=$? + if [[ $rc -eq 130 || $rc -eq 1 || "$HOSTNAME" == "not submitted" ]]; then return 130; fi + if [[ $rc -ne 0 ]]; then exit $rc; fi + if [[ -z "$HOSTNAME" ]] || [[ ! "$HOSTNAME" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then + if [[ -z "$HOSTNAME" ]]; then return 130; fi error "Invalid hostname (use lowercase letters, digits, and hyphens only)" exit 1 fi @@ -380,10 +432,17 @@ configure_user() { info "Set a password for your user account" local pass1 pass2 while true; do - pass1=$(nrun gum input --password --placeholder "Enter user password") - [[ -z "$pass1" ]] && continue + rc=0 + pass1=$(nrun gum input --password --placeholder "Enter user password") || rc=$? + if [[ $rc -eq 130 || $rc -eq 1 || "$pass1" == "not submitted" ]]; then return 130; fi + if [[ $rc -ne 0 ]]; then exit $rc; fi - pass2=$(nrun gum input --password --placeholder "Confirm user password") + if [[ -z "$pass1" ]]; then continue; fi + + rc=0 + pass2=$(nrun gum input --password --placeholder "Confirm user password") || rc=$? + if [[ $rc -eq 130 || $rc -eq 1 || "$pass2" == "not submitted" ]]; then return 130; fi + if [[ $rc -ne 0 ]]; then exit $rc; fi if [[ "$pass1" == "$pass2" ]]; then USER_PASSWORD="$pass1" @@ -407,17 +466,28 @@ configure_user() { select_keymap_locale() { section "Keyboard & Language" + if [[ -n "$KEYMAP_LAYOUT" && -n "$LOCALE" ]]; then + success "Keymap ($KEYMAP_LAYOUT) and Locale ($LOCALE) set" + return 0 + fi + + local choice rc=0 if [[ -z "$KEYMAP_LAYOUT" ]]; then - local choice + rc=0 choice=$(printf '%s\n' "${COMMON_KEYMAPS[@]}" "Other…" \ - | nrun gum choose --header "Keyboard layout") + | nrun gum choose --header "Keyboard layout") || rc=$? + if [[ $rc -eq 130 || $rc -eq 1 || "$choice" == "not submitted" ]]; then return 130; fi + if [[ $rc -ne 0 ]]; then exit $rc; fi if [[ "$choice" == "Other…" ]]; then + rc=0 KEYMAP_LAYOUT=$(localectl list-x11-keymap-layouts 2>/dev/null \ - | nrun gum filter --placeholder "Search keyboard layout…") + | nrun gum filter --placeholder "Search keyboard layout…") || rc=$? + if [[ $rc -eq 130 || $rc -eq 1 || "$KEYMAP_LAYOUT" == "not submitted" ]]; then return 130; fi + if [[ $rc -ne 0 ]]; then exit $rc; fi else KEYMAP_LAYOUT="$choice" fi - [[ -z "$KEYMAP_LAYOUT" ]] && KEYMAP_LAYOUT="us" + if [[ -z "$KEYMAP_LAYOUT" ]]; then KEYMAP_LAYOUT="us"; fi fi success "Keyboard layout: $KEYMAP_LAYOUT" @@ -427,12 +497,20 @@ select_keymap_locale() { variants=$(localectl list-x11-keymap-variants "$KEYMAP_LAYOUT" 2>/dev/null || true) if [[ -n "$variants" ]]; then local v + rc=0 v=$(printf '(none)\n%s\n' "$variants" \ - | nrun gum filter --placeholder "Variant (optional)" --value "(none)") - [[ "$v" == "(none)" || -z "$v" ]] || KEYMAP_VARIANT="$v" + | nrun gum filter --placeholder "Variant (optional)" --value "(none)") || rc=$? + if [[ $rc -eq 130 || $rc -eq 1 || "$v" == "not submitted" ]]; then return 130; fi + if [[ $rc -ne 0 ]]; then exit $rc; fi + if [[ -n "$v" && "$v" != "(none)" ]]; then KEYMAP_VARIANT="$v"; fi fi fi - [[ -n "$KEYMAP_VARIANT" ]] && success "Variant: $KEYMAP_VARIANT" || success "Variant: (default)" + + if [[ -n "$KEYMAP_VARIANT" ]]; then + success "Variant: $KEYMAP_VARIANT" + else + success "Variant: (default)" + fi # Apply to the live session, best-effort. loadkeys "$KEYMAP_LAYOUT" 2>/dev/null || true @@ -442,16 +520,22 @@ select_keymap_locale() { fi if [[ -z "$LOCALE" ]]; then - local choice + local choice rc=0 + rc=0 choice=$(printf '%s\n' "${COMMON_LOCALES[@]}" "Other…" \ - | nrun gum choose --header "Language / locale") + | nrun gum choose --header "Language / locale") || rc=$? + if [[ $rc -eq 130 || $rc -eq 1 || "$choice" == "not submitted" ]]; then return 130; fi + if [[ $rc -ne 0 ]]; then exit $rc; fi if [[ "$choice" == "Other…" ]]; then + rc=0 LOCALE=$(localectl list-locales 2>/dev/null \ - | nrun gum filter --placeholder "Search locale…") + | nrun gum filter --placeholder "Search locale…") || rc=$? + if [[ $rc -eq 130 || $rc -eq 1 || "$LOCALE" == "not submitted" ]]; then return 130; fi + if [[ $rc -ne 0 ]]; then exit $rc; fi else LOCALE="$choice" fi - [[ -z "$LOCALE" ]] && LOCALE="en_US.UTF-8" + if [[ -z "$LOCALE" ]]; then LOCALE="en_US.UTF-8"; fi fi success "Locale: $LOCALE" @@ -465,16 +549,18 @@ select_keymap_locale() { select_timezone() { section "Timezone" - if [[ -n "$TIMEZONE" ]] && [[ "$TIMEZONE" != "UTC" ]]; then - success "Resumed: $TIMEZONE" - return + if [[ -n "$TIMEZONE" ]]; then + success "Timezone set to $TIMEZONE" + return 0 fi - local timezones + local timezones rc=0 timezones=$(timedatectl list-timezones 2>/dev/null || echo "UTC") - TIMEZONE=$(echo "$timezones" | gum filter --placeholder "Search timezone...") - - [[ -z "$TIMEZONE" ]] && TIMEZONE="UTC" + rc=0 + TIMEZONE=$(echo "$timezones" | gum filter --placeholder "Search timezone...") || rc=$? + if [[ $rc -eq 130 || $rc -eq 1 || "$TIMEZONE" == "not submitted" ]]; then return 130; fi + if [[ $rc -ne 0 ]]; then exit $rc; fi + if [[ -z "$TIMEZONE" ]]; then TIMEZONE="UTC"; fi success "Timezone: $TIMEZONE" save_state } @@ -487,8 +573,8 @@ select_hardware() { section "Hardware Configuration" if [[ -n "$HARDWARE_MODULES" ]]; then - success "Resumed hardware modules" - return + success "Hardware configured" + return 0 fi local dmi_vendor dmi_product detect_output @@ -514,24 +600,39 @@ select_hardware() { done <<< "$detect_output" # Let the user accept, extend, or replace the detection. - local choice - choice=$(nrun gum choose --header "Hardware configuration" \ - "Accept detected modules" \ - "Add an extra nixos-hardware module" \ - "Pick from the manual list (override)") + local choice rc=0 + while true; do + rc=0 + choice=$(nrun gum choose --header "Hardware configuration" \ + "Accept detected modules" \ + "Add an extra nixos-hardware module" \ + "Pick from the manual list (override)") || rc=$? + if [[ $rc -eq 130 || $rc -eq 1 || "$choice" == "not submitted" ]]; then return 130; fi + if [[ $rc -ne 0 ]]; then exit $rc; fi - case "$choice" in - "Add an extra nixos-hardware module") - local extra - extra=$(nrun gum input --placeholder "e.g. asus-zephyrus-ga401 (no 'nixos-hardware.' prefix)") - [[ -n "$extra" ]] && modules+=("$extra") - ;; - "Pick from the manual list (override)") - modules=() - hw_opts=() - _select_hardware_manual modules hw_opts - ;; - esac + case "$choice" in + "Add an extra nixos-hardware module") + local extra extra_rc=0 + extra=$(nrun gum input --placeholder "e.g. asus-zephyrus-ga401 (no 'nixos-hardware.' prefix)") || extra_rc=$? + if [[ $extra_rc -eq 130 || $extra_rc -eq 1 || "$extra" == "not submitted" ]]; then continue; fi + if [[ $extra_rc -ne 0 ]]; then exit $extra_rc; fi + if [[ -n "$extra" ]]; then modules+=("$extra"); fi + break + ;; + "Pick from the manual list (override)") + modules=() + hw_opts=() + local manual_rc=0 + _select_hardware_manual modules hw_opts || manual_rc=$? + if [[ $manual_rc -eq 130 ]]; then continue; fi + if [[ $manual_rc -ne 0 ]]; then exit $manual_rc; fi + break + ;; + "Accept detected modules") + break + ;; + esac + done # De-duplicate while preserving order. local seen="" uniq_mods=() m @@ -571,66 +672,76 @@ _select_hardware_manual() { local -n _mods_ref="$1" local -n _opts_ref="$2" - local vendor + local vendor rc=0 + rc=0 vendor=$(nrun gum choose --header "Pick vendor" \ - "Framework" "Dell" "Lenovo" "Apple (T2 Mac)" "Microsoft Surface" "ASUS" "System76" "Other...") - + "Framework" "Dell" "Lenovo" "Apple (T2 Mac)" "Microsoft Surface" "ASUS" "System76" "Other...") || rc=$? + if [[ $rc -eq 130 || $rc -eq 1 || "$vendor" == "not submitted" ]]; then return 130; fi + if [[ $rc -ne 0 ]]; then exit $rc; fi case "$vendor" in Framework) - local model + local model model_rc=0 model=$(nrun gum choose \ "framework-16-7040-amd" \ "framework-13-amd-ai-300-series" \ "framework-13-7040-amd" \ "framework-13-13th-gen-intel" \ "framework-13-12th-gen-intel" \ - "framework-13-11th-gen-intel") + "framework-13-11th-gen-intel") || model_rc=$? + if [[ $model_rc -eq 130 || $model_rc -eq 1 || "$model" == "not submitted" ]]; then return 130; fi + if [[ $model_rc -ne 0 ]]; then exit $model_rc; fi _mods_ref+=("$model") _opts_ref+=("isFramework=true") ;; Dell) - local model + local model model_rc=0 model=$(nrun gum choose \ "dell-xps-15-9500" "dell-xps-15-9510" "dell-xps-15-9520" \ "dell-xps-13-9310" "dell-xps-13-9370" "dell-xps-13-9380" \ - "dell-precision-5530" "dell-latitude-7480") + "dell-precision-5530" "dell-latitude-7480") || model_rc=$? + if [[ $model_rc -eq 130 || $model_rc -eq 1 || "$model" == "not submitted" ]]; then return 130; fi + if [[ $model_rc -ne 0 ]]; then exit $model_rc; fi _mods_ref+=("$model") - [[ "$model" == *xps* ]] && _opts_ref+=("isXPS=true") + if [[ "$model" == *xps* ]]; then _opts_ref+=("isXPS=true"); fi ;; Lenovo) - local model + local model model_rc=0 model=$(nrun gum choose \ "lenovo-thinkpad-x1-carbon-gen11" "lenovo-thinkpad-x1-carbon-gen10" \ "lenovo-thinkpad-x1-carbon-gen9" "lenovo-thinkpad-x1-extreme" \ "lenovo-thinkpad-t14-amd-gen3" "lenovo-thinkpad-t14-amd-gen2" \ - "lenovo-thinkpad-t480" "lenovo-thinkpad-l13") + "lenovo-thinkpad-t480" "lenovo-thinkpad-l13") || model_rc=$? + if [[ $model_rc -eq 130 || $model_rc -eq 1 || "$model" == "not submitted" ]]; then return 130; fi + if [[ $model_rc -ne 0 ]]; then exit $model_rc; fi _mods_ref+=("$model") ;; - "Apple (T2 Mac)") - _mods_ref+=("apple-t2") - _opts_ref+=("isT2Mac=true") - ;; "Microsoft Surface") - local model + local model model_rc=0 model=$(nrun gum choose \ "microsoft-surface-pro-9" "microsoft-surface-pro-8" "microsoft-surface-pro-7" \ - "microsoft-surface-laptop-5" "microsoft-surface-laptop-4" "microsoft-surface-laptop-3") + "microsoft-surface-laptop-5" "microsoft-surface-laptop-4" "microsoft-surface-laptop-3") || model_rc=$? + if [[ $model_rc -eq 130 || $model_rc -eq 1 || "$model" == "not submitted" ]]; then return 130; fi + if [[ $model_rc -ne 0 ]]; then exit $model_rc; fi _mods_ref+=("$model") ;; ASUS) - local model + local model model_rc=0 model=$(nrun gum choose \ "asus-zephyrus-ga403" "asus-zephyrus-ga402" "asus-zephyrus-ga401" \ - "asus-zephyrus-ga503" "asus-rog-strix-g513" "asus-zenbook-ux") + "asus-zephyrus-ga503" "asus-rog-strix-g513" "asus-zenbook-ux") || model_rc=$? + if [[ $model_rc -eq 130 || $model_rc -eq 1 || "$model" == "not submitted" ]]; then return 130; fi + if [[ $model_rc -ne 0 ]]; then exit $model_rc; fi _mods_ref+=("$model") ;; System76) _mods_ref+=("system76") ;; "Other...") - local custom - custom=$(nrun gum input --placeholder "e.g. asus-zephyrus-ga401 (no 'nixos-hardware.' prefix)") - [[ -n "$custom" ]] && _mods_ref+=("$custom") + local custom custom_rc=0 + custom=$(nrun gum input --placeholder "e.g. asus-zephyrus-ga401 (no 'nixos-hardware.' prefix)") || custom_rc=$? + if [[ $custom_rc -eq 130 || $custom_rc -eq 1 || "$custom" == "not submitted" ]]; then return 130; fi + if [[ $custom_rc -ne 0 ]]; then exit $custom_rc; fi + if [[ -n "$custom" ]]; then _mods_ref+=("$custom"); fi ;; esac } @@ -656,9 +767,12 @@ confirm_form_factor() { fi info "Auto-detected: $default" - if nrun gum confirm "Treat this machine as a $default?"; then + rc=0 + nrun gum confirm "Treat this machine as a $default?" || rc=$? + if [[ $rc -eq 0 ]]; then FORM_FACTOR="$default" else + if [[ $rc -eq 130 || $rc -eq 1 ]]; then return 130; fi FORM_FACTOR=$([[ "$default" == "laptop" ]] && echo desktop || echo laptop) fi success "Form factor: $FORM_FACTOR" @@ -672,9 +786,13 @@ confirm_form_factor() { configure_impermanence() { section "Impermanence (Optional)" - if [[ "$RESUME" == "true" ]] && [[ "$ENABLE_IMPERMANENCE" != "" ]]; then - success "Resumed: impermanence = $ENABLE_IMPERMANENCE" - return + if [[ -n "$ENABLE_IMPERMANENCE" ]]; then + if [[ "$ENABLE_IMPERMANENCE" == "true" ]]; then + success "Impermanence enabled" + else + info "Impermanence disabled" + fi + return 0 fi info "Impermanence erases your root filesystem on every boot." @@ -682,10 +800,13 @@ configure_impermanence() { info "This provides a clean, reproducible system." echo "" - if gum confirm "Enable Impermanence?"; then + rc=0 + gum confirm "Enable Impermanence?" || rc=$? + if [[ $rc -eq 0 ]]; then ENABLE_IMPERMANENCE="true" success "Impermanence enabled" else + if [[ $rc -eq 130 || $rc -eq 1 ]]; then return 130; fi info "Impermanence disabled (traditional persistent root)" fi save_state @@ -699,21 +820,23 @@ select_profiles() { section "Software Profiles" if [[ -n "$SELECTED_PROFILES" ]]; then - success "Resumed selected profiles" - return + success "Software profiles selected" + return 0 fi info "Pick optional software profiles to include in your configuration." info "Multiple selection allowed (Space to select, Enter to confirm)." echo "" + rc=0 SELECTED_PROFILES=$(nrun gum choose --no-limit --header "Select Profiles" \ "Dev (VSCode, Git, Lazygit, Zed, Docker)" \ "Gaming (Steam, Gamemode, Lutris, Heroic)" \ "Office (LibreOffice, Thunderbird, Obsidian, Zotero)" \ "Media (VLC, OBS Studio, GIMP, Inkscape, Spotify)" \ - "CLI Utils (ripgrep, fd, bat, eza, zoxide, fzf)") - + "CLI Utils (ripgrep, fd, bat, eza, zoxide, fzf)") || rc=$? + if [[ $rc -eq 130 || $rc -eq 1 || "$SELECTED_PROFILES" == "not submitted" || "$action" == "not submitted" || "$choices" == "not submitted" ]]; then return 130; fi + if [[ $rc -ne 0 ]]; then exit $rc; fi if [[ -z "$SELECTED_PROFILES" ]]; then info "No profiles selected (minimal install)" else @@ -752,11 +875,14 @@ review_configuration() { nrun gum style --foreground 9 "This will DESTROY all data on $TARGET_DRIVE" echo "" - local action + local action rc=0 + rc=0 action=$(nrun gum choose --header "Choose:" \ "Continue with installation" \ "Edit a field" \ - "Abort") + "Abort") || rc=$? + if [[ $rc -eq 130 || $rc -eq 1 || "$action" == "not submitted" ]]; then return 130; fi + if [[ $rc -ne 0 ]]; then exit $rc; fi case "$action" in "Continue with installation") return 0 ;; "Edit a field") edit_fields; return 2 ;; @@ -773,7 +899,8 @@ review_configuration() { edit_fields() { section "Edit Fields" - local choices + local choices rc=0 + rc=0 choices=$(nrun gum choose --no-limit --header "Pick fields to re-enter (space to select, enter to confirm):" \ "Drive ($TARGET_DRIVE)" \ "User and host ($USERNAME @ $HOSTNAME)" \ @@ -783,9 +910,10 @@ edit_fields() { "Form factor ($FORM_FACTOR)" \ "Impermanence ($ENABLE_IMPERMANENCE)" \ "Profiles (${SELECTED_PROFILES:-none})" \ - "Nomarchy rev (${NOMARCHY_REV:-main})") - - [[ -z "$choices" ]] && return + "Nomarchy rev (${NOMARCHY_REV:-main})") || rc=$? + if [[ $rc -eq 130 || $rc -eq 1 || "$choices" == "not submitted" ]]; then return 130; fi + if [[ $rc -ne 0 ]]; then exit $rc; fi + if [[ -z "$choices" ]]; then return 0; fi while IFS= read -r f; do case "$f" in @@ -906,7 +1034,10 @@ execute_installation() { " done - sed "s|@MAIN_DRIVE@|${main_drive}|g; s|@BTRFS_DEVICES@|${btrfs_devs}|g; s|@ADDITIONAL_DISKS@|${additional_disks}|g" "$disko_file" > "$tmp_disko" + # Escape newlines for sed + local escaped_disks + escaped_disks=$(printf '%s\n' "$additional_disks" | sed ':a;N;$!ba;s/\n/\\n/g') + sed "s|@MAIN_DRIVE@|${main_drive}|g; s|@BTRFS_DEVICES@|${btrfs_devs}|g; s|@ADDITIONAL_DISKS@|${escaped_disks}|g" "$disko_file" > "$tmp_disko" else disko_file="$NOMARCHY_REPO/installer/disko-golden.nix" sed "s|@TARGET_DRIVE@|${TARGET_DRIVE}|g" "$disko_file" > "$tmp_disko" @@ -1071,13 +1202,15 @@ EOF generate_flake_config() { local impermanence_opt="" - [[ "$ENABLE_IMPERMANENCE" == "true" ]] && impermanence_opt="nomarchy.system.impermanence.enable = true;" + if [[ "$ENABLE_IMPERMANENCE" == "true" ]]; then + impermanence_opt="nomarchy.system.impermanence.enable = true;" + fi local PROFILE_SYSTEM_OPTS="" local PROFILE_HOME_PACKAGES="" while IFS= read -r profile; do - [[ -z "$profile" ]] && continue + if [[ -z "$profile" ]]; then continue; fi case "$profile" in "Dev "*) PROFILE_SYSTEM_OPTS+=$'\n # Dev profile\n nomarchy.system.virtualization.docker.enable = true;' @@ -1431,11 +1564,7 @@ main() { check_environment - # Loop: prompts (each skips when its var is set) → review → Continue|Edit. - # On Edit, edit_fields() clears the chosen vars and the next iteration - # re-prompts only those. Continue breaks. --resume lands here with vars - # already loaded, so the first pass goes straight to review. - while true; do + local steps=( select_disk get_luks_passphrase configure_user @@ -1445,7 +1574,65 @@ main() { confirm_form_factor configure_impermanence select_profiles - if review_configuration; then break; fi + ) + + local current_step=0 + while true; do + if (( current_step < 0 )); then + error "Installation aborted by user." + exit 1 + fi + + if (( current_step < ${#steps[@]} )); then + local step_func="${steps[current_step]}" + + # Run the step. Each step returns 130 if the user presses Esc. + local rc=0 + "$step_func" || rc=$? + + if (( rc != 0 )); then + if (( rc == 130 )); then + # Clear the current step so it re-prompts if we return to it + clear_step_state "$step_func" + + # Flush terminal input buffer to prevent one Esc from cascading + # through multiple consecutive prompts. + sleep 0.1 + read -t 0.1 -n 10000 discard 2>/dev/null || true + + # Go back one step + current_step=$(( current_step - 1 )) + + # If we aren't at the very beginning, clear the TARGET + # step's variables too, so the user is forced to re-enter. + if (( current_step >= 0 )); then + clear_step_state "${steps[current_step]}" + fi + continue + fi + exit "$rc" + fi + current_step=$(( current_step + 1 )) + else + # All steps done, review the configuration. + local review_rc=0 + review_configuration || review_rc=$? + + if [[ "$review_rc" == "0" ]]; then + break + elif [[ "$review_rc" == "2" ]]; then + # User chose "Edit a field". Restart from step 0; functions + # with still-set variables will return 0 and skip ahead. + current_step=0 + elif [[ "$review_rc" == "130" ]]; then + # Go back to the last step. + current_step=$(( ${#steps[@]} - 1 )) + clear_step_state "${steps[current_step]}" + continue + else + exit "$review_rc" + fi + fi done execute_installation