Files
Nomarchy/installer/install.sh
Bernardo Magri 3aadc36bff fix(installer): implement robust step-based navigation and fix multi-line sed error
- Implement a step-based state machine in main loop to support 'Back' navigation via Esc.
- Refactor all prompts to use safe exit-code capture (rc -eq 130/1) and handle 'not submitted' output.
- Add input flushing after Esc events to prevent cascading backtrack signals.
- Add short-circuit checks to every wizard stage for reliable skip-forward behavior.
- Fix sed error when generating multi-disk configurations by escaping newlines in additional_disks.
- Add explicit 'Set a hostname' message to the hostname prompt.
- Convert unsafe short-circuit lists to safe if statements to prevent set -e crashes.
2026-04-26 22:17:00 +01:00

1650 lines
57 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=""
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).
-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.
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
# ============================================================================
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=""
raw=$(lsblk -d -n -p -o NAME,SIZE,ROTA,TRAN,VENDOR,MODEL,SERIAL 2>/dev/null \
| grep -vE '^(/dev/(loop|ram|zram|sr))')
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
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"
rc=0
nrun gum confirm "Treat this machine as a $default?" || rc=$?
if [[ $rc -eq 0 ]]; then
FORM_FACTOR="$default"
else
if [[ $rc -eq 130 || $rc -eq 1 ]]; then return 130; fi
FORM_FACTOR=$([[ "$default" == "laptop" ]] && echo desktop || echo laptop)
fi
success "Form factor: $FORM_FACTOR"
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 || $rc -eq 1 ]]; then return 130; fi
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.
umount -R /mnt 2>/dev/null || true
cryptsetup close crypted 2>/dev/null || true
swapoff -a 2>/dev/null || true
local part
if compgen -G "${drive}*" >/dev/null; then
for part in "${drive}"?*; do
[[ -b "$part" ]] || continue
wipefs -af "$part" >/dev/null 2>&1 || true
done
fi
wipefs -af "$drive" >/dev/null 2>&1 || true
sgdisk --zap-all "$drive" >/dev/null 2>&1 || true
# 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 2>/dev/null || true
partprobe "$drive" 2>/dev/null || true
udevadm settle
success "Pre-wipe complete"
}
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
local disko_file tmp_disko
tmp_disko=$(mktemp --suffix=.nix)
local drives=($TARGET_DRIVE)
if [[ ${#drives[@]} -gt 1 ]]; then
disko_file="$NOMARCHY_REPO/installer/disko-btrfs-multi.nix"
local main_drive="${drives[0]}"
local btrfs_devs=""
local additional_disks=""
for (( i=1; i<${#drives[@]}; i++ )); do
local d="${drives[$i]}"
local name="extra_$i"
local luks_name="crypted_$name"
btrfs_devs+=", \"/dev/mapper/$luks_name\""
additional_disks+=" $name = {
type = \"disk\";
device = \"$d\";
content = {
type = \"gpt\";
partitions = {
luks = {
size = \"100%\";
content = {
type = \"luks\";
name = \"$luks_name\";
settings = {
allowDiscards = true;
passwordFile = \"/dev/shm/nomarchy-luks.key\";
};
content = {
type = \"btrfs\";
};
};
};
};
};
};
"
done
# Escape newlines for sed
local escaped_disks
escaped_disks=$(printf '%s\n' "$additional_disks" | sed ':a;N;$!ba;s/\n/\\n/g')
sed "s|@MAIN_DRIVE@|${main_drive}|g; s|@BTRFS_DEVICES@|${btrfs_devs}|g; s|@ADDITIONAL_DISKS@|${escaped_disks}|g" "$disko_file" > "$tmp_disko"
else
disko_file="$NOMARCHY_REPO/installer/disko-golden.nix"
sed "s|@TARGET_DRIVE@|${TARGET_DRIVE}|g" "$disko_file" > "$tmp_disko"
fi
# Provide the LUKS passphrase via tmpfs so the secret never touches a
# spinning disk. /dev/shm is tmpfs on the live ISO. We restrict perms
# to root and shred the file (overwrite) on the way out, even though
# it's already in RAM — defense in depth.
local luks_key="/dev/shm/nomarchy-luks.key"
install -m 600 /dev/null "$luks_key"
printf '%s' "$LUKS_PASSWORD" > "$luks_key"
disko --mode disko "$tmp_disko"
shred -u "$luks_key" 2>/dev/null || rm -f "$luks_key"
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 <<FLAKE_EOF
{
description = "My Nomarchy Configuration";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
nomarchy.url = "$nomarchy_url";
nixos-hardware.url = "github:NixOS/nixos-hardware/master";
home-manager = {
url = "github:nix-community/home-manager/release-25.11";
inputs.nixpkgs.follows = "nixpkgs";
};
};
# Two-track Nomarchy workflow:
# * System changes → sudo nixos-rebuild switch --flake /etc/nixos#$HOSTNAME
# * Dotfiles/themes → nomarchy-env-update (home-manager switch, no rebuild)
#
# Both consume the same \`pkgs\` below so overlays and allowUnfree stay in
# sync across the two paths.
outputs = { self, nixpkgs, nomarchy, home-manager, nixos-hardware, ... }@inputs:
let
system = "x86_64-linux";
pkgs = import nixpkgs {
inherit system;
overlays = [ nomarchy.overlays.default ];
config.allowUnfree = true;
};
in
{
nixosConfigurations.$HOSTNAME = nixpkgs.lib.nixosSystem {
inherit pkgs;
specialArgs = { inputs = nomarchy.inputs // inputs; };
modules = [
./hardware-configuration.nix
./hardware-selection.nix
nomarchy.nixosModules.system
./system.nix
];
};
# Standalone Home Manager — \`home-manager switch --flake /etc/nixos#$USERNAME\`
# (which is what \`nomarchy-env-update\` runs). Kept separate from the
# NixOS config so dotfile/theme iterations don't rebuild the system.
homeConfigurations.$USERNAME = home-manager.lib.homeManagerConfiguration {
inherit pkgs;
extraSpecialArgs = { inputs = nomarchy.inputs // 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";
# 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 "$@"