Compare commits

..

23 Commits

Author SHA1 Message Date
Bernardo Magri
a50e9793ea feat(tools): commit the VM test harness + one-command installer regression
Promotes the throwaway harness that verified the installer into tools/:
- tools/vm/qmp.py        QMP keystroke injection / typing / quit
- tools/vm/vncshot.py    GL-safe screenshots via VNC readback (QMP
                         screendump shows "no surface" with virtio-vga-gl)
- tools/vm/gap-analysis.py  drv-graph diff that converged the offline
                         pin set; run it when offline installs build
                         from source
- tools/test-install.sh  the full offline regression: build ISO, boot
                         offline, unattended LUKS+swap install via a
                         config disk (typed long commands drop keys),
                         wait for poweroff, boot the installed disk,
                         screenshot the first boot for visual verdict

docs/TESTING.md §4 now points at the script.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:05:13 +01:00
Bernardo Magri
7b5a22c800 fix(install): run a nix-daemon for the chroot HM activation + backup collisions
The pre-activation log (now persisted) showed activate dying at its
profile operation: as the target user with no daemon in the chroot,
nix can't open the store db ("big-lock: Permission denied"). Run a
temporary nix-daemon inside the chroot and point activate at it via
NIX_REMOTE=daemon. HOME_MANAGER_BACKUP_EXT=bak makes file collisions
(e.g. a stray autogenerated hyprland.conf) move aside instead of
aborting. Verified in the chroot on a real installed disk: HM symlinks
land, collision backed up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:51:28 +01:00
Bernardo Magri
134695806c fix(install): stage the pre-activation script in /root — nixos-enter tmpfs-mounts /tmp
The HM pre-activation never ran: the script was written to /mnt/tmp and
nixos-enter mounts a fresh tmpfs over /tmp inside the chroot, so `bash
/tmp/...` found nothing and the best-effort guard swallowed it. Verified
interactively that every inner step (path: build as root, runuser leg)
works in the chroot — only the staging path was broken. Now staged in
/root, with set -x logging persisted to /var/log/nomarchy-hm-preactivate.log
on the target and the tail echoed into the install transcript on failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:53:21 +01:00
Bernardo Magri
727f21005d fix(install): root-side flake refs must be path:, not git+file
The chown fix made the flake user-owned, and root's libgit2 then refuses
git+file fetches of it ("not owned by current user") — which silently
killed the seeding step under set -e and would equally break
nixos-install --flake. Use path: refs for the live-side archive and the
nixos-install invocation (path flakes read flake.lock, skip libgit2),
and make seeding failure non-fatal as intended.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:39:09 +01:00
Bernardo Magri
1403ea82b2 fix(install): chown the generated flake to the user; harden HM pre-activation
End-to-end VM verification found the installed system perfect (LUKS,
auto-login, swapfile+resume, snapper) except: ~/.nomarchy was root-owned,
so libgit2 refused every `home-manager switch` ("repository not owned by
current user"). chown 1000:users after generation (the account doesn't
exist in the target yet — numeric ids).

The chroot pre-activation now builds via path: instead of git+file —
no libgit2, no ownership sensitivity — and the fallback message suggests
-b bak (a default-config Hyprland session leaves an autogenerated
hyprland.conf in the way otherwise; observed live).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:17:38 +01:00
Bernardo Magri
29818d2fb6 fix(install): offline installs copy the ISO store into the target up front
Substituter plumbing through nixos-install proved unreliable (env config
is overridden by its hardcoded flags; the forwarded --substituters flag
still left the plan building gcc from source). Make every ISO path valid
in the target store with one nix copy --all before nixos-install — the
in-target build then resolves everything locally and only the per-machine
config drvs are built.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:01:49 +01:00
Bernardo Magri
77dba7ca5d fix(install): pass --substituters through nixos-install, not just the env
nixos-install hardcodes --extra-substituters "auto?trusted=1" as a CLI
flag, and flags override NIX_CONFIG for the same setting — so the env
fix never reached the in-target build. nixos-install forwards a
--substituters flag verbatim; use that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:24:00 +01:00
Bernardo Magri
dba349a539 fix(install): substitute from the live daemon store during the target build
nixos-install builds with --store /mnt and passes its own substituter as
"auto?trusted=1", which in that context resolves to the target store
itself — so nothing flowed from the ISO and nix fell back to a
full-source bootstrap (gcc/python/bash tarball fetches → offline death).
Host-store simulation (fresh local?root, substituters cleared) proves
"daemon?trusted=1" makes the whole install substitute + build offline:
1125 paths copied, only the per-machine config drvs built locally.

Also drop --offline from the chroot HM build (that flag disables ALL
substituters; plain empty substituters does what we want without it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:51:30 +01:00
Bernardo Magri
3b0b2872aa fix(install): seed target store via nix copy --no-check-sigs
`nix flake archive --to` rejects locally-evaluated source paths ("lacks
a signature by a trusted key"); enumerate the archive tree as JSON and
copy with --no-check-sigs instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:02:52 +01:00
Bernardo Magri
804d708f87 fix(install): pin the common nixos-hardware profile stacks into the ISO
Round 7 in-VM: autodetection emitted common-cpu-intel, whose nixos-hardware
module pulls the whole Intel graphics stack (intel-media-driver,
compute-runtime, libva, level-zero…) — none of it on the ISO, so the
offline install built graphics drivers from source. The representative
pin now imports the common-* profiles (intel+amd cpu/gpu, laptop, ssd,
pc), covering what the detector emits on ordinary machines.
Model-specific profiles may still need network.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:59:02 +01:00
Bernardo Magri
8da429d611 fix(install): converge the offline pin set via derivation-graph gap analysis
Walking the install-shaped toplevel's drv graph against the ISO's actual
path set (tools/gap analysis) found why offline installs kept building
from source:

- system-path is rebuilt on EVERY machine: buildEnv's package-list order
  follows module-graph order, which any hardware profile reshuffles →
  pin its builders (texinfo, getconf)
- greetd.toml embeds the auto-login user and is generated by remarshal;
  building remarshal offline drags its test closure (matplotlib → ffmpeg
  → x265…, ~1900 drvs) → pin pkgs.remarshal
- second-level tools for the always-rebuilt drvs: kmod.dev +
  nukeReferences (modules-shrunk), xorg.lndir (unit dirs), libxslt.bin
  (dbus config), python3Minimal (etc)
- the representative pin now has a vfat /boot like a real install
  (mtools/dosfstools enter systemPackages via the vfat fs-type gate)

Verified on the host: the remaining offline build set for a foreign
username/hostname/UUID config is 66 trivial config drvs + eval-time file
copies from the pinned nixpkgs tree — zero network. Same check for the
per-username HM activation: 15 drvs, offline-clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:36:46 +01:00
Bernardo Magri
996fdc28c9 revert(install): includeBuildDependencies was a 134 GB hammer
The full system build closure adds ~134 GB to the store and would have
produced an unusable multi-ten-GB ISO. Back to targeted inputDerivation
pins; the still-missing second-level build tools get pinned explicitly
next, from a clean committed-tree gap analysis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:09:22 +01:00
Bernardo Magri
609bb7e7dc fix(install): carry the full system build closure on the ISO
Round 6 still hit a from-source cascade: every per-machine drv (units,
initrd pieces, dbus config, modules-shrunk) needs its own build tools
(lndir, builder.pl, kmod-dev, nuke-refs, libxslt-bin, …), one level
deeper than targeted inputDerivation pins reach. Use the documented
mechanism instead: system.includeBuildDependencies = true on the
representative install pin. Bigger ISO, networkless installs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:41:37 +01:00
Bernardo Magri
ecf94e84ab fix(install): pin build-time deps of per-install drvs; no substituters offline
Offline nixos-install still built from source: the drvs that embed the
user's hostname/username/UUIDs (etc, initrd, toplevel, HM activation)
must be REBUILT, and their builders need tools outside every runtime
closure (etc → python3-minimal, initrd → packing tools). Pin each drv's
inputDerivation into the ISO — the standard nixos-test offline trick.

Also: with no network every substituter query is a DNS retry storm that
ends in a nix goal.cc assertion crash (core dump observed); the installer
now probes connectivity and disables substituters when offline, both on
the live system and inside the nixos-enter HM pre-activation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:05:17 +01:00
Bernardo Magri
c813055578 fix(install): pin the install-shaped system into the ISO, not the bare template
The offline nixos-install failed building from source: the generated
config enables snapper/auto-login/LUKS-initrd/swapfile-resume on a BTRFS
root, none of which the bare template (ext4 placeholder!) pulls in — so
snapper, nixos-rebuild-snap, btrfs-progs-lib and cryptsetup-bin were
absent from the ISO. The pinned system now mirrors the installer's output
(closure-diff verified).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:33:15 +01:00
Bernardo Magri
8881ad3bae fix(install): rewrite follows paths when grafting the upstream lock
Lock follows paths are absolute from the root node; nomarchy's nodes move
one level down in the composed downstream lock, so ["nixpkgs"] must
become ["nomarchy","nixpkgs"] — otherwise every follows dangles
("input 'nomarchy/home-manager/nixpkgs' follows a non-existent input").
Verified: the composed lock now resolves the full input tree with
`nix flake metadata --offline`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:53:11 +01:00
Bernardo Magri
7aba5fa0a8 fix(install): pin a representative disko script into the ISO closure
disko builds its format/mount script at install time; offline, the
script's tool closure (file, which, wrapper hooks) wasn't on the ISO and
nix tried to build it from source — which needs the network. Pre-building
one representative script (luks + swap variant) pins every input; the
user's actual invocation then differs only in the final text-generation
derivation, which builds offline in seconds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:09:08 +01:00
Bernardo Magri
479193b10b fix(install): survive the live ISO offline and its non-interactive bash
Found by running the unattended install in an offline QEMU VM:
- compgen is missing from nixpkgs' non-interactive bash (the package
  shebang) — replaced with plain glob tests in hardware-db and prewipe
- disko's eval resolves <nixpkgs> via NIX_PATH (a dead channel on the
  ISO) and tried channels.nixos.org — export NIX_PATH to the pinned
  nixpkgs source and point the flake registry at an empty baked file
- lock nomarchy by path (the source the ISO carries) instead of
  git+https: git inputs clone even when narHash is known, which kills
  offline installs; `original` keeps the forge URL so a later
  `nix flake update` re-resolves normally. Works from dirty-tree ISOs too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:48:33 +01:00
Bernardo Magri
71614fa6ca feat(install): LUKS by default → auto-login, snapper snapshots, hibernation swapfile
- LUKS2 is the installer default; in exchange the generated config sets
  the new nomarchy.system.greeter.autoLogin (greetd initial_session) —
  the disk passphrase already gates the machine.
- @snapshots subvolume + nomarchy.system.snapper.enable: hourly/daily
  timeline snapshots of / and the nixos-rebuild-snap helper, ported from
  the previous iteration (3bdfc35), guarded to no-op on non-BTRFS roots.
- @swap subvolume with a swapfile sized to RAM by default (disko
  mkswapfile handles NOCOW); the installer computes the resume offset
  and wires boot.resumeDevice + resume_offset for hibernation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:19:14 +01:00
