All checks were successful
Check / eval (push) Successful in 3m18s
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.
670 lines
31 KiB
Bash
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
|