Files
Nomarchy/pkgs/nomarchy-install/nomarchy-install.sh
Bernardo Magri c97ecade63
Some checks failed
Check / eval (push) Has been cancelled
fix(install): label and explain the swap-size prompt
BACKLOG #93 (Acer live pass): the swap field appeared as a bare
prefilled number — gum never renders the placeholder once --value is
set, and the block had no section header, so nothing said what the
value meant. Add a visible "Swap & hibernation" section, two info lines
(default = RAM keeps hibernation possible; 0 = no swap), a labeled
--header on the input, and make the info + Review lines state the
outcome in full ("N GiB swapfile (enables hibernation)" /
"none (hibernation disabled)"). Default behavior unchanged (swap = RAM).

Verified: V2 — interactive KVM UI drive of the built ISO; viewed
10-swap-prompt.png (section + explanations + labeled input) and the
Review box ("Swap: 6 GiB swapfile (enables hibernation)") in
/tmp/nomarchy-v2-swap-93/; the wipe confirmation was aborted, nothing
touched. bash -n + nix flake check --no-build green.

Side-finding recorded on #112: QEMU's /dev/fd0 was listed first by the
disk picker (live repro evidence in the same artifacts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:33:01 +01:00

826 lines
37 KiB
Bash

#!/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/<user>/.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 <layout> <variant>" >&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 <prompt> — 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 <nixpkgs> (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/tcp/cache.nixos.org/443' 2>/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" <<PYJSON
{
"hostname": $(printf '%s' "$HOSTNAME_" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"username": $(printf '%s' "$USERNAME" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"timezone": $(printf '%s' "$TIMEZONE" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"locale": $(printf '%s' "$LOCALE" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"keyboardLayout": $(printf '%s' "$KB_LAYOUT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"keyboardVariant": $(printf '%s' "$KB_VARIANT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"hashedPassword": $(printf '%s' "$HASHED_PASSWORD" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"autoLogin": $([[ $WITH_LUKS == true ]] && echo true || echo false),
"laptop": $is_laptop,
"thermald": $thermald,
"hardwareProfiles": $hw_json,
"hardware": {
"intel": $has_intel,
"intelGucOff": $intel_guc_off,
"amd": $has_amd,
"fingerprint": $has_fp,
"cameraIr": $has_camera_ir,
"nvidia": $has_nvidia,
"npu": $(if [[ -n "$NPU_VENDOR" ]]; then printf '%s' "$NPU_VENDOR" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'; else echo null; fi)
},
"resumeOffset": $resume_json,
"rootUuid": $root_uuid_json
}
PYJSON
# 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 <<EOF
set -ex
exec > /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" <<HINT
Desktop pre-activation failed during install — see
/var/log/nomarchy-hm-preactivate.log for details. Finish it with:
home-manager switch --flake ~/.nomarchy -b bak
(delete this file once done)
HINT
chown "$USER_UID:$USER_GID" "$hint_file"
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