#!/usr/bin/env bash # nomarchy-install — guided installer for the Nomarchy live ISO. # # Partitions one disk (GPT + 1 GiB ESP + BTRFS, optional LUKS2) with disko, # generates a downstream machine flake at /home//.nomarchy (one # nomarchy.lib.mkFlake call — the flake the user never hand-edits), and # runs nixos-install. Works offline: the ISO pins every flake input, and # the target flake.lock is composed from the rev the ISO was built from. # # Ported from the previous iteration's installer/install.sh (3bdfc35) — # the pre-wipe and ordering comments carry its hard-won fixes. v1 scope: # single disk, UEFI only. Multi-disk RAID and impermanence live in git # history when they're wanted back. # # Unattended mode (for CI/VM tests): # NOMARCHY_UNATTENDED=1 NOMARCHY_DISK=/dev/vda NOMARCHY_USERNAME=me \ # NOMARCHY_PASSWORD=secret [NOMARCHY_HOSTNAME=nomarchy] \ # [NOMARCHY_TIMEZONE=UTC] [NOMARCHY_LUKS_PASSPHRASE=...] \ # [NOMARCHY_LOCALE=en_US.UTF-8] [NOMARCHY_KB_LAYOUT=us] [NOMARCHY_KB_VARIANT=] \ # [NOMARCHY_SWAP_GB=N (default: RAM size; 0 = none)] \ # [NOMARCHY_HW="auto"|"none"|"mod1 mod2"] [NOMARCHY_FINISH=none|reboot|poweroff] # nomarchy-install set -euo pipefail # Baked in by the package wrapper: # NOMARCHY_INSTALL_SHARE — disko-config.nix, hardware-db.sh, # compose-lock.py, flake.lock, template/, # hardware-modules.txt # NOMARCHY_FLAKE_URL — flake URL written into the generated flake.nix # NOMARCHY_REV — nomarchy rev this ISO was built from ("" if dirty) # NOMARCHY_LOCKED_JSON / NOMARCHY_ORIGINAL_JSON — flake.lock node fields SHARE="${NOMARCHY_INSTALL_SHARE:?not run via the packaged wrapper}" UNATTENDED="${NOMARCHY_UNATTENDED:-0}" LUKS_KEY_PATH="/tmp/nomarchy-luks.key" # ─── UI helpers ───────────────────────────────────────────────────────── header() { gum style --border rounded --padding "0 2" --margin "1 0" \ --border-foreground 212 "$@"; } section() { gum style --foreground 212 --bold "── $* ──"; } info() { gum style --foreground 245 " $*"; } success() { gum style --foreground 42 " ✓ $*"; } warn() { gum style --foreground 214 " ⚠ $*"; } fail() { gum style --foreground 9 " ✗ $*"; exit 1; } confirm() { # confirm — auto-yes when unattended [[ "$UNATTENDED" == "1" ]] && return 0 gum confirm "$1" } # ─── Environment checks ───────────────────────────────────────────────── if [[ $EUID -ne 0 ]]; then exec sudo --preserve-env "$0" "$@" fi header "Nomarchy installer" "NixOS, themed and ready to go." [[ -d /sys/firmware/efi ]] \ || fail "No UEFI firmware detected. v1 installs systemd-boot and needs UEFI (BIOS/legacy: see roadmap)." command -v nixos-install >/dev/null \ || fail "nixos-install not found — run this from the Nomarchy live ISO." grep -q nomarchy-live /etc/hostname 2>/dev/null \ || warn "This doesn't look like the Nomarchy live ISO; proceeding anyway." # Offline-proof nix: disko's eval resolves (a dead channel on # the live ISO) and flake commands may consult the global registry — # point both at what the ISO already carries. export NIX_PATH="nixpkgs=$NOMARCHY_NIXPKGS" export NIX_CONFIG="flake-registry = $SHARE/registry.json" # With no network, every substituter query is a DNS timeout + retry storm # (and tickles a nix goal.cc assertion crash). Everything an install needs # is pinned into the ISO — drop the binary caches and substitute from the # live store's daemon instead. The explicit daemon substituter matters: # nixos-install builds with --store /mnt and its own "auto?trusted=1" # substituter resolves to the TARGET store (i.e. itself), so without # this nothing flows from the ISO and nix bootstraps gcc from source. NIXOS_INSTALL_OPTS=() if ! timeout 3 bash -c '/dev/null; then info "No network — substituting from the ISO store only." NIX_CONFIG+=$'\nsubstituters =\nextra-substituters = daemon?trusted=1\nbuilders =' # Must ALSO go through nixos-install as a flag: it passes its own # --extra-substituters "auto?trusted=1", and flags override the env # config for the same setting — without this the in-target build # substitutes from itself (= nothing) and bootstraps gcc from source. NIXOS_INSTALL_OPTS+=(--substituters "daemon?trusted=1") fi # ─── Disk selection ───────────────────────────────────────────────────── section "Target disk" # Never offer the medium we're running from. live_disk="" live_src=$(findmnt -no SOURCE /iso 2>/dev/null || true) [[ -n "$live_src" ]] && live_disk=$(lsblk -no PKNAME "$live_src" 2>/dev/null || true) mapfile -t disks < <(lsblk -dpno NAME,SIZE,MODEL,TYPE \ | awk -v skip="/dev/${live_disk:-NONE}" \ '$NF == "disk" && $1 != skip && $1 !~ /loop|zram|sr[0-9]/ {NF--; print}') [[ ${#disks[@]} -gt 0 ]] || fail "No installable disks found." if [[ "$UNATTENDED" == "1" ]]; then TARGET_DISK="${NOMARCHY_DISK:?NOMARCHY_DISK required in unattended mode}" else choice=$(printf '%s\n' "${disks[@]}" \ | gum choose --header "Install Nomarchy on which disk? (EVERYTHING on it will be erased)") TARGET_DISK="${choice%% *}" fi [[ -b "$TARGET_DISK" ]] || fail "$TARGET_DISK is not a block device." info "Target: $TARGET_DISK" # ─── Encryption ───────────────────────────────────────────────────────── section "Disk encryption" # LUKS is the default: full-disk encryption, and in exchange the machine # logs you straight into the desktop (the passphrase already gates access). LUKS_PASSPHRASE="" if [[ "$UNATTENDED" == "1" ]]; then LUKS_PASSPHRASE="${NOMARCHY_LUKS_PASSPHRASE:-}" elif gum confirm --default=yes "Encrypt the disk with LUKS? (default — also enables passwordless desktop login)"; then while true; do p1=$(gum input --password --placeholder "LUKS passphrase (min 8 chars)") [[ ${#p1} -ge 8 ]] || { warn "Too short."; continue; } p2=$(gum input --password --placeholder "Repeat passphrase") [[ "$p1" == "$p2" ]] && { LUKS_PASSPHRASE="$p1"; break; } warn "Passphrases don't match." done fi WITH_LUKS=false; [[ -n "$LUKS_PASSPHRASE" ]] && WITH_LUKS=true info "Encryption: $([[ $WITH_LUKS == true ]] && echo "LUKS2 (desktop auto-login)" || echo none)" # ─── Swap / hibernation ───────────────────────────────────────────────── # A swapfile ≥ RAM on its own BTRFS subvolume makes hibernation possible; # the resume offset is wired into the config below. ram_gb=$(awk '/MemTotal/ {print int(($2 + 1048575) / 1048576)}' /proc/meminfo) if [[ "$UNATTENDED" == "1" ]]; then SWAP_GB="${NOMARCHY_SWAP_GB:-$ram_gb}" else SWAP_GB=$(gum input --value "$ram_gb" \ --placeholder "swap size in GiB (≥ RAM enables hibernation, 0 = none)") fi [[ "$SWAP_GB" =~ ^[0-9]+$ ]] || fail "Swap size must be a whole number of GiB." info "Swap: $([[ "$SWAP_GB" == "0" ]] && echo none || echo "${SWAP_GB}G swapfile (hibernation-ready)")" # ─── User account ─────────────────────────────────────────────────────── section "Your account" if [[ "$UNATTENDED" == "1" ]]; then USERNAME="${NOMARCHY_USERNAME:?}" PASSWORD="${NOMARCHY_PASSWORD:?}" HOSTNAME_="${NOMARCHY_HOSTNAME:-nomarchy}" TIMEZONE="${NOMARCHY_TIMEZONE:-UTC}" LOCALE="${NOMARCHY_LOCALE:-en_US.UTF-8}" KB_LAYOUT="${NOMARCHY_KB_LAYOUT:-us}" KB_VARIANT="${NOMARCHY_KB_VARIANT:-}" else while true; do USERNAME=$(gum input --placeholder "username (lowercase, e.g. ada)") [[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]*$ ]] && break warn "Invalid username (lowercase letters, digits, - and _)." done while true; do PASSWORD=$(gum input --password --placeholder "password for $USERNAME") [[ -n "$PASSWORD" ]] || { warn "Empty password."; continue; } p2=$(gum input --password --placeholder "repeat password") [[ "$PASSWORD" == "$p2" ]] && break warn "Passwords don't match." done while true; do HOSTNAME_=$(gum input --value "nomarchy" --placeholder "hostname") [[ "$HOSTNAME_" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$ ]] && break warn "Invalid hostname." done TIMEZONE=$(timedatectl list-timezones | gum filter --placeholder "timezone (type to search)" || echo UTC) # Curated UTF-8 locales: the live system only generates en_US, so # `locale -a` can't enumerate what the TARGET could use. LOCALE=$(printf '%s\n' \ en_US en_GB de_DE fr_FR es_ES es_MX pt_PT pt_BR it_IT nl_NL \ pl_PL ru_RU uk_UA cs_CZ sk_SK sv_SE nb_NO da_DK fi_FI tr_TR \ el_GR hu_HU ro_RO bg_BG hr_HR sl_SI lt_LT lv_LV et_EE ja_JP \ ko_KR zh_CN zh_TW ar_EG he_IL hi_IN th_TH vi_VN id_ID \ | sed 's/$/.UTF-8/' \ | gum filter --placeholder "language / locale (type to search)" \ || echo en_US.UTF-8) KB_LAYOUT=$(localectl list-x11-keymap-layouts 2>/dev/null \ | gum filter --placeholder "keyboard layout (type to search)" \ || echo us) KB_VARIANT="" if [[ "$KB_LAYOUT" != "us" ]] || gum confirm --default=No "Pick a keyboard variant (intl, nodeadkeys, …)?"; then KB_VARIANT=$( { echo "(none)"; localectl list-x11-keymap-variants "$KB_LAYOUT" 2>/dev/null; } \ | gum filter --placeholder "variant for $KB_LAYOUT (pick '(none)' for the default)" \ || echo "(none)") [[ "$KB_VARIANT" == "(none)" ]] && KB_VARIANT="" fi fi [[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]*$ ]] || fail "Invalid username '$USERNAME'." [[ -f "/usr/share/zoneinfo/$TIMEZONE" || -e "/etc/zoneinfo/$TIMEZONE" ]] \ || timedatectl list-timezones 2>/dev/null | grep -qx "$TIMEZONE" \ || warn "Timezone '$TIMEZONE' not verifiable; continuing." HASHED_PASSWORD=$(printf '%s' "$PASSWORD" | mkpasswd -m sha-512 -s) unset PASSWORD info "User: $USERNAME @ $HOSTNAME_ ($TIMEZONE)" info "Locale: $LOCALE · keyboard: $KB_LAYOUT${KB_VARIANT:+ ($KB_VARIANT)}" # ─── Hardware profile ─────────────────────────────────────────────────── section "Hardware detection" # shellcheck source=hardware-db.sh source "$SHARE/hardware-db.sh" HW_PROFILES=() hw_mode="${NOMARCHY_HW:-auto}" if [[ "$hw_mode" == "none" ]]; then info "Hardware profiles skipped." elif [[ "$hw_mode" != "auto" && "$UNATTENDED" == "1" ]]; then read -ra HW_PROFILES <<< "$hw_mode" else detection=$(nomarchy_detect_hw || true) if [[ -n "$detection" ]]; then while IFS= read -r line; do case "$line" in MODULE\ *) HW_PROFILES+=("${line#MODULE }") ;; DETAIL\ *) info "→ ${line#DETAIL }" ;; esac done <<< "$detection" fi if [[ "$UNATTENDED" != "1" ]]; then if [[ ${#HW_PROFILES[@]} -gt 0 ]] \ && ! gum confirm "Use these nixos-hardware profiles: ${HW_PROFILES[*]}?"; then HW_PROFILES=() picked=$(gum filter --no-limit \ --placeholder "pick profiles manually (tab to select, enter to finish, esc for none)" \ < "$SHARE/hardware-modules.txt" || true) [[ -n "$picked" ]] && mapfile -t HW_PROFILES <<< "$picked" fi fi fi info "Profiles: ${HW_PROFILES[*]:-(none)}" # ─── Review & point of no return ──────────────────────────────────────── section "Review" gum style --border normal --padding "0 2" \ "Disk: $TARGET_DISK (WILL BE ERASED)" \ "Encryption: $([[ $WITH_LUKS == true ]] && echo "LUKS2 + desktop auto-login" || echo none)" \ "Swap: $([[ "$SWAP_GB" == "0" ]] && echo none || echo "${SWAP_GB}G (hibernation)")" \ "User: $USERNAME" \ "Hostname: $HOSTNAME_" \ "Timezone: $TIMEZONE" \ "Hardware: ${HW_PROFILES[*]:-none}" \ "Snapshots: snapper timeline on /" \ "Source: nomarchy ${NOMARCHY_REV:0:12}${NOMARCHY_REV:+ }$([[ -z "${NOMARCHY_REV:-}" ]] && echo "(dirty tree) ")— pinned into the ISO, no network needed" if [[ "$UNATTENDED" != "1" ]]; then typed=$(gum input --placeholder "type the disk name ($(basename "$TARGET_DISK")) to confirm the wipe") [[ "$typed" == "$(basename "$TARGET_DISK")" ]] || fail "Confirmation mismatch — aborting, nothing touched." fi # ─── Partitioning ─────────────────────────────────────────────────────── section "Partitioning" # Pre-wipe: disko gates destructive steps on blkid — on a previously # installed disk it would overlay the old GPT and skip mkfs on a stale # ESP ("wrong fs type, bad superblock" at mount). Wipe first. Teardown # order matters: mounts → swap → LUKS mappings → signatures. prewipe() { local drive="$1" name backing part info "Pre-wiping $drive..." umount -R /mnt 2>/dev/null || true swapoff -a 2>/dev/null || true if command -v dmsetup >/dev/null 2>&1; then while read -r name _; do [[ -n "$name" && "$name" != "No" ]] || continue backing=$(cryptsetup status "$name" 2>/dev/null \ | awk '/^[[:space:]]*device:/ { print $2; exit }') || continue [[ "$backing" == "$drive"* ]] || continue info "closing stale LUKS mapping $name" cryptsetup close "$name" done < <(dmsetup ls --target crypt 2>/dev/null) fi for part in "${drive}"?*; do # unmatched glob stays literal; -b filters it out [[ -b "$part" ]] || continue wipefs -af "$part" >/dev/null done wipefs -af "$drive" >/dev/null sgdisk --zap-all "$drive" >/dev/null # 16 MiB covers LUKS2 headers and the first BTRFS superblock — # wipefs alone misses damaged variants. dd if=/dev/zero of="$drive" bs=1M count=16 conv=fsync status=none partprobe "$drive" 2>/dev/null || true udevadm settle --timeout=30 || info "udevadm settle timed out; continuing." if lsblk -no MOUNTPOINTS "$drive" 2>/dev/null | grep -qE '\S'; then fail "$drive still has active mountpoints after pre-wipe." fi } prewipe "$TARGET_DISK" if [[ $WITH_LUKS == true ]]; then install -m 600 /dev/null "$LUKS_KEY_PATH" trap 'rm -f "$LUKS_KEY_PATH" 2>/dev/null || true' EXIT printf '%s' "$LUKS_PASSPHRASE" > "$LUKS_KEY_PATH" unset LUKS_PASSPHRASE fi disko_log=$(mktemp --suffix=.disko.log) if ! disko --mode destroy,format,mount --yes-wipe-all-disks \ --argstr mainDrive "$TARGET_DISK" \ --arg withLuks "$WITH_LUKS" \ --argstr swapSize "${SWAP_GB}G" \ "$SHARE/disko-config.nix" >"$disko_log" 2>&1; then tail -n 30 "$disko_log" fail "disko failed — full log: $disko_log" fi rm -f "$LUKS_KEY_PATH" "$disko_log" success "Disk partitioned and mounted at /mnt" # Hibernation plumbing: the swapfile's physical offset goes into the # kernel cmdline. Deactivate swap first so nixos-generate-config doesn't # also emit a swapDevices entry (we write our own, with resume wiring). RESUME_CONFIG="" if [[ "$SWAP_GB" != "0" ]]; then swapoff -a 2>/dev/null || true resume_offset=$(btrfs inspect-internal map-swapfile -r /mnt/swap/swapfile) root_uuid=$(findmnt -no UUID /mnt) RESUME_CONFIG=$(cat < "$FLAKE_DIR/home.nix" < "$FLAKE_DIR/flake.nix" </dev/null; then power_lines+=" nomarchy.system.power.thermal.enable = true; # thermald (Intel)" fi POWER_CONFIG=$(cat < "$FLAKE_DIR/system.nix" < /mnt/root/nomarchy-hm-activate.sh < /var/log/nomarchy-hm-preactivate.log 2>&1 export PATH=/run/current-system/sw/bin:\$PATH # Normally pre-built in the live env and copied over; the in-chroot # build (default substituters — the live-side build already proved the # no-network case) is a last-resort fallback. out="$hm_out" if [ -z "\$out" ]; then out=\$(nix --extra-experimental-features "nix-command flakes" \ build --no-link --print-out-paths \ "path:/home/$USERNAME/.nomarchy#homeConfigurations.$USERNAME.activationPackage") fi install -d -o "$USERNAME" -g users /nix/var/nix/profiles/per-user/$USERNAME install -d -o "$USERNAME" -g users /nix/var/nix/gcroots/per-user/$USERNAME # activate's profile ops need store access; as the user that means a # daemon, and the chroot has none — run one for the duration. nix-daemon & daemon_pid=\$! trap 'kill \$daemon_pid 2>/dev/null || true' EXIT sleep 2 # BACKUP_EXT: collisions can't abort the activation (a stray # autogenerated config gets moved aside instead). runuser -u "$USERNAME" -- bash -lc \ "USER=$USERNAME HOME=/home/$USERNAME NIX_REMOTE=daemon HOME_MANAGER_BACKUP_EXT=bak \$out/activate" EOF if nixos-enter --root /mnt -- bash /root/nomarchy-hm-activate.sh; then success "Desktop pre-activated — first boot is fully themed" else warn "Desktop pre-activation failed (see /var/log/nomarchy-hm-preactivate.log" warn "on the installed system); after first login run:" warn " home-manager switch --flake ~/.nomarchy -b bak" tail -n 5 /mnt/var/log/nomarchy-hm-preactivate.log 2>/dev/null || true fi rm -f /mnt/root/nomarchy-hm-activate.sh header "Nomarchy installed on $TARGET_DISK" \ "User: $USERNAME @ $HOSTNAME_" \ "Remove the USB stick when the machine is off." finish="${NOMARCHY_FINISH:-ask}" case "$finish" in reboot) systemctl reboot ;; poweroff) systemctl poweroff ;; none) : ;; *) confirm "Reboot into Nomarchy now?" && systemctl reboot || true ;; esac