From f3707357a6c9e6b3fca77a8bd3846475456b4ead Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Wed, 10 Jun 2026 16:05:29 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20nomarchy-install=20=E2=80=94=20guided?= =?UTF-8?q?=20live-ISO=20installer=20(disko=20+=20mkFlake)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 21 +- docs/TESTING.md | 24 +- flake.nix | 42 ++- hosts/live.nix | 3 +- lib.nix | 31 +- pkgs/nomarchy-install/compose-lock.py | 57 ++++ pkgs/nomarchy-install/default.nix | 71 ++++ pkgs/nomarchy-install/disko-config.nix | 70 ++++ pkgs/nomarchy-install/hardware-db.sh | 177 ++++++++++ pkgs/nomarchy-install/nomarchy-install.sh | 384 ++++++++++++++++++++++ 10 files changed, 862 insertions(+), 18 deletions(-) create mode 100644 pkgs/nomarchy-install/compose-lock.py create mode 100644 pkgs/nomarchy-install/default.nix create mode 100644 pkgs/nomarchy-install/disko-config.nix create mode 100644 pkgs/nomarchy-install/hardware-db.sh create mode 100644 pkgs/nomarchy-install/nomarchy-install.sh diff --git a/README.md b/README.md index 7a48b18..9fe3410 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/TESTING.md b/docs/TESTING.md index aa1608b..7a7868b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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 ` 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`. diff --git a/flake.nix b/flake.nix index 8948555..9afec61 100644 --- a/flake.nix +++ b/flake.nix @@ -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 + ] + ); } ]; }; diff --git a/hosts/live.nix b/hosts/live.nix index de73237..786a0bc 100644 --- a/hosts/live.nix +++ b/hosts/live.nix @@ -64,6 +64,7 @@ The graphical session autologins as '${username}' (no password). Theme switching: nomarchy-theme-sync apply (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\"'" ]; }; }; diff --git a/lib.nix b/lib.nix index ff49439..674ad95 100644 --- a/lib.nix +++ b/lib.nix @@ -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 diff --git a/pkgs/nomarchy-install/compose-lock.py b/pkgs/nomarchy-install/compose-lock.py new file mode 100644 index 0000000..71f6199 --- /dev/null +++ b/pkgs/nomarchy-install/compose-lock.py @@ -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 + +where / 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() diff --git a/pkgs/nomarchy-install/default.nix b/pkgs/nomarchy-install/default.nix new file mode 100644 index 0000000..26e8c96 --- /dev/null +++ b/pkgs/nomarchy-install/default.nix @@ -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; + }; +} diff --git a/pkgs/nomarchy-install/disko-config.nix b/pkgs/nomarchy-install/disko-config.nix new file mode 100644 index 0000000..04502ca --- /dev/null +++ b/pkgs/nomarchy-install/disko-config.nix @@ -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; + }; + }; + }; + }; +} diff --git a/pkgs/nomarchy-install/hardware-db.sh b/pkgs/nomarchy-install/hardware-db.sh new file mode 100644 index 0000000..d34d01f --- /dev/null +++ b/pkgs/nomarchy-install/hardware-db.sh @@ -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 +# DETAIL +# 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 )) +} diff --git a/pkgs/nomarchy-install/nomarchy-install.sh b/pkgs/nomarchy-install/nomarchy-install.sh new file mode 100644 index 0000000..bf655e5 --- /dev/null +++ b/pkgs/nomarchy-install/nomarchy-install.sh @@ -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//.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 — 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" < "$FLAKE_DIR/system.nix" </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 <