feat(installer): UX polish — dry-run, resume, UEFI gate, pre-flight, zram

Adds command-line flags and safety rails on top of the existing install.sh.

CLI:
- `--dry-run` generates the flake into /tmp/nomarchy-dryrun.* and parse-checks
  every produced file without touching the disk. Skips LUKS / user password
  prompts and the destructive confirmation; sets safe stub values.
- `--resume` reloads non-secret answers from /tmp/nomarchy-install.state.sh
  (saved via `declare -p` after each step) and skips already-answered prompts.
  Passwords are NEVER persisted — the user re-enters them.
- `--help` documents the flags.

Safety:
- Bail early in check_environment if /sys/firmware/efi is absent. The disko
  config assumes UEFI + ESP; on a BIOS-booted host we'd partially install
  before failing.
- After nixos-install, run `nixos-rebuild dry-build --flake /etc/nixos#$HOSTNAME`
  inside `nixos-enter` to surface evaluation errors while the live ISO is
  still around to fix them.
- ENABLE_IMPERMANENCE now defaults to "" so the resume path can distinguish
  "not yet asked" from a deliberate "false" answer.

Generated config:
- system.nix gets `zramSwap.enable = true;` — near-free memory headroom on
  small machines, harmless on big ones (kernel only uses it under pressure).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-04-25 10:18:41 +01:00
parent 04512eabcd
commit e66537523a

View File

