- Pre-activation failure ("desktop pre-activation failed"): the in-chroot
HM build hard-coded `--option substituters ""`, which only works on
OFFLINE installs (where the ISO store is copied in). Online installs
(now common — wifi works) had no pins and built from source. Fix: build
the generation in the live env (its store always has the pins), nix copy
the closure to the target, chroot only activates. In-chroot build kept
as fallback. Uses path: (not git+file:) to sidestep libgit2 ownership.
- Read-only home.nix: templates come out of the store mode 0444 and cp
preserves it — chmod -R u+w after staging so the user can edit it.
- Keyboard + locale selection: gum prompts (localectl layouts/variants +
curated UTF-8 locale list) and NOMARCHY_LOCALE/KB_LAYOUT/KB_VARIANT for
unattended. One choice flows everywhere — services.xserver.xkb (source
of truth) + console.useXkbConfig (tty/greeter) + boot.initrd.systemd
(so the LUKS passphrase prompt uses the layout; script initrd can't) +
the generated home.nix's nomarchy.keyboard.* for the Hyprland session.
- test-install.sh: NOMARCHY_OVMF env override for sandboxed OVMF paths.
Verified end-to-end in QEMU: offline LUKS install completes, boots to a
themed desktop (no autogenerated-config banner), Plymouth renders the LUKS
prompt on the theme-tinted background.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
600 lines
27 KiB
Bash
600 lines
27 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_HW="auto"|"none"|"mod1 mod2"] [NOMARCHY_FINISH=none|reboot|poweroff]
|
|
# nomarchy-install
|
|
|
|
set -euo pipefail
|
|
|
|
# Baked in by the package wrapper:
|
|
# NOMARCHY_INSTALL_SHARE — disko-config.nix, hardware-db.sh,
|
|
# compose-lock.py, flake.lock, template/,
|
|
# hardware-modules.txt
|
|
# NOMARCHY_FLAKE_URL — flake URL written into the generated flake.nix
|
|
# NOMARCHY_REV — nomarchy rev this ISO was built from ("" if dirty)
|
|
# NOMARCHY_LOCKED_JSON / NOMARCHY_ORIGINAL_JSON — flake.lock node fields
|
|
SHARE="${NOMARCHY_INSTALL_SHARE:?not run via the packaged wrapper}"
|
|
|
|
UNATTENDED="${NOMARCHY_UNATTENDED:-0}"
|
|
LUKS_KEY_PATH="/tmp/nomarchy-luks.key"
|
|
|
|
# ─── UI helpers ─────────────────────────────────────────────────────────
|
|
header() { gum style --border rounded --padding "0 2" --margin "1 0" \
|
|
--border-foreground 212 "$@"; }
|
|
section() { gum style --foreground 212 --bold "── $* ──"; }
|
|
info() { gum style --foreground 245 " $*"; }
|
|
success() { gum style --foreground 42 " ✓ $*"; }
|
|
warn() { gum style --foreground 214 " ⚠ $*"; }
|
|
fail() { gum style --foreground 9 " ✗ $*"; exit 1; }
|
|
|
|
confirm() { # confirm <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
|
|
|
|
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=()
|
|
if ! timeout 3 bash -c '</dev/tcp/cache.nixos.org/443' 2>/dev/null; then
|
|
info "No network — substituting from the ISO store only."
|
|
NIX_CONFIG+=$'\nsubstituters =\nextra-substituters = daemon?trusted=1\nbuilders ='
|
|
# Must ALSO go through nixos-install as a flag: it passes its own
|
|
# --extra-substituters "auto?trusted=1", and flags override the env
|
|
# config for the same setting — without this the in-target build
|
|
# substitutes from itself (= nothing) and bootstraps gcc from source.
|
|
NIXOS_INSTALL_OPTS+=(--substituters "daemon?trusted=1")
|
|
fi
|
|
|
|
# ─── Disk selection ─────────────────────────────────────────────────────
|
|
section "Target disk"
|
|
|
|
# Never offer the medium we're running from.
|
|
live_disk=""
|
|
live_src=$(findmnt -no SOURCE /iso 2>/dev/null || true)
|
|
[[ -n "$live_src" ]] && live_disk=$(lsblk -no PKNAME "$live_src" 2>/dev/null || true)
|
|
|
|
mapfile -t disks < <(lsblk -dpno NAME,SIZE,MODEL,TYPE \
|
|
| awk -v skip="/dev/${live_disk:-NONE}" \
|
|
'$NF == "disk" && $1 != skip && $1 !~ /loop|zram|sr[0-9]/ {NF--; print}')
|
|
[[ ${#disks[@]} -gt 0 ]] || fail "No installable disks found."
|
|
|
|
if [[ "$UNATTENDED" == "1" ]]; then
|
|
TARGET_DISK="${NOMARCHY_DISK:?NOMARCHY_DISK required in unattended mode}"
|
|
else
|
|
choice=$(printf '%s\n' "${disks[@]}" \
|
|
| gum choose --header "Install Nomarchy on which disk? (EVERYTHING on it will be erased)")
|
|
TARGET_DISK="${choice%% *}"
|
|
fi
|
|
[[ -b "$TARGET_DISK" ]] || fail "$TARGET_DISK is not a block device."
|
|
info "Target: $TARGET_DISK"
|
|
|
|
# ─── Encryption ─────────────────────────────────────────────────────────
|
|
section "Disk encryption"
|
|
|
|
# LUKS is the default: full-disk encryption, and in exchange the machine
|
|
# logs you straight into the desktop (the passphrase already gates access).
|
|
LUKS_PASSPHRASE=""
|
|
if [[ "$UNATTENDED" == "1" ]]; then
|
|
LUKS_PASSPHRASE="${NOMARCHY_LUKS_PASSPHRASE:-}"
|
|
elif gum confirm --default=yes "Encrypt the disk with LUKS? (default — also enables passwordless desktop login)"; then
|
|
while true; do
|
|
p1=$(gum input --password --placeholder "LUKS passphrase (min 8 chars)")
|
|
[[ ${#p1} -ge 8 ]] || { warn "Too short."; continue; }
|
|
p2=$(gum input --password --placeholder "Repeat passphrase")
|
|
[[ "$p1" == "$p2" ]] && { LUKS_PASSPHRASE="$p1"; break; }
|
|
warn "Passphrases don't match."
|
|
done
|
|
fi
|
|
WITH_LUKS=false; [[ -n "$LUKS_PASSPHRASE" ]] && WITH_LUKS=true
|
|
info "Encryption: $([[ $WITH_LUKS == true ]] && echo "LUKS2 (desktop auto-login)" || echo none)"
|
|
|
|
# ─── Swap / hibernation ─────────────────────────────────────────────────
|
|
# A swapfile ≥ RAM on its own BTRFS subvolume makes hibernation possible;
|
|
# the resume offset is wired into the config below.
|
|
ram_gb=$(awk '/MemTotal/ {print int(($2 + 1048575) / 1048576)}' /proc/meminfo)
|
|
if [[ "$UNATTENDED" == "1" ]]; then
|
|
SWAP_GB="${NOMARCHY_SWAP_GB:-$ram_gb}"
|
|
else
|
|
SWAP_GB=$(gum input --value "$ram_gb" \
|
|
--placeholder "swap size in GiB (≥ RAM enables hibernation, 0 = none)")
|
|
fi
|
|
[[ "$SWAP_GB" =~ ^[0-9]+$ ]] || fail "Swap size must be a whole number of GiB."
|
|
info "Swap: $([[ "$SWAP_GB" == "0" ]] && echo none || echo "${SWAP_GB}G swapfile (hibernation-ready)")"
|
|
|
|
# ─── User account ───────────────────────────────────────────────────────
|
|
section "Your account"
|
|
|
|
if [[ "$UNATTENDED" == "1" ]]; then
|
|
USERNAME="${NOMARCHY_USERNAME:?}"
|
|
PASSWORD="${NOMARCHY_PASSWORD:?}"
|
|
HOSTNAME_="${NOMARCHY_HOSTNAME:-nomarchy}"
|
|
TIMEZONE="${NOMARCHY_TIMEZONE:-UTC}"
|
|
LOCALE="${NOMARCHY_LOCALE:-en_US.UTF-8}"
|
|
KB_LAYOUT="${NOMARCHY_KB_LAYOUT:-us}"
|
|
KB_VARIANT="${NOMARCHY_KB_VARIANT:-}"
|
|
else
|
|
while true; do
|
|
USERNAME=$(gum input --placeholder "username (lowercase, e.g. ada)")
|
|
[[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]*$ ]] && break
|
|
warn "Invalid username (lowercase letters, digits, - and _)."
|
|
done
|
|
while true; do
|
|
PASSWORD=$(gum input --password --placeholder "password for $USERNAME")
|
|
[[ -n "$PASSWORD" ]] || { warn "Empty password."; continue; }
|
|
p2=$(gum input --password --placeholder "repeat password")
|
|
[[ "$PASSWORD" == "$p2" ]] && break
|
|
warn "Passwords don't match."
|
|
done
|
|
while true; do
|
|
HOSTNAME_=$(gum input --value "nomarchy" --placeholder "hostname")
|
|
[[ "$HOSTNAME_" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$ ]] && break
|
|
warn "Invalid hostname."
|
|
done
|
|
TIMEZONE=$(timedatectl list-timezones | gum filter --placeholder "timezone (type to search)" || echo UTC)
|
|
# Curated UTF-8 locales: the live system only generates en_US, so
|
|
# `locale -a` can't enumerate what the TARGET could use.
|
|
LOCALE=$(printf '%s\n' \
|
|
en_US en_GB de_DE fr_FR es_ES es_MX pt_PT pt_BR it_IT nl_NL \
|
|
pl_PL ru_RU uk_UA cs_CZ sk_SK sv_SE nb_NO da_DK fi_FI tr_TR \
|
|
el_GR hu_HU ro_RO bg_BG hr_HR sl_SI lt_LT lv_LV et_EE ja_JP \
|
|
ko_KR zh_CN zh_TW ar_EG he_IL hi_IN th_TH vi_VN id_ID \
|
|
| sed 's/$/.UTF-8/' \
|
|
| gum filter --placeholder "language / locale (type to search)" \
|
|
|| echo en_US.UTF-8)
|
|
KB_LAYOUT=$(localectl list-x11-keymap-layouts 2>/dev/null \
|
|
| gum filter --placeholder "keyboard layout (type to search)" \
|
|
|| echo us)
|
|
KB_VARIANT=""
|
|
if [[ "$KB_LAYOUT" != "us" ]] || gum confirm --default=No "Pick a keyboard variant (intl, nodeadkeys, …)?"; then
|
|
KB_VARIANT=$( { echo "(none)"; localectl list-x11-keymap-variants "$KB_LAYOUT" 2>/dev/null; } \
|
|
| gum filter --placeholder "variant for $KB_LAYOUT (pick '(none)' for the default)" \
|
|
|| echo "(none)")
|
|
[[ "$KB_VARIANT" == "(none)" ]] && KB_VARIANT=""
|
|
fi
|
|
fi
|
|
[[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]*$ ]] || fail "Invalid username '$USERNAME'."
|
|
[[ -f "/usr/share/zoneinfo/$TIMEZONE" || -e "/etc/zoneinfo/$TIMEZONE" ]] \
|
|
|| timedatectl list-timezones 2>/dev/null | grep -qx "$TIMEZONE" \
|
|
|| warn "Timezone '$TIMEZONE' not verifiable; continuing."
|
|
HASHED_PASSWORD=$(printf '%s' "$PASSWORD" | mkpasswd -m sha-512 -s)
|
|
unset PASSWORD
|
|
info "User: $USERNAME @ $HOSTNAME_ ($TIMEZONE)"
|
|
info "Locale: $LOCALE · keyboard: $KB_LAYOUT${KB_VARIANT:+ ($KB_VARIANT)}"
|
|
|
|
# ─── Hardware profile ───────────────────────────────────────────────────
|
|
section "Hardware detection"
|
|
|
|
# shellcheck source=hardware-db.sh
|
|
source "$SHARE/hardware-db.sh"
|
|
|
|
HW_PROFILES=()
|
|
hw_mode="${NOMARCHY_HW:-auto}"
|
|
if [[ "$hw_mode" == "none" ]]; then
|
|
info "Hardware profiles skipped."
|
|
elif [[ "$hw_mode" != "auto" && "$UNATTENDED" == "1" ]]; then
|
|
read -ra HW_PROFILES <<< "$hw_mode"
|
|
else
|
|
detection=$(nomarchy_detect_hw || true)
|
|
if [[ -n "$detection" ]]; then
|
|
while IFS= read -r line; do
|
|
case "$line" in
|
|
MODULE\ *) HW_PROFILES+=("${line#MODULE }") ;;
|
|
DETAIL\ *) info "→ ${line#DETAIL }" ;;
|
|
esac
|
|
done <<< "$detection"
|
|
fi
|
|
if [[ "$UNATTENDED" != "1" ]]; then
|
|
if [[ ${#HW_PROFILES[@]} -gt 0 ]] \
|
|
&& ! gum confirm "Use these nixos-hardware profiles: ${HW_PROFILES[*]}?"; then
|
|
HW_PROFILES=()
|
|
picked=$(gum filter --no-limit \
|
|
--placeholder "pick profiles manually (tab to select, enter to finish, esc for none)" \
|
|
< "$SHARE/hardware-modules.txt" || true)
|
|
[[ -n "$picked" ]] && mapfile -t HW_PROFILES <<< "$picked"
|
|
fi
|
|
fi
|
|
fi
|
|
info "Profiles: ${HW_PROFILES[*]:-(none)}"
|
|
|
|
# ─── Review & point of no return ────────────────────────────────────────
|
|
section "Review"
|
|
|
|
gum style --border normal --padding "0 2" \
|
|
"Disk: $TARGET_DISK (WILL BE ERASED)" \
|
|
"Encryption: $([[ $WITH_LUKS == true ]] && echo "LUKS2 + desktop auto-login" || echo none)" \
|
|
"Swap: $([[ "$SWAP_GB" == "0" ]] && echo none || echo "${SWAP_GB}G (hibernation)")" \
|
|
"User: $USERNAME" \
|
|
"Hostname: $HOSTNAME_" \
|
|
"Timezone: $TIMEZONE" \
|
|
"Hardware: ${HW_PROFILES[*]:-none}" \
|
|
"Snapshots: snapper timeline on /" \
|
|
"Source: nomarchy ${NOMARCHY_REV:0:12}${NOMARCHY_REV:+ }$([[ -z "${NOMARCHY_REV:-}" ]] && echo "(dirty tree) ")— pinned into the ISO, no network needed"
|
|
|
|
if [[ "$UNATTENDED" != "1" ]]; then
|
|
typed=$(gum input --placeholder "type the disk name ($(basename "$TARGET_DISK")) to confirm the wipe")
|
|
[[ "$typed" == "$(basename "$TARGET_DISK")" ]] || fail "Confirmation mismatch — aborting, nothing touched."
|
|
fi
|
|
|
|
# ─── Partitioning ───────────────────────────────────────────────────────
|
|
section "Partitioning"
|
|
|
|
# Pre-wipe: disko gates destructive steps on blkid — on a previously
|
|
# installed disk it would overlay the old GPT and skip mkfs on a stale
|
|
# ESP ("wrong fs type, bad superblock" at mount). Wipe first. Teardown
|
|
# order matters: mounts → swap → LUKS mappings → signatures.
|
|
prewipe() {
|
|
local drive="$1" name backing part
|
|
info "Pre-wiping $drive..."
|
|
umount -R /mnt 2>/dev/null || true
|
|
swapoff -a 2>/dev/null || true
|
|
if command -v dmsetup >/dev/null 2>&1; then
|
|
while read -r name _; do
|
|
[[ -n "$name" && "$name" != "No" ]] || continue
|
|
backing=$(cryptsetup status "$name" 2>/dev/null \
|
|
| awk '/^[[:space:]]*device:/ { print $2; exit }') || continue
|
|
[[ "$backing" == "$drive"* ]] || continue
|
|
info "closing stale LUKS mapping $name"
|
|
cryptsetup close "$name"
|
|
done < <(dmsetup ls --target crypt 2>/dev/null)
|
|
fi
|
|
for part in "${drive}"?*; do
|
|
# unmatched glob stays literal; -b filters it out
|
|
[[ -b "$part" ]] || continue
|
|
wipefs -af "$part" >/dev/null
|
|
done
|
|
wipefs -af "$drive" >/dev/null
|
|
sgdisk --zap-all "$drive" >/dev/null
|
|
# 16 MiB covers LUKS2 headers and the first BTRFS superblock —
|
|
# wipefs alone misses damaged variants.
|
|
dd if=/dev/zero of="$drive" bs=1M count=16 conv=fsync status=none
|
|
partprobe "$drive" 2>/dev/null || true
|
|
udevadm settle --timeout=30 || info "udevadm settle timed out; continuing."
|
|
if lsblk -no MOUNTPOINTS "$drive" 2>/dev/null | grep -qE '\S'; then
|
|
fail "$drive still has active mountpoints after pre-wipe."
|
|
fi
|
|
}
|
|
prewipe "$TARGET_DISK"
|
|
|
|
if [[ $WITH_LUKS == true ]]; then
|
|
install -m 600 /dev/null "$LUKS_KEY_PATH"
|
|
trap 'rm -f "$LUKS_KEY_PATH" 2>/dev/null || true' EXIT
|
|
printf '%s' "$LUKS_PASSPHRASE" > "$LUKS_KEY_PATH"
|
|
unset LUKS_PASSPHRASE
|
|
fi
|
|
|
|
disko_log=$(mktemp --suffix=.disko.log)
|
|
if ! disko --mode destroy,format,mount --yes-wipe-all-disks \
|
|
--argstr mainDrive "$TARGET_DISK" \
|
|
--arg withLuks "$WITH_LUKS" \
|
|
--argstr swapSize "${SWAP_GB}G" \
|
|
"$SHARE/disko-config.nix" >"$disko_log" 2>&1; then
|
|
tail -n 30 "$disko_log"
|
|
fail "disko failed — full log: $disko_log"
|
|
fi
|
|
rm -f "$LUKS_KEY_PATH" "$disko_log"
|
|
success "Disk partitioned and mounted at /mnt"
|
|
|
|
# Hibernation plumbing: the swapfile's physical offset goes into the
|
|
# kernel cmdline. Deactivate swap first so nixos-generate-config doesn't
|
|
# also emit a swapDevices entry (we write our own, with resume wiring).
|
|
RESUME_CONFIG=""
|
|
if [[ "$SWAP_GB" != "0" ]]; then
|
|
swapoff -a 2>/dev/null || true
|
|
resume_offset=$(btrfs inspect-internal map-swapfile -r /mnt/swap/swapfile)
|
|
root_uuid=$(findmnt -no UUID /mnt)
|
|
RESUME_CONFIG=$(cat <<NIX
|
|
|
|
# Swapfile (hibernation-ready: resume points into it).
|
|
swapDevices = [{ device = "/swap/swapfile"; }];
|
|
boot.resumeDevice = "/dev/disk/by-uuid/$root_uuid";
|
|
boot.kernelParams = [ "resume_offset=$resume_offset" ];
|
|
NIX
|
|
)
|
|
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
|
|
|
|
cp "$SHARE/template/theme-state.json" "$FLAKE_DIR/"
|
|
|
|
# home.nix is generated (not copied from the template) so the chosen
|
|
# keyboard layout reaches the Hyprland session — standalone HM cannot
|
|
# read system.nix.
|
|
cat > "$FLAKE_DIR/home.nix" <<EOF
|
|
# Your user environment. The Nomarchy desktop (Hyprland, Waybar,
|
|
# Ghostty, theming engine, Stylix) comes from homeModules.nomarchy;
|
|
# tune it via the nomarchy.* options, add your own packages and
|
|
# programs below.
|
|
{ pkgs, ... }:
|
|
|
|
{
|
|
# Keyboard for the desktop session; console + LUKS prompt get the
|
|
# same layout from system.nix (xkb + console.useXkbConfig).
|
|
nomarchy.keyboard.layout = "$KB_LAYOUT";
|
|
nomarchy.keyboard.variant = "$KB_VARIANT";
|
|
|
|
# Examples:
|
|
# nomarchy.terminal = "kitty"; # swap the default terminal
|
|
# nomarchy.waybar.enable = false; # bring your own bar
|
|
# nomarchy.stylix.enable = false; # opt out of GTK/Qt theming
|
|
|
|
home.packages = with pkgs; [
|
|
# firefox
|
|
];
|
|
}
|
|
EOF
|
|
|
|
hw_nix=""
|
|
for p in "${HW_PROFILES[@]:-}"; do
|
|
[[ -n "$p" ]] && hw_nix+=" \"$p\""
|
|
done
|
|
|
|
cat > "$FLAKE_DIR/flake.nix" <<EOF
|
|
{
|
|
description = "$HOSTNAME_ — my Nomarchy machine";
|
|
|
|
# The only input. nixpkgs, home-manager etc. come pinned through it —
|
|
# tested together upstream. Generated by nomarchy-install; your machine
|
|
# lives in system.nix and home.nix, this file is never hand-edited.
|
|
inputs.nomarchy.url = "${NOMARCHY_FLAKE_URL}";
|
|
|
|
outputs = { nomarchy, ... }:
|
|
nomarchy.lib.mkFlake {
|
|
src = ./.;
|
|
username = "$USERNAME";
|
|
hardwareProfile = [$hw_nix ];
|
|
};
|
|
}
|
|
EOF
|
|
|
|
AUTOLOGIN_CONFIG=""
|
|
if [[ $WITH_LUKS == true ]]; then
|
|
AUTOLOGIN_CONFIG=$(cat <<NIX
|
|
|
|
# The LUKS passphrase already gates this machine — skip the second
|
|
# password prompt and boot straight into the desktop.
|
|
nomarchy.system.greeter.autoLogin = "$USERNAME";
|
|
NIX
|
|
)
|
|
fi
|
|
|
|
# initialHashedPassword is safe to template: mkpasswd's alphabet is
|
|
# [a-zA-Z0-9./$] — no Nix string metacharacters.
|
|
cat > "$FLAKE_DIR/system.nix" <<EOF
|
|
# Your machine: hostname, users, services. The distro itself comes from
|
|
# Nomarchy (via flake.nix); override its defaults here with plain NixOS
|
|
# options, or the nomarchy.system.* toggles.
|
|
{ pkgs, username, ... }:
|
|
|
|
{
|
|
boot.loader.systemd-boot.enable = true;
|
|
boot.loader.efi.canTouchEfiVariables = true;
|
|
|
|
networking.hostName = "$HOSTNAME_";
|
|
time.timeZone = "$TIMEZONE";
|
|
i18n.defaultLocale = "$LOCALE";
|
|
|
|
# One keyboard layout everywhere: xkb is the source of truth, the
|
|
# virtual console derives from it (greeter/ttys), and the systemd
|
|
# initrd applies it to the LUKS passphrase prompt too (the classic
|
|
# script initrd cannot). The Hyprland session reads the same layout
|
|
# from nomarchy.keyboard.* in home.nix.
|
|
services.xserver.xkb.layout = "$KB_LAYOUT";
|
|
services.xserver.xkb.variant = "$KB_VARIANT";
|
|
console.useXkbConfig = true;
|
|
boot.initrd.systemd.enable = true;
|
|
|
|
# Your login user — \`username\` flows in from flake.nix automatically.
|
|
users.users.\${username} = {
|
|
isNormalUser = true;
|
|
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
|
|
initialHashedPassword = "$HASHED_PASSWORD";
|
|
};
|
|
$AUTOLOGIN_CONFIG$RESUME_CONFIG
|
|
# Hourly/daily BTRFS timeline snapshots + nixos-rebuild-snap.
|
|
nomarchy.system.snapper.enable = true;
|
|
|
|
system.stateVersion = "26.05";
|
|
}
|
|
EOF
|
|
|
|
# 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).
|
|
if ! python3 "$SHARE/compose-lock.py" "$SHARE/flake.lock" "$FLAKE_DIR/flake.lock" \
|
|
"$NOMARCHY_LOCKED_JSON" "$NOMARCHY_ORIGINAL_JSON"; then
|
|
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 user must own their flake — libgit2 refuses repositories owned by
|
|
# someone else, which breaks `home-manager switch` (and theme switching)
|
|
# outright. The first normal NixOS user is always 1000:users(100); the
|
|
# account doesn't exist in the target yet, so numeric ids it is.
|
|
chown -R 1000:100 "$FLAKE_DIR"
|
|
# The templates come out of the nix store mode 0444 and cp preserves
|
|
# that — without this the user owns home.nix but can't edit it.
|
|
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)"
|
|
|
|
# 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
|
|
# Normally pre-built in the live env and copied over; the in-chroot
|
|
# build (default substituters — the live-side build already proved the
|
|
# no-network case) is a last-resort fallback.
|
|
out="$hm_out"
|
|
if [ -z "\$out" ]; then
|
|
out=\$(nix --extra-experimental-features "nix-command flakes" \
|
|
build --no-link --print-out-paths \
|
|
"path:/home/$USERNAME/.nomarchy#homeConfigurations.$USERNAME.activationPackage")
|
|
fi
|
|
install -d -o "$USERNAME" -g users /nix/var/nix/profiles/per-user/$USERNAME
|
|
install -d -o "$USERNAME" -g users /nix/var/nix/gcroots/per-user/$USERNAME
|
|
# activate's profile ops need store access; as the user that means a
|
|
# daemon, and the chroot has none — run one for the duration.
|
|
nix-daemon &
|
|
daemon_pid=\$!
|
|
trap 'kill \$daemon_pid 2>/dev/null || true' EXIT
|
|
sleep 2
|
|
# BACKUP_EXT: collisions can't abort the activation (a stray
|
|
# autogenerated config gets moved aside instead).
|
|
runuser -u "$USERNAME" -- bash -lc \
|
|
"USER=$USERNAME HOME=/home/$USERNAME NIX_REMOTE=daemon HOME_MANAGER_BACKUP_EXT=bak \$out/activate"
|
|
EOF
|
|
if nixos-enter --root /mnt -- bash /root/nomarchy-hm-activate.sh; then
|
|
success "Desktop pre-activated — first boot is fully themed"
|
|
else
|
|
warn "Desktop pre-activation failed (see /var/log/nomarchy-hm-preactivate.log"
|
|
warn "on the installed system); after first login run:"
|
|
warn " home-manager switch --flake ~/.nomarchy -b bak"
|
|
tail -n 5 /mnt/var/log/nomarchy-hm-preactivate.log 2>/dev/null || true
|
|
fi
|
|
rm -f /mnt/root/nomarchy-hm-activate.sh
|
|
|
|
header "Nomarchy installed on $TARGET_DISK" \
|
|
"User: $USERNAME @ $HOSTNAME_" \
|
|
"Remove the USB stick when the machine is off."
|
|
|
|
finish="${NOMARCHY_FINISH:-ask}"
|
|
case "$finish" in
|
|
reboot) systemctl reboot ;;
|
|
poweroff) systemctl poweroff ;;
|
|
none) : ;;
|
|
*) confirm "Reboot into Nomarchy now?" && systemctl reboot || true ;;
|
|
esac
|