#!/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="" 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 < 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. save_state() { 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" } load_state() { if [[ "$RESUME" == "true" ]] && [[ -f "$STATE_FILE" ]]; then # shellcheck disable=SC1090 source "$STATE_FILE" info "Resumed from $STATE_FILE" 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="" ;; 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. if 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 info "Could not determine Nomarchy revision; downstream flake will track main." 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/ # 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 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 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" 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 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 "" rc=0 gum confirm "Enable Impermanence?" || rc=$? if [[ $rc -eq 0 ]]; then ENABLE_IMPERMANENCE="true" success "Impermanence enabled" else if [[ $rc -eq 130 ]]; then return 130; fi ENABLE_IMPERMANENCE="false" info "Impermanence disabled (traditional persistent root)" fi save_state } # ============================================================================ # 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 (0–4 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="/dev/shm/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. 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 set +e disko --mode destroy,format,mount \ --argstr mainDrive "$main_drive" \ --arg extraDrives "$extras_nix" \ "$disko_file" 2>&1 | tee "$log" rc=${PIPESTATUS[0]} set -e 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 git init -q nrun git git add . nrun git git config user.name "Nomarchy Installer" nrun git git config user.email "installer@nomarchy" nrun git 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 Activate Home Manager for $USERNAME inside the new system so the # user's first login already has Nomarchy's dotfiles. `home-manager # switch` must run as the target user with a real $HOME, so we use # `runuser` (sudo -u keeps the caller's HOME → files land in /root). info "Activating Home Manager for $USERNAME..." if nixos-enter --root /mnt -- bash -c " set -e install -d -o '$USERNAME' -g users -m 0755 '/home/$USERNAME' runuser -u '$USERNAME' -- env HOME='/home/$USERNAME' \ nix --extra-experimental-features 'nix-command flakes' \ run 'home-manager/release-25.11' -- switch \ --flake '/etc/nixos#$USERNAME' --impure "; then success "Home Manager activated" else error "Home Manager activation failed (non-fatal)." info "Run \`nomarchy-env-update\` after the first login to retry." fi # 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 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() { local impermanence_opt="" 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 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 < /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"; # 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; initialPassword = "$USER_PASSWORD"; 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 } # ============================================================================ # 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: sudo nixos-rebuild switch --flake /etc/nixos#$HOSTNAME" echo " • Dotfiles: nomarchy-env-update (runs 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 "" if nrun gum confirm "Reboot now?"; then reboot 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 "$@"