Bernardo Magri
f3707357a6 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>
2026-06-10 16:05:29 +01:00
Bernardo Magri
8ded92ea63 chore: drop renamed package aliases (swww→awww, greetd.tuigreet→tuigreet)
Silences nixpkgs' rename warnings on every eval.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:43:16 +01:00
Bernardo Magri
1f22e996dc feat: nomarchy.lib.mkFlake — one-call downstream wrapper + hardware profiles
Downstream users now own only system.nix and home.nix; their flake.nix is
generated once from the template and never hand-edited. mkFlake (lib.nix)
wires both layers and maps hardwareProfile = "<name>" to nixos-hardware
modules (new input, pinned here, nixpkgs follows ours; unknown names fail
with did-you-mean suggestions). Flake checks evaluate the template through
mkFlake — including a real profile — so drift fails `nix flake check`.

Also from the live-VM testing session: install the home-manager CLI in the
live ISO and pin flake inputs transitively (offline theme switching needs
stylix's own inputs too), plus swww→awww doc updates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:39:21 +01:00
Bernardo Magri
8a0ec763e0 fix: live-VM desktop fixes — Hyprland 0.55 compat + swww→awww rename
Found by booting the live ISO (docs/TESTING.md checklist):
- pin HM configType = "hyprlang" (26.05 stateVersion defaults to the new
  Lua config and renders these settings as broken hl.$mod(...) calls,
  booting Hyprland into emergency mode)
- drop dwindle:pseudotile (removed in Hyprland 0.55; aborts config parse)
- disable splash rendering alongside the logo
- launch awww-daemon and call awww with swww fallback (nixpkgs' swww is
  the renamed awww fork; no `swww` binary exists anymore)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:38:32 +01:00
25 changed files with 2189 additions and 91 deletions

View File

@@ -23,7 +23,7 @@ partial.
│ Stylix → GTK, Qt, cursors, fonts │
└───────────────────────────────────┬──────────────────────────────────────┘
wallpaper via swww (the one runtime piece:
wallpaper via awww (the one runtime piece:
applied post-switch + at session start, `bg next` cycles)
```
@@ -34,6 +34,7 @@ Flat on purpose. Two module trees, one options file each, no hidden layers.
```
.
├── flake.nix # inputs + the downstream API (exports below)
├── lib.nix # nomarchy.lib.mkFlake — one-call downstream wrapper
├── theme-state.json # ★ THE single source of truth (git-tracked!)
├── themes/ # 21 presets: <slug>.json + optional <slug>/ assets
│ ├── nord.json # palette (required, works alone)
@@ -57,12 +58,17 @@ 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
├── import-palettes.py # converts old-distro themes → JSON + assets
── test-live-iso.sh # build the ISO + boot it in QEMU
── test-live-iso.sh # build the ISO + boot it in QEMU
├── test-install.sh # full offline-install regression in QEMU
└── vm/ # headless-VM helpers (QMP keys, VNC shots,
# offline-pin gap analysis)
```
**Rule of thumb:** `modules/` is the distro (reusable, no machine specifics),
@@ -85,6 +91,20 @@ 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, LUKS2 full-disk encryption **by default**
(in exchange the desktop logs in passwordless — the passphrase already
gates the machine), user + hostname + timezone, hardware autodetection
(DMI → nixos-hardware profile), a hibernation-ready swapfile sized to RAM,
then disko partitions (GPT + ESP + BTRFS subvolumes incl. `@snapshots`
snapper timeline snapshots are on) 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:
@@ -94,8 +114,30 @@ mkdir my-machine && cd my-machine
nix flake init -t github:YOUR-USER/nomarchy
```
You own three files: `system.nix`, `home.nix`, and **your own
`theme-state.json`**. Two deliberately separate rebuild paths:
You own two files day-to-day: `system.nix` and `home.nix` (plus
`theme-state.json`, written by the CLI). Your `flake.nix` is set up once —
later by the installer — and never hand-edited; it's a single call:
```nix
outputs = { nomarchy, ... }:
nomarchy.lib.mkFlake {
src = ./.;
username = "me";
hardwareProfile = "framework-13-7040-amd"; # optional, nixos-hardware name
};
```
| `mkFlake` arg | Default | Purpose |
|---|---|---|
| `src` | — (required) | Your flake directory (`./.`) |
| `username` | — (required) | Login name; flows into `system.nix` and names the HM config |
| `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` /
`homeModules.nomarchy` / `overlays.default` by hand — the wrapper is sugar.)
Two deliberately separate rebuild paths:
```sh
sudo nixos-rebuild switch --flake .#default # system: rare
@@ -141,9 +183,10 @@ fonts. No runtime patching means no partial states, and `home-manager
generations` is also your theme history. Waybar even restyles in place: it
re-reads `style.css` when the symlink flips.
The **wallpaper** is the one runtime piece (swww is imperative; nothing in
Nix consumes the path): applied at session start and after every switch via
a tiny activation hook, cycled instantly with `bg next`.
The **wallpaper** is the one runtime piece (awww — nixpkgs' swww — is
imperative; nothing in Nix consumes the path): applied at session start and
after every switch via a tiny activation hook, cycled instantly with
`bg next`.
### Per-theme app assets (`themes/<slug>/`)
@@ -192,6 +235,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, hardware
profiles) — 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

@@ -18,7 +18,9 @@ python3 -m py_compile pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py
`nix flake check` needs a Linux machine (or a Linux builder) since all
outputs are `x86_64-linux`. It catches type errors, missing options, and
bad merges — most breakage stops here.
bad merges — most breakage stops here. It also evaluates the downstream
template through `lib.mkFlake` (including a real nixos-hardware profile),
so template/wrapper drift fails fast too.
## 2. Build and boot the live ISO
@@ -51,7 +53,7 @@ Work through these in order; each one exercises a different layer.
| # | Check | Verifies |
|---|---|---|
| 1 | Boots to Hyprland with Tokyo Night wallpaper visible | greetd autologin, swww, session start |
| 1 | Boots to Hyprland with Tokyo Night wallpaper visible | greetd autologin, awww, session start |
| 2 | Waybar shows at top, themed (blue accent on dark) | HM waybar module, palette baking |
| 3 | `SUPER+Return` opens Ghostty with Tokyo Night colors | terminal default, ANSI palette |
| 4 | `btop` in the terminal is themed | per-theme asset baking |
@@ -65,7 +67,33 @@ 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
One command runs the whole offline regression:
```sh
tools/test-install.sh
```
It builds the ISO, boots it in QEMU **with networking disabled**, runs an
unattended LUKS+swap install onto a blank disk via the installer's
`NOMARCHY_UNATTENDED=1` mode (config delivered on a small vfat disk —
typing long commands into a guest drops keystrokes), waits for the
installer's poweroff, then boots the installed disk, enters the
passphrase, and screenshots the first boot. The machine-checkable parts
(install completes, disk boots) fail the script; the visual verdict —
**themed desktop, no autogenerated-config banner, no login prompt** — is
yours, from `first-boot.png`. The helpers it builds on live in
`tools/vm/` (QMP key injection, GL-safe VNC screenshots) and work for any
manual VM poking; `tools/vm/gap-analysis.py` is the maintainer tool for
diagnosing offline installs that try to build from source.
To test by hand instead, replicate what `tools/test-install.sh` does: the
unattended env it uses is in the script, and the same flow works
interactively from the live terminal. If the desktop comes up unthemed,
read `/var/log/nomarchy-hm-preactivate.log` on the installed system.
## 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
@@ -77,7 +105,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`.

347
flake.lock generated Normal file
View File

@@ -0,0 +1,347 @@
{
"nodes": {
"base16": {
"inputs": {
"fromYaml": "fromYaml"
},
"locked": {
"lastModified": 1755819240,
"narHash": "sha256-qcMhnL7aGAuFuutH4rq9fvAhCpJWVHLcHVZLtPctPlo=",
"owner": "SenchoPens",
"repo": "base16.nix",
"rev": "75ed5e5e3fce37df22e49125181fa37899c3ccd6",
"type": "github"
},
"original": {
"owner": "SenchoPens",
"repo": "base16.nix",
"type": "github"
}
},
"base16-fish": {
"flake": false,
"locked": {
"lastModified": 1765809053,
"narHash": "sha256-XCUQLoLfBJ8saWms2HCIj4NEN+xNsWBlU1NrEPcQG4s=",
"owner": "tomyun",
"repo": "base16-fish",
"rev": "86cbea4dca62e08fb7fd83a70e96472f92574782",
"type": "github"
},
"original": {
"owner": "tomyun",
"repo": "base16-fish",
"rev": "86cbea4dca62e08fb7fd83a70e96472f92574782",
"type": "github"
}
},
"base16-helix": {
"flake": false,
"locked": {
"lastModified": 1776754714,
"narHash": "sha256-E3OAK27smtATTmX45uoTSRsVD+Y+ZiVVfgM/tjpbtYg=",
"owner": "tinted-theming",
"repo": "base16-helix",
"rev": "4d508123037e7851ad36ebf7d9c48b0e9e1eb581",
"type": "github"
},
"original": {
"owner": "tinted-theming",
"repo": "base16-helix",
"type": "github"
}
},
"base16-vim": {
"flake": false,
"locked": {
"lastModified": 1732806396,
"narHash": "sha256-e0bpPySdJf0F68Ndanwm+KWHgQiZ0s7liLhvJSWDNsA=",
"owner": "tinted-theming",
"repo": "base16-vim",
"rev": "577fe8125d74ff456cf942c733a85d769afe58b7",
"type": "github"
},
"original": {
"owner": "tinted-theming",
"repo": "base16-vim",
"rev": "577fe8125d74ff456cf942c733a85d769afe58b7",
"type": "github"
}
},
"firefox-gnome-theme": {
"flake": false,
"locked": {
"lastModified": 1779670703,
"narHash": "sha256-UdfMivNMwCCqQsYDg5pSz8X2IOaOrIZLIIy+Bg3CO2o=",
"owner": "rafaelmardojai",
"repo": "firefox-gnome-theme",
"rev": "942159e73e40bf785816f7f1f5feed9ef3d7c8f9",
"type": "github"
},
"original": {
"owner": "rafaelmardojai",
"repo": "firefox-gnome-theme",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"stylix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"fromYaml": {
"flake": false,
"locked": {
"lastModified": 1731966426,
"narHash": "sha256-lq95WydhbUTWig/JpqiB7oViTcHFP8Lv41IGtayokA8=",
"owner": "SenchoPens",
"repo": "fromYaml",
"rev": "106af9e2f715e2d828df706c386a685698f3223b",
"type": "github"
},
"original": {
"owner": "SenchoPens",
"repo": "fromYaml",
"type": "github"
}
},
"gnome-shell": {
"flake": false,
"locked": {
"lastModified": 1767737596,
"narHash": "sha256-eFujfIUQDgWnSJBablOuG+32hCai192yRdrNHTv0a+s=",
"owner": "GNOME",
"repo": "gnome-shell",
"rev": "ef02db02bf0ff342734d525b5767814770d85b49",
"type": "github"
},
"original": {
"owner": "GNOME",
"repo": "gnome-shell",
"rev": "ef02db02bf0ff342734d525b5767814770d85b49",
"type": "github"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1781063120,
"narHash": "sha256-1UIF/mDJluwJQjmmcZ2j1L2+mjYsefe82QCLj0TYSOg=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "baa46aeb6d02e0ba13de67cd35e3d57aedfacf01",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "release-26.05",
"repo": "home-manager",
"type": "github"
}
},
"nixos-hardware": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1781020964,
"narHash": "sha256-fS7xTi2j2iso5Hj7RNZLv/acDlCT+fgMVkVk40A7Uco=",
"owner": "NixOS",
"repo": "nixos-hardware",
"rev": "32c2cd9e46286c4eced3dc6b613c659126bf3cca",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "master",
"repo": "nixos-hardware",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1780902259,
"narHash": "sha256-q8yYEC5f1mFlQO9RGna4LTc9QrcvWunX6FYp83munkQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "bd0ff2d3eac24699c3664d5966b9ef36f388e2ca",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nur": {
"inputs": {
"flake-parts": [
"stylix",
"flake-parts"
],
"nixpkgs": [
"stylix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1780281641,
"narHash": "sha256-M/+hUKoKbHXpV0xGVfELbN1Ds1aoe3pL5p5/t46YhVo=",
"owner": "nix-community",
"repo": "NUR",
"rev": "30f9ae2f04174de63ba8bcf3580ca90843b28a01",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "NUR",
"type": "github"
}
},
"root": {
"inputs": {
"home-manager": "home-manager",
"nixos-hardware": "nixos-hardware",
"nixpkgs": "nixpkgs",
"stylix": "stylix"
}
},
"stylix": {
"inputs": {
"base16": "base16",
"base16-fish": "base16-fish",
"base16-helix": "base16-helix",
"base16-vim": "base16-vim",
"firefox-gnome-theme": "firefox-gnome-theme",
"flake-parts": "flake-parts",
"gnome-shell": "gnome-shell",
"nixpkgs": [
"nixpkgs"
],
"nur": "nur",
"systems": "systems",
"tinted-kitty": "tinted-kitty",
"tinted-schemes": "tinted-schemes",
"tinted-tmux": "tinted-tmux",
"tinted-zed": "tinted-zed"
},
"locked": {
"lastModified": 1781018772,
"narHash": "sha256-C+cGIUaC6dqfwTbI+BwCd572PbESGA3WYxR1sLTqxkY=",
"owner": "danth",
"repo": "stylix",
"rev": "a378e4c09031fb15a4d65da88aa628f71fc52f6b",
"type": "github"
},
"original": {
"owner": "danth",
"repo": "stylix",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"tinted-kitty": {
"flake": false,
"locked": {
"lastModified": 1735730497,
"narHash": "sha256-4KtB+FiUzIeK/4aHCKce3V9HwRvYaxX+F1edUrfgzb8=",
"owner": "tinted-theming",
"repo": "tinted-kitty",
"rev": "de6f888497f2c6b2279361bfc790f164bfd0f3fa",
"type": "github"
},
"original": {
"owner": "tinted-theming",
"repo": "tinted-kitty",
"type": "github"
}
},
"tinted-schemes": {
"flake": false,
"locked": {
"lastModified": 1777806186,
"narHash": "sha256-PDF0/wObw4nIsSBeXVYLsloXOiphXCgIdsrNcVXguKs=",
"owner": "tinted-theming",
"repo": "schemes",
"rev": "0c94645546f4f3ddac77a1a5fce54eb95bf50795",
"type": "github"
},
"original": {
"owner": "tinted-theming",
"repo": "schemes",
"type": "github"
}
},
"tinted-tmux": {
"flake": false,
"locked": {
"lastModified": 1778379944,
"narHash": "sha256-wPDFzMGSlARlw0Sfsn48Q2+jPSfk6N0Ng6BC/d+7Q24=",
"owner": "tinted-theming",
"repo": "tinted-tmux",
"rev": "fe0203a198690e71a5ff11e08812a4673de3678d",
"type": "github"
},
"original": {
"owner": "tinted-theming",
"repo": "tinted-tmux",
"type": "github"
}
},
"tinted-zed": {
"flake": false,
"locked": {
"lastModified": 1778378178,
"narHash": "sha256-OXPXRIQgGwV77HjYRryOHguh4ALX96jkg+tseLkGgHA=",
"owner": "tinted-theming",
"repo": "base16-zed",
"rev": "9cd816033ff969415b190722cddf134e78a5665f",
"type": "github"
},
"original": {
"owner": "tinted-theming",
"repo": "base16-zed",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

196
flake.nix
View File

@@ -14,9 +14,17 @@
inputs.nixpkgs.follows = "nixpkgs";
inputs.home-manager.follows = "home-manager";
};
# Pinned by Nomarchy so distro + hardware quirks are tested together
# (consumed by lib.mkFlake's hardwareProfile). Its nixpkgs input is
# only used by its own CI — follow ours to keep locks/ISO lean.
nixos-hardware = {
url = "github:NixOS/nixos-hardware/master";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, home-manager, stylix, ... }:
outputs = { self, nixpkgs, home-manager, stylix, nixos-hardware, ... }:
let
system = "x86_64-linux";
username = "nomarchy"; # reference host only; downstream picks its own
@@ -25,6 +33,31 @@
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));
nixpkgsPath = nixpkgs.outPath;
flakeUrl = "git+${gitUrl}";
# Future updates re-resolve from the forge (original); the install
# itself resolves from the ISO store (locked = path) — fully
# offline, even from a dirty-tree ISO.
originalNode = { type = "git"; url = gitUrl; };
lockedNode = {
type = "path";
path = self.outPath;
narHash = self.narHash;
lastModified = self.lastModified or 0;
};
rev = self.rev or null;
};
in
{
# ─── Downstream API ────────────────────────────────────────────
@@ -52,6 +85,14 @@
imports = [ stylix.homeModules.stylix ./modules/home ];
};
# One-call downstream wrapper — see templates/downstream/flake.nix.
# The generated flake is never hand-edited; machines live in
# system.nix / home.nix.
lib = import ./lib.nix {
inherit nixpkgs home-manager nixos-hardware;
nomarchy = self;
};
templates.default = {
path = ./templates/downstream;
description = "Nomarchy machine configuration (downstream flake)";
@@ -59,10 +100,35 @@
packages.${system} = {
nomarchy-theme-sync = pkgs.nomarchy-theme-sync;
nomarchy-install = nomarchyInstall;
default = pkgs.nomarchy-theme-sync;
};
# ─── Checks ────────────────────────────────────────────────────
# `nix flake check` never evaluates templates/, so exercise the
# downstream template through mkFlake directly (this bypasses the
# template's own three-line flake.nix, which only resolves once
# instantiated — everything else is covered here, including the
# hardwareProfile → nixos-hardware mapping).
checks.${system} =
let
downstream = self.lib.mkFlake {
src = ./templates/downstream;
username = "me";
hardwareProfile = "framework-13-7040-amd"; # exercises the mapping
};
in
{
downstream-template-system =
downstream.nixosConfigurations.default.config.system.build.toplevel;
downstream-template-home =
downstream.homeConfigurations.me.activationPackage;
};
# ─── Reference host ────────────────────────────────────────────
# Manually wired rather than via lib.mkFlake: the repo-root
# theme-state.json is load-bearing (live ISO + in-repo CLI dev),
# which doesn't match mkFlake's src-layout convention.
# Deliberately split rebuild paths:
# system → sudo nixos-rebuild switch --flake .#nomarchy (rare)
# desktop → home-manager switch --flake .#nomarchy (no sudo;
@@ -122,15 +188,131 @@
};
};
# The standalone CLI that `nomarchy-theme-sync apply` runs for
# 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
# switching on the live system works without a network: the
# lockfile's narHashes resolve against these paths instead
# of fetching from GitHub.
system.extraDependencies = [
nixpkgs.outPath
home-manager.outPath
stylix.outPath
];
# of fetching from GitHub. Must be *transitive* — evaluating
# the seeded flake also fetches stylix's own inputs.
system.extraDependencies =
let
collectInputs = input:
[ input.outPath ] ++ builtins.concatMap collectInputs
(builtins.attrValues (input.inputs or { }));
in
collectInputs self ++ (
# Pre-build a system shaped like what nomarchy-install
# GENERATES (snapper, auto-login, LUKS initrd, swapfile +
# resume — not the bare template!) plus the template
# desktop, so the offline install never builds packages
# from source: a custom username/hostname then changes
# only a handful of derivations. No hardware profile —
# matches a VM install; profiled installs share most of it.
let
template = self.lib.mkFlake {
src = ./templates/downstream;
username = username;
};
representativeInstall = nixpkgs.lib.nixosSystem {
inherit system;
specialArgs = { inherit username; };
modules = [
self.nixosModules.nomarchy
{ environment.systemPackages = [ home-manager.packages.${system}.home-manager ]; }
# The installer's autodetection emits these on real
# machines (and the intel CPU module pulls the whole
# iGPU stack) — without them an offline install on
# common hardware builds graphics drivers from
# source. Model-specific profiles may still need
# network; the common ground is covered.
nixos-hardware.nixosModules.common-cpu-intel
nixos-hardware.nixosModules.common-cpu-amd
nixos-hardware.nixosModules.common-gpu-amd
nixos-hardware.nixosModules.common-pc-laptop
nixos-hardware.nixosModules.common-pc-ssd
nixos-hardware.nixosModules.common-pc
(./templates/downstream + "/hardware-configuration.nix")
(./templates/downstream + "/system.nix")
({ lib, ... }: {
nomarchy.system.snapper.enable = true;
nomarchy.system.greeter.autoLogin = username;
swapDevices = [{ device = "/swap/swapfile"; }];
boot.resumeDevice = "/dev/disk/by-uuid/00000000-0000-0000-0000-000000000000";
boot.kernelParams = [ "resume_offset=1" ];
boot.initrd.luks.devices.crypted.device = "/dev/disk/by-partlabel/disk-main-root";
# The real install is BTRFS with a vfat ESP — the
# placeholder's bare ext4 would leave fs-gated bits
# out of this pin (snapper via btrfs; mtools and
# dosfstools enter systemPackages via vfat!).
fileSystems."/" = lib.mkForce {
device = "/dev/mapper/crypted";
fsType = "btrfs";
options = [ "subvol=@" "compress=zstd" "noatime" ];
};
fileSystems."/boot" = {
device = "/dev/disk/by-uuid/0000-0000";
fsType = "vfat";
options = [ "umask=0077" ];
};
})
];
};
in [
representativeInstall.config.system.build.toplevel
template.homeConfigurations.${username}.activationPackage
# A real install rebuilds the drvs that embed the
# user's hostname/username/UUIDs (etc, initrd, units,
# toplevel, HM activation). inputDerivation pins each
# one's direct build inputs…
representativeInstall.config.system.build.toplevel.inputDerivation
representativeInstall.config.system.build.etc.inputDerivation
representativeInstall.config.system.build.initialRamdisk.inputDerivation
template.homeConfigurations.${username}.activationPackage.inputDerivation
] ++ [
# …and the second-level build tools those rebuilds need
# (their parents are themselves rebuilt, so parent
# inputDerivations don't reach them): kernel-module
# shrinking, unit-dir assembly, dbus config, /etc.
pkgs.kmod.dev
pkgs.nukeReferences
pkgs.xorg.lndir
pkgs.libxslt.bin
pkgs.python3Minimal
# system-path is rebuilt on EVERY machine: buildEnv's
# package-list order follows module-graph order, and any
# hardware profile/import reshuffles it. Its builder
# wants these.
pkgs.texinfo
pkgs.getconf
# greetd.toml embeds the auto-login user and is generated
# by remarshal — without this pin an offline install
# tries to BUILD remarshal, whose test closure pulls
# matplotlib/ffmpeg/x265 (the ~1900-drv cascade).
pkgs.remarshal
# …and one representative disko script: its tool closure
# (file, which, wrapper hooks, …) isn't otherwise on the
# ISO, and offline nix would try to build it from source.
# The user's actual args produce a different script drv,
# but with identical inputs — built offline in seconds.
(import "${pkgs.disko}/share/disko/cli.nix" {
inherit pkgs;
mode = "destroy,format,mount";
diskoFile = ./pkgs/nomarchy-install/disko-config.nix;
mainDrive = "/dev/vda";
withLuks = true;
swapSize = "2G";
})
]
);
}
];
};

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\"'"
];
};
};

