Files
Nomarchy/installer/install.sh
Bernardo Magri f318585dc4 fix(installer): harden disk selection and partitioning phase
The disk phase was the dominant source of incomplete installs. Six
concrete failure modes addressed in one pass:

1. Live-ISO USB excluded from the disk picker. select_disk previously
   filtered loop|ram|zram|sr but not the device the installer booted
   from; picking it would format the boot media mid-install. New
   detect_live_iso_devices walks /, /iso, /run/initramfs/live,
   /nix/.ro-store, /nix/store and resolves each backing device to its
   parent disk via lsblk -no PKNAME. Override with
   NOMARCHY_INSTALL_ALLOW_ISO_TARGET=1 for the developer case.

2. 10 GiB minimum-capacity preflight. Disko fails late and obscurely
   on undersized media; surface it while the picker is still open.

3. prewipe_target_drive rewritten:
   - Enumerates every active dm-crypt mapping via dmsetup ls and
     closes those whose backing device is on the target drive. The
     old version only knew about the hardcoded names "crypted" /
     "crypted_main" so an aborted multi-disk run or a non-Nomarchy
     install would leave a holder open and silently break the wipe.
   - Drops `|| true` from wipefs / sgdisk / dd. After the LUKS and
     swap teardown above, a real failure means something is still
     holding the device — surface that instead of papering over it.
   - udevadm settle bounded to 30s so a flapping USB can't hang.
   - Post-wipe sanity check: refuse to hand the disk to disko if
     anything is still mounted off it.

4. run_disko_with_retry wraps the disko call. On failure, shows the
   last 30 lines of output via gum style and offers Retry /
   View full log / Abort. set -e is suspended for the disko call so
   the exit code can be inspected. The previous bare `disko --mode
   disko` aborted the whole installer with output scrolled past.

