Files
Nomarchy/installer/install.sh
Bernardo Magri 9c672953bc fix(installer): pre-flight resume polish (4 gaps)
Four resume-flow papercuts in `installer/install.sh` that hurt the
"interrupted install" path the most.

1. `--resume` with no state file is no longer silent.
   The most common operator confusion: reboot the live ISO, forget
   /tmp/ is tmpfs, re-run with --resume, watch the installer start
   over from scratch without saying anything. Now: loud error, tmpfs
   explanation, exit 1.

2. Validate the saved TARGET_DRIVE still exists on resume.
   Live ISO USB sticks get unplugged between sessions, dev hosts
   sometimes have non-deterministic /dev/sdX numbering. Without the
   guard the install proceeds and fails with cryptic disko / mount
   errors deep in execute_installation. Now we fail at load_state
   with the actual reason and a clean recovery path.

3. Resume now shows what's being resumed.
   `save_state` stamps an ISO-8601 timestamp; `load_state` prints
   "Resumed from <path> (saved Xm ago)" plus a "Target: /dev/X → user
   @ host" summary line. Lets the user Ctrl-C before any destructive
   prompt fires if they're resuming onto the wrong machine.

4. `--help` documents the tmpfs limitation.
   Saved state lives in /tmp/ which is tmpfs on the live ISO; --resume
   only works within the same boot. The man-page now says so instead
   of letting users discover it the hard way.

`format_age` is the one new helper — pretty-prints "Xs/Xm/Xh Ym/Xd"
relative to now, falls back to the raw timestamp if `date -d` can't
parse the input. shellcheck --severity=error passes.

Out of scope (potential future work):
- Persistent state across reboots (would need a writable USB / external
  drive — chicken/egg with the installer setting up the only persistent
  storage in the first place).
- `--show-state` flag to inspect a saved file without running.
- State-file schema versioning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:00:02 +01:00