93
lib.nix Normal file
View File

@@ -0,0 +1,93 @@
# Downstream sugar: one call wires a whole machine flake.
# Users own ONLY system.nix, home.nix and theme-state.json; their flake.nix
# is generated once (by `nix flake init -t` or the future installer) and
# never hand-edited. The raw exports (nixosModules/homeModules/overlays)
# in flake.nix remain the escape hatch for power users.
{ nixpkgs, home-manager, nixos-hardware, nomarchy }:
{
mkFlake =
{ src # the downstream flake directory (./.)
, username # login name; also the homeConfigurations attr
, hardwareProfile ? null # nixos-hardware module name(s): string, list, or null
, system ? "x86_64-linux"
}:
let
inherit (nixpkgs) lib;
# 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 "${name}".
${let
close = builtins.filter
(candidate: lib.strings.levenshteinAtMost 3 candidate name)
(builtins.attrNames nixos-hardware.nixosModules);
in
lib.optionalString (close != [ ])
"Did you mean: ${lib.concatStringsSep ", " close}?\n"
}Valid names are the attribute names of nixos-hardware.nixosModules:
https://github.com/NixOS/nixos-hardware
or list them locally with:
nix eval github:NixOS/nixos-hardware#nixosModules --apply builtins.attrNames
Omit hardwareProfile entirely if your machine has no profile.
'';
hardwareModules = map lookupProfile profileNames;
pkgs = import nixpkgs {
inherit system;
overlays = [ nomarchy.overlays.default ];
};
in
{
# System layer — rebuilt rarely:
# sudo nixos-rebuild switch --flake .#default
nixosConfigurations.default = nixpkgs.lib.nixosSystem {
inherit system;
specialArgs = { inherit username; };
modules =
[
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
(src + "/system.nix")
];
};
# Desktop layer — every theme change, no sudo:
# home-manager switch --flake .#<username>
# (`nomarchy-theme-sync apply` runs this for you.)
homeConfigurations.${username} = home-manager.lib.homeManagerConfiguration {
inherit pkgs;
modules = [
nomarchy.homeModules.nomarchy
(src + "/home.nix")
{
# Written by nomarchy-theme-sync; reading it is pure — the
# file is part of the downstream flake's source.
nomarchy.stateFile = src + "/theme-state.json";
home = {
inherit username;
homeDirectory = "/home/${username}";
};
}
];
};
};
}