5. Sed-templated disko-golden.nix + disko-btrfs-multi.nix pair
   replaced by a single disko-config.nix Nix function of
   { mainDrive, extraDrives ? [] } called via --argstr / --arg.
   Templating Nix via shell-escaped string substitution caused at
   least one production bug (3aadc36 fixed embedded-newline
   escaping); function arguments are the right shape and eliminate
   the entire class of escaping concerns. Single-disk path is
   `extraDrives = []`; multi-disk gets BTRFS `-d single -m raid1`
   plus the additional /dev/mapper/* devices. Hosts that shipped
   /etc/disko-golden.nix now ship /etc/disko-config.nix.

6. EXIT trap added so the tmpfs LUKS key file (/dev/shm/nomarchy-
   luks.key) is removed even if the script aborts between key-write
   and the explicit unset. Replaced redundant `shred -u` on tmpfs
   with `rm -f` (already in RAM).

Verification: bash -n on install.sh, nix-instantiate parse + strict
eval on disko-config.nix in both single and multi shapes, full
nix flake check --no-build evaluating all three NixOS configurations
(default, nomarchy-installer, nomarchy-live) plus the installerVm.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 19:42:00 +01:00

1767 lines
62 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
# ============================================================================
# Resolve the block device(s) backing the running live ISO so the disk
# picker can hide them. Picking the live USB by mistake destroys the
# installer's own boot media mid-run — always the worst-case outcome.
# We walk the live-ISO mountpoints (NixOS live ISO uses /iso for the
# squashfs source plus an overlay at /), resolve each to its parent
# disk via `lsblk -no PKNAME`, and emit a deduped list of /dev/<disk>
# entries on stdout. Nothing emitted = no live-ISO devices detected
# (e.g. running the installer from a regular shell during development).
detect_live_iso_devices() {
local seen=" "
local mp src parent
for mp in / /iso /run/initramfs/live /nix/.ro-store /nix/store; do
src=$(findmnt -no SOURCE "$mp" 2>/dev/null) || continue
[[ "$src" == /dev/* ]] || continue
parent=$(lsblk -no PKNAME "$src" 2>/dev/null | head -n1)
if [[ -n "$parent" ]]; then
parent="/dev/$parent"
else
parent="$src"
fi
case "$seen" in
*" $parent "*) ;;
*) seen+="$parent "; printf '%s\n' "$parent" ;;
esac
done
}
# Minimum total capacity across all picked drives. 10 GiB is the smallest
# size where the install completes without immediate disk-pressure failures
# (1 GiB ESP + ~5 GiB nix closure + working set).
_MIN_INSTALL_BYTES=$((10 * 1024 * 1024 * 1024))
select_disk() {
section "Disk Selection"
if [[ -n "$TARGET_DRIVE" ]]; then
success "Selected: $TARGET_DRIVE"
return 0
fi
# Build a richer drive table than the bare `NAME SIZE` lsblk default.
# Columns: NAME, SIZE, TYPE (NVMe/USB/SSD/HDD), VENDOR, MODEL, SERIAL.
# Empty fields render as "--" so column -t can still align them.
local raw rows=""
# Filter out pseudo-devices and the live-ISO boot media. The boot-media
# filter is the important one: without it the user can pick the USB
# they booted from and the installer will format its own boot device
# mid-run. NOMARCHY_INSTALL_ALLOW_ISO_TARGET=1 disables this guard
# for the rare case someone genuinely wants to install onto the same
# device (e.g. a developer testing in a VM without a second disk).
local exclude_re='^(/dev/(loop|ram|zram|sr))'
local live_devices=()
if [[ "${NOMARCHY_INSTALL_ALLOW_ISO_TARGET:-0}" != "1" ]]; then
mapfile -t live_devices < <(detect_live_iso_devices)
local d
for d in "${live_devices[@]}"; do
[[ -n "$d" ]] || continue
# Anchor to end-of-line so /dev/sda doesn't also match /dev/sdaa.
exclude_re+="|^${d}$"
done
if (( ${#live_devices[@]} > 0 )); then
info "Excluding live-ISO device(s) from picker: ${live_devices[*]}"
fi
fi
raw=$(lsblk -d -n -p -o NAME,SIZE,ROTA,TRAN,VENDOR,MODEL,SERIAL 2>/dev/null \
| grep -vE "$exclude_re")
while IFS= read -r line; do
if [[ -z "$line" ]]; then continue; fi
# NAME and SIZE are reliably whitespace-free; ROTA/TRAN are short
# tokens; VENDOR/MODEL/SERIAL can carry internal spaces. Pull the
# first four fields off the front, treat the rest as the
# vendor/model/serial trio split via the original lsblk column
# widths — easier to just re-query each device for clean values.
local dev size rota tran
read -r dev size rota tran _ <<<"$line"
local type vendor model serial
case "$tran" in
nvme) type="NVMe" ;;
usb) type="USB" ;;
sata|scsi)
if [[ "$rota" == "1" ]]; then type="HDD"; else type="SSD"; fi
;;
*)
if [[ "$rota" == "1" ]]; then type="HDD"; else type="SSD"; fi
;;
esac
vendor=$(lsblk -d -n -o VENDOR "$dev" 2>/dev/null | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//')
model=$(lsblk -d -n -o MODEL "$dev" 2>/dev/null | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//')
serial=$(lsblk -d -n -o SERIAL "$dev" 2>/dev/null | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//')
if [[ -z "$vendor" ]]; then vendor="--"; fi
if [[ -z "$model" ]]; then model="--"; fi
if [[ -z "$serial" ]]; then serial="--"; fi
# Tab-separated for column -t -s, then collapse internal whitespace
# in MODEL so multi-space brand strings don't break alignment.
rows+=$(printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
"$dev" "$size" "$type" "$vendor" "${model//$'\t'/ }" "$serial")
rows+=$'\n'
done <<<"$raw"
if [[ -z "$rows" ]]; then
error "No installable drives found."
exit 1
fi
info "Available drives:"
echo ""
{
printf 'NAME\tSIZE\tTYPE\tVENDOR\tMODEL\tSERIAL\n'
printf '%s' "$rows"
} | column -t -s $'\t'
echo ""
# gum choose gets the same aligned rows so the picker reads like the table.
local picker
picker=$(printf '%s' "$rows" | column -t -s $'\t')
local choice rc=0
choice=$(printf '%s\n' "$picker" | nrun gum choose --no-limit --header "Select target drive(s) - Use Space to select multiple for BTRFS RAID/Single") || rc=$?
if [[ $rc -eq 130 || $rc -eq 1 || "$choice" == "not submitted" ]]; then return 130; fi
if [[ $rc -ne 0 ]]; then exit $rc; fi
TARGET_DRIVE=$(awk '{print $1}' <<<"$choice" | xargs)
if [[ -z "$TARGET_DRIVE" ]]; then
error "No drive selected"
return 130
fi
if [[ "$DRY_RUN" != "true" ]]; then
# Total-capacity preflight. Disko fails late and obscurely on
# undersized media; surface it here while the picker is still open.
local total_bytes=0 sz d
for d in $TARGET_DRIVE; do
sz=$(lsblk -bdno SIZE "$d" 2>/dev/null) || sz=0
total_bytes=$((total_bytes + sz))
done
if (( total_bytes < _MIN_INSTALL_BYTES )); then
local human
human=$(numfmt --to=iec --suffix=B "$total_bytes" 2>/dev/null || echo "${total_bytes} B")
error "Total target capacity is $human; Nomarchy needs at least 10 GiB."
TARGET_DRIVE=""
return 130
fi
echo ""
nrun gum style --foreground 9 --bold "⚠ WARNING: All data on $TARGET_DRIVE will be DESTROYED!"
echo ""
rc=0
nrun gum confirm "Are you sure you want to use $TARGET_DRIVE?" || rc=$?
if [[ $rc -ne 0 ]]; then
if [[ $rc -eq 130 || $rc -eq 1 ]]; then return 130; fi
error "Aborted"
exit 1
fi
fi
success "Selected: $TARGET_DRIVE"
save_state
}
# ============================================================================
# STEP 3: LUKS PASSPHRASE
# ============================================================================
get_luks_passphrase() {
if [[ "$DRY_RUN" == "true" ]]; then
info "Dry run: skipping LUKS passphrase prompt."
LUKS_PASSWORD="dryrun-not-used"
return
fi
# Already set this session (review-edit loop iterated). Don't re-prompt;
# password isn't persisted across runs but is held in memory until
# execute_installation unsets it.
if [[ -n "${LUKS_PASSWORD:-}" ]]; then return; fi
section "Disk Encryption"
info "Your disk will be encrypted with LUKS2."
info "Enter a strong passphrase (you'll need this at every boot)."
echo ""
local pass1 pass2 rc=0
while true; do
rc=0
pass1=$(nrun gum input --password --placeholder "Enter LUKS passphrase") || rc=$?
if [[ $rc -eq 130 || $rc -eq 1 || "$pass1" == "not submitted" ]]; then return 130; fi
if [[ $rc -ne 0 ]]; then exit $rc; fi
if [[ -z "$pass1" ]]; then continue; fi
rc=0
pass2=$(nrun gum input --password --placeholder "Confirm passphrase") || rc=$?
if [[ $rc -eq 130 || $rc -eq 1 || "$pass2" == "not submitted" ]]; then return 130; fi
if [[ $rc -ne 0 ]]; then exit $rc; fi
if [[ "$pass1" == "$pass2" ]]; then
LUKS_PASSWORD="$pass1"
break
else
error "Passphrases do not match. Try again."
fi
done
success "Encryption passphrase set"
}
# ============================================================================
# STEP 4: USER CONFIGURATION
# ============================================================================
configure_user() {
section "User Configuration"
if [[ -n "$USERNAME" && -n "$HOSTNAME" ]]; then
# Password check skipped in dry run or if already 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. Order matters:
# mount holders -> swap -> LUKS mappings -> wipe.
umount -R /mnt 2>/dev/null || true
swapoff -a 2>/dev/null || true
# Enumerate every active dm-crypt mapping and close those whose backing
# device is on this drive. The previous version only knew about the
# hardcoded names "crypted" and "crypted_main"; an aborted multi-disk
# run, a manual experiment, or a non-Nomarchy install would leave a
# mapping with a different name holding the device busy and silently
# break the wipe.
if command -v dmsetup >/dev/null 2>&1; then
local name backing
while read -r name _; do
[[ -n "$name" && "$name" != "No" ]] || continue # "No devices found"
backing=$(cryptsetup status "$name" 2>/dev/null \
| awk '/^[[:space:]]*device:/ { print $2; exit }') || continue
[[ -n "$backing" ]] || continue
if [[ "$backing" == "$drive" || "$backing" == "${drive}"* ]]; then
info " Closing stale LUKS mapping: $name (backed by $backing)"
cryptsetup close "$name"
fi
done < <(dmsetup ls --target crypt 2>/dev/null)
fi
# Wipe partition signatures. No `|| true` — the LUKS/swap teardown
# above should have released every holder; if wipefs still fails the
# device is genuinely busy and we want to surface that, not silently
# paper over it and let disko fail later with a confusing blkid error.
local part
if compgen -G "${drive}?*" >/dev/null; then
for part in "${drive}"?*; do
[[ -b "$part" ]] || continue
wipefs -af "$part" >/dev/null
done
fi
wipefs -af "$drive" >/dev/null
sgdisk --zap-all "$drive" >/dev/null
# 16 MiB covers LUKS2 binary headers (04 MiB) and the BTRFS first
# superblock (64 KiB) — wipefs alone misses damaged variants of these.
dd if=/dev/zero of="$drive" bs=1M count=16 conv=fsync status=none
partprobe "$drive" 2>/dev/null || true
# Bound the settle so a flapping USB device can't hang the installer.
udevadm settle --timeout=30 || info "udevadm settle timed out; continuing."
# Sanity check: nothing should still be mounted off this drive after
# the wipe. If something is, refuse to hand the disk to disko.
if lsblk -no MOUNTPOINTS "$drive" 2>/dev/null | grep -qE '\S'; then
error "Drive $drive still has active mountpoints after pre-wipe."
error "Investigate with: lsblk $drive ; mount | grep $drive"
return 1
fi
success "Pre-wipe complete"
}
_LUKS_KEY_PATH="/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 disko \
--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 <<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 "$@"