feat: nomarchy-install — guided live-ISO installer (disko + mkFlake)

Ported from the previous iteration's installer (3bdfc35), adapted to the
mkFlake world. gum TUI: disk pick, optional LUKS2, user/hostname/timezone,
DMI → nixos-hardware autodetection (hardware-db.sh). disko partitions
GPT + 1 GiB ESP + BTRFS subvolumes; the generated machine flake lands at
~user/.nomarchy (one mkFlake call, /etc/nixos symlinks to it) and
nixos-install makes it bootable (UEFI/systemd-boot, v1 single disk).

Offline by construction: the target flake.lock is composed from the rev
the ISO was built from (compose-lock.py), `nix flake archive` seeds the
target store, and the ISO pre-builds the template system + desktop.
First boot lands themed: the HM generation is pre-activated in chroot.

mkFlake grows two things for this: hardwareProfile now also takes a list
(autodetection emits several common-* modules), and installed systems get
the standalone home-manager CLI from the pinned input. Unattended mode
(NOMARCHY_UNATTENDED=1 + env) documented in docs/TESTING.md §4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-06-10 16:05:29 +01:00
parent 8ded92ea63
commit f3707357a6
10 changed files with 862 additions and 18 deletions

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Compose a flake.lock for the freshly installed machine — offline.
The generated downstream flake has exactly one input, `nomarchy`. A normal
`nix flake lock` would fetch it from its forge; on an offline install we
already KNOW the answer: the live ISO was built from a specific nomarchy
rev whose source (and all transitive inputs, via the ISO's pinned store
paths) is on hand. So we write the lock nix would have written:
root ─▶ nomarchy (locked to the baked rev+narHash)
└─▶ every node from nomarchy's own flake.lock, verbatim
Nix resolves locked inputs by narHash against the store before touching
the network, so evaluation on the target works without connectivity.
Usage:
compose-lock.py <nomarchy-flake.lock> <out> <locked-json> <original-json>
where <locked-json>/<original-json> are the flake-lock node fields for the
nomarchy input (any input type — github, git, …), baked at package build.
"""
import json
import sys
def main() -> None:
src_lock, out, locked_json, original_json = sys.argv[1:5]
locked = json.loads(locked_json)
if not locked.get("rev"):
sys.exit("compose-lock: no rev in locked metadata (ISO built from a dirty tree?)")
with open(src_lock) as f:
upstream = json.load(f)
nodes = dict(upstream["nodes"])
# nomarchy's root node describes ITS inputs; that mapping becomes the
# input set of the new `nomarchy` node.
nomarchy_inputs = nodes.pop("root")["inputs"]
if "nomarchy" in nodes:
sys.exit("compose-lock: upstream lock already has a 'nomarchy' node")
nodes["nomarchy"] = {
"inputs": nomarchy_inputs,
"locked": locked,
"original": json.loads(original_json),
}
nodes["root"] = {"inputs": {"nomarchy": "nomarchy"}}
with open(out, "w") as f:
json.dump({"nodes": nodes, "root": "root", "version": 7}, f, indent=2)
f.write("\n")
print(f"compose-lock: wrote {out} (nomarchy @ {locked['rev'][:12]})")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,71 @@
{ lib
, stdenvNoCC
, makeWrapper
, bash
, gum
, disko
, whois # mkpasswd
, git
, python3
, util-linux # lsblk, wipefs, swapoff, lscpu
, gptfdisk # sgdisk
, parted # partprobe
, cryptsetup
, lvm2 # dmsetup
, pciutils # lspci
# Baked metadata — what this installer installs and where it came from.
, templateDir # templates/downstream (home.nix, theme-state.json)
, nomarchyLock # the distro's flake.lock (for offline lock composition)
, hardwareModuleNames # newline-separated nixos-hardware module names
, flakeUrl # flake-style URL written into the generated flake.nix
, lockedNode # attrset: the flake.lock "locked" node for nomarchy
# (rev = null when the ISO was built from a dirty tree)
, originalNode # attrset: the flake.lock "original" node
}:
stdenvNoCC.mkDerivation {
pname = "nomarchy-install";
version = "0.1.0";
src = ./.;
nativeBuildInputs = [ makeWrapper ];
installPhase = ''
runHook preInstall
install -Dm755 nomarchy-install.sh $out/bin/nomarchy-install
patchShebangs $out/bin/nomarchy-install
share=$out/share/nomarchy-install
install -Dm644 disko-config.nix "$share/disko-config.nix"
install -Dm644 hardware-db.sh "$share/hardware-db.sh"
install -Dm644 compose-lock.py "$share/compose-lock.py"
install -Dm644 ${nomarchyLock} "$share/flake.lock"
install -Dm644 ${hardwareModuleNames} "$share/hardware-modules.txt"
mkdir -p "$share/template"
cp ${templateDir}/home.nix ${templateDir}/theme-state.json "$share/template/"
# nixos-install / nixos-generate-config / nixos-enter / nix / systemd
# tools come from the live system on purpose they must match it.
wrapProgram $out/bin/nomarchy-install \
--prefix PATH : ${lib.makeBinPath [
bash gum disko whois git python3
util-linux gptfdisk parted cryptsetup lvm2 pciutils
]} \
--set NOMARCHY_INSTALL_SHARE "$share" \
--set NOMARCHY_FLAKE_URL ${lib.escapeShellArg flakeUrl} \
--set NOMARCHY_REV ${lib.escapeShellArg (toString (lockedNode.rev or ""))} \
--set NOMARCHY_LOCKED_JSON ${lib.escapeShellArg (builtins.toJSON lockedNode)} \
--set NOMARCHY_ORIGINAL_JSON ${lib.escapeShellArg (builtins.toJSON originalNode)}
runHook postInstall
'';
meta = {
description = "Nomarchy guided installer (disko + mkFlake + nixos-install)";
license = lib.licenses.mit;
mainProgram = "nomarchy-install";
platforms = lib.platforms.linux;
};
}

View File

@@ -0,0 +1,70 @@
# Nomarchy installer disk layout — single disk, GPT, 1 GiB ESP, BTRFS
# root with subvolumes, optionally under LUKS2. Invoked by
# nomarchy-install via:
#
# disko --mode destroy,format,mount \
# --argstr mainDrive /dev/nvme0n1 \
# --arg withLuks true \
# disko-config.nix
#
# Ported from the previous iteration's golden-path config (multi-disk
# RAID and the impermanence snapshot dropped for v1 — see git history
# of installer/disko-config.nix at 3bdfc35 when adding them back).
# Function arguments, not sed-templating: escaping shell into Nix
# proved fragile twice before.
{ mainDrive
, withLuks ? true
, ...
}:
let
btrfsMountOptions = [ "compress=zstd" "noatime" ];
rootBtrfs = {
type = "btrfs";
extraArgs = [ "-f" ];
subvolumes = {
"@" = { mountpoint = "/"; mountOptions = btrfsMountOptions; };
"@home" = { mountpoint = "/home"; mountOptions = btrfsMountOptions; };
"@nix" = { mountpoint = "/nix"; mountOptions = btrfsMountOptions; };
"@log" = { mountpoint = "/var/log"; mountOptions = btrfsMountOptions; };
};
};
in
{
disko.devices.disk.main = {
type = "disk";
device = mainDrive;
content = {
type = "gpt";
partitions = {
# 1 GiB ESP — fits several kernel generations + initrd.
ESP = {
priority = 1;
name = "ESP";
start = "1M";
end = "1G";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
root = {
size = "100%";
content =
if withLuks then {
type = "luks";
name = "crypted";
passwordFile = "/tmp/nomarchy-luks.key";
settings.allowDiscards = true;
content = rootBtrfs;
} else
rootBtrfs;
};
};
};
};
}

View File

@@ -0,0 +1,177 @@
# Nomarchy hardware database — sourced by nomarchy-install.
#
# Flat table mapping detected DMI fields to nixos-hardware module names
# (the strings nomarchy.lib.mkFlake's hardwareProfile accepts). Ported
# from the previous iteration (installer/hardware-db.sh at 3bdfc35).
#
# Format (pipe-separated):
# sys_vendor_regex | product_regex | nixos_hardware_module
#
# - Matching is bash `[[ =~ ]]` regex, case-insensitive.
# - First match wins — put more specific entries (e.g. exact model years)
# above broader fallbacks.
#
# To add a new device: grab `/sys/class/dmi/id/product_name` on the
# target machine and add a line. Module names must exist in
# nixos-hardware.nixosModules (mkFlake fails with suggestions if not).
HARDWARE_DB=(
# Framework ---------------------------------------------------------------
# Module names follow nixos-hardware's actual attrs — for Framework 13
# the per-generation modules dropped the "13-" prefix.
"Framework|Laptop 16.*AMD|framework-16-7040-amd"
"Framework|Laptop 16.*Ryzen AI 300|framework-16-amd-ai-300-series"
"Framework|Laptop 13.*Ryzen AI 300|framework-amd-ai-300-series"
"Framework|Laptop 13.*Ryzen 7040|framework-13-7040-amd"
"Framework|Laptop 13.*Core Ultra|framework-intel-core-ultra-series1"
"Framework|Laptop 13.*13th Gen Intel|framework-13th-gen-intel"
"Framework|Laptop 13.*12th Gen Intel|framework-12th-gen-intel"
"Framework|Laptop 13.*11th Gen Intel|framework-11th-gen-intel"
"Framework|Laptop \(13.*|framework"
# Dell XPS / Precision / Latitude ----------------------------------------
"Dell|XPS 15 9500|dell-xps-15-9500"
"Dell|XPS 15 9510|dell-xps-15-9510"
"Dell|XPS 15 9520|dell-xps-15-9520"
"Dell|XPS 13 9310|dell-xps-13-9310"
"Dell|XPS 13 9370|dell-xps-13-9370"
"Dell|XPS 13 9380|dell-xps-13-9380"
"Dell|XPS 13 7390|dell-xps-13-7390"
"Dell|Precision 5530|dell-precision-5530"
"Dell|Latitude 7490|dell-latitude-7490"
"Dell|Latitude 7430|dell-latitude-7430"
"Dell|Latitude 7420|dell-latitude-7420"
# Lenovo ThinkPad --------------------------------------------------------
# X1 Carbon: the per-gen modules are named "x1-Nth-gen", not
# "x1-carbon-genN" — the X1 series IS the Carbon line.
"LENOVO|ThinkPad X1 Carbon Gen 11|lenovo-thinkpad-x1-11th-gen"
"LENOVO|ThinkPad X1 Carbon Gen 10|lenovo-thinkpad-x1-10th-gen"
"LENOVO|ThinkPad X1 Carbon Gen 9|lenovo-thinkpad-x1-9th-gen"
"LENOVO|ThinkPad X1 Carbon Gen 7|lenovo-thinkpad-x1-7th-gen"
"LENOVO|ThinkPad X1 Carbon Gen 6|lenovo-thinkpad-x1-6th-gen"
"LENOVO|ThinkPad X1 Extreme|lenovo-thinkpad-x1-extreme"
"LENOVO|ThinkPad X1 Nano|lenovo-thinkpad-x1-nano-gen1"
"LENOVO|ThinkPad T14 Gen 3|lenovo-thinkpad-t14-amd-gen3"
"LENOVO|ThinkPad T14 Gen 2|lenovo-thinkpad-t14-amd-gen2"
"LENOVO|ThinkPad T14 Gen 1|lenovo-thinkpad-t14-amd-gen1"
"LENOVO|ThinkPad T480|lenovo-thinkpad-t480"
"LENOVO|ThinkPad L13|lenovo-thinkpad-l13"
"LENOVO|ThinkPad P14s.*Gen 5|lenovo-thinkpad-p14s-amd-gen5"
"LENOVO|ThinkPad P14s.*Gen 4|lenovo-thinkpad-p14s-amd-gen4"
"LENOVO|ThinkPad P14s.*Gen 3|lenovo-thinkpad-p14s-amd-gen3"
# Microsoft Surface ------------------------------------------------------
# nixos-hardware ships per-chip modules, not per-revision.
"Microsoft|Surface Pro 10|microsoft-surface-pro-intel"
"Microsoft|Surface Pro 9|microsoft-surface-pro-9"
"Microsoft|Surface Pro 8|microsoft-surface-pro-intel"
"Microsoft|Surface Pro 7|microsoft-surface-pro-intel"
"Microsoft|Surface Pro 6|microsoft-surface-pro-intel"
"Microsoft|Surface Pro 3|microsoft-surface-pro-3"
"Microsoft|Surface Laptop.*Ryzen|microsoft-surface-laptop-amd"
"Microsoft|Surface Go|microsoft-surface-go"
# ASUS ROG Ally / Zephyrus / Strix ---------------------------------------
"ASUS.*|RC71L|asus-ally-rc71l"
"ASUS.*|RC72LA|asus-ally-rc71l"
"ASUS.*|ROG Ally|asus-ally-rc71l"
"ASUS.*|ROG Zephyrus G14.*GA402X|asus-zephyrus-ga402x"
"ASUS.*|ROG Zephyrus G14.*GA402|asus-zephyrus-ga402"
"ASUS.*|ROG Zephyrus G14.*GA401|asus-zephyrus-ga401"
"ASUS.*|ROG Zephyrus G15|asus-zephyrus-ga503"
"ASUS.*|ROG Zephyrus.*GU603|asus-zephyrus-gu603h"
"ASUS.*|ROG Strix G513|asus-rog-strix-g513im"
"ASUS.*|ROG Strix G533|asus-rog-strix-g533zw"
# Apple (T2 Intel; M-series is aarch64 — out of scope) -------------------
"Apple.*|MacBookPro15|apple-t2"
"Apple.*|MacBookPro16|apple-t2"
"Apple.*|MacBookAir8|apple-t2"
"Apple.*|MacBookAir9|apple-t2"
"Apple.*|iMac19|apple-t2"
"Apple.*|iMacPro1|apple-t2"
# System76 ---------------------------------------------------------------
"System76|Oryx Pro.*|system76"
"System76|Lemur Pro.*|system76"
"System76|Darter Pro.*|system76"
"System76|Galago Pro.*|system76"
"System76|Pangolin.*|system76"
# Known-unsupported (so contributors know they're not missing):
# - Valve Steam Deck: Jovian-NixOS territory, not nixos-hardware.
# - Snapdragon X laptops / Raspberry Pi: aarch64; installer is x86_64.
)
# ----------------------------------------------------------------------------
# nomarchy_detect_hw — inspects DMI + lspci + /sys, emits one line per hit:
# MODULE <nixos-hardware module name>
# DETAIL <human-readable explanation>
# Returns 0 if at least one module was detected.
# ----------------------------------------------------------------------------
nomarchy_detect_hw() {
local sys_vendor product_name cpu_vendor
sys_vendor=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null || echo "")
product_name=$(cat /sys/class/dmi/id/product_name 2>/dev/null || echo "")
cpu_vendor=$(lscpu 2>/dev/null | awk -F: '/Vendor ID/{gsub(/ /,"",$2); print $2; exit}')
local detected=0
# CPU
case "$cpu_vendor" in
AuthenticAMD) echo "MODULE common-cpu-amd"; echo "DETAIL cpu: AMD"; detected=1 ;;
GenuineIntel) echo "MODULE common-cpu-intel"; echo "DETAIL cpu: Intel"; detected=1 ;;
esac
# GPU (lspci may list several; report all)
if command -v lspci >/dev/null 2>&1; then
local gpu_line nvidia=0 amdgpu=0 intelgpu=0
while IFS= read -r gpu_line; do
case "$gpu_line" in
*"[10de:"*|*"NVIDIA"*) nvidia=1 ;;
*"[1002:"*|*"AMD/ATI"*|*"Advanced Micro Devices"*) amdgpu=1 ;;
*"[8086:"*|*"Intel Corporation"*) intelgpu=1 ;;
esac
done < <(lspci -nn 2>/dev/null | grep -iE 'vga|3d|display')
(( nvidia )) && { echo "MODULE common-gpu-nvidia"; echo "DETAIL gpu: NVIDIA"; detected=1; }
(( amdgpu )) && { echo "MODULE common-gpu-amd"; echo "DETAIL gpu: AMD"; detected=1; }
(( intelgpu )) && { echo "MODULE common-gpu-intel"; echo "DETAIL gpu: Intel"; detected=1; }
fi
# Chassis
if compgen -G "/sys/class/power_supply/BAT*" >/dev/null; then
echo "MODULE common-pc-laptop"
echo "DETAIL chassis: laptop (battery present)"
else
echo "MODULE common-pc"
echo "DETAIL chassis: desktop"
fi
detected=1
# SSD
local rotational
rotational=$(cat /sys/block/nvme0n1/queue/rotational 2>/dev/null \
|| cat /sys/block/sda/queue/rotational 2>/dev/null || echo "")
if [[ "$rotational" == "0" ]]; then
echo "MODULE common-pc-ssd"
echo "DETAIL storage: SSD"
fi
# Model-specific match from the DB
local entry v_re p_re mod
for entry in "${HARDWARE_DB[@]}"; do
IFS='|' read -r v_re p_re mod <<< "$entry"
shopt -s nocasematch
if [[ "$sys_vendor" =~ $v_re ]] && [[ "$product_name" =~ $p_re ]]; then
shopt -u nocasematch
echo "MODULE $mod"
echo "DETAIL model: $sys_vendor $product_name$mod"
break
fi
shopt -u nocasematch
done
return $(( 1 - detected ))
}

View File

@@ -0,0 +1,384 @@
#!/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_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."
# ─── 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_PASSPHRASE=""
if [[ "$UNATTENDED" == "1" ]]; then
LUKS_PASSPHRASE="${NOMARCHY_LUKS_PASSPHRASE:-}"
elif gum confirm "Encrypt the disk with LUKS? (recommended for laptops)"; 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 || echo none)"
# ─── User account ───────────────────────────────────────────────────────
section "Your account"
if [[ "$UNATTENDED" == "1" ]]; then
USERNAME="${NOMARCHY_USERNAME:?}"
PASSWORD="${NOMARCHY_PASSWORD:?}"
HOSTNAME_="${NOMARCHY_HOSTNAME:-nomarchy}"
TIMEZONE="${NOMARCHY_TIMEZONE:-UTC}"
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)
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)"
# ─── 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 || echo none)" \
"User: $USERNAME" \
"Hostname: $HOSTNAME_" \
"Timezone: $TIMEZONE" \
"Hardware: ${HW_PROFILES[*]:-none}" \
"Offline: $([[ -n "${NOMARCHY_REV:-}" ]] && echo "yes (pinned $NOMARCHY_REV)" || echo "no (will fetch nomarchy from GitHub)")"
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
if compgen -G "${drive}?*" >/dev/null; then
for part in "${drive}"?*; do
[[ -b "$part" ]] && wipefs -af "$part" >/dev/null
done
fi
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" \
"$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"
# ─── 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/home.nix" "$FLAKE_DIR/"
cp "$SHARE/template/theme-state.json" "$FLAKE_DIR/"
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
# 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 = "en_US.UTF-8";
# Your login user — \`username\` flows in from flake.nix automatically.
users.users.\${username} = {
isNormalUser = true;
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
initialHashedPassword = "$HASHED_PASSWORD";
};
system.stateVersion = "26.05";
}
EOF
# The flake.lock: offline when the ISO knows what it was built from,
# otherwise resolve over the network.
if [[ -n "${NOMARCHY_REV:-}" ]]; then
python3 "$SHARE/compose-lock.py" "$SHARE/flake.lock" "$FLAKE_DIR/flake.lock" \
"$NOMARCHY_LOCKED_JSON" "$NOMARCHY_ORIGINAL_JSON"
else
warn "ISO built from a dirty tree — resolving nomarchy from its forge (needs 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"
)
# /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.
info "Seeding flake inputs into the target store..."
nix --extra-experimental-features "nix-command flakes" \
flake archive --to "local?root=/mnt" "$FLAKE_DIR" >/dev/null \
|| warn "flake archive failed — first rebuild will need network."
nixos-install --no-root-passwd --flake "$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"
cat > /mnt/tmp/nomarchy-hm-activate.sh <<EOF
set -e
export PATH=/run/current-system/sw/bin:\$PATH
out=\$(nix --extra-experimental-features "nix-command flakes" \
build --offline --no-link --print-out-paths \
"/home/$USERNAME/.nomarchy#homeConfigurations.$USERNAME.activationPackage")
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
runuser -u "$USERNAME" -- bash -lc "USER=$USERNAME HOME=/home/$USERNAME \$out/activate"
EOF
if nixos-enter --root /mnt -- bash /tmp/nomarchy-hm-activate.sh; then
success "Desktop pre-activated — first boot is fully themed"
else
warn "Desktop pre-activation failed; after first login run:"
warn " home-manager switch --flake ~/.nomarchy"
fi
rm -f /mnt/tmp/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