View File

@@ -17,7 +17,7 @@
home.stateVersion = "26.05";
home.packages = with pkgs; [
swww # wallpaper daemon with animated transitions
awww # wallpaper daemon with animated transitions (the swww fork)
fuzzel # launcher / dmenu (used by the theme picker bind)
libnotify
];

View File

@@ -26,6 +26,11 @@ in
package = null;
portalPackage = null;
# HM defaults stateVersion >= 26.05 to the new Lua config and would
# render these hyprlang-style settings as broken `hl.$mod(...)` Lua
# (Hyprland then boots into emergency mode with no binds).
configType = "hyprlang";
settings = {
"$mod" = "SUPER";
"$terminal" = config.nomarchy.terminal;
@@ -33,9 +38,10 @@ in
monitor = [ ",preferred,auto,1" ];
exec-once = [
"swww-daemon"
# nixpkgs' swww is awww (renamed fork); the binary is awww-daemon.
"awww-daemon"
# Paint the wallpaper as soon as the session is up (waits for
# swww-daemon internally).
# the daemon internally).
"nomarchy-theme-sync wallpaper"
];
@@ -89,13 +95,13 @@ in
touchpad.natural_scroll = true;
};
dwindle = {
pseudotile = true;
preserve_split = true;
};
# dwindle:pseudotile was removed in Hyprland 0.55 (the `pseudo`
# dispatcher still exists); setting it aborts config parsing.
dwindle.preserve_split = true;
misc = {
disable_hyprland_logo = true;
disable_splash_rendering = true;
force_default_wallpaper = 0;
};

View File

