Files
Nomarchy/pkgs/nomarchy-install/nomarchy-install.sh
Bernardo Magri 9d0abe5422
All checks were successful
Check / eval (push) Successful in 3m18s
fix(install): normalize empty keyboard variants
Treat the picker-only (none) row as an empty XKB variant at the shared input boundary and again in the template patcher. Guard the real downstream output and console keymap, and carry the sentinel through the offline installer VM.

Verified: V0 full flake evaluation plus shell/Python/Nix/diff checks; V1 installer-keyboard, installer-safety, and template-SoT builds; V2 full KVM offline LUKS+swap install and themed first boot. No V3 required.
2026-07-13 15:01:51 +01:00

670 lines
31 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
# 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.
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 (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)
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)")
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
[[ "$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_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 || 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) ")$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