#!/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_LUKS_PASSPHRASE=... | NOMARCHY_NO_LUKS=1] \ # [NOMARCHY_HW="auto"|"none"|"mod1 mod2"] [NOMARCHY_FINISH=none|reboot|poweroff] # nomarchy-install # Unattended encryption is fail-closed: set a passphrase, or explicit # NOMARCHY_NO_LUKS=1 — never silently install cleartext. set -euo pipefail keyboard_layout_catalog() { local rules rules=$(keyboard_rules_file) || return 1 awk ' /^! layout/ { in_section=1; next } /^!/ && in_section { exit } in_section && NF { print $1 } ' "$rules" } keyboard_variant_catalog() { local layout="$1" rules rules=$(keyboard_rules_file) || return 1 awk -v wanted_layout="$layout:" ' /^! variant/ { in_section=1; next } /^!/ && in_section { exit } in_section && $2 == wanted_layout { print $1 } ' "$rules" } keyboard_rules_file() { local rules for rules in "${NOMARCHY_XKB_RULES:-}" \ /run/current-system/sw/share/X11/xkb/rules/base.lst \ /usr/share/X11/xkb/rules/base.lst; do [[ -n "$rules" && -r "$rules" ]] && { echo "$rules"; return 0; } done return 1 } keyboard_layout_name() { local layout="$1" rules rules=$(keyboard_rules_file) || { echo "layout $layout"; return; } awk -v wanted="$layout" ' /^! layout/ { in_section=1; next } /^!/ && in_section { exit } in_section && $1 == wanted { $1=""; sub(/^[[:space:]]+/, ""); print; found=1; exit } END { if (!found) print "layout " wanted } ' "$rules" } keyboard_variant_name() { local layout="$1" variant="$2" rules rules=$(keyboard_rules_file) || { echo "$variant key behaviour"; return; } awk -v wanted_layout="$layout:" -v wanted_variant="$variant" ' /^! variant/ { in_section=1; next } /^!/ && in_section { exit } in_section && $1 == wanted_variant && $2 == wanted_layout { $1=""; $2=""; sub(/^[[:space:]]+/, ""); print; found=1; exit } END { if (!found) print wanted_variant " key behaviour" } ' "$rules" } keyboard_layout_choices() { local layout while IFS= read -r layout; do [[ -n "$layout" ]] || continue printf '%s\t%s\n' "$layout" "$(keyboard_layout_name "$layout")" done < <(keyboard_layout_catalog) } keyboard_variant_choices() { local layout="$1" variant printf '(none)\tStandard keys (no special variant)\n' while IFS= read -r variant; do [[ -n "$variant" ]] || continue printf '%s\t%s\n' "$variant" "$(keyboard_variant_name "$layout" "$variant")" done < <(keyboard_variant_catalog "$layout") } keyboard_catalog_has() { local wanted="$1" candidate while IFS= read -r candidate; do [[ "$candidate" == "$wanted" ]] && return 0 done return 1 } # Common trust boundary for both gum and unattended input. The picker is a # search over these catalogs, never a text field; this second check means even # surprising gum behaviour or a mistyped NOMARCHY_KB_* value cannot become # generated Nix and fail much later during installation. validate_keyboard_choice() { local layout="$1" variant="$2" layouts variants layouts=$(keyboard_layout_catalog) || { echo "Could not read the installed keyboard-layout catalog." >&2 return 2 } if [[ -z "$layouts" ]] || ! keyboard_catalog_has "$layout" <<< "$layouts"; then echo "Unknown keyboard layout '$layout'. Choose an installed layout (NOMARCHY_KB_LAYOUT)." >&2 return 2 fi [[ -z "$variant" ]] && return 0 variants=$(keyboard_variant_catalog "$layout") || true if [[ -z "$variants" ]] || ! keyboard_catalog_has "$variant" <<< "$variants"; then echo "Unknown keyboard variant '$variant' for layout '$layout'. Choose an installed variant or no variant (NOMARCHY_KB_VARIANT)." >&2 return 2 fi } # Small, side-effect-free interface used by the deterministic installer guard. # It deliberately runs before root/live-ISO checks and uses the exact same # validation function as a real install. if [[ "${1:-}" == "--validate-keyboard" ]]; then [[ $# -eq 3 ]] || { echo "usage: nomarchy-install --validate-keyboard " >&2 exit 64 } validate_keyboard_choice "$2" "$3" exit $? fi # 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 # `sudo --preserve-env` (needed to carry the NOMARCHY_* vars) also drags in # the live session user's HOME=/home/nomarchy. Root-run `nix` calls below # would then scribble an eval cache + .nix-defexpr into /home/nomarchy — # and the in-chroot one lands on the TARGET disk as a stray, orphaned # /home/nomarchy (no such user on the installed system). Pin root's own # HOME so every root nix invocation stays in /root; the user activation # sets HOME=/home/$USERNAME explicitly and is unaffected. export HOME=/root 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=() OFFLINE=false if ! timeout 3 bash -c '/dev/null; then OFFLINE=true 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" # Single whole-disk install only (no dual-boot path) — if the chosen disk # already carries a recognizable OS/filesystem signature, call it out # explicitly before the pre-wipe below destroys it. existing_sig="$(lsblk -no FSTYPE,LABEL "$TARGET_DISK" 2>/dev/null || true blkid "$TARGET_DISK"* 2>/dev/null || true)" if grep -qiE 'ntfs|bitlocker|microsoft|crypto_luks' <<< "$existing_sig"; then warn "$TARGET_DISK has existing data (Windows/BitLocker/NTFS or LUKS) — it will be destroyed." fi # ─── 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 # Fail-closed: unattended without a passphrase used to install # cleartext (easy CI footgun). Require an explicit opt-out. if [[ "${NOMARCHY_NO_LUKS:-}" == "1" ]]; then LUKS_PASSPHRASE="" elif [[ -n "${NOMARCHY_LUKS_PASSPHRASE:-}" ]]; then LUKS_PASSPHRASE="$NOMARCHY_LUKS_PASSPHRASE" else fail "Unattended install needs NOMARCHY_LUKS_PASSPHRASE or NOMARCHY_NO_LUKS=1" fi 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. section "Swap & hibernation" ram_gb=$(awk '/MemTotal/ {print int(($2 + 1048575) / 1048576)}' /proc/meminfo) if [[ "$UNATTENDED" == "1" ]]; then SWAP_GB="${NOMARCHY_SWAP_GB:-$ram_gb}" else info "A swapfile sized ≥ RAM lets this machine hibernate (suspend to disk)." info "Default = ${ram_gb} GiB (this machine's RAM). Enter 0 for no swap (disables hibernation)." SWAP_GB=$(gum input --value "$ram_gb" \ --header "Swap size in GiB (default ${ram_gb} = RAM, 0 = no swap)" \ --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 (hibernation disabled)" || echo "${SWAP_GB} GiB 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 (min 8 chars)") [[ ${#PASSWORD} -ge 8 ]] || { warn "Too short (min 8 chars)."; 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) keyboard_layouts=$(keyboard_layout_catalog) || \ fail "Could not read the installed keyboard-layout catalog." [[ -n "$keyboard_layouts" ]] || \ fail "The installed keyboard-layout catalog is empty." if gum confirm --default=yes "Use the standard US English keyboard (no special variant)?"; then KB_LAYOUT="us" KB_VARIANT="" else while true; do if keyboard_pick=$(keyboard_layout_choices \ | gum filter --strict \ --placeholder "keyboard layout — type to search installed choices"); then KB_LAYOUT="${keyboard_pick%%$'\t'*}" if validate_keyboard_choice "$KB_LAYOUT" "" 2>/dev/null; then break fi fi warn "Choose one of the listed keyboard layouts; typed text is search only." done while true; do if keyboard_pick=$( \ keyboard_variant_choices "$KB_LAYOUT" \ | gum filter --strict \ --placeholder "key behaviour for $KB_LAYOUT — '(none)' means the standard keys" \ ); then KB_VARIANT="${keyboard_pick%%$'\t'*}" if [[ "$KB_VARIANT" == "(none)" ]] \ || validate_keyboard_choice "$KB_LAYOUT" "$KB_VARIANT" 2>/dev/null; then break fi fi warn "Choose a listed key behaviour, or '(none)' for the standard keys; typed text is search only." done fi fi # `(none)` is gum's display-only sentinel, never an XKB variant. Keep the # normalization at the common boundary so interactive and unattended installs # cannot write it into either generated Nix file. if [[ "$KB_VARIANT" == "(none)" ]]; then KB_VARIANT="" fi keyboard_error="" if ! keyboard_error=$(validate_keyboard_choice "$KB_LAYOUT" "$KB_VARIANT" 2>&1); then fail "$keyboard_error" 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)" if [[ -n "$KB_VARIANT" ]]; then KEYBOARD_SUMMARY="$(keyboard_layout_name "$KB_LAYOUT") [$KB_LAYOUT] — $(keyboard_variant_name "$KB_LAYOUT" "$KB_VARIANT") [$KB_VARIANT]" else KEYBOARD_SUMMARY="$(keyboard_layout_name "$KB_LAYOUT") [$KB_LAYOUT] — standard keys (no special variant)" fi info "Locale: $LOCALE" info "Keyboard: $KEYBOARD_SUMMARY" # ─── Hardware profile ─────────────────────────────────────────────────── section "Hardware detection" # shellcheck source=hardware-db.sh source "$SHARE/hardware-db.sh" HW_PROFILES=() HW_NOMARCHY=() # NOMARCHY hardware.* assignments from detection NPU_VENDOR="" # "intel" | "amd" if an NPU was detected (commented opt-in) 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 }") ;; NOMARCHY-NPU\ *) NPU_VENDOR="${line#NOMARCHY-NPU }" ;; NOMARCHY\ *) HW_NOMARCHY+=("${line#NOMARCHY }") ;; 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" # Match the Source line to the same cache.nixos.org probe that sets OFFLINE # (above): offline = ISO store only; online may still hit substituters. if [[ "$OFFLINE" == true ]]; then SOURCE_NET="pinned into the ISO, no network needed" else SOURCE_NET="pinned into the ISO; may use network binary caches" fi 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 (hibernation disabled)" || echo "${SWAP_GB} GiB swapfile (enables hibernation)")" \ "User: $USERNAME" \ "Hostname: $HOSTNAME_" \ "Timezone: $TIMEZONE" \ "Keyboard: $KEYBOARD_SUMMARY" \ "Hardware: ${HW_PROFILES[*]:-none}" \ "Snapshots: snapper timeline on /" \ "Source: nomarchy ${NOMARCHY_REV:0:12}${NOMARCHY_REV:+ }$([[ -z "${NOMARCHY_REV:-}" ]] && echo "(dirty tree) ")— $SOURCE_NET" 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-config treats exact "0" as no-swap; "${SWAP_GB}G" would pass "0G" # and still create a useless @swap subvolume (layout vs resume disagreed). if [[ "$SWAP_GB" == "0" ]]; then DISKO_SWAP_SIZE="0" else DISKO_SWAP_SIZE="${SWAP_GB}G" 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 "$DISKO_SWAP_SIZE" \ "$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 (patched into system.nix). Deactivate swap first so # nixos-generate-config doesn't also emit a swapDevices entry. resume_offset="" root_uuid="" 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) success "Swapfile created (resume offset $resume_offset)" fi # ─── Configuration generation ─────────────────────────────────────────── section "Generating configuration" FLAKE_DIR="/mnt/home/$USERNAME/.nomarchy" mkdir -p "$FLAKE_DIR" nixos-generate-config --root /mnt mv /mnt/etc/nixos/hardware-configuration.nix "$FLAKE_DIR/" rm -rf /mnt/etc/nixos # templates/downstream is the single source of truth (same files as # `nix flake init -t`). Copy, then patch install-time values only. cp "$SHARE/template/flake.nix" \ "$SHARE/template/system.nix" \ "$SHARE/template/home.nix" \ "$SHARE/template/theme-state.json" \ "$FLAKE_DIR/" # Detected hardware → flags for the patcher (safe defaults active). has_intel=false; has_amd=false; has_fp=false intel_guc_off=false; has_camera_ir=false if [[ ${#HW_NOMARCHY[@]} -gt 0 ]]; then for nm in "${HW_NOMARCHY[@]}"; do case "$nm" in hardware.intel.enable=true) has_intel=true ;; hardware.intel.guc=false) intel_guc_off=true ;; hardware.amd.enable=true) has_amd=true ;; hardware.fingerprint.enable=true) has_fp=true ;; hardware.camera.hideIrSensor=true) has_camera_ir=true ;; esac done fi # NVIDIA is a nixos-hardware MODULE only (no nomarchy.hardware.nvidia.*) — # still emit commented plain-NixOS guidance in system.nix (BACKLOG #59). has_nvidia=false [[ " ${HW_PROFILES[*]:-} " == *" common-gpu-nvidia "* ]] && has_nvidia=true is_laptop=false [[ " ${HW_PROFILES[*]:-} " == *" common-pc-laptop "* ]] && is_laptop=true thermald=false [[ $is_laptop == true ]] && grep -q GenuineIntel /proc/cpuinfo 2>/dev/null && thermald=true # JSON for patch-template.py (stdin). Hardware profiles as a JSON array. hw_json="[" first=1 for p in "${HW_PROFILES[@]:-}"; do [[ -z "$p" ]] && continue if [[ $first -eq 1 ]]; then first=0; else hw_json+=","; fi hw_json+=$(printf '%s' "$p" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))') done hw_json+="]" resume_json="null" root_uuid_json="null" if [[ -n "$resume_offset" && "$SWAP_GB" != "0" ]]; then resume_json=$(printf '%s' "$resume_offset" | python3 -c 'import json,sys; print(json.dumps(int(sys.stdin.read().strip())))') root_uuid_json=$(printf '%s' "$root_uuid" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))') fi python3 "$SHARE/patch-template.py" "$FLAKE_DIR" </dev/null 2>&1; then gl_ver=$( { DISPLAY="${DISPLAY:-:0}" WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-}" \ glxinfo -B 2>/dev/null || glxinfo -B 2>/dev/null || true; } \ | sed -n 's/.*OpenGL version string: \([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' \ | head -1 ) fi if [[ -n "$gl_ver" ]]; then gl_major=${gl_ver%%.*} gl_minor=${gl_ver#*.} gl_minor=${gl_minor%%.*} if (( gl_major < 4 || (gl_major == 4 && gl_minor < 3) )); then info "OpenGL $gl_ver < 4.3 — default terminal → kitty (Ghostty needs 4.3)" python3 - "$FLAKE_DIR/theme-state.json" <<'PY' import json, sys path = sys.argv[1] with open(path, encoding="utf-8") as f: state = json.load(f) settings = state.setdefault("settings", {}) # Do not clobber an explicit user/template choice. if not settings.get("terminal"): settings["terminal"] = "kitty" settings["terminalReason"] = "opengl-below-4.3" with open(path, "w", encoding="utf-8") as f: json.dump(state, f, indent=2) f.write("\n") PY else info "OpenGL $gl_ver ≥ 4.3 — keeping Ghostty as the default terminal" fi else info "OpenGL version not probed (glxinfo unavailable or no display) — Ghostty stays default" fi # The flake.lock: composed offline — nomarchy is path-locked to the very # source the ISO carries (original stays the forge URL, so a later # `nix flake update` on the installed machine re-resolves normally). # NOMARCHY_TEST_FORCE_COMPOSE_FAIL=1 — unattended harness only (#54 V2): # pretends compose-lock failed so the offline fail-closed arm is exercised # without poisoning the ISO store. compose_ok=0 if [[ "${NOMARCHY_TEST_FORCE_COMPOSE_FAIL:-}" == 1 ]]; then compose_ok=1 elif python3 "$SHARE/compose-lock.py" "$SHARE/flake.lock" "$FLAKE_DIR/flake.lock" \ "$NOMARCHY_LOCKED_JSON" "$NOMARCHY_ORIGINAL_JSON"; then compose_ok=0 else compose_ok=1 fi if (( compose_ok != 0 )); then if [[ "$OFFLINE" == true ]]; then fail "Offline lock composition failed and there is no network to fall back to — cannot finish an offline install." fi warn "Offline lock composition failed — resolving over the network." (cd "$FLAKE_DIR" && nix --extra-experimental-features "nix-command flakes" flake lock) fi # A flake worktree must be git-tracked (theme-state.json especially). ( cd "$FLAKE_DIR" git init -q git add -A git -c user.name="Nomarchy Installer" -c user.email="installer@nomarchy" \ commit -qm "Initial Nomarchy configuration" ) # The templates come out of the nix store mode 0444 and cp preserves # that — without this the user can't edit home.nix after they own it. # Ownership is applied after nixos-install (real uid/gid; see below). chmod -R u+w "$FLAKE_DIR" # /etc/nixos on the installed system points at the user-owned flake. mkdir -p /mnt/etc ln -sfn "/home/$USERNAME/.nomarchy" /mnt/etc/nixos success "Configuration written to ~$USERNAME/.nomarchy" # ─── Install ──────────────────────────────────────────────────────────── section "Installing (this takes a while)" # Seed the target store with the flake source + all inputs so the first # `nomarchy-theme-sync apply` (and the HM pre-activation below) work # before the machine has ever seen a network. Two steps because # `flake archive --to` enforces signatures and locally-evaluated source # paths have none; plain `nix copy` accepts --no-check-sigs. info "Seeding flake inputs into the target store..." # path: (not git+file) — the flake dir is owned by the target user and # root's libgit2 refuses repositories owned by someone else. flake_paths=$(nix --extra-experimental-features "nix-command flakes" \ flake archive --json "path:$FLAKE_DIR" \ | python3 -c ' import json, sys def walk(node): yield node["path"] for child in node.get("inputs", {}).values(): yield from walk(child) print("\n".join(walk(json.load(sys.stdin))))' || true) if [[ -n "$flake_paths" ]]; then # shellcheck disable=SC2086 nix --extra-experimental-features "nix-command flakes" \ copy --no-check-sigs --to "local?root=/mnt" $flake_paths \ || warn "input seeding failed — first rebuild will need network." else warn "flake archive failed — first rebuild will need network." fi # Offline: sidestep substituter plumbing entirely — make every ISO store # path valid in the target store up front. nixos-install's in-target # build then finds all build tools locally and only the per-machine # config derivations are built. (Two earlier attempts to route this # through substituters — env config and the forwarded --substituters # flag — still left the plan building gcc from source.) if [[ ${#NIXOS_INSTALL_OPTS[@]} -gt 0 ]]; then info "Copying the ISO store into the target (offline install)..." nix --extra-experimental-features nix-command \ copy --all --no-check-sigs --to "local?root=/mnt" fi nixos-install --no-root-passwd "${NIXOS_INSTALL_OPTS[@]}" --flake "path:$FLAKE_DIR#default" success "System installed (bootloader in place)" # The user must own their flake — libgit2 refuses repositories owned by # someone else, which breaks `home-manager switch` (and theme switching) # outright. Resolve real uid/gid from the target after nixos-install # created the account (do not hard-code 1000:100 — first free uid or # primary group can differ). USER_UID=$(nixos-enter --root /mnt -- id -u "$USERNAME") \ || fail "Could not resolve uid for install user '$USERNAME' on target" USER_GID=$(nixos-enter --root /mnt -- id -g "$USERNAME") \ || fail "Could not resolve gid for install user '$USERNAME' on target" chown -R "$USER_UID:$USER_GID" "$FLAKE_DIR" # Pre-activate the Home Manager generation so the FIRST boot lands in the # fully themed desktop, not bare Hyprland. Best-effort: a failure here # only costs the user one `home-manager switch` after logging in. section "Baking the desktop" # Build the generation HERE in the live environment, not in the chroot: # the live store always carries the offline pin set, while the target # store only gets the full ISO store on OFFLINE installs — an online # install skips that copy, and the old in-chroot build (substituters # deliberately empty) then tried to compile the world from source. # Building against the live store is the exact path the live session's # own theme switching exercises, online or off. info "Building the desktop generation..." hm_out="" if hm_out=$(nix --extra-experimental-features "nix-command flakes" \ build --no-link --print-out-paths \ --option substituters "" \ "path:$FLAKE_DIR#homeConfigurations.$USERNAME.activationPackage"); then nix --extra-experimental-features "nix-command flakes" \ copy --no-check-sigs --to "local?root=/mnt" "$hm_out" \ || hm_out="" fi [[ -n "$hm_out" ]] || warn "live-side desktop build failed — retrying inside the chroot" # NOT /mnt/tmp: nixos-enter mounts a fresh tmpfs over /tmp inside the # chroot, which silently vaporizes any script staged there (cost us a # full verification round to find). /root persists into the chroot. cat > /mnt/root/nomarchy-hm-activate.sh < /var/log/nomarchy-hm-preactivate.log 2>&1 export PATH=/run/current-system/sw/bin:\$PATH # Keep root's nix state in /root, not a stray /home/nomarchy on the target. export HOME=/root # 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 # NOMARCHY_TEST_FORCE_HM_FAIL=1 — unattended harness only (#54 V2): take # the failure arm so the durable recovery hint is exercised without a # real activation breakage. hm_activate_ok=0 if [[ "${NOMARCHY_TEST_FORCE_HM_FAIL:-}" == 1 ]]; then hm_activate_ok=1 warn "NOMARCHY_TEST_FORCE_HM_FAIL=1 — skipping real pre-activate (test harness)" elif nixos-enter --root /mnt -- bash /root/nomarchy-hm-activate.sh; then hm_activate_ok=0 else hm_activate_ok=1 fi if (( hm_activate_ok == 0 )); 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 # The live session (and this warning) ends with this install — drop a # durable hint on the TARGET so the fix still surfaces on first login. # Numeric ids from the target account (USER_UID/USER_GID above) — the # name does not exist in the live ISO's passwd. hint_file="/mnt/home/$USERNAME/NOMARCHY-DESKTOP-NOT-THEMED.txt" cat > "$hint_file" <