From e66537523ade6d437249cdce91312431c1f9199f Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Sat, 25 Apr 2026 10:18:41 +0100 Subject: [PATCH] =?UTF-8?q?feat(installer):=20UX=20polish=20=E2=80=94=20dr?= =?UTF-8?q?y-run,=20resume,=20UEFI=20gate,=20pre-flight,=20zram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- installer/install.sh | 236 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 219 insertions(+), 17 deletions(-) diff --git a/installer/install.sh b/installer/install.sh index 5f348be..c628254 100755 --- a/installer/install.sh +++ b/installer/install.sh @@ -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 < 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 - 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 + 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" - 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 + 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" - 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 + 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 }