@@ -26,9 +26,16 @@ in
services.greetd = lib.mkIf cfg.greeter.enable {
enable = lib.mkDefault true;
settings.default_session = {
command = lib.mkDefault "${pkgs.greetd.tuigreet}/bin/tuigreet --time --remember --cmd Hyprland";
user = "greeter";
settings = {
default_session = {
command = lib.mkDefault "${pkgs.tuigreet}/bin/tuigreet --time --remember --cmd Hyprland";
user = "greeter";
};
# Boot straight into the session once; logout → normal greeter.
initial_session = lib.mkIf (cfg.greeter.autoLogin != null) {
command = "Hyprland";
user = cfg.greeter.autoLogin;
};
};
};
@@ -53,6 +60,24 @@ in
hardware.bluetooth.enable = lib.mkDefault cfg.bluetooth.enable;
services.blueman.enable = lib.mkDefault cfg.bluetooth.enable;
# ── BTRFS timeline snapshots (ported from the previous iteration) ─
# Guarded on the actual filesystem so enabling it on an ext4 machine
# is a clean no-op rather than a failing timer.
services.snapper.configs = lib.mkIf
(cfg.snapper.enable && (config.fileSystems."/".fsType or "") == "btrfs")
{
root = {
SUBVOLUME = "/";
TIMELINE_CREATE = true;
TIMELINE_CLEANUP = true;
TIMELINE_LIMIT_HOURLY = "5";
TIMELINE_LIMIT_DAILY = "7";
TIMELINE_LIMIT_WEEKLY = "0";
TIMELINE_LIMIT_MONTHLY = "0";
TIMELINE_LIMIT_YEARLY = "0";
};
};
# ── Fonts ────────────────────────────────────────────────────────
fonts = {
packages = with pkgs; [
@@ -82,7 +107,21 @@ in
wl-clipboard
grim
slurp
];
] ++ lib.optional (cfg.snapper.enable && (config.fileSystems."/".fsType or "") == "btrfs")
# Snapshot, then rebuild — rollback material for system changes
# (theme changes don't need it; HM generations already roll back).
(pkgs.writeShellScriptBin "nixos-rebuild-snap" ''
if [ "$(id -u)" -ne 0 ]; then
echo "This script must be run as root (use sudo)" >&2
exit 1
fi
echo "Creating pre-rebuild snapshot..."
${pkgs.snapper}/bin/snapper -c root create \
-d "Pre-rebuild $(date +'%Y-%m-%d %H:%M:%S')" \
--cleanup-algorithm number
echo "Rebuilding..."
nixos-rebuild switch --flake /etc/nixos#default "$@"
'');
# ── Nix itself ───────────────────────────────────────────────────
nix = {

View File

@@ -8,7 +8,26 @@
{
options.nomarchy.system = {
greeter.enable = lib.mkEnableOption "the greetd/tuigreet login screen" // { default = true; };
greeter.autoLogin = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "ada";
description = ''
Log this user straight into Hyprland on boot (greetd
initial_session); logging out lands on the normal greeter.
The installer sets it on LUKS-encrypted machines the disk
passphrase already gates access, a second prompt is ceremony.
'';
};
audio.enable = lib.mkEnableOption "the Pipewire audio stack" // { default = true; };
bluetooth.enable = lib.mkEnableOption "Bluetooth support with blueman" // { default = true; };
snapper.enable = lib.mkEnableOption ''
hourly/daily BTRFS timeline snapshots of / via snapper, plus the
`nixos-rebuild-snap` pre-rebuild-snapshot helper. No-op unless the
root filesystem is BTRFS with a /.snapshots subvolume (the installer
creates one)'';
};
}

View File

@@ -0,0 +1,67 @@
#!/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("narHash"):
sys.exit("compose-lock: no narHash in locked metadata")
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")
# Follows paths (list-valued inputs) are absolute from the lock's
# root; the copied nodes now live one level down, so ["nixpkgs"]
# must become ["nomarchy", "nixpkgs"]. Plain string values reference
# node keys directly and stay as they are.
for node in nodes.values():
for name, ref in node.get("inputs", {}).items():
if isinstance(ref, list):
node["inputs"][name] = ["nomarchy"] + ref
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")
pin = locked.get("rev", locked["narHash"])[:12]
print(f"compose-lock: wrote {out} (nomarchy @ {pin})")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,78 @@
{ 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
, btrfs-progs # inspect-internal map-swapfile (hibernation offset)
# 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
, nixpkgsPath # pinned nixpkgs source (NIX_PATH for disko's eval)
, flakeUrl # flake-style URL written into the generated flake.nix
, lockedNode # attrset: the flake.lock "locked" node for nomarchy
# (path-type: resolves from the ISO store, offline)
, originalNode # attrset: the flake.lock "original" node (the forge,
# so `nix flake update` later re-resolves normally)
, rev ? null # informational: rev the ISO was built from
}:
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"
# Empty flake registry: no network lookups for indirect refs.
echo '{"flakes":[],"version":2}' > "$share/registry.json"
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 btrfs-progs
]} \
--set NOMARCHY_INSTALL_SHARE "$share" \
--set NOMARCHY_NIXPKGS ${nixpkgsPath} \
--set NOMARCHY_FLAKE_URL ${lib.escapeShellArg flakeUrl} \
--set NOMARCHY_REV ${lib.escapeShellArg (toString rev)} \
--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,81 @@
# 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 \
# --argstr swapSize 16G \
# 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
, swapSize ? "0" # "0" = no swap; otherwise e.g. "16G" (sized for hibernation)
, ...
}:
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; };
# snapper timeline snapshots (nomarchy.system.snapper.enable)
"@snapshots" = { mountpoint = "/.snapshots"; mountOptions = btrfsMountOptions; };
} // (if swapSize == "0" then { } else {
# Hibernation-ready swapfile on its own subvolume; disko's
# mkswapfile handles the BTRFS NOCOW requirements.
"@swap" = {
mountpoint = "/swap";
swap.swapfile.size = swapSize;
};
});
};
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,179 @@
# 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 (glob test, not compgen — nixpkgs' non-interactive bash
# is built without the completion builtins)
local bats=(/sys/class/power_supply/BAT*)
if [[ -e "${bats[0]}" ]]; 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,511 @@
#!/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_SWAP_GB=N (default: RAM size; 0 = none)] \
# [NOMARCHY_HW="auto"|"none"|"mod1 mod2"] [NOMARCHY_FINISH=none|reboot|poweroff]
# nomarchy-install
set -euo pipefail
# Baked in by the package wrapper:
# NOMARCHY_INSTALL_SHARE — disko-config.nix, hardware-db.sh,
# compose-lock.py, flake.lock, template/,
# hardware-modules.txt
# NOMARCHY_FLAKE_URL — flake URL written into the generated flake.nix
# NOMARCHY_REV — nomarchy rev this ISO was built from ("" if dirty)
# NOMARCHY_LOCKED_JSON / NOMARCHY_ORIGINAL_JSON — flake.lock node fields
SHARE="${NOMARCHY_INSTALL_SHARE:?not run via the packaged wrapper}"
UNATTENDED="${NOMARCHY_UNATTENDED:-0}"
LUKS_KEY_PATH="/tmp/nomarchy-luks.key"
# ─── UI helpers ─────────────────────────────────────────────────────────
header() { gum style --border rounded --padding "0 2" --margin "1 0" \
--border-foreground 212 "$@"; }
section() { gum style --foreground 212 --bold "── $* ──"; }
info() { gum style --foreground 245 " $*"; }
success() { gum style --foreground 42 "$*"; }
warn() { gum style --foreground 214 "$*"; }
fail() { gum style --foreground 9 "$*"; exit 1; }
confirm() { # confirm <prompt> — auto-yes when unattended
[[ "$UNATTENDED" == "1" ]] && return 0
gum confirm "$1"
}
# ─── Environment checks ─────────────────────────────────────────────────
if [[ $EUID -ne 0 ]]; then
exec sudo --preserve-env "$0" "$@"
fi
header "Nomarchy installer" "NixOS, themed and ready to go."
[[ -d /sys/firmware/efi ]] \
|| fail "No UEFI firmware detected. v1 installs systemd-boot and needs UEFI (BIOS/legacy: see roadmap)."
command -v nixos-install >/dev/null \
|| fail "nixos-install not found — run this from the Nomarchy live ISO."
grep -q nomarchy-live /etc/hostname 2>/dev/null \
|| warn "This doesn't look like the Nomarchy live ISO; proceeding anyway."
# Offline-proof nix: disko's eval resolves <nixpkgs> (a dead channel on
# the live ISO) and flake commands may consult the global registry —
# point both at what the ISO already carries.
export NIX_PATH="nixpkgs=$NOMARCHY_NIXPKGS"
export NIX_CONFIG="flake-registry = $SHARE/registry.json"
# With no network, every substituter query is a DNS timeout + retry storm
# (and tickles a nix goal.cc assertion crash). Everything an install needs
# is pinned into the ISO — drop the binary caches and substitute from the
# live store's daemon instead. The explicit daemon substituter matters:
# nixos-install builds with --store /mnt and its own "auto?trusted=1"
# substituter resolves to the TARGET store (i.e. itself), so without
# this nothing flows from the ISO and nix bootstraps gcc from source.
NIXOS_INSTALL_OPTS=()
if ! timeout 3 bash -c '</dev/tcp/cache.nixos.org/443' 2>/dev/null; then
info "No network — substituting from the ISO store only."
NIX_CONFIG+=$'\nsubstituters =\nextra-substituters = daemon?trusted=1\nbuilders ='
# Must ALSO go through nixos-install as a flag: it passes its own
# --extra-substituters "auto?trusted=1", and flags override the env
# config for the same setting — without this the in-target build
# substitutes from itself (= nothing) and bootstraps gcc from source.
NIXOS_INSTALL_OPTS+=(--substituters "daemon?trusted=1")
fi
# ─── Disk selection ─────────────────────────────────────────────────────
section "Target disk"
# Never offer the medium we're running from.
live_disk=""
live_src=$(findmnt -no SOURCE /iso 2>/dev/null || true)
[[ -n "$live_src" ]] && live_disk=$(lsblk -no PKNAME "$live_src" 2>/dev/null || true)
mapfile -t disks < <(lsblk -dpno NAME,SIZE,MODEL,TYPE \
| awk -v skip="/dev/${live_disk:-NONE}" \
'$NF == "disk" && $1 != skip && $1 !~ /loop|zram|sr[0-9]/ {NF--; print}')
[[ ${#disks[@]} -gt 0 ]] || fail "No installable disks found."
if [[ "$UNATTENDED" == "1" ]]; then
TARGET_DISK="${NOMARCHY_DISK:?NOMARCHY_DISK required in unattended mode}"
else
choice=$(printf '%s\n' "${disks[@]}" \
| gum choose --header "Install Nomarchy on which disk? (EVERYTHING on it will be erased)")
TARGET_DISK="${choice%% *}"
fi
[[ -b "$TARGET_DISK" ]] || fail "$TARGET_DISK is not a block device."
info "Target: $TARGET_DISK"
# ─── Encryption ─────────────────────────────────────────────────────────
section "Disk encryption"
# LUKS is the default: full-disk encryption, and in exchange the machine
# logs you straight into the desktop (the passphrase already gates access).
LUKS_PASSPHRASE=""
if [[ "$UNATTENDED" == "1" ]]; then
LUKS_PASSPHRASE="${NOMARCHY_LUKS_PASSPHRASE:-}"
elif gum confirm --default=yes "Encrypt the disk with LUKS? (default — also enables passwordless desktop login)"; then
while true; do
p1=$(gum input --password --placeholder "LUKS passphrase (min 8 chars)")
[[ ${#p1} -ge 8 ]] || { warn "Too short."; continue; }
p2=$(gum input --password --placeholder "Repeat passphrase")
[[ "$p1" == "$p2" ]] && { LUKS_PASSPHRASE="$p1"; break; }
warn "Passphrases don't match."
done
fi
WITH_LUKS=false; [[ -n "$LUKS_PASSPHRASE" ]] && WITH_LUKS=true
info "Encryption: $([[ $WITH_LUKS == true ]] && echo "LUKS2 (desktop auto-login)" || echo none)"
# ─── Swap / hibernation ─────────────────────────────────────────────────
# A swapfile ≥ RAM on its own BTRFS subvolume makes hibernation possible;
# the resume offset is wired into the config below.
ram_gb=$(awk '/MemTotal/ {print int(($2 + 1048575) / 1048576)}' /proc/meminfo)
if [[ "$UNATTENDED" == "1" ]]; then
SWAP_GB="${NOMARCHY_SWAP_GB:-$ram_gb}"
else
SWAP_GB=$(gum input --value "$ram_gb" \
--placeholder "swap size in GiB (≥ RAM enables hibernation, 0 = none)")
fi
[[ "$SWAP_GB" =~ ^[0-9]+$ ]] || fail "Swap size must be a whole number of GiB."
info "Swap: $([[ "$SWAP_GB" == "0" ]] && echo none || echo "${SWAP_GB}G swapfile (hibernation-ready)")"
# ─── User account ───────────────────────────────────────────────────────
section "Your account"
if [[ "$UNATTENDED" == "1" ]]; then
USERNAME="${NOMARCHY_USERNAME:?}"
PASSWORD="${NOMARCHY_PASSWORD:?}"
HOSTNAME_="${NOMARCHY_HOSTNAME:-nomarchy}"
TIMEZONE="${NOMARCHY_TIMEZONE:-UTC}"
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 + desktop auto-login" || echo none)" \
"Swap: $([[ "$SWAP_GB" == "0" ]] && echo none || echo "${SWAP_GB}G (hibernation)")" \
"User: $USERNAME" \
"Hostname: $HOSTNAME_" \
"Timezone: $TIMEZONE" \
"Hardware: ${HW_PROFILES[*]:-none}" \
"Snapshots: snapper timeline on /" \
"Source: nomarchy ${NOMARCHY_REV:0:12}${NOMARCHY_REV:+ }$([[ -z "${NOMARCHY_REV:-}" ]] && echo "(dirty tree) ")— pinned into the ISO, no network needed"
if [[ "$UNATTENDED" != "1" ]]; then
typed=$(gum input --placeholder "type the disk name ($(basename "$TARGET_DISK")) to confirm the wipe")
[[ "$typed" == "$(basename "$TARGET_DISK")" ]] || fail "Confirmation mismatch — aborting, nothing touched."
fi
# ─── Partitioning ───────────────────────────────────────────────────────
section "Partitioning"
# Pre-wipe: disko gates destructive steps on blkid — on a previously
# installed disk it would overlay the old GPT and skip mkfs on a stale
# ESP ("wrong fs type, bad superblock" at mount). Wipe first. Teardown
# order matters: mounts → swap → LUKS mappings → signatures.
prewipe() {
local drive="$1" name backing part
info "Pre-wiping $drive..."
umount -R /mnt 2>/dev/null || true
swapoff -a 2>/dev/null || true
if command -v dmsetup >/dev/null 2>&1; then
while read -r name _; do
[[ -n "$name" && "$name" != "No" ]] || continue
backing=$(cryptsetup status "$name" 2>/dev/null \
| awk '/^[[:space:]]*device:/ { print $2; exit }') || continue
[[ "$backing" == "$drive"* ]] || continue
info "closing stale LUKS mapping $name"
cryptsetup close "$name"
done < <(dmsetup ls --target crypt 2>/dev/null)
fi
for part in "${drive}"?*; do
# unmatched glob stays literal; -b filters it out
[[ -b "$part" ]] || continue
wipefs -af "$part" >/dev/null
done
wipefs -af "$drive" >/dev/null
sgdisk --zap-all "$drive" >/dev/null
# 16 MiB covers LUKS2 headers and the first BTRFS superblock —
# wipefs alone misses damaged variants.
dd if=/dev/zero of="$drive" bs=1M count=16 conv=fsync status=none
partprobe "$drive" 2>/dev/null || true
udevadm settle --timeout=30 || info "udevadm settle timed out; continuing."
if lsblk -no MOUNTPOINTS "$drive" 2>/dev/null | grep -qE '\S'; then
fail "$drive still has active mountpoints after pre-wipe."
fi
}
prewipe "$TARGET_DISK"
if [[ $WITH_LUKS == true ]]; then
install -m 600 /dev/null "$LUKS_KEY_PATH"
trap 'rm -f "$LUKS_KEY_PATH" 2>/dev/null || true' EXIT
printf '%s' "$LUKS_PASSPHRASE" > "$LUKS_KEY_PATH"
unset LUKS_PASSPHRASE
fi
disko_log=$(mktemp --suffix=.disko.log)
if ! disko --mode destroy,format,mount --yes-wipe-all-disks \
--argstr mainDrive "$TARGET_DISK" \
--arg withLuks "$WITH_LUKS" \
--argstr swapSize "${SWAP_GB}G" \
"$SHARE/disko-config.nix" >"$disko_log" 2>&1; then
tail -n 30 "$disko_log"
fail "disko failed — full log: $disko_log"
fi
rm -f "$LUKS_KEY_PATH" "$disko_log"
success "Disk partitioned and mounted at /mnt"
# Hibernation plumbing: the swapfile's physical offset goes into the
# kernel cmdline. Deactivate swap first so nixos-generate-config doesn't
# also emit a swapDevices entry (we write our own, with resume wiring).
RESUME_CONFIG=""
if [[ "$SWAP_GB" != "0" ]]; then
swapoff -a 2>/dev/null || true
resume_offset=$(btrfs inspect-internal map-swapfile -r /mnt/swap/swapfile)
root_uuid=$(findmnt -no UUID /mnt)
RESUME_CONFIG=$(cat <<NIX
# Swapfile (hibernation-ready: resume points into it).
swapDevices = [{ device = "/swap/swapfile"; }];
boot.resumeDevice = "/dev/disk/by-uuid/$root_uuid";
boot.kernelParams = [ "resume_offset=$resume_offset" ];
NIX
)
success "Swapfile created (resume offset $resume_offset)"
fi
# ─── Configuration generation ───────────────────────────────────────────
section "Generating configuration"
FLAKE_DIR="/mnt/home/$USERNAME/.nomarchy"
mkdir -p "$FLAKE_DIR"
nixos-generate-config --root /mnt
mv /mnt/etc/nixos/hardware-configuration.nix "$FLAKE_DIR/"
rm -rf /mnt/etc/nixos
cp "$SHARE/template/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
AUTOLOGIN_CONFIG=""
if [[ $WITH_LUKS == true ]]; then
AUTOLOGIN_CONFIG=$(cat <<NIX
# The LUKS passphrase already gates this machine — skip the second
# password prompt and boot straight into the desktop.
nomarchy.system.greeter.autoLogin = "$USERNAME";
NIX
)
fi
# initialHashedPassword is safe to template: mkpasswd's alphabet is
# [a-zA-Z0-9./$] — no Nix string metacharacters.
cat > "$FLAKE_DIR/system.nix" <<EOF
# Your machine: hostname, users, services. The distro itself comes from
# Nomarchy (via flake.nix); override its defaults here with plain NixOS
# options, or the nomarchy.system.* toggles.
{ pkgs, username, ... }:
{
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
networking.hostName = "$HOSTNAME_";
time.timeZone = "$TIMEZONE";
i18n.defaultLocale = "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";
};
$AUTOLOGIN_CONFIG$RESUME_CONFIG
# Hourly/daily BTRFS timeline snapshots + nixos-rebuild-snap.
nomarchy.system.snapper.enable = true;
system.stateVersion = "26.05";
}
EOF
# The flake.lock: composed offline — nomarchy is path-locked to the very
# source the ISO carries (original stays the forge URL, so a later
# `nix flake update` on the installed machine re-resolves normally).
if ! python3 "$SHARE/compose-lock.py" "$SHARE/flake.lock" "$FLAKE_DIR/flake.lock" \
"$NOMARCHY_LOCKED_JSON" "$NOMARCHY_ORIGINAL_JSON"; then
warn "Offline lock composition failed — resolving over the network."
(cd "$FLAKE_DIR" && nix --extra-experimental-features "nix-command flakes" flake lock)
fi
# A flake worktree must be git-tracked (theme-state.json especially).
(
cd "$FLAKE_DIR"
git init -q
git add -A
git -c user.name="Nomarchy Installer" -c user.email="installer@nomarchy" \
commit -qm "Initial Nomarchy configuration"
)
# The user must own their flake — libgit2 refuses repositories owned by
# someone else, which breaks `home-manager switch` (and theme switching)
# outright. The first normal NixOS user is always 1000:users(100); the
# account doesn't exist in the target yet, so numeric ids it is.
chown -R 1000:100 "$FLAKE_DIR"
# /etc/nixos on the installed system points at the user-owned flake.
mkdir -p /mnt/etc
ln -sfn "/home/$USERNAME/.nomarchy" /mnt/etc/nixos
success "Configuration written to ~$USERNAME/.nomarchy"
# ─── Install ────────────────────────────────────────────────────────────
section "Installing (this takes a while)"
# Seed the target store with the flake source + all inputs so the first
# `nomarchy-theme-sync apply` (and the HM pre-activation below) work
# before the machine has ever seen a network. Two steps because
# `flake archive --to` enforces signatures and locally-evaluated source
# paths have none; plain `nix copy` accepts --no-check-sigs.
info "Seeding flake inputs into the target store..."
# path: (not git+file) — the flake dir is owned by the target user and
# root's libgit2 refuses repositories owned by someone else.
flake_paths=$(nix --extra-experimental-features "nix-command flakes" \
flake archive --json "path:$FLAKE_DIR" \
| python3 -c '
import json, sys
def walk(node):
yield node["path"]
for child in node.get("inputs", {}).values():
yield from walk(child)
print("\n".join(walk(json.load(sys.stdin))))' || true)
if [[ -n "$flake_paths" ]]; then
# shellcheck disable=SC2086
nix --extra-experimental-features "nix-command flakes" \
copy --no-check-sigs --to "local?root=/mnt" $flake_paths \
|| warn "input seeding failed — first rebuild will need network."
else
warn "flake archive failed — first rebuild will need network."
fi
# Offline: sidestep substituter plumbing entirely — make every ISO store
# path valid in the target store up front. nixos-install's in-target
# build then finds all build tools locally and only the per-machine
# config derivations are built. (Two earlier attempts to route this
# through substituters — env config and the forwarded --substituters
# flag — still left the plan building gcc from source.)
if [[ ${#NIXOS_INSTALL_OPTS[@]} -gt 0 ]]; then
info "Copying the ISO store into the target (offline install)..."
nix --extra-experimental-features nix-command \
copy --all --no-check-sigs --to "local?root=/mnt"
fi
nixos-install --no-root-passwd "${NIXOS_INSTALL_OPTS[@]}" --flake "path:$FLAKE_DIR#default"
success "System installed (bootloader in place)"
# Pre-activate the Home Manager generation so the FIRST boot lands in the
# fully themed desktop, not bare Hyprland. Best-effort: a failure here
# only costs the user one `home-manager switch` after logging in.
section "Baking the desktop"
# NOT /mnt/tmp: nixos-enter mounts a fresh tmpfs over /tmp inside the
# chroot, which silently vaporizes any script staged there (cost us a
# full verification round to find). /root persists into the chroot.
cat > /mnt/root/nomarchy-hm-activate.sh <<EOF
set -ex
exec > /var/log/nomarchy-hm-preactivate.log 2>&1
export PATH=/run/current-system/sw/bin:\$PATH
out=\$(nix --extra-experimental-features "nix-command flakes" \
build --no-link --print-out-paths \
--option substituters "" \
"path:/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
# activate's profile ops need store access; as the user that means a
# daemon, and the chroot has none — run one for the duration.
nix-daemon &
daemon_pid=\$!
trap 'kill \$daemon_pid 2>/dev/null || true' EXIT
sleep 2
# BACKUP_EXT: collisions can't abort the activation (a stray
# autogenerated config gets moved aside instead).
runuser -u "$USERNAME" -- bash -lc \
"USER=$USERNAME HOME=/home/$USERNAME NIX_REMOTE=daemon HOME_MANAGER_BACKUP_EXT=bak \$out/activate"
EOF
if nixos-enter --root /mnt -- bash /root/nomarchy-hm-activate.sh; then
success "Desktop pre-activated — first boot is fully themed"
else
warn "Desktop pre-activation failed (see /var/log/nomarchy-hm-preactivate.log"
warn "on the installed system); after first login run:"
warn " home-manager switch --flake ~/.nomarchy -b bak"
tail -n 5 /mnt/var/log/nomarchy-hm-preactivate.log 2>/dev/null || true
fi
rm -f /mnt/root/nomarchy-hm-activate.sh
header "Nomarchy installed on $TARGET_DISK" \
"User: $USERNAME @ $HOSTNAME_" \
"Remove the USB stick when the machine is off."
finish="${NOMARCHY_FINISH:-ask}"
case "$finish" in
reboot) systemctl reboot ;;
poweroff) systemctl poweroff ;;
none) : ;;
*) confirm "Reboot into Nomarchy now?" && systemctl reboot || true ;;
esac

View File

@@ -2,7 +2,7 @@
, stdenvNoCC
, python3
, makeWrapper
, swww
, awww
, libnotify
, git
# Shipped theme presets, baked into the package as a fallback so
@@ -33,7 +33,7 @@ stdenvNoCC.mkDerivation {
# Stdlib-only Python. home-manager is deliberately NOT wrapped in
# the rebuild must use the user's own home-manager from their PATH.
wrapProgram $out/bin/nomarchy-theme-sync \
--prefix PATH : ${lib.makeBinPath [ swww libnotify git ]} \
--prefix PATH : ${lib.makeBinPath [ awww libnotify git ]} \
${lib.optionalString (themesDir != null)
"--set NOMARCHY_DEFAULT_THEMES $out/share/nomarchy/themes"}

View File

@@ -168,16 +168,18 @@ def apply_wallpaper(state: dict, wait: bool = False) -> None:
if wallpaper is None:
log(f"no wallpaper for theme '{state.get('slug', '?')}', skipping")
return
if shutil.which("swww") is None:
log("swww not found, skipping wallpaper")
# nixpkgs renamed swww to awww (CLI-compatible fork); accept either.
swww = shutil.which("awww") or shutil.which("swww")
if swww is None:
log("awww/swww not found, skipping wallpaper")
return
# At session start swww-daemon may still be coming up.
# At session start the daemon may still be coming up.
for _ in range(10 if wait else 1):
if subprocess.run(["swww", "query"], capture_output=True).returncode == 0:
if subprocess.run([swww, "query"], capture_output=True).returncode == 0:
break
time.sleep(0.5)
result = subprocess.run(
["swww", "img", str(wallpaper),
[swww, "img", str(wallpaper),
"--transition-type", "grow",
"--transition-pos", "center",
"--transition-duration", "1",
@@ -185,7 +187,7 @@ def apply_wallpaper(state: dict, wait: bool = False) -> None:
capture_output=True,
)
log(f"wallpaper: {wallpaper.name}" if result.returncode == 0
else "wallpaper: swww failed (daemon not running?)")
else "wallpaper: awww failed (daemon not running?)")
# ─── Commands ─────────────────────────────────────────────────────────────

View File

@@ -1,8 +1,13 @@
# My Nomarchy machine
1. `nixos-generate-config --show-hardware-config > hardware-configuration.nix`
2. Edit `flake.nix` (Nomarchy repo URL, username), `system.nix` (hostname,
user), `home.nix` (your packages).
1. Overwrite the placeholder hardware config with the real one:
`nixos-generate-config --show-hardware-config > hardware-configuration.nix`
2. Set `flake.nix` up **once** (Nomarchy repo URL, your username, optionally
a `hardwareProfile` from <https://github.com/NixOS/nixos-hardware> — an
unknown name fails the rebuild with suggestions). After that the flake is
never touched again: your machine lives in `system.nix` (hostname,
services — the login user is created from `username` automatically) and
`home.nix` (your packages).
3. `git init && git add -A` — flakes only see tracked files, including
`theme-state.json`.
4. System: `sudo nixos-rebuild switch --flake .#default`

View File

@@ -1,53 +1,20 @@
{
description = "My Nomarchy machine";
inputs = {
# Pin Nomarchy; nixpkgs and home-manager follow whatever it ships.
nomarchy.url = "github:YOUR-USER/nomarchy"; # <- point at the distro repo
nixpkgs.follows = "nomarchy/nixpkgs";
home-manager.follows = "nomarchy/home-manager";
};
# The only input. nixpkgs, home-manager etc. come pinned through it —
# tested together upstream. This file is written once (by you or the
# installer) and never hand-edited afterwards; your machine lives in
# system.nix and home.nix.
inputs.nomarchy.url = "github:YOUR-USER/nomarchy"; # <- the distro repo
outputs = { self, nomarchy, nixpkgs, home-manager, ... }:
let
system = "x86_64-linux";
outputs = { nomarchy, ... }:
nomarchy.lib.mkFlake {
src = ./.;
username = "me"; # <- your login name
pkgs = import nixpkgs {
inherit system;
overlays = [ nomarchy.overlays.default ];
};
in
{
# System layer — rebuilt rarely:
# sudo nixos-rebuild switch --flake .#default
nixosConfigurations.default = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
nomarchy.nixosModules.nomarchy # the distro
./hardware-configuration.nix # from `nixos-generate-config`
./system.nix # your machine
];
};
# Desktop layer — rebuilt on every theme change, no sudo:
# home-manager switch --flake .#me
# (`nomarchy-theme-sync apply <theme>` runs this for you.)
homeConfigurations.${username} = home-manager.lib.homeManagerConfiguration {
inherit pkgs;
modules = [
nomarchy.homeModules.nomarchy
./home.nix
{
# Your theme state — written by nomarchy-theme-sync, baked
# in on every switch. Keep it git-tracked.
nomarchy.stateFile = ./theme-state.json;
home = {
inherit username;
homeDirectory = "/home/${username}";
};
}
];
};
# Optional: a nixos-hardware module name for your machine, e.g.
# hardwareProfile = "framework-13-7040-amd";
# Names: https://github.com/NixOS/nixos-hardware
# (the future installer fills this in automatically from DMI data)
};
}

View File

@@ -0,0 +1,15 @@
# PLACEHOLDER — replace with the output of `nixos-generate-config` on the
# target machine. The mkDefault values below only exist so the flake
# evaluates and `nix flake check` passes before first install.
{ lib, ... }:
{
boot.initrd.availableKernelModules = lib.mkDefault [ "xhci_pci" "ahci" "nvme" "usbhid" "sd_mod" ];
fileSystems."/" = lib.mkDefault {
device = "/dev/disk/by-label/nixos";
fsType = "ext4";
};
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
}

View File

@@ -2,7 +2,7 @@
# The distro itself comes from nomarchy.nixosModules.nomarchy — override
# any of its defaults here with plain NixOS options (they use mkDefault),
# or via the nomarchy.system.* toggles.
{ pkgs, ... }:
{ pkgs, username, ... }:
{
boot.loader.systemd-boot.enable = true;
@@ -12,7 +12,8 @@
time.timeZone = "UTC";
i18n.defaultLocale = "en_US.UTF-8";
users.users.me = { # <- keep in sync with `username` in flake.nix
# Your login user — `username` flows in from flake.nix automatically.
users.users.${username} = {
isNormalUser = true;
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
};

151
tools/test-install.sh Executable file
View File

@@ -0,0 +1,151 @@
#!/usr/bin/env bash
# End-to-end regression test for nomarchy-install: build the live ISO,
# run a fully OFFLINE unattended install into a fresh VM disk, then boot
# the installed system and capture what the first boot looks like.
#
# This automates the verification protocol from docs/TESTING.md §4 that
# debugged the installer in the first place. It asserts the machine-
# checkable parts (installer reaches poweroff; installed disk boots);
# the visual part — a fully themed desktop, no autogenerated-config
# banner — lands as screenshots for a human (or agent) to inspect:
#
# $NOMARCHY_VM_DIR/install-typed.png the typed command, pre-enter
# $NOMARCHY_VM_DIR/luks-prompt.png initrd passphrase prompt
# $NOMARCHY_VM_DIR/first-boot.png THE result: themed desktop
#
# Requirements: KVM, qemu, OVMF, dosfstools (mkfs.vfat), mtools (mcopy),
# python3. Tunables (env):
# NOMARCHY_VM_DIR scratch + screenshots (default /tmp/nomarchy-vm)
# NOMARCHY_TARGET_DIR where the 25G target.img lives — must NOT be
# tmpfs (default ~/.cache/nomarchy-vm)
# NOMARCHY_BOOT_WAIT seconds to wait for the live desktop (default 480)
# NOMARCHY_INSTALL_TIMEOUT seconds before declaring a hang (default 2700)
set -euo pipefail
cd "$(dirname "$0")/.."
VM_DIR="${NOMARCHY_VM_DIR:-/tmp/nomarchy-vm}"
TARGET_DIR="${NOMARCHY_TARGET_DIR:-$HOME/.cache/nomarchy-vm}"
BOOT_WAIT="${NOMARCHY_BOOT_WAIT:-480}"
INSTALL_TIMEOUT="${NOMARCHY_INSTALL_TIMEOUT:-2700}"
export NOMARCHY_QMP_SOCK="$VM_DIR/qmp.sock"
QMP=tools/vm/qmp.py
SHOT=tools/vm/vncshot.py
VNC_DISPLAY=17 # port 5917
for tool in qemu-system-x86_64 mkfs.vfat mcopy python3; do
command -v "$tool" >/dev/null || {
echo "missing $tool — try: nix shell nixpkgs#qemu nixpkgs#dosfstools nixpkgs#mtools" >&2
exit 1
}
done
[ -r /dev/kvm ] || { echo "no KVM — this test is too slow without it" >&2; exit 1; }
mkdir -p "$VM_DIR" "$TARGET_DIR"
if df --output=fstype "$TARGET_DIR" | tail -1 | grep -q tmpfs; then
echo "NOMARCHY_TARGET_DIR=$TARGET_DIR is tmpfs — the install writes ~10G; pick a real disk" >&2
exit 1
fi
OVMF=""
for c in /run/current-system/sw/share/qemu/edk2-x86_64-code.fd \
/run/current-system/sw/share/OVMF/OVMF_CODE.fd \
/usr/share/OVMF/OVMF_CODE.fd; do
[ -f "$c" ] && { OVMF="$c"; break; }
done
[ -n "$OVMF" ] || { echo "no OVMF firmware found" >&2; exit 1; }
echo "==> Building the live ISO..."
nix build .#nixosConfigurations.nomarchy-live.config.system.build.isoImage
ISO=$(ls -1 result/iso/*.iso | head -n1)
echo "==> Preparing disks..."
# Unattended config rides in on a tiny vfat disk: the installer test
# can't reliably TYPE a 200-char env line into the guest (keystrokes
# get dropped), but one short mount-and-run command works.
cat > "$VM_DIR/go.sh" <<'EOF'
#!/usr/bin/env bash
export NOMARCHY_UNATTENDED=1
export NOMARCHY_DISK=/dev/vda
export NOMARCHY_USERNAME=ada
export NOMARCHY_PASSWORD=nomarchy
export NOMARCHY_HOSTNAME=testbox
export NOMARCHY_LUKS_PASSPHRASE=testtest1
export NOMARCHY_SWAP_GB=2
export NOMARCHY_FINISH=poweroff
nomarchy-install 2>&1 | tee /tmp/install.log
EOF
rm -f "$VM_DIR/config.img"
truncate -s 8M "$VM_DIR/config.img"
mkfs.vfat "$VM_DIR/config.img" >/dev/null
mcopy -i "$VM_DIR/config.img" "$VM_DIR/go.sh" ::go.sh
rm -f "$TARGET_DIR/target.img" "$NOMARCHY_QMP_SOCK"
qemu-img create -f raw "$TARGET_DIR/target.img" 25G >/dev/null
qemu_base=(
qemu-system-x86_64
-enable-kvm -cpu host -m 6144 -smp 2
-drive "if=pflash,format=raw,readonly=on,file=$OVMF"
-device virtio-vga-gl -display egl-headless
-vnc "127.0.0.1:$VNC_DISPLAY"
-device virtio-net-pci,netdev=n0 -netdev user,id=n0,restrict=on # OFFLINE
-qmp "unix:$NOMARCHY_QMP_SOCK,server,nowait"
)
echo "==> Phase 1: installing (offline) — this takes ~20-30 min..."
"${qemu_base[@]}" \
-drive "file=$TARGET_DIR/target.img,if=virtio,format=raw" \
-drive "file=$VM_DIR/config.img,if=virtio,format=raw,readonly=on" \
-cdrom "$ISO" -boot d \
>"$VM_DIR/qemu-install.log" 2>&1 &
QPID=$!
trap 'kill $QPID 2>/dev/null || true' EXIT
until [ -S "$NOMARCHY_QMP_SOCK" ]; do sleep 2; done
echo " waiting ${BOOT_WAIT}s for the live desktop..."
sleep "$BOOT_WAIT"
python3 "$QMP" key meta_l-ret
sleep 8
python3 "$QMP" type 'sudo bash -c "mkdir -p /m; mount /dev/vdb /m; bash /m/go.sh"'
sleep 2
python3 "$SHOT" "$VM_DIR/install-typed.png" 127.0.0.1 "59$VNC_DISPLAY"
python3 "$QMP" key ret
echo " install running (typed command captured in install-typed.png)..."
waited=0
while kill -0 $QPID 2>/dev/null; do
if (( waited >= INSTALL_TIMEOUT )); then
python3 "$SHOT" "$VM_DIR/install-hung.png" 127.0.0.1 "59$VNC_DISPLAY" || true
echo "FAIL: installer did not power off within ${INSTALL_TIMEOUT}s (see install-hung.png)" >&2
exit 1
fi
sleep 20; waited=$((waited + 20))
done
trap - EXIT
echo " VM powered off after ~${waited}s — install completed."
echo "==> Phase 2: first boot of the installed system..."
rm -f "$NOMARCHY_QMP_SOCK"
"${qemu_base[@]}" \
-drive "file=$TARGET_DIR/target.img,if=virtio,format=raw" \
>"$VM_DIR/qemu-boot.log" 2>&1 &
QPID=$!
trap 'kill $QPID 2>/dev/null || true' EXIT
until [ -S "$NOMARCHY_QMP_SOCK" ]; do sleep 2; done
sleep 40
python3 "$SHOT" "$VM_DIR/luks-prompt.png" 127.0.0.1 "59$VNC_DISPLAY"
python3 "$QMP" type 'testtest1'
python3 "$QMP" key ret
sleep 150
python3 "$SHOT" "$VM_DIR/first-boot.png" 127.0.0.1 "59$VNC_DISPLAY"
python3 "$QMP" quit || true
trap - EXIT
echo
echo "PASS (machine-checkable part): install powered off, installed disk boots."
echo "Now INSPECT $VM_DIR/first-boot.png:"
echo " expected: themed desktop (wallpaper + waybar), NO red banner, no login prompt"
echo " if it shows the Hyprland water-drop or an 'autogenerated config' banner,"
echo " log in on tty2 (ada/nomarchy) and read /var/log/nomarchy-hm-preactivate.log"

74
tools/vm/gap-analysis.py Executable file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Which store paths would an offline nixos-install need that the ISO lacks?
Walk the target drv graph: an output that's PRESENT needs nothing; a
missing output forces building its drv, which needs all its input
sources present and all referenced input outputs (recursively).
This is the maintainer tool that converged the offline pin set in
flake.nix (extraDependencies): when an offline install builds from
source, run this instead of bisecting the VM. Healthy output is a few
dozen per-machine config drvs (etc, initrd-*, unit-*, …) and zero
[FETCH/NETWORK] leaves; any real package name here needs pinning.
Usage:
# 1. the present set = everything the ISO carries:
live=$(nix build --no-link --print-out-paths \
.#nixosConfigurations.nomarchy-live.config.system.build.toplevel)
nix path-info -r "$live" | sort -u > /tmp/present.txt
# 2. instantiate an install-shaped toplevel for a FOREIGN
# username/hostname/uuids (see docs/TESTING.md) and:
gap-analysis.py <target.drv> /tmp/present.txt
"""
import json
import subprocess
import sys
target_drv, present_file = sys.argv[1], sys.argv[2]
present = set(open(present_file).read().split())
graph = json.loads(subprocess.check_output(
["nix", "derivation", "show", "-r", target_drv + "^*"]))
must_build = set()
missing_srcs = set()
visited = set()
def outputs_of(drv_name):
return [o["path"] for o in graph[drv_name]["outputs"].values()]
def need_drv(drv_name):
if drv_name in visited:
return
visited.add(drv_name)
must_build.add(drv_name)
node = graph[drv_name]
for src in node.get("inputSrcs", []):
if src not in present:
missing_srcs.add(src)
for dep_drv, want in node.get("inputDrvs", {}).items():
dep_outputs = graph.get(dep_drv)
if dep_outputs is None:
continue
wanted = want.get("outputs", []) if isinstance(want, dict) else want
for out_name in wanted:
path = graph[dep_drv]["outputs"][out_name]["path"]
if path not in present:
need_drv(dep_drv)
break
# Roots: the target's own outputs are by definition "to build".
need_drv(target_drv)
print(f"drvs that must be built offline: {len(must_build)}")
for d in sorted(must_build):
name = d.split("-", 1)[1] if "-" in d else d
builder = graph[d].get("builder", "")
fetch = " [FETCH/NETWORK]" if graph[d].get("env", {}).get("urls") or graph[d].get("env", {}).get("url") else ""
print(f" {name}{fetch}")
print(f"missing input sources: {len(missing_srcs)}")
for s in sorted(missing_srcs):
print(f" SRC {s}")

105
tools/vm/qmp.py Executable file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Minimal QMP driver for the Nomarchy test VMs.
Drives a QEMU instance through its QMP socket: inject keystrokes, type
text, take screendumps, power off. Used by tools/test-install.sh; also
handy interactively while debugging a VM that has no SSH.
Usage:
qmp.py key <combo> # e.g. meta_l-ret, ctrl-alt-f2, ret
qmp.py type <text...> # types literal text (US layout)
qmp.py screenshot <out.png> # NOTE: with virtio-vga-gl this fails
# ("no surface") once the guest uses GL —
# use vncshot.py instead for desktops
qmp.py quit # hard power-off
The socket path comes from $NOMARCHY_QMP_SOCK
(default /tmp/nomarchy-vm/qmp.sock).
Keystroke pacing: the guest drops keys when busy (especially during
boot). $NOMARCHY_TYPE_DELAY (seconds between keys, default 0.08) trades
speed for reliability; verify long typed lines with a screenshot before
pressing enter.
"""
import json
import os
import socket
import sys
import time
SOCK = os.environ.get("NOMARCHY_QMP_SOCK", "/tmp/nomarchy-vm/qmp.sock")
TYPE_DELAY = float(os.environ.get("NOMARCHY_TYPE_DELAY", "0.08"))
CHAR_KEYS = {
" ": "spc", "-": "minus", "=": "equal", "[": "bracket_left",
"]": "bracket_right", ";": "semicolon", "'": "apostrophe",
"`": "grave_accent", "\\": "backslash", ",": "comma", ".": "dot",
"/": "slash", "\n": "ret", "\t": "tab",
}
SHIFT_CHARS = {
"!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6",
"&": "7", "*": "8", "(": "9", ")": "0", "_": "minus", "+": "equal",
"{": "bracket_left", "}": "bracket_right", ":": "semicolon",
'"': "apostrophe", "~": "grave_accent", "|": "backslash",
"<": "comma", ">": "dot", "?": "slash",
}
class QMP:
def __init__(self):
self.s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.s.connect(SOCK)
self.f = self.s.makefile("rw")
self._read() # greeting
self.cmd("qmp_capabilities")
def _read(self):
while True:
msg = json.loads(self.f.readline())
if "event" in msg:
continue
return msg
def cmd(self, name, **args):
self.f.write(json.dumps({"execute": name, "arguments": args}) + "\n")
self.f.flush()
resp = self._read()
if "error" in resp:
raise RuntimeError(f"{name}: {resp['error']}")
return resp
def send_keys(self, names, hold=40):
keys = [{"type": "qcode", "data": n} for n in names]
self.cmd("send-key", keys=keys, **{"hold-time": hold})
def type_text(self, text):
for ch in text:
if ch.isalpha() and ch.lower() != ch:
self.send_keys(["shift", ch.lower()])
elif ch in SHIFT_CHARS:
self.send_keys(["shift", SHIFT_CHARS[ch]])
elif ch in CHAR_KEYS:
self.send_keys([CHAR_KEYS[ch]])
else:
self.send_keys([ch])
time.sleep(TYPE_DELAY)
def main():
q = QMP()
cmd = sys.argv[1]
if cmd == "screenshot":
q.cmd("screendump", filename=sys.argv[2], format="png")
elif cmd == "key":
q.send_keys(sys.argv[2].split("-"))
elif cmd == "type":
q.type_text(" ".join(sys.argv[2:]))
elif cmd == "quit":
q.f.write(json.dumps({"execute": "quit"}) + "\n")
q.f.flush()
else:
sys.exit(f"unknown command {cmd}")
if __name__ == "__main__":
main()

104
tools/vm/vncshot.py Executable file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Grab one full framebuffer from a no-auth VNC server and save as PNG.
Why VNC and not QMP screendump: with virtio-vga-gl the guest renders
into a GL scanout and screendump fails with "no surface"; QEMU's VNC
server reads the GL framebuffer back, so this always works. Pair the VM
with `-display egl-headless -vnc 127.0.0.1:17`.
Stdlib only (manual RFB handshake + raw-encoding framebuffer + PNG
writer) so it runs anywhere python3 exists.
Usage: vncshot.py <out.png> [host] [port] # default 127.0.0.1:5917
"""
import socket, struct, sys, zlib
def recv_exact(s, n):
buf = b""
while len(buf) < n:
chunk = s.recv(n - len(buf))
if not chunk:
raise EOFError("server closed")
buf += chunk
return buf
def png_write(path, w, h, rgb):
def chunk(tag, data):
c = struct.pack(">I", len(data)) + tag + data
return c + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
raw = b"".join(b"\x00" + rgb[y * w * 3:(y + 1) * w * 3] for y in range(h))
out = (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 6))
+ chunk(b"IEND", b""))
with open(path, "wb") as f:
f.write(out)
def main():
out = sys.argv[1]
host = sys.argv[2] if len(sys.argv) > 2 else "127.0.0.1"
port = int(sys.argv[3]) if len(sys.argv) > 3 else 5917
s = socket.create_connection((host, port), timeout=30)
s.settimeout(30)
ver = recv_exact(s, 12) # "RFB 003.008\n"
s.sendall(b"RFB 003.008\n")
ntypes = recv_exact(s, 1)[0]
types = recv_exact(s, ntypes)
if 1 not in types:
sys.exit(f"no 'None' security type offered: {list(types)}")
s.sendall(b"\x01")
res = struct.unpack(">I", recv_exact(s, 4))[0]
if res != 0:
sys.exit("security handshake failed")
s.sendall(b"\x01") # ClientInit: shared
w, h = struct.unpack(">HH", recv_exact(s, 4))
recv_exact(s, 16) # server pixel format (ignored)
namelen = struct.unpack(">I", recv_exact(s, 4))[0]
recv_exact(s, namelen)
# SetPixelFormat: 32bpp truecolor, shifts r=16 g=8 b=0 (little-endian BGRX)
pf = struct.pack(">BBBBHHHBBBxxx", 32, 24, 0, 1, 255, 255, 255, 16, 8, 0)
s.sendall(b"\x00\x00\x00\x00" + pf)
s.sendall(struct.pack(">BxH i", 2, 1, 0)) # SetEncodings: Raw only
s.sendall(struct.pack(">BBHHHH", 3, 0, 0, 0, w, h)) # full update request
fb = bytearray(w * h * 4)
got = False
while not got:
mtype = recv_exact(s, 1)[0]
if mtype == 0: # FramebufferUpdate
recv_exact(s, 1)
nrects = struct.unpack(">H", recv_exact(s, 2))[0]
for _ in range(nrects):
x, y, rw, rh = struct.unpack(">HHHH", recv_exact(s, 8))
enc = struct.unpack(">i", recv_exact(s, 4))[0]
if enc == 0:
data = recv_exact(s, rw * rh * 4)
for row in range(rh):
off = ((y + row) * w + x) * 4
fb[off:off + rw * 4] = data[row * rw * 4:(row + 1) * rw * 4]
elif enc == -224: # LastRect
break
else:
sys.exit(f"unsupported encoding {enc}")
got = True
elif mtype == 2: # Bell
pass
elif mtype == 3: # ServerCutText
recv_exact(s, 3)
ln = struct.unpack(">I", recv_exact(s, 4))[0]
recv_exact(s, ln)
else:
sys.exit(f"unexpected server message {mtype}")
rgb = bytearray(w * h * 3)
for i in range(w * h):
rgb[i * 3 + 0] = fb[i * 4 + 2]
rgb[i * 3 + 1] = fb[i * 4 + 1]
rgb[i * 3 + 2] = fb[i * 4 + 0]
png_write(out, w, h, bytes(rgb))
print(f"saved {out} ({w}x{h})")
if __name__ == "__main__":
main()