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

@@ -58,7 +58,9 @@ Flat on purpose. Two module trees, one options file each, no hidden layers.
├── hosts/
│ ├── default/ # reference machine (thin: boot, user, hostname)
│ └── live.nix # bootable live ISO (try the distro, no install)
├── pkgs/nomarchy-theme-sync/ # state writer + rebuild dispatcher (Python)
├── pkgs/
│ ├── nomarchy-theme-sync/ # state writer + rebuild dispatcher (Python)
│ └── nomarchy-install/ # live-ISO installer (gum + disko + mkFlake)
├── templates/downstream/ # `nix flake init -t` starter for users
├── docs/TESTING.md # how to verify changes (incl. AI-agent rules)
└── tools/ # maintainer-only
@@ -86,6 +88,17 @@ the locked inputs into the ISO store — so theme switching (including the
`home-manager switch` it triggers) works **offline**, exactly like on an
installed system. Verification checklist: [docs/TESTING.md](docs/TESTING.md).
Like what you see? **`nomarchy-install`** (in a terminal) walks you through
installing to disk: pick a disk, optional LUKS2 full-disk encryption, user +
hostname + timezone, hardware autodetection (DMI → nixos-hardware profile),
then disko partitions (GPT + ESP + BTRFS subvolumes) and `nixos-install`
runs — **without a network** when the ISO was built from a clean tree (the
target's `flake.lock` is composed from the rev the ISO carries). The
installed machine gets the standard downstream layout: the flake at
`~/.nomarchy` (`/etc/nixos` symlinks to it), one `mkFlake` call, your
`system.nix`/`home.nix`. First boot lands in the fully themed desktop —
the installer pre-activates the Home Manager generation. UEFI only for now.
## 3. Using Nomarchy on your machine (downstream)
Nomarchy is consumed as a flake input — you never fork or edit this repo:
@@ -112,7 +125,7 @@ outputs = { nomarchy, ... }:
|---|---|---|
| `src` | — (required) | Your flake directory (`./.`) |
| `username` | — (required) | Login name; flows into `system.nix` and names the HM config |
| `hardwareProfile` | `null` | A [nixos-hardware](https://github.com/NixOS/nixos-hardware) module name (pinned + tested by Nomarchy; unknown names fail with suggestions) |
| `hardwareProfile` | `null` | One [nixos-hardware](https://github.com/NixOS/nixos-hardware) module name, or a list of them (pinned + tested by Nomarchy; unknown names fail with suggestions) |
| `system` | `"x86_64-linux"` | Platform |
(Power users can skip `mkFlake` and wire `nixosModules.nomarchy` /
@@ -216,6 +229,6 @@ workspaces · `Print` region screenshot.
re-copy on every state write is the main eval tax), then pre-built theme
variants if still needed
- Plymouth + SDDM/greeter theming from the same JSON
- Interactive installer on the live ISO (disko BTRFS+LUKS, DMI →
`hardwareProfile` autodetection feeding `mkFlake`) — port from old_distro
- Installer round 2: multi-disk BTRFS RAID, impermanence, BIOS/legacy
boot (v1 `nomarchy-install` is single-disk UEFI — see `pkgs/nomarchy-install`)
- `hyprlock`/`hypridle`, swayosd, launch-or-focus UX scripts

View File

@@ -67,7 +67,27 @@ Work through these in order; each one exercises a different layer.
Items 6 and 8 are the big ones — they prove the all-Home-Manager theming
model end to end.
## 4. VM-specific gotchas
## 4. Testing the installer
The installer has an unattended mode for exactly this. Boot the live ISO in
QEMU **with a second, blank disk attached** (e.g. `-drive
file=target.img,if=virtio,format=raw` on a 20 G image), then in the live
terminal:
```sh
sudo NOMARCHY_UNATTENDED=1 NOMARCHY_DISK=/dev/vda \
NOMARCHY_USERNAME=me NOMARCHY_PASSWORD=test \
NOMARCHY_FINISH=poweroff nomarchy-install
```
Then boot the VM again **from the target disk** (drop `-cdrom`). The
machine must reach the themed desktop on its own — greeter autostart,
wallpaper, Waybar — and `nomarchy-theme-sync apply <x>` must work. For the
offline claim, add `restrict=on` to the `-netdev` and re-run the whole
flow: it must behave identically (requires an ISO built from a clean git
tree, which is what bakes the rev into the installer).
## 5. VM-specific gotchas
- **No KVM** (e.g. nested without acceleration): everything works but
`home-manager switch` will be painfully slow. Don't read slowness as
@@ -79,7 +99,7 @@ model end to end.
- **First theme switch is the slowest**: it evaluates the flake on a RAM
disk. Subsequent switches reuse the eval cache.
## 5. When something fails
## 6. When something fails
- Session/login problems: `journalctl -b -u greetd`, then `journalctl
--user -b`.

View File

@@ -33,6 +33,28 @@
inherit system;
overlays = [ self.overlays.default ];
};
# The guided installer — live-ISO only (not in the overlay): it bakes
# in the downstream template, this checkout's flake.lock, and the
# source identity of this very revision so installs work offline.
gitUrl = "https://git.bemagri.xyz/bernardo/nomarchy.git";
nomarchyInstall = pkgs.callPackage ./pkgs/nomarchy-install {
templateDir = ./templates/downstream;
nomarchyLock = ./flake.lock;
hardwareModuleNames = pkgs.writeText "nixos-hardware-modules.txt"
(nixpkgs.lib.concatStringsSep "\n"
(builtins.attrNames nixos-hardware.nixosModules));
flakeUrl = "git+${gitUrl}";
originalNode = { type = "git"; url = gitUrl; };
lockedNode = {
type = "git";
url = gitUrl;
rev = self.rev or null; # null on a dirty tree → installer needs network
narHash = self.narHash;
lastModified = self.lastModified or 0;
revCount = self.revCount or 0;
};
};
in
{
# ─── Downstream API ────────────────────────────────────────────
@@ -75,6 +97,7 @@
packages.${system} = {
nomarchy-theme-sync = pkgs.nomarchy-theme-sync;
nomarchy-install = nomarchyInstall;
default = pkgs.nomarchy-theme-sync;
};
@@ -163,9 +186,11 @@
};
# The standalone CLI that `nomarchy-theme-sync apply` runs for
# theme switching, from the same pinned input.
# theme switching, from the same pinned input — plus the
# guided installer.
environment.systemPackages = [
home-manager.packages.${system}.home-manager
nomarchyInstall
];
# Keep the locked flake inputs in the ISO store so theme
@@ -179,7 +204,20 @@
[ input.outPath ] ++ builtins.concatMap collectInputs
(builtins.attrValues (input.inputs or { }));
in
collectInputs self;
collectInputs self ++ (
# Pre-build the downstream template's system + desktop so
# nomarchy-install can build the target offline (and fast):
# a custom username/hostname changes only a handful of
# derivations on top of these. No hardware profile — that
# matches a VM install; profiled installs share most of it.
let template = self.lib.mkFlake {
src = ./templates/downstream;
username = username;
}; in [
template.nixosConfigurations.default.config.system.build.toplevel
template.homeConfigurations.${username}.activationPackage
]
);
}
];
};