@@ -35,7 +35,61 @@ USER_PASSWORD=""
TIMEZONE="UTC" TIMEZONE="UTC"
HARDWARE_MODULES="" HARDWARE_MODULES=""
NOMARCHY_HW_OPTS="" NOMARCHY_HW_OPTS=""
ENABLE_IMPERMANENCE="false" # "" = not yet answered; "true"/"false" set by configure_impermanence.
ENABLE_IMPERMANENCE=""
# 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 \
ENABLE_IMPERMANENCE HARDWARE_MODULES NOMARCHY_HW_OPTS 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 # UTILITY FUNCTIONS
@@ -89,6 +143,16 @@ check_environment() {
fi fi
success "Running as root" 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 # Find Nomarchy repo
if [[ -d "/etc/nomarchy" ]]; then if [[ -d "/etc/nomarchy" ]]; then
NOMARCHY_REPO="/etc/nomarchy" NOMARCHY_REPO="/etc/nomarchy"
@@ -135,6 +199,11 @@ check_environment() {
select_disk() { select_disk() {
section "Disk Selection" section "Disk Selection"
if [[ -n "$TARGET_DRIVE" ]]; then
success "Resumed: $TARGET_DRIVE"
return
fi
info "Available drives:" info "Available drives:"
echo "" echo ""
lsblk -d -n -p -o NAME,SIZE,MODEL | grep -v loop lsblk -d -n -p -o NAME,SIZE,MODEL | grep -v loop
@@ -149,16 +218,18 @@ select_disk() {
exit 1 exit 1
fi fi
if [[ "$DRY_RUN" != "true" ]]; then
echo "" echo ""
gum style --foreground 9 --bold "⚠ WARNING: All data on $TARGET_DRIVE will be DESTROYED!" gum style --foreground 9 --bold "⚠ WARNING: All data on $TARGET_DRIVE will be DESTROYED!"
echo "" echo ""
if ! gum confirm "Are you sure you want to use $TARGET_DRIVE?"; then if ! gum confirm "Are you sure you want to use $TARGET_DRIVE?"; then
error "Aborted" error "Aborted"
exit 1 exit 1
fi fi
fi
success "Selected: $TARGET_DRIVE" success "Selected: $TARGET_DRIVE"
save_state
} }
# ============================================================================ # ============================================================================
@@ -166,6 +237,12 @@ select_disk() {
# ============================================================================ # ============================================================================
get_luks_passphrase() { get_luks_passphrase() {
if [[ "$DRY_RUN" == "true" ]]; then
info "Dry run: skipping LUKS passphrase prompt."
LUKS_PASSWORD="dryrun-not-used"
return
fi
section "Disk Encryption" section "Disk Encryption"
info "Your disk will be encrypted with LUKS2." info "Your disk will be encrypted with LUKS2."
@@ -197,21 +274,31 @@ get_luks_passphrase() {
configure_user() { configure_user() {
section "User Configuration" section "User Configuration"
if [[ -z "$USERNAME" ]]; then
USERNAME=$(nrun gum input --placeholder "Enter username (lowercase, no spaces)") USERNAME=$(nrun gum input --placeholder "Enter username (lowercase, no spaces)")
if [[ -z "$USERNAME" ]] || [[ ! "$USERNAME" =~ ^[a-z][a-z0-9_-]*$ ]]; then if [[ -z "$USERNAME" ]] || [[ ! "$USERNAME" =~ ^[a-z][a-z0-9_-]*$ ]]; then
error "Invalid username" error "Invalid username"
exit 1 exit 1
fi fi
fi
success "Username: $USERNAME" success "Username: $USERNAME"
if [[ -z "$HOSTNAME" ]]; then
HOSTNAME=$(nrun gum input --value "nomarchy" --placeholder "Hostname for this machine") HOSTNAME=$(nrun gum input --value "nomarchy" --placeholder "Hostname for this machine")
if [[ -z "$HOSTNAME" ]] || [[ ! "$HOSTNAME" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then if [[ -z "$HOSTNAME" ]] || [[ ! "$HOSTNAME" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
error "Invalid hostname (use lowercase letters, digits, and hyphens only)" error "Invalid hostname (use lowercase letters, digits, and hyphens only)"
exit 1 exit 1
fi fi
fi
success "Hostname: $HOSTNAME" 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) # User password (can be same as LUKS or different)
info "Set a password for your user account" info "Set a password for your user account"
local pass1 pass2 local pass1 pass2
@@ -230,6 +317,7 @@ configure_user() {
done done
success "User password set" success "User password set"
save_state
} }
# ============================================================================ # ============================================================================
@@ -239,12 +327,18 @@ configure_user() {
select_timezone() { select_timezone() {
section "Timezone" section "Timezone"
if [[ -n "$TIMEZONE" ]] && [[ "$TIMEZONE" != "UTC" ]]; then
success "Resumed: $TIMEZONE"
return
fi
local timezones local timezones
timezones=$(timedatectl list-timezones 2>/dev/null || echo "UTC") timezones=$(timedatectl list-timezones 2>/dev/null || echo "UTC")
TIMEZONE=$(echo "$timezones" | gum filter --placeholder "Search timezone...") TIMEZONE=$(echo "$timezones" | gum filter --placeholder "Search timezone...")
[[ -z "$TIMEZONE" ]] && TIMEZONE="UTC" [[ -z "$TIMEZONE" ]] && TIMEZONE="UTC"
success "Timezone: $TIMEZONE" success "Timezone: $TIMEZONE"
save_state
} }
# ============================================================================ # ============================================================================
@@ -254,6 +348,11 @@ select_timezone() {
select_hardware() { select_hardware() {
section "Hardware Configuration" section "Hardware Configuration"
if [[ -n "$HARDWARE_MODULES" ]]; then
success "Resumed hardware modules"
return
fi
local dmi_vendor dmi_product detect_output local dmi_vendor dmi_product detect_output
dmi_vendor=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null || echo "Unknown") 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") dmi_product=$(cat /sys/class/dmi/id/product_name 2>/dev/null || echo "Unknown")
@@ -325,6 +424,7 @@ select_hardware() {
done done
success "Hardware configuration set (${#uniq_mods[@]} module$([[ ${#uniq_mods[@]} -eq 1 ]] || echo s))" 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. # Manual fallback menu, kept for odd hardware the DB doesn't recognise yet.
@@ -404,6 +504,11 @@ _select_hardware_manual() {
configure_impermanence() { configure_impermanence() {
section "Impermanence (Optional)" section "Impermanence (Optional)"
if [[ "$RESUME" == "true" ]] && [[ "$ENABLE_IMPERMANENCE" != "" ]]; then
success "Resumed: impermanence = $ENABLE_IMPERMANENCE"
return
fi
info "Impermanence erases your root filesystem on every boot." info "Impermanence erases your root filesystem on every boot."
info "Only explicitly persisted files survive reboots." info "Only explicitly persisted files survive reboots."
info "This provides a clean, reproducible system." info "This provides a clean, reproducible system."
@@ -415,6 +520,7 @@ configure_impermanence() {
else else
info "Impermanence disabled (traditional persistent root)" info "Impermanence disabled (traditional persistent root)"
fi fi
save_state
} }
# ============================================================================ # ============================================================================
@@ -432,6 +538,11 @@ review_configuration() {
echo " Nomarchy rev: ${NOMARCHY_REV:-main (unpinned)}" echo " Nomarchy rev: ${NOMARCHY_REV:-main (unpinned)}"
echo "" echo ""
if [[ "$DRY_RUN" == "true" ]]; then
info "Dry run: skipping destructive confirmation."
return
fi
nrun gum style --foreground 9 "This will DESTROY all data on $TARGET_DRIVE" nrun gum style --foreground 9 "This will DESTROY all data on $TARGET_DRIVE"
echo "" echo ""
@@ -446,6 +557,11 @@ review_configuration() {
# ============================================================================ # ============================================================================
execute_installation() { execute_installation() {
if [[ "$DRY_RUN" == "true" ]]; then
execute_dry_run
return
fi
section "Installing Nomarchy" section "Installing Nomarchy"
# 9.1 Partition with disko # 9.1 Partition with disko
@@ -534,7 +650,79 @@ execute_installation() {
info "Run \`nomarchy-env-update\` after the first login to retry." info "Run \`nomarchy-env-update\` after the first login to retry."
fi 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!" 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."
} }
# ============================================================================ # ============================================================================
@@ -639,6 +827,11 @@ EOF
$impermanence_opt $impermanence_opt
# 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 # 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 # things here that need to be available to all users or to root (e.g. CLI
# tools used by sudo, system admin utilities). # tools used by sudo, system admin utilities).
@@ -806,7 +999,9 @@ finish() {
# ============================================================================ # ============================================================================
main() { main() {
parse_args "$@"
header header
load_state
check_environment check_environment
select_disk select_disk
@@ -817,6 +1012,13 @@ main() {
configure_impermanence configure_impermanence
review_configuration review_configuration
execute_installation 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 finish
} }