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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user