- nrun git git init -q passed 'git' as a subcommand to git itself, failing the post-disko repo init. Drop the duplicated arg. - disko's luks module reads passwordFile at the top level; placing it under settings.* meant it was silently ignored and disko fell back to askPassword=true, prompting the user again on luksOpen. Move the option to the right scope. - configure_impermanence now uses local rc, nrun gum confirm, and an explicit case (0/1/130) with a final return 0 so a No answer no longer aborts the installer. - run_disko_with_retry hides disko's chatty output behind a gum spin by default and surfaces the captured log on failure. Set NOMARCHY_VERBOSE_DISKO=1 to stream output live. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1797 lines
63 KiB
Bash
Executable File
1797 lines
63 KiB
Bash
Executable File
#!/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"
|
||
|
||
local rc=0
|
||
nrun gum confirm "Treat this machine as a $default?" || rc=$?
|
||
if [[ $rc -eq 0 ]]; then
|
||
FORM_FACTOR="$default"
|
||
else
|
||
if [[ $rc -eq 130 ]]; then return 130; fi
|
||
FORM_FACTOR=$([[ "$default" == "laptop" ]] && echo desktop || echo laptop)
|
||
fi
|
||
success "Form factor: $FORM_FACTOR"
|
||
save_state
|
||
}
|
||
|
||
# ============================================================================
|
||
# STEP 7: IMPERMANENCE (OPTIONAL)
|
||
# ============================================================================
|
||
|
||
configure_impermanence() {
|
||
section "Impermanence (Optional)"
|
||
|
||
if [[ -n "$ENABLE_IMPERMANENCE" ]]; then
|
||
if [[ "$ENABLE_IMPERMANENCE" == "true" ]]; then
|
||
success "Impermanence enabled"
|
||
else
|
||
info "Impermanence disabled"
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
info "Impermanence erases your root filesystem on every boot."
|
||
info "Only explicitly persisted files survive reboots."
|
||
info "This provides a clean, reproducible system."
|
||
echo ""
|
||
|
||
local rc=0
|
||
nrun gum confirm "Enable Impermanence?" || rc=$?
|
||
case "$rc" in
|
||
0)
|
||
ENABLE_IMPERMANENCE="true"
|
||
success "Impermanence enabled"
|
||
;;
|
||
1)
|
||
ENABLE_IMPERMANENCE="false"
|
||
info "Impermanence disabled (traditional persistent root)"
|
||
;;
|
||
130)
|
||
return 130
|
||
;;
|
||
*)
|
||
ENABLE_IMPERMANENCE="false"
|
||
info "Impermanence disabled (traditional persistent root)"
|
||
;;
|
||
esac
|
||
save_state
|
||
return 0
|
||
}
|
||
|
||
# ============================================================================
|
||
# STEP 8: SOFTWARE PROFILES (OPTIONAL)
|
||
# ============================================================================
|
||
|
||
select_profiles() {
|
||
section "Software Profiles"
|
||
|
||
if [[ -n "$SELECTED_PROFILES" ]]; then
|
||
success "Software profiles selected"
|
||
return 0
|
||
fi
|
||
|
||
info "Pick optional software profiles to include in your configuration."
|
||
info "Multiple selection allowed (Space to select, Enter to confirm)."
|
||
echo ""
|
||
|
||
rc=0
|
||
SELECTED_PROFILES=$(nrun gum choose --no-limit --header "Select Profiles" \
|
||
"Dev (VSCode, Git, Lazygit, Zed, Docker)" \
|
||
"Gaming (Steam, Gamemode, Lutris, Heroic)" \
|
||
"Office (LibreOffice, Thunderbird, Obsidian, Zotero)" \
|
||
"Media (VLC, OBS Studio, GIMP, Inkscape, Spotify)" \
|
||
"CLI Utils (ripgrep, fd, bat, eza, zoxide, fzf)") || rc=$?
|
||
if [[ $rc -eq 130 || $rc -eq 1 || "$SELECTED_PROFILES" == "not submitted" || "$action" == "not submitted" || "$choices" == "not submitted" ]]; then return 130; fi
|
||
if [[ $rc -ne 0 ]]; then exit $rc; fi
|
||
if [[ -z "$SELECTED_PROFILES" ]]; then
|
||
info "No profiles selected (minimal install)"
|
||
else
|
||
# Count selected profiles by number of lines
|
||
local count
|
||
count=$(echo "$SELECTED_PROFILES" | grep -c "^" || echo 0)
|
||
success "Selected $count profile(s)"
|
||
fi
|
||
save_state
|
||
}
|
||
|
||
# ============================================================================
|
||
# STEP 9: REVIEW & CONFIRM
|
||
# ============================================================================
|
||
|
||
review_configuration() {
|
||
section "Review Configuration"
|
||
|
||
echo " Drive: $TARGET_DRIVE (BTRFS + LUKS2)"
|
||
echo " Hostname: $HOSTNAME"
|
||
echo " Username: $USERNAME"
|
||
echo " Keymap: $KEYMAP_LAYOUT${KEYMAP_VARIANT:+ ($KEYMAP_VARIANT)}"
|
||
echo " Locale: $LOCALE"
|
||
echo " Timezone: $TIMEZONE"
|
||
echo " Form factor: $FORM_FACTOR"
|
||
echo " Impermanence: $ENABLE_IMPERMANENCE"
|
||
echo " Profiles: ${SELECTED_PROFILES:-None}"
|
||
echo " Nomarchy rev: ${NOMARCHY_REV:-main (unpinned)}"
|
||
echo ""
|
||
|
||
if [[ "$DRY_RUN" == "true" ]]; then
|
||
info "Dry run: skipping destructive confirmation."
|
||
return 0
|
||
fi
|
||
|
||
nrun gum style --foreground 9 "This will DESTROY all data on $TARGET_DRIVE"
|
||
echo ""
|
||
|
||
local action rc=0
|
||
rc=0
|
||
action=$(nrun gum choose --header "Choose:" \
|
||
"Continue with installation" \
|
||
"Edit a field" \
|
||
"Abort") || rc=$?
|
||
if [[ $rc -eq 130 || $rc -eq 1 || "$action" == "not submitted" ]]; then return 130; fi
|
||
if [[ $rc -ne 0 ]]; then exit $rc; fi
|
||
case "$action" in
|
||
"Continue with installation") return 0 ;;
|
||
"Edit a field") edit_fields; return 2 ;;
|
||
*) error "Aborted"; exit 1 ;;
|
||
esac
|
||
}
|
||
|
||
# Multi-select clear: each cleared field is re-prompted on the next loop
|
||
# iteration in main() because every prompt short-circuits when its var is
|
||
# non-empty. Hardware clears the cached `nixos-hardware` modules and per-host
|
||
# option lines together so they stay consistent. Passwords are never offered
|
||
# here — get_luks_passphrase short-circuits when LUKS_PASSWORD is already set,
|
||
# so editing a field doesn't re-ask for the LUKS passphrase.
|
||
edit_fields() {
|
||
section "Edit Fields"
|
||
|
||
local choices rc=0
|
||
rc=0
|
||
choices=$(nrun gum choose --no-limit --header "Pick fields to re-enter (space to select, enter to confirm):" \
|
||
"Drive ($TARGET_DRIVE)" \
|
||
"User and host ($USERNAME @ $HOSTNAME)" \
|
||
"Keymap and locale ($KEYMAP_LAYOUT / $LOCALE)" \
|
||
"Timezone ($TIMEZONE)" \
|
||
"Hardware ($HARDWARE_MODULES)" \
|
||
"Form factor ($FORM_FACTOR)" \
|
||
"Impermanence ($ENABLE_IMPERMANENCE)" \
|
||
"Profiles (${SELECTED_PROFILES:-none})" \
|
||
"Nomarchy rev (${NOMARCHY_REV:-main})") || rc=$?
|
||
if [[ $rc -eq 130 || $rc -eq 1 || "$choices" == "not submitted" ]]; then return 130; fi
|
||
if [[ $rc -ne 0 ]]; then exit $rc; fi
|
||
if [[ -z "$choices" ]]; then return 0; fi
|
||
|
||
while IFS= read -r f; do
|
||
case "$f" in
|
||
"Drive"*) TARGET_DRIVE="" ;;
|
||
"User and host"*) USERNAME=""; HOSTNAME="" ;;
|
||
"Keymap"*) KEYMAP_LAYOUT=""; KEYMAP_VARIANT=""; LOCALE="" ;;
|
||
"Timezone"*) TIMEZONE="" ;;
|
||
"Hardware"*) HARDWARE_MODULES=""; NOMARCHY_HW_OPTS="" ;;
|
||
"Form factor"*) FORM_FACTOR="" ;;
|
||
"Impermanence"*) ENABLE_IMPERMANENCE="" ;;
|
||
"Profiles"*) SELECTED_PROFILES="" ;;
|
||
"Nomarchy rev"*) NOMARCHY_REV="" ;;
|
||
esac
|
||
done <<<"$choices"
|
||
}
|
||
|
||
# ============================================================================
|
||
# STEP 9: EXECUTION
|
||
# ============================================================================
|
||
|
||
# Pre-wipe the target drive before invoking disko.
|
||
#
|
||
# disko (at our pinned revision) gates two destructive steps on blkid:
|
||
# - lib/types/gpt.nix runs `sgdisk --clear` only when blkid sees no PT
|
||
# - lib/types/filesystem.nix skips mkfs entirely when blkid reports the
|
||
# target FS type already exists on the partition device
|
||
#
|
||
# On a previously-installed disk those branches mis-fire: blkid sees the old
|
||
# GPT and the old vfat ESP, so disko overlays its new partition entries on
|
||
# the existing table without zapping and skips mkfs.vfat, leaving the kernel
|
||
# to read a stale FAT BPB on the new (slightly different) ESP extent. mount
|
||
# then errors with "wrong fs type, bad option, bad superblock".
|
||
prewipe_target_drive() {
|
||
local drive="$1"
|
||
|
||
info "Pre-wiping $drive (clearing stale signatures)..."
|
||
|
||
# Tear down anything a prior aborted run left active. Order matters:
|
||
# mount holders -> swap -> LUKS mappings -> wipe.
|
||
umount -R /mnt 2>/dev/null || true
|
||
swapoff -a 2>/dev/null || true
|
||
|
||
# Enumerate every active dm-crypt mapping and close those whose backing
|
||
# device is on this drive. The previous version only knew about the
|
||
# hardcoded names "crypted" and "crypted_main"; an aborted multi-disk
|
||
# run, a manual experiment, or a non-Nomarchy install would leave a
|
||
# mapping with a different name holding the device busy and silently
|
||
# break the wipe.
|
||
if command -v dmsetup >/dev/null 2>&1; then
|
||
local name backing
|
||
while read -r name _; do
|
||
[[ -n "$name" && "$name" != "No" ]] || continue # "No devices found"
|
||
backing=$(cryptsetup status "$name" 2>/dev/null \
|
||
| awk '/^[[:space:]]*device:/ { print $2; exit }') || continue
|
||
[[ -n "$backing" ]] || continue
|
||
if [[ "$backing" == "$drive" || "$backing" == "${drive}"* ]]; then
|
||
info " Closing stale LUKS mapping: $name (backed by $backing)"
|
||
cryptsetup close "$name"
|
||
fi
|
||
done < <(dmsetup ls --target crypt 2>/dev/null)
|
||
fi
|
||
|
||
# Wipe partition signatures. No `|| true` — the LUKS/swap teardown
|
||
# above should have released every holder; if wipefs still fails the
|
||
# device is genuinely busy and we want to surface that, not silently
|
||
# paper over it and let disko fail later with a confusing blkid error.
|
||
local part
|
||
if compgen -G "${drive}?*" >/dev/null; then
|
||
for part in "${drive}"?*; do
|
||
[[ -b "$part" ]] || continue
|
||
wipefs -af "$part" >/dev/null
|
||
done
|
||
fi
|
||
wipefs -af "$drive" >/dev/null
|
||
sgdisk --zap-all "$drive" >/dev/null
|
||
|
||
# 16 MiB covers LUKS2 binary headers (0–4 MiB) and the BTRFS first
|
||
# superblock (64 KiB) — wipefs alone misses damaged variants of these.
|
||
dd if=/dev/zero of="$drive" bs=1M count=16 conv=fsync status=none
|
||
|
||
partprobe "$drive" 2>/dev/null || true
|
||
# Bound the settle so a flapping USB device can't hang the installer.
|
||
udevadm settle --timeout=30 || info "udevadm settle timed out; continuing."
|
||
|
||
# Sanity check: nothing should still be mounted off this drive after
|
||
# the wipe. If something is, refuse to hand the disk to disko.
|
||
if lsblk -no MOUNTPOINTS "$drive" 2>/dev/null | grep -qE '\S'; then
|
||
error "Drive $drive still has active mountpoints after pre-wipe."
|
||
error "Investigate with: lsblk $drive ; mount | grep $drive"
|
||
return 1
|
||
fi
|
||
|
||
success "Pre-wipe complete"
|
||
}
|
||
|
||
_LUKS_KEY_PATH="/dev/shm/nomarchy-luks.key"
|
||
|
||
# Wrap the disko invocation so a failure surfaces the last few lines of
|
||
# output and offers Retry / View full log / Abort. set -e is suspended for
|
||
# the disko call so we can inspect its exit code; restored on every path.
|
||
#
|
||
# By default disko's chatty output is hidden behind a `gum spin` spinner;
|
||
# the full log is captured and shown on failure or via `gum pager`. Set
|
||
# NOMARCHY_VERBOSE_DISKO=1 to stream disko output live instead.
|
||
run_disko_with_retry() {
|
||
local main_drive="$1"
|
||
local extras_nix="$2"
|
||
local disko_file="$NOMARCHY_REPO/installer/disko-config.nix"
|
||
local log
|
||
log=$(mktemp --suffix=.disko.log)
|
||
|
||
while true; do
|
||
local rc=0
|
||
if [[ "$NOMARCHY_VERBOSE_DISKO" == "1" ]]; then
|
||
set +e
|
||
disko --mode destroy,format,mount \
|
||
--argstr mainDrive "$main_drive" \
|
||
--arg extraDrives "$extras_nix" \
|
||
"$disko_file" 2>&1 | tee "$log"
|
||
rc=${PIPESTATUS[0]}
|
||
set -e
|
||
else
|
||
# Silent path: run disko in the background, write all output to
|
||
# $log, and show a spinner. Use a status file for the exit code
|
||
# because `gum spin` doesn't propagate the wrapped command's rc.
|
||
local status_file
|
||
status_file=$(mktemp --suffix=.disko.rc)
|
||
set +e
|
||
nrun gum spin --spinner dot --title "Partitioning disk(s) with disko..." -- \
|
||
bash -c '
|
||
disko --mode destroy,format,mount \
|
||
--argstr mainDrive "$1" \
|
||
--arg extraDrives "$2" \
|
||
"$3" > "$4" 2>&1
|
||
echo $? > "$5"
|
||
' _ "$main_drive" "$extras_nix" "$disko_file" "$log" "$status_file"
|
||
set -e
|
||
rc=$(cat "$status_file" 2>/dev/null || echo 1)
|
||
rm -f "$status_file"
|
||
fi
|
||
|
||
if [[ $rc -eq 0 ]]; then
|
||
rm -f "$log"
|
||
return 0
|
||
fi
|
||
|
||
error "disko failed (exit $rc). Last lines of output:"
|
||
tail -n 30 "$log" | nrun gum style --foreground 9 --border normal --padding "0 1"
|
||
|
||
local choice
|
||
choice=$(printf 'Retry\nView full log\nAbort\n' \
|
||
| nrun gum choose --header "Disk partitioning failed. What now?")
|
||
case "$choice" in
|
||
Retry)
|
||
info "Re-running pre-wipe and retrying disko..."
|
||
local d
|
||
for d in $TARGET_DRIVE; do prewipe_target_drive "$d"; done
|
||
;;
|
||
"View full log")
|
||
nrun gum pager < "$log" || less -RFX "$log" || cat "$log"
|
||
;;
|
||
*)
|
||
rm -f "$log"
|
||
return $rc
|
||
;;
|
||
esac
|
||
done
|
||
}
|
||
|
||
execute_installation() {
|
||
if [[ "$DRY_RUN" == "true" ]]; then
|
||
execute_dry_run
|
||
return
|
||
fi
|
||
|
||
section "Installing Nomarchy"
|
||
|
||
# 9.1 Partition with disko
|
||
info "Partitioning disk(s)..."
|
||
for d in $TARGET_DRIVE; do
|
||
prewipe_target_drive "$d"
|
||
done
|
||
|
||
# Build the extraDrives Nix-list literal for disko-config.nix. Empty
|
||
# list = single-disk path. The list is well-formed by construction
|
||
# here (each element is a /dev/* path the user already picked) so
|
||
# there's no escaping concern — unlike the previous sed-templated Nix.
|
||
local drives=($TARGET_DRIVE)
|
||
local main_drive="${drives[0]}"
|
||
local extras_nix="[]"
|
||
if (( ${#drives[@]} > 1 )); then
|
||
extras_nix="["
|
||
local i
|
||
for (( i=1; i<${#drives[@]}; i++ )); do
|
||
extras_nix+=" \"${drives[$i]}\""
|
||
done
|
||
extras_nix+=" ]"
|
||
fi
|
||
|
||
# Provide the LUKS passphrase via tmpfs so the secret never touches a
|
||
# spinning disk. /dev/shm is tmpfs on the live ISO. The EXIT trap
|
||
# below guarantees the file is removed even if the script aborts
|
||
# between writing the key and the unset below.
|
||
install -m 600 /dev/null "$_LUKS_KEY_PATH"
|
||
trap 'rm -f "$_LUKS_KEY_PATH" 2>/dev/null || true' EXIT
|
||
printf '%s' "$LUKS_PASSWORD" > "$_LUKS_KEY_PATH"
|
||
|
||
run_disko_with_retry "$main_drive" "$extras_nix" || exit 1
|
||
|
||
rm -f "$_LUKS_KEY_PATH"
|
||
unset LUKS_PASSWORD
|
||
success "Disk partitioned"
|
||
|
||
# 9.2 Generate hardware config
|
||
info "Generating hardware configuration..."
|
||
mkdir -p /mnt/etc/nixos
|
||
nixos-generate-config --root /mnt
|
||
success "Hardware configuration generated"
|
||
|
||
# 9.3 Generate flake configuration
|
||
info "Creating system configuration..."
|
||
generate_flake_config
|
||
success "Configuration generated"
|
||
|
||
# 9.4 Resolve inputs once, here, and lock them. First boot then consumes
|
||
# the same flake.lock and doesn't re-resolve a newer upstream.
|
||
info "Resolving flake inputs (this pins nomarchy, nixpkgs, etc.)..."
|
||
(
|
||
cd /mnt/etc/nixos
|
||
nix --extra-experimental-features "nix-command flakes" flake lock >/dev/null
|
||
)
|
||
success "flake.lock written"
|
||
|
||
# 9.5 Initialize git repo so `nix` treats /etc/nixos as a flake worktree.
|
||
info "Initializing git repository..."
|
||
(
|
||
cd /mnt/etc/nixos
|
||
nrun git init -q
|
||
nrun git add .
|
||
nrun git config user.name "Nomarchy Installer"
|
||
nrun git config user.email "installer@nomarchy"
|
||
nrun git commit -qm "Initial Nomarchy configuration"
|
||
)
|
||
success "Git repository initialized"
|
||
|
||
# 9.6 Handle impermanence
|
||
if [[ "$ENABLE_IMPERMANENCE" == "true" ]]; then
|
||
info "Setting up impermanence..."
|
||
mkdir -p /mnt/persist/etc
|
||
mv /mnt/etc/nixos /mnt/persist/etc/
|
||
mkdir -p /mnt/etc
|
||
ln -s /persist/etc/nixos /mnt/etc/nixos
|
||
success "Impermanence configured"
|
||
fi
|
||
|
||
# 9.7 Install the Nomarchy system from the freshly-generated flake.
|
||
info "Running nixos-install (this will take a while)..."
|
||
nixos-install --flake "/mnt/etc/nixos#$HOSTNAME" --no-root-passwd
|
||
success "Nomarchy installed"
|
||
|
||
# 9.8 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 = {
|
||
nomarchy.url = "$nomarchy_url";
|
||
};
|
||
|
||
# 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, nomarchy, ... }@inputs:
|
||
let
|
||
inherit (nomarchy.inputs) nixpkgs home-manager;
|
||
system = "x86_64-linux";
|
||
pkgs = import nixpkgs {
|
||
inherit system;
|
||
overlays = [ nomarchy.overlays.default ];
|
||
config.allowUnfree = true;
|
||
};
|
||
in
|
||
{
|
||
nixosConfigurations.$HOSTNAME = nixpkgs.lib.nixosSystem {
|
||
inherit pkgs;
|
||
specialArgs = { inputs = nomarchy.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; };
|
||
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 "$@"
|