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:
384
pkgs/nomarchy-install/nomarchy-install.sh
Normal file
384
pkgs/nomarchy-install/nomarchy-install.sh
Normal 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
|
||||
Reference in New Issue
Block a user