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"
HARDWARE_MODULES=""
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
@@ -89,6 +143,16 @@ check_environment() {
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"
@@ -135,6 +199,11 @@ check_environment() {
select_disk() {
section "Disk Selection"
if [[ -n "$TARGET_DRIVE" ]]; then
success "Resumed: $TARGET_DRIVE"
return
fi
info "Available drives:"
echo ""
lsblk -d -n -p -o NAME,SIZE,MODEL | grep -v loop
@@ -149,16 +218,18 @@ select_disk() {
exit 1
fi
if [[ "$DRY_RUN" != "true" ]]; then
echo ""
gum style --foreground 9 --bold "⚠ WARNING: All data on $TARGET_DRIVE will be DESTROYED!"
echo ""
if ! gum confirm "Are you sure you want to use $TARGET_DRIVE?"; then
error "Aborted"
exit 1
fi
fi
success "Selected: $TARGET_DRIVE"
save_state
}
# ============================================================================
@@ -166,6 +237,12 @@ select_disk() {
# ============================================================================
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"
info "Your disk will be encrypted with LUKS2."
@@ -197,21 +274,31 @@ get_luks_passphrase() {
configure_user() {
section "User Configuration"
if [[ -z "$USERNAME" ]]; then
USERNAME=$(nrun gum input --placeholder "Enter username (lowercase, no spaces)")
if [[ -z "$USERNAME" ]] || [[ ! "$USERNAME" =~ ^[a-z][a-z0-9_-]*$ ]]; then
error "Invalid username"
exit 1
fi
fi
success "Username: $USERNAME"
if [[ -z "$HOSTNAME" ]]; then
HOSTNAME=$(nrun gum input --value "nomarchy" --placeholder "Hostname for this machine")
if [[ -z "$HOSTNAME" ]] || [[ ! "$HOSTNAME" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
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
@@ -230,6 +317,7 @@ configure_user() {
done
success "User password set"
save_state
}
# ============================================================================
@@ -239,12 +327,18 @@ configure_user() {
select_timezone() {
section "Timezone"
if [[ -n "$TIMEZONE" ]] && [[ "$TIMEZONE" != "UTC" ]]; then
success "Resumed: $TIMEZONE"
return
fi
local timezones
timezones=$(timedatectl list-timezones 2>/dev/null || echo "UTC")
TIMEZONE=$(echo "$timezones" | gum filter --placeholder "Search timezone...")
[[ -z "$TIMEZONE" ]] && TIMEZONE="UTC"
success "Timezone: $TIMEZONE"
save_state
}
# ============================================================================
@@ -254,6 +348,11 @@ select_timezone() {
select_hardware() {
section "Hardware Configuration"
if [[ -n "$HARDWARE_MODULES" ]]; then
success "Resumed hardware modules"
return
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")
@@ -325,6 +424,7 @@ select_hardware() {
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.
@@ -404,6 +504,11 @@ _select_hardware_manual() {
configure_impermanence() {
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 "Only explicitly persisted files survive reboots."
info "This provides a clean, reproducible system."
@@ -415,6 +520,7 @@ configure_impermanence() {
else
info "Impermanence disabled (traditional persistent root)"
fi
save_state
}
# ============================================================================
@@ -432,6 +538,11 @@ review_configuration() {
echo " Nomarchy rev: ${NOMARCHY_REV:-main (unpinned)}"
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"
echo ""
@@ -446,6 +557,11 @@ review_configuration() {
# ============================================================================
execute_installation() {
if [[ "$DRY_RUN" == "true" ]]; then
execute_dry_run
return
fi
section "Installing Nomarchy"
# 9.1 Partition with disko
@@ -534,7 +650,79 @@ execute_installation() {
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."
}
# ============================================================================
@@ -639,6 +827,11 @@ EOF
$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
# things here that need to be available to all users or to root (e.g. CLI
# tools used by sudo, system admin utilities).
@@ -806,7 +999,9 @@ finish() {
# ============================================================================
main() {
parse_args "$@"
header
load_state
check_environment
select_disk
@@ -817,6 +1012,13 @@ main() {
configure_impermanence
review_configuration
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
}