1962 lines
71 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -e
# Nomarchy TTY Installer
# Golden path: BTRFS + LUKS2 encryption
#
# This is a minimal, single-path installer designed for TTY-only environments.
# For a customized installation, manually set up your disk and use the generated
# flake configuration as a starting point.
# Load the hardware-detection database — resolved relative to this script so it
# works whether we're invoked from /etc/install.sh on the live ISO or straight
# from a checkout.
_NOMARCHY_INSTALL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=hardware-db.sh
source "$_NOMARCHY_INSTALL_DIR/hardware-db.sh"
# Colors and styling
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# Installer state
NOMARCHY_REPO=""
NOMARCHY_REV=""
HOSTNAME=""
TARGET_DRIVE=""
USERNAME=""
LUKS_PASSWORD=""
USER_PASSWORD=""
USER_PASSWORD_HASH=""
TIMEZONE="UTC"
KEYMAP_LAYOUT=""
KEYMAP_VARIANT=""
LOCALE=""
FORM_FACTOR=""
HARDWARE_MODULES=""
NOMARCHY_HW_OPTS=""
SELECTED_PROFILES=""
# "" = not yet answered; "true"/"false" set by configure_impermanence.
ENABLE_IMPERMANENCE=""
# Curated short lists for the keymap/locale prompts. "Other…" drops the user
# into the full `localectl` list via gum filter.
COMMON_KEYMAPS=(
us de fr gb es it pt br se no fi dk nl pl ru ua ch jp kr cn ar
)
COMMON_LOCALES=(
en_US.UTF-8 en_GB.UTF-8 de_DE.UTF-8 fr_FR.UTF-8 es_ES.UTF-8
it_IT.UTF-8 pt_BR.UTF-8 pt_PT.UTF-8 nl_NL.UTF-8 sv_SE.UTF-8
ja_JP.UTF-8 zh_CN.UTF-8 ko_KR.UTF-8
)
# CLI flags
DRY_RUN="false"
RESUME="false"
# Resumable answers — passwords are NEVER persisted; the user re-enters them.
STATE_FILE="/tmp/nomarchy-install.state.sh"
# ============================================================================
# CLI / PERSISTENCE
# ============================================================================
usage() {
cat <<USAGE
Usage: install.sh [--dry-run] [--resume] [-h|--help]
--dry-run Generate the flake into a tmpdir and run \`nix flake check\`.
Doesn't touch the disk, doesn't run nixos-install.
--resume Reuse answers from a previous interrupted run
(saved at $STATE_FILE — passwords excluded).
NOTE: the live ISO uses tmpfs, so the state file is lost
on reboot. --resume only works within the same live-ISO
session as the original interrupted run.
-h, --help Print this message.
USAGE
}
parse_args() {
while (( $# > 0 )); do
case "$1" in
--dry-run) DRY_RUN="true" ;;
--resume) RESUME="true" ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
shift
done
}
# Persist non-secret answers so an interrupted install can pick up where it
# left off. Uses `declare -p` so each line is a self-contained `declare --`
# statement that `source` re-establishes verbatim.
#
# USER_PASSWORD_HASH is intentionally NOT persisted, even though a SHA-512
# crypt hash isn't reversible. The contract is: after --resume, the password
# prompt re-runs. configure_user's early-return guard at the top of the
# function checks `[[ -n "$USER_PASSWORD_HASH" ]]` for exactly this reason —
# if you ever change that guard to skip on USERNAME+HOSTNAME alone, --resume
# will silently install a system with an empty password hash and lock the
# user out. Keep the guard checking the hash.
save_state() {
# The leading timestamp lets --resume surface "how old is this state?"
# and is parsed back via NOMARCHY_INSTALL_STATE_SAVED_AT.
{
echo "NOMARCHY_INSTALL_STATE_SAVED_AT=\"$(date -Iseconds)\""
declare -p \
TARGET_DRIVE USERNAME HOSTNAME TIMEZONE \
KEYMAP_LAYOUT KEYMAP_VARIANT LOCALE FORM_FACTOR \
ENABLE_IMPERMANENCE HARDWARE_MODULES NOMARCHY_HW_OPTS \
SELECTED_PROFILES NOMARCHY_REV
} > "$STATE_FILE"
}
# Pretty-print "X minutes/hours/days ago" from an ISO-8601 timestamp.
# Falls back to the raw string if `date -d` can't parse it (defensive —
# the timestamp is always produced by `date -Iseconds` above, but we
# don't want a stale state file to crash --resume).
format_age() {
local saved="$1" saved_epoch now_epoch diff
saved_epoch=$(date -d "$saved" +%s 2>/dev/null) || { echo "$saved"; return; }
now_epoch=$(date +%s)
diff=$(( now_epoch - saved_epoch ))
if (( diff < 60 )); then echo "${diff}s ago"
elif (( diff < 3600 )); then echo "$((diff / 60))m ago"
elif (( diff < 86400 )); then echo "$((diff / 3600))h $((diff % 3600 / 60))m ago"
else echo "$((diff / 86400))d ago"
fi
}
load_state() {
if [[ "$RESUME" != "true" ]]; then
return
fi
# --resume with no state file is almost always operator error — the
# most common cause is "rebooted the live ISO and forgot tmpfs eats
# /tmp/". Fail loudly so the user doesn't sit through a fresh prompt
# cycle thinking it was resumed.
if [[ ! -f "$STATE_FILE" ]]; then
error "--resume was passed but no saved state exists at $STATE_FILE."
info "The live ISO uses tmpfs — saved state doesn't survive a reboot."
info "Re-run install.sh without --resume to start fresh."
exit 1
fi
# shellcheck disable=SC1090
source "$STATE_FILE"
# If the saved target drive isn't visible right now, every later
# disk-phase step will fail with cryptic errors. Catch it here.
# Live ISOs frequently get their non-boot USB sticks unplugged
# between sessions, and dev hosts sometimes have non-deterministic
# /dev/sdX numbering.
if [[ -n "${TARGET_DRIVE:-}" ]] && [[ ! -b "$TARGET_DRIVE" ]]; then
error "Saved target drive $TARGET_DRIVE is no longer a block device."
info "The drive may have been unplugged or renamed since the saved run."
info "Delete $STATE_FILE and re-run without --resume."
exit 1
fi
# Show what we're resuming into so the user can Ctrl-C if they're on
# the wrong host before any password / disk-wipe prompts fire.
local age="unknown age"
if [[ -n "${NOMARCHY_INSTALL_STATE_SAVED_AT:-}" ]]; then
age=$(format_age "$NOMARCHY_INSTALL_STATE_SAVED_AT")
fi
info "Resumed from $STATE_FILE (saved $age)"
if [[ -n "${USERNAME:-}" || -n "${HOSTNAME:-}" || -n "${TARGET_DRIVE:-}" ]]; then
info " Target: ${TARGET_DRIVE:-?}${USERNAME:-?} @ ${HOSTNAME:-?}"
fi
}
# ============================================================================
# UTILITY FUNCTIONS
# ============================================================================
# Helper to run commands via nix run
nrun() {
local cmd="$1"
shift
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=""; USER_PASSWORD_HASH="" ;;
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() {
clear
nrun gum style \
--foreground 212 --border-foreground 212 --border double \
--align center --width 60 --margin "1 2" --padding "2 4" \
"NOMARCHY INSTALLER" "Nomarchy Distribution"
echo ""
}
section() {
echo ""
nrun gum style --foreground 14 --bold "━━━ $1 ━━━"
echo ""
}
success() {
nrun gum style --foreground 10 "$1"
}
error() {
nrun gum style --foreground 9 "$1"
}
info() {
nrun gum style --foreground 12 "$1"
}
# ============================================================================
# STEP 1: ENVIRONMENT CHECK
# ============================================================================
check_environment() {
section "Environment Check"
# Check for root
if [[ $EUID -ne 0 ]]; then
error "This installer must be run as root (use sudo)"
exit 1
fi
success "Running as root"
# Confirm we booted via UEFI. The disko config lays out a GPT + ESP and
# we install systemd-boot/grub-efi; on a BIOS-booted host that doesn't
# work and would partial-install before failing.
if [[ ! -d /sys/firmware/efi ]]; then
error "This installer requires a UEFI system (no /sys/firmware/efi)."
info "Reboot in UEFI mode (disable CSM/legacy in firmware setup) and try again."
exit 1
fi
success "UEFI boot detected"
# Find Nomarchy repo
if [[ -d "/etc/nomarchy" ]]; then
NOMARCHY_REPO="/etc/nomarchy"
elif [[ -d "$(dirname "$0")/.." ]] && [[ -f "$(dirname "$0")/../flake.nix" ]]; then
NOMARCHY_REPO="$(realpath "$(dirname "$0")/..")"
fi
if [[ -z "$NOMARCHY_REPO" ]]; then
error "Nomarchy repository not found"
exit 1
fi
success "Found Nomarchy at $NOMARCHY_REPO"
# Capture the exact commit we're installing from. The generated flake
# pins `nomarchy.url` to this revision so the installed system can't
# silently drift onto a newer (possibly breaking) main.
#
# Three sources, in priority order:
# 1. /etc/nomarchy-rev — written at ISO build time from `inputs.self.rev`
# (the only source that works on a normal live-ISO install, because
# `inputs.self` strips .git from the Nix store copy at /etc/nomarchy).
# 2. `git rev-parse HEAD` in the repo — works when running the installer
# from a dev checkout instead of the live ISO.
# 3. Empty → unpinned, user gets a loud confirmation prompt below.
if [[ -z "$NOMARCHY_REV" ]] && [[ -f /etc/nomarchy-rev ]]; then
NOMARCHY_REV=$(tr -d '[:space:]' < /etc/nomarchy-rev)
fi
if [[ -z "$NOMARCHY_REV" ]] && command -v git >/dev/null 2>&1 && [[ -d "$NOMARCHY_REPO/.git" ]]; then
NOMARCHY_REV=$(git -C "$NOMARCHY_REPO" rev-parse HEAD 2>/dev/null || echo "")
fi
if [[ -n "$NOMARCHY_REV" ]]; then
success "Pinning Nomarchy to $NOMARCHY_REV"
else
error "Could not determine Nomarchy revision."
info "The installed system would silently track upstream main, which"
info "defeats the point of locking inputs at install time."
if [[ "$DRY_RUN" != "true" ]]; then
if ! nrun gum confirm --default=false \
"Continue anyway with an unpinned (tracking main) configuration?"; then
exit 1
fi
fi
fi
# Check internet
gum spin --spinner dot --title "Checking internet connection..." -- sleep 1
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") || exit 1
case "$choice" in
*nmtui*) nmtui ;;
*Exit*) exit 1 ;;
esac
done
success "Internet connection verified"
}
# ============================================================================
# STEP 2: DISK SELECTION
# ============================================================================
# Resolve the block device(s) backing the running live ISO so the disk
# picker can hide them. Picking the live USB by mistake destroys the
# installer's own boot media mid-run — always the worst-case outcome.
# We walk the live-ISO mountpoints (NixOS live ISO uses /iso for the
# squashfs source plus an overlay at /), resolve each to its parent
# disk via `lsblk -no PKNAME`, and emit a deduped list of /dev/<disk>
# entries on stdout. Nothing emitted = no live-ISO devices detected
# (e.g. running the installer from a regular shell during development).
detect_live_iso_devices() {
local seen=" "
local mp src parent
for mp in / /iso /run/initramfs/live /nix/.ro-store /nix/store; do
src=$(findmnt -no SOURCE "$mp" 2>/dev/null) || continue
[[ "$src" == /dev/* ]] || continue
parent=$(lsblk -no PKNAME "$src" 2>/dev/null | head -n1)
if [[ -n "$parent" ]]; then
parent="/dev/$parent"
else
parent="$src"
fi
case "$seen" in
*" $parent "*) ;;
*) seen+="$parent "; printf '%s\n' "$parent" ;;
esac
done
}
# Minimum total capacity across all picked drives. 10 GiB is the smallest
# size where the install completes without immediate disk-pressure failures
# (1 GiB ESP + ~5 GiB nix closure + working set).
_MIN_INSTALL_BYTES=$((10 * 1024 * 1024 * 1024))
select_disk() {
section "Disk Selection"
if [[ -n "$TARGET_DRIVE" ]]; then
success "Selected: $TARGET_DRIVE"
return 0
fi
# Build a richer drive table than the bare `NAME SIZE` lsblk default.
# Columns: NAME, SIZE, TYPE (NVMe/USB/SSD/HDD), VENDOR, MODEL, SERIAL.
# Empty fields render as "--" so column -t can still align them.
local raw rows=""
# Filter out pseudo-devices and the live-ISO boot media. The boot-media
# filter is the important one: without it the user can pick the USB
# they booted from and the installer will format its own boot device
# mid-run. NOMARCHY_INSTALL_ALLOW_ISO_TARGET=1 disables this guard
# for the rare case someone genuinely wants to install onto the same
# device (e.g. a developer testing in a VM without a second disk).
local exclude_re='^(/dev/(loop|ram|zram|sr))'
local live_devices=()
if [[ "${NOMARCHY_INSTALL_ALLOW_ISO_TARGET:-0}" != "1" ]]; then
mapfile -t live_devices < <(detect_live_iso_devices)
local d
for d in "${live_devices[@]}"; do
[[ -n "$d" ]] || continue
# Anchor to end-of-line so /dev/sda doesn't also match /dev/sdaa.
exclude_re+="|^${d}$"
done
if (( ${#live_devices[@]} > 0 )); then
info "Excluding live-ISO device(s) from picker: ${live_devices[*]}"
fi
fi
raw=$(lsblk -d -n -p -o NAME,SIZE,ROTA,TRAN,VENDOR,MODEL,SERIAL 2>/dev/null \
| grep -vE "$exclude_re")
while IFS= read -r line; do
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
# vendor/model/serial trio split via the original lsblk column
# widths — easier to just re-query each device for clean values.
local dev size rota tran
read -r dev size rota tran _ <<<"$line"
local type vendor model serial
case "$tran" in
nvme) type="NVMe" ;;
usb) type="USB" ;;
sata|scsi)
if [[ "$rota" == "1" ]]; then type="HDD"; else type="SSD"; fi
;;
*)
if [[ "$rota" == "1" ]]; then type="HDD"; else type="SSD"; fi
;;
esac
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:]]+$//')
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.
rows+=$(printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
"$dev" "$size" "$type" "$vendor" "${model//$'\t'/ }" "$serial")
rows+=$'\n'
done <<<"$raw"
if [[ -z "$rows" ]]; then
error "No installable drives found."
exit 1
fi
info "Available drives:"
echo ""
{
printf 'NAME\tSIZE\tTYPE\tVENDOR\tMODEL\tSERIAL\n'
printf '%s' "$rows"
} | column -t -s $'\t'
echo ""
# 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 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"
return 130
fi
if [[ "$DRY_RUN" != "true" ]]; then
# Total-capacity preflight. Disko fails late and obscurely on
# undersized media; surface it here while the picker is still open.
local total_bytes=0 sz d
for d in $TARGET_DRIVE; do
sz=$(lsblk -bdno SIZE "$d" 2>/dev/null) || sz=0
total_bytes=$((total_bytes + sz))
done
if (( total_bytes < _MIN_INSTALL_BYTES )); then
local human
human=$(numfmt --to=iec --suffix=B "$total_bytes" 2>/dev/null || echo "${total_bytes} B")
error "Total target capacity is $human; Nomarchy needs at least 10 GiB."
TARGET_DRIVE=""
return 130
fi
echo ""
nrun gum style --foreground 9 --bold "⚠ WARNING: All data on $TARGET_DRIVE will be DESTROYED!"
echo ""
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
fi
success "Selected: $TARGET_DRIVE"
save_state
}
# ============================================================================
# STEP 3: LUKS PASSPHRASE
# ============================================================================
get_luks_passphrase() {
if [[ "$DRY_RUN" == "true" ]]; then
info "Dry run: skipping LUKS passphrase prompt."
LUKS_PASSWORD="dryrun-not-used"
return
fi
# 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.
if [[ -n "${LUKS_PASSWORD:-}" ]]; then return; fi
section "Disk Encryption"
info "Your disk will be encrypted with LUKS2."
info "Enter a strong passphrase (you'll need this at every boot)."
echo ""
local pass1 pass2 rc=0
while true; do
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
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"
break
else
error "Passphrases do not match. Try again."
fi
done
success "Encryption passphrase set"
}
# ============================================================================
# STEP 4: USER CONFIGURATION
# ============================================================================
configure_user() {
section "User Configuration"
if [[ -n "$USERNAME" && -n "$HOSTNAME" ]]; then
# Password check skipped in dry run or if already hashed in this session.
if [[ "$DRY_RUN" == "true" ]] || [[ -n "$USER_PASSWORD_HASH" ]]; then
success "User $USERNAME @ $HOSTNAME configured"
return 0
fi
fi
local rc=0
if [[ -z "$USERNAME" ]]; then
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
fi
success "Username: $USERNAME"
if [[ -z "$HOSTNAME" ]]; then
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
fi
success "Hostname: $HOSTNAME"
if [[ "$DRY_RUN" == "true" ]]; then
info "Dry run: skipping user password prompt."
USER_PASSWORD="dryrun-not-used"
# Stable placeholder hash so generated system.nix still parses as Nix.
# Never lands on a real install — dry-run skips nixos-install.
USER_PASSWORD_HASH='$6$dryrun$3xxK3aQ.0bGcv0fM2RhV4Q9oN3p1mYxz5kSjQ.bC8tZpZ7QnFv2cN0Yhd5lDqJ8X9mP2K1L0vR6BqWqzNk7Yo/'
save_state
return
fi
# User password (can be same as LUKS or different)
info "Set a password for your user account"
local pass1 pass2
while true; do
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
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"
break
else
error "Passwords do not match. Try again."
fi
done
# Hash now so we never have to embed the cleartext in /etc/nixos/system.nix
# (where it would be world-readable and break Nix parsing on quotes/backslash).
# mkpasswd is added to the live ISO via hosts/nomarchy-live.nix.
if ! command -v mkpasswd >/dev/null 2>&1; then
error "mkpasswd not found on the live ISO — cannot hash the user password."
exit 1
fi
USER_PASSWORD_HASH=$(printf '%s' "$USER_PASSWORD" | mkpasswd -m sha-512 -s)
if [[ -z "$USER_PASSWORD_HASH" ]]; then
error "mkpasswd produced an empty hash."
exit 1
fi
unset pass1 pass2 USER_PASSWORD
success "User password set"
save_state
}
# ============================================================================
# STEP 5a: KEYBOARD & LANGUAGE
# ============================================================================
# Curated short list first ("Other…" drops into the full localectl list).
# Applied IMMEDIATELY to the running session so the rest of the install types
# correctly — TTY uses loadkeys, Hyprland uses hyprctl. Both best-effort.
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
rc=0
choice=$(printf '%s\n' "${COMMON_KEYMAPS[@]}" "Other…" \
| 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…") || 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
if [[ -z "$KEYMAP_LAYOUT" ]]; then KEYMAP_LAYOUT="us"; fi
fi
success "Keyboard layout: $KEYMAP_LAYOUT"
# Variant — optional. Only prompt if the layout actually has variants.
if [[ -z "$KEYMAP_VARIANT" ]]; then
local variants
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)") || 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
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
if [[ -n "${WAYLAND_DISPLAY:-}" ]] && command -v hyprctl >/dev/null 2>&1; then
hyprctl keyword input:kb_layout "$KEYMAP_LAYOUT" >/dev/null 2>&1 || true
hyprctl keyword input:kb_variant "$KEYMAP_VARIANT" >/dev/null 2>&1 || true
fi
if [[ -z "$LOCALE" ]]; then
local choice rc=0
rc=0
choice=$(printf '%s\n' "${COMMON_LOCALES[@]}" "Other…" \
| 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…") || 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
if [[ -z "$LOCALE" ]]; then LOCALE="en_US.UTF-8"; fi
fi
success "Locale: $LOCALE"
save_state
}
# ============================================================================
# STEP 5: TIMEZONE
# ============================================================================
select_timezone() {
section "Timezone"
if [[ -n "$TIMEZONE" ]]; then
success "Timezone set to $TIMEZONE"
return 0
fi
local timezones rc=0
timezones=$(timedatectl list-timezones 2>/dev/null || echo "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
}
# ============================================================================
# STEP 6: HARDWARE VENDOR
# ============================================================================
select_hardware() {
section "Hardware Configuration"
if [[ -n "$HARDWARE_MODULES" ]]; then
success "Hardware configured"
return 0
fi
local dmi_vendor dmi_product detect_output
dmi_vendor=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null || echo "Unknown")
dmi_product=$(cat /sys/class/dmi/id/product_name 2>/dev/null || echo "Unknown")
info "DMI: $dmi_vendor / $dmi_product"
echo ""
# Auto-detect CPU, GPU, chassis, and known model from hardware-db.sh.
detect_output=$(nomarchy_detect_hw || true)
echo "Auto-detected:"
nomarchy_hw_summary <<< "$detect_output"
echo ""
# Collect modules + nomarchy options from the detector output.
local modules=() hw_opts=()
while IFS= read -r line; do
case "$line" in
"MODULE "*) modules+=("${line#MODULE }") ;;
"OPT "*) hw_opts+=("${line#OPT }") ;;
esac
done <<< "$detect_output"
# Let the user accept, extend, or replace the detection.
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_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
for m in "${modules[@]}"; do
if [[ ":$seen:" != *":$m:"* ]]; then
uniq_mods+=("$m")
seen="$seen:$m"
fi
done
# Emit a list the heredoc in generate_flake_config splats into
# hardware-selection.nix's imports. The heredoc already indents the first
# line by 4 spaces — we add real newlines + 4 spaces (via $'\n ') for
# subsequent lines so every entry lines up.
HARDWARE_MODULES=""
for m in "${uniq_mods[@]}"; do
[[ -z "$HARDWARE_MODULES" ]] || HARDWARE_MODULES+=$'\n '
HARDWARE_MODULES+="inputs.nixos-hardware.nixosModules.${m}"
done
# Same treatment for nomarchy.hardware.* toggles.
NOMARCHY_HW_OPTS=""
local o
for o in "${hw_opts[@]}"; do
# opt is e.g. `isFramework=true` → `nomarchy.hardware.isFramework = true;`
local key="${o%%=*}" val="${o#*=}"
NOMARCHY_HW_OPTS+="nomarchy.hardware.${key} = ${val};"$'\n '
done
success "Hardware configuration set (${#uniq_mods[@]} module$([[ ${#uniq_mods[@]} -eq 1 ]] || echo s))"
save_state
}
# Manual fallback menu, kept for odd hardware the DB doesn't recognise yet.
# Writes into the two arrays named by its arguments (bash 4.3+ nameref).
_select_hardware_manual() {
local -n _mods_ref="$1"
local -n _opts_ref="$2"
local vendor rc=0
rc=0
vendor=$(nrun gum choose --header "Pick vendor" \
"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 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") || 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 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") || 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")
if [[ "$model" == *xps* ]]; then _opts_ref+=("isXPS=true"); fi
;;
Lenovo)
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") || 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")
;;
"Microsoft Surface")
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") || 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 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") || 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_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
}
# ============================================================================
# STEP 6b: FORM FACTOR (LAPTOP / DESKTOP)
# ============================================================================
# Auto-detects via /sys/class/power_supply/BAT* — same signal hardware-db.sh
# uses to pick common-pc-laptop vs common-pc. The user can flip the answer
# (e.g. a desktop with a UPS that exposes a BAT*, or a laptop that doesn't).
confirm_form_factor() {
section "Form Factor"
if [[ -n "$FORM_FACTOR" ]]; then
success "Resumed: $FORM_FACTOR"
return
fi
local default="desktop"
if compgen -G "/sys/class/power_supply/BAT*" >/dev/null; then
default="laptop"
fi
info "Auto-detected: $default"
local 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 ]]; then return 130; fi
FORM_FACTOR=$([[ "$default" == "laptop" ]] && echo desktop || echo laptop)
fi
success "Form factor: $FORM_FACTOR"
save_state
}
# ============================================================================
# STEP 7: IMPERMANENCE (OPTIONAL)
# ============================================================================
configure_impermanence() {
section "Impermanence (Optional)"
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."
info "Only explicitly persisted files survive reboots."
info "This provides a clean, reproducible system."
echo ""
local rc=0
nrun gum confirm "Enable Impermanence?" || rc=$?
case "$rc" in
0)
ENABLE_IMPERMANENCE="true"
success "Impermanence enabled"
;;
1)
ENABLE_IMPERMANENCE="false"
info "Impermanence disabled (traditional persistent root)"
;;
130)
return 130
;;
*)
ENABLE_IMPERMANENCE="false"
info "Impermanence disabled (traditional persistent root)"
;;
esac
save_state
return 0
}
# ============================================================================
# STEP 8: SOFTWARE PROFILES (OPTIONAL)
# ============================================================================
select_profiles() {
section "Software Profiles"
if [[ -n "$SELECTED_PROFILES" ]]; then
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)") || 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
# Count selected profiles by number of lines
local count
count=$(echo "$SELECTED_PROFILES" | grep -c "^" || echo 0)
success "Selected $count profile(s)"
fi
save_state
}
# ============================================================================
# STEP 9: REVIEW & CONFIRM
# ============================================================================
review_configuration() {
section "Review Configuration"
echo " Drive: $TARGET_DRIVE (BTRFS + LUKS2)"
echo " Hostname: $HOSTNAME"
echo " Username: $USERNAME"
echo " Keymap: $KEYMAP_LAYOUT${KEYMAP_VARIANT:+ ($KEYMAP_VARIANT)}"
echo " Locale: $LOCALE"
echo " Timezone: $TIMEZONE"
echo " Form factor: $FORM_FACTOR"
echo " Impermanence: $ENABLE_IMPERMANENCE"
echo " Profiles: ${SELECTED_PROFILES:-None}"
echo " Nomarchy rev: ${NOMARCHY_REV:-main (unpinned)}"
echo ""
if [[ "$DRY_RUN" == "true" ]]; then
info "Dry run: skipping destructive confirmation."
return 0
fi
nrun gum style --foreground 9 "This will DESTROY all data on $TARGET_DRIVE"
echo ""
local action rc=0
rc=0
action=$(nrun gum choose --header "Choose:" \
"Continue with installation" \
"Edit a field" \
"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 ;;
*) error "Aborted"; exit 1 ;;
esac
}
# Multi-select clear: each cleared field is re-prompted on the next loop
# iteration in main() because every prompt short-circuits when its var is
# non-empty. Hardware clears the cached `nixos-hardware` modules and per-host
# option lines together so they stay consistent. Passwords are never offered
# here — get_luks_passphrase short-circuits when LUKS_PASSWORD is already set,
# so editing a field doesn't re-ask for the LUKS passphrase.
edit_fields() {
section "Edit Fields"
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)" \
"Keymap and locale ($KEYMAP_LAYOUT / $LOCALE)" \
"Timezone ($TIMEZONE)" \
"Hardware ($HARDWARE_MODULES)" \
"Form factor ($FORM_FACTOR)" \
"Impermanence ($ENABLE_IMPERMANENCE)" \
"Profiles (${SELECTED_PROFILES:-none})" \
"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
"Drive"*) TARGET_DRIVE="" ;;
"User and host"*) USERNAME=""; HOSTNAME="" ;;
"Keymap"*) KEYMAP_LAYOUT=""; KEYMAP_VARIANT=""; LOCALE="" ;;
"Timezone"*) TIMEZONE="" ;;
"Hardware"*) HARDWARE_MODULES=""; NOMARCHY_HW_OPTS="" ;;
"Form factor"*) FORM_FACTOR="" ;;
"Impermanence"*) ENABLE_IMPERMANENCE="" ;;
"Profiles"*) SELECTED_PROFILES="" ;;
"Nomarchy rev"*) NOMARCHY_REV="" ;;
esac
done <<<"$choices"
}
# ============================================================================
# STEP 9: EXECUTION
# ============================================================================
# Pre-wipe the target drive before invoking disko.
#
# disko (at our pinned revision) gates two destructive steps on blkid:
# - lib/types/gpt.nix runs `sgdisk --clear` only when blkid sees no PT
# - lib/types/filesystem.nix skips mkfs entirely when blkid reports the
# target FS type already exists on the partition device
#
# On a previously-installed disk those branches mis-fire: blkid sees the old
# GPT and the old vfat ESP, so disko overlays its new partition entries on
# the existing table without zapping and skips mkfs.vfat, leaving the kernel
# to read a stale FAT BPB on the new (slightly different) ESP extent. mount
# then errors with "wrong fs type, bad option, bad superblock".
prewipe_target_drive() {
local drive="$1"
info "Pre-wiping $drive (clearing stale signatures)..."
# Tear down anything a prior aborted run left active. Order matters:
# mount holders -> swap -> LUKS mappings -> wipe.
umount -R /mnt 2>/dev/null || true
swapoff -a 2>/dev/null || true
# Enumerate every active dm-crypt mapping and close those whose backing
# device is on this drive. The previous version only knew about the
# hardcoded names "crypted" and "crypted_main"; an aborted multi-disk
# run, a manual experiment, or a non-Nomarchy install would leave a
# mapping with a different name holding the device busy and silently
# break the wipe.
if command -v dmsetup >/dev/null 2>&1; then
local name backing
while read -r name _; do
[[ -n "$name" && "$name" != "No" ]] || continue # "No devices found"
backing=$(cryptsetup status "$name" 2>/dev/null \
| awk '/^[[:space:]]*device:/ { print $2; exit }') || continue
[[ -n "$backing" ]] || continue
if [[ "$backing" == "$drive" || "$backing" == "${drive}"* ]]; then
info " Closing stale LUKS mapping: $name (backed by $backing)"
cryptsetup close "$name"
fi
done < <(dmsetup ls --target crypt 2>/dev/null)
fi
# Wipe partition signatures. No `|| true` — the LUKS/swap teardown
# above should have released every holder; if wipefs still fails the
# device is genuinely busy and we want to surface that, not silently
# paper over it and let disko fail later with a confusing blkid error.
local part
if compgen -G "${drive}?*" >/dev/null; then
for part in "${drive}"?*; do
[[ -b "$part" ]] || continue
wipefs -af "$part" >/dev/null
done
fi
wipefs -af "$drive" >/dev/null
sgdisk --zap-all "$drive" >/dev/null
# 16 MiB covers LUKS2 binary headers (04 MiB) and the BTRFS first
# superblock (64 KiB) — wipefs alone misses damaged variants of these.
dd if=/dev/zero of="$drive" bs=1M count=16 conv=fsync status=none
partprobe "$drive" 2>/dev/null || true
# Bound the settle so a flapping USB device can't hang the installer.
udevadm settle --timeout=30 || info "udevadm settle timed out; continuing."
# Sanity check: nothing should still be mounted off this drive after
# the wipe. If something is, refuse to hand the disk to disko.
if lsblk -no MOUNTPOINTS "$drive" 2>/dev/null | grep -qE '\S'; then
error "Drive $drive still has active mountpoints after pre-wipe."
error "Investigate with: lsblk $drive ; mount | grep $drive"
return 1
fi
success "Pre-wipe complete"
}
_LUKS_KEY_PATH="/tmp/nomarchy-luks.key"
# Wrap the disko invocation so a failure surfaces the last few lines of
# output and offers Retry / View full log / Abort. set -e is suspended for
# the disko call so we can inspect its exit code; restored on every path.
#
# By default disko's chatty output is hidden behind a `gum spin` spinner;
# the full log is captured and shown on failure or via `gum pager`. Set
# NOMARCHY_VERBOSE_DISKO=1 to stream disko output live instead.
run_disko_with_retry() {
local main_drive="$1"
local extras_nix="$2"
local disko_file="$NOMARCHY_REPO/installer/disko-config.nix"
local log
log=$(mktemp --suffix=.disko.log)
while true; do
local rc=0
if [[ "$NOMARCHY_VERBOSE_DISKO" == "1" ]]; then
set +e
disko --mode destroy,format,mount \
--yes-wipe-all-disks \
--argstr mainDrive "$main_drive" \
--arg extraDrives "$extras_nix" \
"$disko_file" 2>&1 | tee "$log"
rc=${PIPESTATUS[0]}
set -e
else
# Silent path: run disko in the background, write all output to
# $log, and show a spinner. Use a status file for the exit code
# because `gum spin` doesn't propagate the wrapped command's rc.
local status_file
status_file=$(mktemp --suffix=.disko.rc)
set +e
nrun gum spin --spinner dot --title "Partitioning disk(s) with disko..." -- \
bash -c '
disko --mode destroy,format,mount \
--yes-wipe-all-disks \
--argstr mainDrive "$1" \
--arg extraDrives "$2" \
"$3" > "$4" 2>&1
echo $? > "$5"
' _ "$main_drive" "$extras_nix" "$disko_file" "$log" "$status_file"
set -e
rc=$(cat "$status_file" 2>/dev/null || echo 1)
rm -f "$status_file"
fi
if [[ $rc -eq 0 ]]; then
rm -f "$log"
return 0
fi
error "disko failed (exit $rc). Last lines of output:"
tail -n 30 "$log" | nrun gum style --foreground 9 --border normal --padding "0 1"
local choice
choice=$(printf 'Retry\nView full log\nAbort\n' \
| nrun gum choose --header "Disk partitioning failed. What now?")
case "$choice" in
Retry)
info "Re-running pre-wipe and retrying disko..."
local d
for d in $TARGET_DRIVE; do prewipe_target_drive "$d"; done
;;
"View full log")
nrun gum pager < "$log" || less -RFX "$log" || cat "$log"
;;
*)
rm -f "$log"
return $rc
;;
esac
done
}
execute_installation() {
if [[ "$DRY_RUN" == "true" ]]; then
execute_dry_run
return
fi
section "Installing Nomarchy"
# 9.1 Partition with disko
info "Partitioning disk(s)..."
for d in $TARGET_DRIVE; do
prewipe_target_drive "$d"
done
# Build the extraDrives Nix-list literal for disko-config.nix. Empty
# list = single-disk path. The list is well-formed by construction
# here (each element is a /dev/* path the user already picked) so
# there's no escaping concern — unlike the previous sed-templated Nix.
local drives=($TARGET_DRIVE)
local main_drive="${drives[0]}"
local extras_nix="[]"
if (( ${#drives[@]} > 1 )); then
extras_nix="["
local i
for (( i=1; i<${#drives[@]}; i++ )); do
extras_nix+=" \"${drives[$i]}\""
done
extras_nix+=" ]"
fi
# Provide the LUKS passphrase via tmpfs so the secret never touches a
# spinning disk. /dev/shm is tmpfs on the live ISO. The EXIT trap
# below guarantees the file is removed even if the script aborts
# between writing the key and the unset below.
install -m 600 /dev/null "$_LUKS_KEY_PATH"
trap 'rm -f "$_LUKS_KEY_PATH" 2>/dev/null || true' EXIT
printf '%s' "$LUKS_PASSWORD" > "$_LUKS_KEY_PATH"
run_disko_with_retry "$main_drive" "$extras_nix" || exit 1
rm -f "$_LUKS_KEY_PATH"
unset LUKS_PASSWORD
success "Disk partitioned"
# 9.2 Generate hardware config
info "Generating hardware configuration..."
mkdir -p /mnt/etc/nixos
nixos-generate-config --root /mnt
success "Hardware configuration generated"
# 9.3 Generate flake configuration
info "Creating system configuration..."
generate_flake_config
success "Configuration generated"
# 9.4 Resolve inputs once, here, and lock them. First boot then consumes
# the same flake.lock and doesn't re-resolve a newer upstream.
info "Resolving flake inputs (this pins nomarchy, nixpkgs, etc.)..."
(
cd /mnt/etc/nixos
nix --extra-experimental-features "nix-command flakes" flake lock >/dev/null
)
success "flake.lock written"
# 9.5 Initialize git repo so `nix` treats /etc/nixos as a flake worktree.
info "Initializing git repository..."
(
cd /mnt/etc/nixos
nrun git init -q
nrun git add .
nrun git config user.name "Nomarchy Installer"
nrun git config user.email "installer@nomarchy"
nrun git commit -qm "Initial Nomarchy configuration"
)
success "Git repository initialized"
# 9.6 Handle impermanence
if [[ "$ENABLE_IMPERMANENCE" == "true" ]]; then
info "Setting up impermanence..."
mkdir -p /mnt/persist/etc
mv /mnt/etc/nixos /mnt/persist/etc/
mkdir -p /mnt/etc
ln -s /persist/etc/nixos /mnt/etc/nixos
success "Impermanence configured"
fi
# 9.7 Install the Nomarchy system from the freshly-generated flake.
info "Running nixos-install (this will take a while)..."
nixos-install --flake "/mnt/etc/nixos#$HOSTNAME" --no-root-passwd
success "Nomarchy installed"
# 9.8 (Removed) Standalone Home Manager activation in a chroot.
# HM is now wired as a NixOS module in the generated flake, so
# `nixos-install` above already activated the user's dotfiles as part
# of system activation. No chroot dance required.
# 9.9 Pre-flight: catch evaluation errors in the freshly-installed
# configuration *now*, while we can still fix them with `vi`, instead of
# at the user's first post-reboot rebuild.
info "Verifying configuration evaluates (nixos-rebuild dry-build)..."
if nixos-enter --root /mnt -- bash -c "
nixos-rebuild dry-build --flake /etc/nixos#$HOSTNAME 2>&1 | tail -20
"; then
success "Configuration evaluates cleanly"
else
error "Pre-flight rebuild check failed."
info "The system is installed; fix /etc/nixos before rebooting if possible."
fi
# 9.10 Set ownership of /etc/nixos to the main user
info "Setting ownership of /etc/nixos for $USERNAME..."
nixos-enter --root /mnt -- chown -R "$USERNAME:users" /etc/nixos
success "Ownership updated"
success "Installation complete!"
rm -f "$STATE_FILE"
}
# ----------------------------------------------------------------------------
# Dry run: generate the flake into a tmpdir and parse-check it. Doesn't
# touch the disk; useful while iterating on the installer or to validate a
# saved state file before actually committing to the install.
# ----------------------------------------------------------------------------
execute_dry_run() {
section "Dry Run"
local tmp
tmp=$(mktemp -d -t nomarchy-dryrun.XXXXXX)
info "Generating configuration in $tmp"
# Mock /mnt so the existing generator writes into the tmpdir.
local fake_root="$tmp/mnt"
mkdir -p "$fake_root/etc/nixos"
ln -snf "$fake_root" "$tmp/.mntlink"
# generate_flake_config writes to /mnt/etc/nixos directly. We can't
# easily re-target without a refactor — bind-mount instead so the
# absolute paths in the function still resolve to our tmpdir.
mount --bind "$fake_root" /mnt 2>/dev/null || true
if [[ ! -d /mnt/etc/nixos ]]; then
mkdir -p /mnt/etc/nixos
fi
# Stub hardware-configuration.nix — `nixos-generate-config` requires
# actually-mounted target filesystems, so we provide a syntactically
# valid placeholder for parse-checking only.
cat > /mnt/etc/nixos/hardware-configuration.nix <<'EOF'
{ ... }: { boot.loader.systemd-boot.enable = true; fileSystems."/" = { device = "/dev/null"; fsType = "tmpfs"; }; }
EOF
generate_flake_config
info "Running \`nix-instantiate --parse\` on each generated file..."
local f rc=0
for f in flake.nix hardware-selection.nix system.nix home.nix; do
if nix-instantiate --parse "/mnt/etc/nixos/$f" >/dev/null 2>&1; then
success " $f"
else
error " $f failed to parse"
rc=1
fi
done
info "Generated files:"
ls -1 /mnt/etc/nixos/
umount /mnt 2>/dev/null || true
info "Output kept at $fake_root for inspection."
if (( rc != 0 )); then
error "Dry run reported parse errors."
exit "$rc"
fi
success "Dry run OK — no disk touched."
}
# ============================================================================
# GENERATE FLAKE CONFIGURATION
# ============================================================================
generate_flake_config() {
# Impermanence must mount the same /dev/mapper name that disko created.
# disko-config.nix uses "crypted" for single-disk and "crypted_main" once
# extraDrives is non-empty — keep these in sync.
local impermanence_opt=""
if [[ "$ENABLE_IMPERMANENCE" == "true" ]]; then
local _main_luks_name="crypted"
local _drives=($TARGET_DRIVE)
if (( ${#_drives[@]} > 1 )); then
_main_luks_name="crypted_main"
fi
impermanence_opt=$'nomarchy.system.impermanence.enable = true;\n nomarchy.system.impermanence.mainLuksName = "'"$_main_luks_name"$'";'
fi
local PROFILE_SYSTEM_OPTS=""
local PROFILE_HOME_PACKAGES=""
while IFS= read -r profile; do
if [[ -z "$profile" ]]; then continue; fi
case "$profile" in
"Dev "*)
PROFILE_SYSTEM_OPTS+=$'\n # Dev profile\n nomarchy.system.virtualization.docker.enable = true;'
PROFILE_HOME_PACKAGES+=$'\n vscode\n zed-editor\n lazygit\n gh\n docker-compose'
;;
"Gaming "*)
PROFILE_SYSTEM_OPTS+=$'\n # Gaming profile\n programs.steam.enable = true;\n programs.gamemode.enable = true;'
PROFILE_HOME_PACKAGES+=$'\n steam\n lutris\n heroic'
;;
"Office "*)
PROFILE_HOME_PACKAGES+=$'\n libreoffice\n thunderbird\n obsidian\n zotero'
;;
"Media "*)
PROFILE_HOME_PACKAGES+=$'\n vlc\n obs-studio\n gimp\n inkscape\n spotify'
;;
"CLI Utils "*)
PROFILE_HOME_PACKAGES+=$'\n ripgrep\n fd\n bat\n eza\n zoxide\n fzf'
;;
esac
done <<< "$SELECTED_PROFILES"
# Pin the upstream Nomarchy flake to the exact commit we're installing
# from so the first post-reboot `nixos-rebuild` doesn't silently pull a
# newer main. Fall back to tracking main if we couldn't resolve a SHA.
# Upstream lives on the self-hosted Gitea at git.bemagri.xyz; flakes
# consume it via the `git+https://` URL form.
local nomarchy_url
if [[ -n "$NOMARCHY_REV" ]]; then
nomarchy_url="git+https://git.bemagri.xyz/bernardo/Nomarchy.git?rev=$NOMARCHY_REV"
else
nomarchy_url="git+https://git.bemagri.xyz/bernardo/Nomarchy.git"
fi
# flake.nix — the generator uses a non-quoted heredoc so $HOSTNAME,
# $USERNAME, and $nomarchy_url expand inline.
cat > /mnt/etc/nixos/flake.nix <<FLAKE_EOF
{
description = "My Nomarchy Configuration";
inputs = {
nomarchy.url = "$nomarchy_url";
};
# Two-track Nomarchy workflow:
# * System + dotfiles (full) → sudo nixos-rebuild switch --flake /etc/nixos#$HOSTNAME
# * Dotfiles only (fast iter) → nomarchy-env-update (standalone home-manager switch)
#
# Home Manager is wired both ways:
# - As a NixOS module under \`nixosConfigurations.$HOSTNAME\` so first boot
# after install already has dotfiles in place and every nixos-rebuild
# reconciles them. This is what makes the install actually usable.
# - As a standalone \`homeConfigurations.$USERNAME\` so theme switches and
# dotfile iteration don't require a full system rebuild.
outputs = { self, nomarchy, ... }@inputs:
let
inherit (nomarchy.inputs) nixpkgs home-manager;
system = "x86_64-linux";
pkgs = import nixpkgs {
inherit system;
overlays = [ nomarchy.overlays.default ];
config.allowUnfree = true;
};
in
{
nixosConfigurations.$HOSTNAME = nixpkgs.lib.nixosSystem {
specialArgs = { inputs = nomarchy.inputs; };
modules = [
{
nixpkgs.hostPlatform = system;
nixpkgs.overlays = [ nomarchy.overlays.default ];
}
./hardware-configuration.nix
./hardware-selection.nix
nomarchy.nixosModules.system
./system.nix
home-manager.nixosModules.home-manager
{
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
home-manager.backupFileExtension = "hm-bak";
home-manager.extraSpecialArgs = { inputs = nomarchy.inputs; };
home-manager.users.$USERNAME = {
imports = [ nomarchy.nixosModules.home ./home.nix ];
home.stateVersion = "25.11";
};
}
];
};
# Standalone Home Manager — \`home-manager switch --flake /etc/nixos#$USERNAME\`
# (which is what \`nomarchy-env-update\` runs). Kept alongside the NixOS
# module above so dotfile/theme iterations can skip a full system rebuild.
homeConfigurations.$USERNAME = home-manager.lib.homeManagerConfiguration {
inherit pkgs;
extraSpecialArgs = { inputs = nomarchy.inputs; };
modules = [
nomarchy.nixosModules.home
./home.nix
{
home.username = "$USERNAME";
home.homeDirectory = "/home/$USERNAME";
home.stateVersion = "25.11";
}
];
};
};
}
FLAKE_EOF
# hardware-selection.nix
cat > /mnt/etc/nixos/hardware-selection.nix << EOF
{ inputs, ... }:
{
imports = [
$HARDWARE_MODULES
];
$NOMARCHY_HW_OPTS
}
EOF
# system.nix — curated system-level options. Uncomment what you want and
# run \`sudo nixos-rebuild switch --flake /etc/nixos#$HOSTNAME\` to apply.
# XKB variant is optional — only emit when the user picked one.
local xkb_variant_line=""
if [[ -n "$KEYMAP_VARIANT" ]]; then
xkb_variant_line=" variant = \"$KEYMAP_VARIANT\";"
fi
cat > /mnt/etc/nixos/system.nix << EOF
{ pkgs, ... }:
{
networking.hostName = "$HOSTNAME";
time.timeZone = "$TIMEZONE";
# UEFI bootloader. Disko lays out a 1 GiB ESP at /boot — switch to
# boot.loader.grub if you're installing on a legacy-BIOS machine.
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
# Keyboard & language — set by the installer.
console.keyMap = "$KEYMAP_LAYOUT";
i18n.defaultLocale = "$LOCALE";
services.xserver.xkb = {
layout = "$KEYMAP_LAYOUT";
$xkb_variant_line
};
# Physical form factor — gates UI affordances (battery widget, etc).
nomarchy.system.formFactor = "$FORM_FACTOR";
$impermanence_opt
$PROFILE_SYSTEM_OPTS
# Compressed RAM swap. Near-free memory headroom on small machines and
# harmless on big ones — kernel only uses it under pressure. Disable if
# you've enabled disk swap or hibernation.
zramSwap.enable = true;
# System-wide packages. Most tools belong in home.nix instead — only put
# things here that need to be available to all users or to root (e.g. CLI
# tools used by sudo, system admin utilities).
environment.systemPackages = with pkgs; [
home-manager
# --- CLI tools useful as root ---
# wget
# curl
# rsync
# htop
# tree
# tmux
];
services.displayManager.autoLogin.enable = true;
services.displayManager.autoLogin.user = "$USERNAME";
users.users."$USERNAME" = {
isNormalUser = true;
# SHA-512 crypt hash generated by mkpasswd during install. Cleartext
# never touches /etc/nixos. Change later with \`passwd\`.
initialHashedPassword = "$USER_PASSWORD_HASH";
extraGroups = [ "networkmanager" "wheel" "video" "audio" "render" ];
};
# --- Optional system services ---
# Uncomment to enable. Some require extra groups on your user (see below).
# Containers / virtualization
# virtualisation.docker.enable = true; # adds "docker" group
# virtualisation.libvirtd.enable = true; # adds "libvirtd" group — needed for virt-manager
# Networking / sync
# services.tailscale.enable = true;
# services.syncthing = {
# enable = true;
# user = "$USERNAME";
# dataDir = "/home/$USERNAME";
# };
# Printing
# services.printing.enable = true;
# Flatpak (alternative app delivery)
# services.flatpak.enable = true;
# xdg.portal.enable = true;
# Gaming (system-level — pairs with home.packages.steam)
# programs.steam.enable = true;
# programs.gamemode.enable = true;
# --- Optional Nomarchy modules ---
# Each line is a one-shot toggle for a Tier 1 system feature.
# BTRFS timeline snapshots of /. Auto-skips on non-BTRFS.
# nomarchy.system.snapper.enable = true;
# Suspend-then-hibernate on lid/idle/power-key. Requires disk swap.
# nomarchy.system.hibernation.enable = true;
# nomarchy.system.hibernation.idleMinutes = 30;
# Rootless Podman with \`docker\` compatibility.
# nomarchy.system.containers.enable = true;
# libvirt + virt-manager + OVMF.
# nomarchy.system.virtualization.libvirt.enable = true;
# GNOME Keyring auto-unlock + gcr SSH agent (default: on).
# nomarchy.system.keyring.enable = false;
# fcitx5 input method (CJK / IME).
# nomarchy.system.inputMethod.enable = true;
# voxtype voice-typing wiring (you must install voxtype yourself).
# nomarchy.system.voxtype.enable = true;
system.stateVersion = "25.11";
}
EOF
# home.nix — curated app menu. Uncomment what you want and run
# `nomarchy-env-update` to apply.
#
# NB: not heredoc-quoted — we expand $FORM_FACTOR. Any literal `$` or
# backtick in the body must be escaped.
cat > /mnt/etc/nixos/home.nix << EOF
{ pkgs, ... }:
{
# Physical form factor — mirrors nomarchy.system.formFactor in system.nix.
# Gates UI affordances like the waybar battery widget.
nomarchy.formFactor = "$FORM_FACTOR";
# User-level packages (Home Manager).
#
# Nomarchy already ships a minimal desktop (firefox, thunar, mpv, imv, mako,
# hyprlock, swww, wl-clipboard, grim, slurp, rofi-wayland, etc.). The list
# below is a menu of extras — uncomment what you want and run
# \`nomarchy-env-update\`.
home.packages = with pkgs; [
# --- Enabled by default ---
btop # Resource monitor (TUI)
fastfetch # System info at login
chromium # Secondary browser
$PROFILE_HOME_PACKAGES
# --- Editors & dev ---
# vscode
# jetbrains.idea-community
# neovide
# zed-editor
# lazygit
# gh # GitHub CLI
# docker-compose
# postman
# dbeaver-bin
# --- Productivity ---
# obsidian
# libreoffice
# thunderbird
# zathura # PDF viewer
# zotero
# xournalpp
# --- Media ---
# vlc
# obs-studio
# gimp
# inkscape
# kdenlive
# spotify
# audacity
# yt-dlp
# --- Comms ---
# discord
# telegram-desktop
# signal-desktop
# slack
# zoom-us
# --- Security ---
# keepassxc
# bitwarden-desktop
# _1password-gui
# --- Gaming ---
# steam
# lutris
# heroic
# --- CLI / utilities ---
# ripgrep
# fd
# bat
# eza
# zoxide
# fzf
# httpie
# tldr
];
# --- Optional Nomarchy app modules ---
# opencode AI coding CLI integration (deploys ~/.config/opencode/opencode.json).
# The \`opencode\` package itself is not installed automatically — add it to
# \`home.packages\` above if you want it on PATH.
# nomarchy.apps.opencode.enable = true;
# Extra Home Manager modules go here (program configs, services, etc.).
}
EOF
# state.json — consumed by core/system/state.nix at every nixos-rebuild and
# mutated by toggle scripts (nomarchy-tz-select, nomarchy-setup-fido2,
# nomarchy-toggle-hybrid-gpu, nomarchy-wifi-powersave, ...). Those scripts
# `jq` the file in place and fail hard if it doesn't exist or isn't valid
# JSON, so a fresh install MUST ship one. Shape must match lib/state-schema.nix.
# Quoted heredoc — no shell expansion except the explicit $TIMEZONE below.
local _state_tz="${TIMEZONE:-UTC}"
cat > /mnt/etc/nixos/state.json <<JSON_EOF
{
"theme": "nord",
"timezone": "${_state_tz}",
"dns": "DHCP",
"customDns": [],
"wifi": {
"powersave": true
},
"features": {
"fingerprint": false,
"fido2": false,
"hybridGPU": false,
"makima": false
}
}
JSON_EOF
}
# ============================================================================
# FINISH
# ============================================================================
finish() {
header
nrun gum style --foreground 10 --bold --align center "INSTALLATION COMPLETE!"
echo ""
echo "Nomarchy has been successfully installed."
echo ""
echo "Next steps:"
echo " 1. Remove the installation media"
echo " 2. Reboot your computer"
echo " 3. Log in as: $USERNAME (host: $HOSTNAME)"
echo " 4. Your configuration lives at /etc/nixos/"
echo ""
echo "Rebuild commands:"
echo " • System + dotfiles: sudo nixos-rebuild switch --flake /etc/nixos#$HOSTNAME"
echo " • Dotfiles only: nomarchy-env-update (fast standalone home-manager switch)"
echo ""
echo "Tip: run 'nomarchy-themes-prebuild' once to pre-cache every theme"
echo " variant. Theme switches after that are instant (no rebuild)."
echo ""
# Unmount /mnt before either reboot or returning to the live shell:
# - Reboot: clean unmount avoids dirty BTRFS, which would otherwise
# force a longer first-boot fsck/replay.
# - Decline: leaving /mnt mounted blocks a second `install.sh` run on
# the same live ISO (disko refuses to wipe a busy device).
# `-R` recursively unmounts /mnt/boot, /mnt/home, /mnt/nix, etc.; the
# `|| true` absorbs the case where /mnt was already torn down.
if nrun gum confirm "Reboot now?"; then
umount -R /mnt 2>/dev/null || true
reboot
else
umount -R /mnt 2>/dev/null || true
fi
}
# ============================================================================
# MAIN
# ============================================================================
main() {
parse_args "$@"
header
load_state
check_environment
local steps=(
select_disk
get_luks_passphrase
configure_user
select_keymap_locale
select_timezone
select_hardware
confirm_form_factor
configure_impermanence
select_profiles
)
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
# Skip the reboot prompt on a dry run — nothing to reboot into.
if [[ "$DRY_RUN" == "true" ]]; then
info "Dry run finished."
exit 0
fi
finish
}
main "$@"