View File

@@ -64,6 +64,7 @@
The graphical session autologins as '${username}' (no password).
Theme switching: nomarchy-theme-sync apply <name> (or SUPER+T)
Wallpapers: nomarchy-theme-sync bg next (or SUPER+SHIFT+T)
Install to disk: nomarchy-install
The flake lives at ~/.nomarchy.
'';
@@ -76,7 +77,7 @@
# Welcome toast once the session is up (concatenated onto the
# base exec-once list).
exec-once = [
"sh -c 'sleep 3; notify-send -a Nomarchy \"Welcome to Nomarchy\" \"SUPER+Return terminal · SUPER+T themes · SUPER+SHIFT+T wallpaper\"'"
"sh -c 'sleep 3; notify-send -a Nomarchy \"Welcome to Nomarchy\" \"SUPER+Return terminal · SUPER+T themes · install with nomarchy-install\"'"
];
};
};

31
lib.nix
View File

@@ -9,23 +9,28 @@
mkFlake =
{ src # the downstream flake directory (./.)
, username # login name; also the homeConfigurations attr
, hardwareProfile ? null # a nixos-hardware module name, or null
, hardwareProfile ? null # nixos-hardware module name(s): string, list, or null
, system ? "x86_64-linux"
}:
let
inherit (nixpkgs) lib;
hardwareModules =
if hardwareProfile == null then
[ ]
else if builtins.hasAttr hardwareProfile nixos-hardware.nixosModules then
[ nixos-hardware.nixosModules.${hardwareProfile} ]
# One profile or several (the installer's autodetection emits a few
# common-* modules alongside the model-specific one).
profileNames =
if hardwareProfile == null then [ ]
else if builtins.isList hardwareProfile then hardwareProfile
else [ hardwareProfile ];
lookupProfile = name:
if builtins.hasAttr name nixos-hardware.nixosModules then
nixos-hardware.nixosModules.${name}
else
throw ''
nomarchy.lib.mkFlake: unknown hardwareProfile "${hardwareProfile}".
nomarchy.lib.mkFlake: unknown hardwareProfile "${name}".
${let
close = builtins.filter
(name: lib.strings.levenshteinAtMost 3 name hardwareProfile)
(candidate: lib.strings.levenshteinAtMost 3 candidate name)
(builtins.attrNames nixos-hardware.nixosModules);
in
lib.optionalString (close != [ ])
@@ -37,6 +42,8 @@
Omit hardwareProfile entirely if your machine has no profile.
'';
hardwareModules = map lookupProfile profileNames;
pkgs = import nixpkgs {
inherit system;
overlays = [ nomarchy.overlays.default ];
@@ -49,7 +56,13 @@
inherit system;
specialArgs = { inherit username; };
modules =
[ nomarchy.nixosModules.nomarchy ]
[
nomarchy.nixosModules.nomarchy
# The standalone HM CLI ships with the system so theme switching
# (`nomarchy-theme-sync apply` → `home-manager switch`) works
# out of the box — same pinned input as the desktop modules.
{ environment.systemPackages = [ home-manager.packages.${system}.home-manager ]; }
]
++ hardwareModules
++ [
(src + "/hardware-configuration.nix") # from nixos-generate-config

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