{ description = "Nomarchy — the rock-solid reproducibility of NixOS, the polish of an opinionated desktop"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; home-manager = { url = "github:nix-community/home-manager/release-26.05"; inputs.nixpkgs.follows = "nixpkgs"; }; stylix = { # Track the release branch matching nixpkgs/HM — master is built # against unstable and warns about the version skew on every # activation. (No home-manager input to follow anymore: stylix # vendors its HM integration.) url = "github:nix-community/stylix/release-26.05"; inputs.nixpkgs.follows = "nixpkgs"; }; # 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, nixos-hardware, ... }: let system = "x86_64-linux"; username = "nomarchy"; # reference host only; downstream picks its own pkgs = import nixpkgs { inherit system; overlays = [ self.overlays.default ]; # Matches nixpkgs.config.allowUnfree in modules/nixos — the # standalone HM generation must come from like-configured pkgs. config.allowUnfree = true; }; # 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; # Branch model: `main` is development; `v1` is the release pointer # (advanced to main after human QA). Installs from a *main-built* ISO # must seed `?ref=main` — seeding v1 while it lags hundreds of # commits left machines with installer-written `nomarchy.hardware.*` # that v1 does not have (#124). When cutting a release ISO from the # v1 pointer, change both of these to "v1". flakeUrl = "git+${gitUrl}?ref=main"; # 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; ref = "main"; }; lockedNode = { type = "path"; path = self.outPath; narHash = self.narHash; lastModified = self.lastModified or 0; }; rev = self.rev or null; }; in { # ─── Downstream API ──────────────────────────────────────────── # A user's machine flake imports these and layers its own # system.nix / home.nix on top (see templates/downstream). # Their state.json lives in THEIR flake and is wired up via # the `nomarchy.stateFile` option — reading it stays pure. overlays.default = final: prev: { nomarchy-state-sync = final.callPackage ./pkgs/nomarchy-state-sync { # Presets baked into the package, so `nomarchy-state-sync list` # works on machines that don't check out this repo. themesDir = ./themes; }; nomarchy-doctor = final.callPackage ./pkgs/nomarchy-doctor { }; nomarchy-battery-notify = final.callPackage ./pkgs/nomarchy-battery-notify { }; nomarchy-first-boot = final.callPackage ./pkgs/nomarchy-first-boot { }; nomarchy-what-changed = final.callPackage ./pkgs/nomarchy-what-changed { }; nomarchy-detect-hw = final.callPackage ./pkgs/nomarchy-detect-hw { }; nomarchy-airplane = final.callPackage ./pkgs/nomarchy-airplane { }; nomarchy-suspend = final.callPackage ./pkgs/nomarchy-suspend { }; nomarchy-gen-prune = final.callPackage ./pkgs/nomarchy-gen-prune { }; # pull / rebuild / home (+ legacy aliases) — also on HM so a home # switch can refresh a broken system-package nomarchy-pull. # Includes nomarchy-what-changed (generation readability, #82). nomarchy-lifecycle = final.callPackage ./pkgs/nomarchy-lifecycle { }; pam-fprint-grosshack = final.callPackage ./pkgs/pam-fprint-grosshack { }; # NOTE: no chromium override here on purpose — the enableWideVine # wrapper's duplicate unwrapped build is a real upstream bug but is # worth ~195 MiB of download and ~19 MiB of disk, not the gigabytes it # looks like. Decided not to work around it locally # (ROADMAP § one chromium, not two, #121). }; nixosModules.nomarchy = { imports = [ ./modules/nixos ]; nixpkgs.overlays = [ self.overlays.default ]; }; homeModules.nomarchy = { # stylix's home module is injected here because it needs the # flake input; modules/home/stylix.nix only configures it. 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)"; }; packages.${system} = { nomarchy-state-sync = pkgs.nomarchy-state-sync; # #107 compat alias — same derivation; drop after release notes say so. nomarchy-theme-sync = pkgs.nomarchy-state-sync; nomarchy-install = nomarchyInstall; # Overlay tools exported for `nix build .#nomarchy-doctor` etc. # (maintainer/CI convenience — modules install them via the overlay). nomarchy-doctor = pkgs.nomarchy-doctor; nomarchy-lifecycle = pkgs.nomarchy-lifecycle; nomarchy-battery-notify = pkgs.nomarchy-battery-notify; nomarchy-first-boot = pkgs.nomarchy-first-boot; nomarchy-what-changed = pkgs.nomarchy-what-changed; nomarchy-detect-hw = pkgs.nomarchy-detect-hw; default = pkgs.nomarchy-state-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 }; # state.json fixtures for the state-bridge checks below, # built off the real shipped template so they stay honest about # what a machine actually carries. baseState = builtins.fromJSON (builtins.readFile ./templates/downstream/state.json); stateFileOf = name: overrides: pkgs.writeText name (builtins.toJSON (nixpkgs.lib.recursiveUpdate baseState overrides)); # The shipped template carries none of these keys — the # absent-key path every existing machine is on. absentState = stateFileOf "state-absent.json" { }; flippedState = stateFileOf "state-flipped.json" { settings.bluetooth.enable = false; # default true → flipped settings.printing.enable = true; # default false → flipped settings.greeter.autoLogin = "me"; settings.power.suspendThenHibernate = false; # default true → off }; # #115: s2h on needs a resume device or logind never gets the policy. s2hOnState = stateFileOf "state-s2h-on.json" { settings.power.suspendThenHibernate = true; }; in { downstream-template-system = downstream.nixosConfigurations.default.config.system.build.toplevel; downstream-template-home = downstream.homeConfigurations.me.activationPackage; # The NixOS-side state bridges (ROADMAP § state bridges, #116). `nomarchy.settings` # exists ONLY on the Home Manager side, so a NixOS option that # defaulted from `config.nomarchy.settings.… or X` silently got X # forever — and `or` makes a dead bridge look exactly like a live # one, which is why two Control Center toggles wrote JSON nothing # read for as long as they existed. Nothing in a build fails when a # bridge dies, so assert the flip: no VM, pure eval. state-bridges = let configWith = stateFile: extra: (self.nixosConfigurations.nomarchy.extendModules { modules = [ ({ lib, ... }: lib.mkMerge [ { nomarchy.system.stateFile = lib.mkForce stateFile; } extra ]) ]; }).config; absent = configWith absentState { }; flipped = configWith flippedState { }; # A hand-set value in system.nix must still outrank the state, # or the documented "setting it by hand pins it" is a lie. pinned = configWith flippedState { nomarchy.system.bluetooth.enable = true; nomarchy.services.printing.enable = false; nomarchy.system.greeter.autoLogin = null; nomarchy.system.power.suspendThenHibernate = true; }; # #115: resume device required for s2h logind policy. s2hOn = configWith s2hOnState { boot.resumeDevice = "/dev/disk/by-uuid/00000000-0000-0000-0000-0000000000aa"; }; s2hOff = configWith flippedState { boot.resumeDevice = "/dev/disk/by-uuid/00000000-0000-0000-0000-0000000000aa"; }; # Toggle true but no resume → must NOT claim s2h lid policy. s2hNoResume = configWith s2hOnState { }; cases = [ { n = "absent key leaves bluetooth at its default (true)"; ok = absent.hardware.bluetooth.enable == true; } { n = "absent key leaves printing at its default (false)"; ok = absent.services.printing.enable == false; } { n = "absent key leaves autoLogin unset"; ok = !(absent.services.greetd.settings ? initial_session); } { n = "state bluetooth=false reaches hardware.bluetooth"; ok = flipped.hardware.bluetooth.enable == false; } { n = "state bluetooth=false reaches services.blueman"; ok = flipped.services.blueman.enable == false; } { n = "state printing=true reaches services.printing"; ok = flipped.services.printing.enable == true; } { n = "state printing=true reaches services.avahi (mDNS)"; ok = flipped.services.avahi.enable == true; } { n = "state autoLogin reaches greetd initial_session"; ok = (flipped.services.greetd.settings.initial_session.user or null) == "me"; } { n = "hand-set bluetooth outranks the state"; ok = pinned.hardware.bluetooth.enable == true; } { n = "hand-set printing outranks the state"; ok = pinned.services.printing.enable == false; } { n = "hand-set autoLogin outranks the state"; ok = !(pinned.services.greetd.settings ? initial_session); } { n = "s2h on + resume → HandleLidSwitch=suspend-then-hibernate"; ok = (s2hOn.services.logind.settings.Login.HandleLidSwitch or null) == "suspend-then-hibernate"; } { n = "s2h on + resume → HibernateDelaySec=1h"; ok = (s2hOn.systemd.sleep.settings.Sleep.HibernateDelaySec or null) == "1h"; } { n = "s2h on + resume → HibernateOnACPower=false"; ok = (s2hOn.systemd.sleep.settings.Sleep.HibernateOnACPower or null) == false; } { n = "s2h on + resume → ExternalPower lid = suspend"; ok = (s2hOn.services.logind.settings.Login.HandleLidSwitchExternalPower or null) == "suspend"; } { n = "s2h off + resume → lid not forced to s2h"; ok = (s2hOff.services.logind.settings.Login.HandleLidSwitch or null) != "suspend-then-hibernate"; } { n = "s2h on without resume → no s2h lid policy"; ok = (s2hNoResume.services.logind.settings.Login.HandleLidSwitch or null) != "suspend-then-hibernate"; } { n = "hand-set s2h=true outranks state false"; ok = pinned.nomarchy.system.power.suspendThenHibernate == true; } ]; failed = builtins.filter (c: !c.ok) cases; in if failed != [ ] then throw '' State bridge broken — a settings.* key no longer reaches the config: ${nixpkgs.lib.concatMapStringsSep "\n" (c: " ✗ ${c.n}") failed} Read the state via modules/state-read.nix and mkDefault it in the module — `config.nomarchy.settings` does NOT exist on the NixOS side, and `or ` swallows that silently. See ROADMAP § "NixOS-side state bridges (#116)".'' else pkgs.runCommand "nomarchy-state-bridges" { } "touch $out"; # all ${toString (builtins.length cases)} assertions held at eval # The bridge, end to end on a booted machine: checks.state-bridges # proves the option flips at eval, this proves a state key alone # reaches a RUNNING service. The node sets no # nomarchy.services.printing.enable anywhere — the only thing # asking for CUPS is settings.printing.enable in the state file, # which is exactly what Control Center's Printing toggle writes and # what nothing read until #116 (ROADMAP § state bridges). printing-from-state = pkgs.testers.runNixOSTest { name = "nomarchy-printing-from-state"; nodes.machine = { ... }: { imports = [ ./modules/nixos/options.nix ./modules/nixos/services.nix ]; nomarchy.system.stateFile = flippedState; }; testScript = '' machine.wait_for_unit("multi-user.target") # CUPS is up because a JSON key said so, and for no other reason. machine.wait_for_unit("cups.service") # mDNS rides along, so a network printer is discoverable — # the half of the toggle's promise that isn't the daemon. machine.wait_for_unit("avahi-daemon.service") ''; }; # Every hex-on-hex text pairing the generated swaync CSS uses # must contrast in EVERY palette — summer-day's body text was # invisible on hardware (item 25: subtext==base there). Cheap # (no VM); extend the script's PAIRINGS as more generated # surfaces move to palette-safe roles (item 27: waybar/rofi). theme-contrast = pkgs.runCommand "nomarchy-theme-contrast" { nativeBuildInputs = [ pkgs.python3 ]; } '' python3 ${./tools/check-theme-contrast.py} ${./themes} touch $out ''; # Whole-swap waybar.css is read raw (no palette prepend) — every # @color it references must be @define-color'd in the same file # (neon-glass shipped zero defines; BACKLOG #62). Also gate # preview.png + non-empty backgrounds/ per preset. theme-wholeswap = pkgs.runCommand "nomarchy-theme-wholeswap" { nativeBuildInputs = [ pkgs.python3 ]; } '' python3 ${./tools/check-theme-wholeswap.py} ${./themes} touch $out ''; # The README option tables must track the live `nomarchy.*` # surface. The surface is walked at eval time from the four # option-declaring modules (their lazy `config` halves stay # unevaluated), so the live side of the diff can't go stale; # the script wants a table row per option (inline `.leaf` # mentions ok for non-enable sub-knobs) and flags stale rows. option-docs = let lib = nixpkgs.lib; names = prefix: opts: lib.concatLists (lib.mapAttrsToList (n: v: if lib.isOption v then lib.optional (!(v.readOnly or false)) "${prefix}${n}" else names "${prefix}${n}." v) opts); surface = lib.concatLists [ (names "nomarchy." ((import ./modules/home/options.nix { inherit lib pkgs; config = { }; }).options.nomarchy)) (names "nomarchy.system." ((import ./modules/nixos/options.nix { inherit lib; config = { }; }).options.nomarchy.system)) (names "nomarchy.hardware." ((import ./modules/nixos/hardware.nix { inherit lib pkgs; config = { }; }).options.nomarchy.hardware)) (names "nomarchy.services." ((import ./modules/nixos/services.nix { inherit lib pkgs; config = { }; }).options.nomarchy.services)) ]; in pkgs.runCommand "nomarchy-option-docs" { nativeBuildInputs = [ pkgs.python3 ]; } '' python3 ${./tools/check-option-docs.py} \ ${pkgs.writeText "option-surface.txt" (lib.concatStringsSep "\n" surface + "\n")} \ ${./README.md} touch $out ''; # The state-file friendly-errors contract (item 11): validate # reports broken fixtures by field with a fix, good state # passes, and an invalid `set` is refused BEFORE the write — # the on-disk file must be byte-identical after the attempt. state-sync-validate = pkgs.runCommand "nomarchy-state-sync-validate" { nativeBuildInputs = [ pkgs.python3 pkgs.jq ]; } '' set -eu tool=${./pkgs/nomarchy-state-sync/nomarchy-state-sync.py} export NOMARCHY_DEFAULT_THEMES=${./themes} mkdir good && cp ${./templates/downstream/state.json} good/state.json chmod +w good/state.json NOMARCHY_PATH=$PWD/good python3 "$tool" validate mkdir syntax && printf '{ "slug": "x", }' > syntax/state.json if NOMARCHY_PATH=$PWD/syntax python3 "$tool" validate 2>err; then echo "FAIL: syntax fixture accepted"; exit 1; fi grep -q "syntax error at line" err mkdir badhex && jq '.colors.accent = "7aa2f7"' \ good/state.json > badhex/state.json if NOMARCHY_PATH=$PWD/badhex python3 "$tool" validate 2>err; then echo "FAIL: bad hex accepted"; exit 1; fi grep -q "colors.accent" err && grep -q "fix:" err mkdir badtype && jq '.ui.gapsOut = "12"' \ good/state.json > badtype/state.json if NOMARCHY_PATH=$PWD/badtype python3 "$tool" validate 2>err; then echo "FAIL: bad type accepted"; exit 1; fi grep -q "ui.gapsOut" err mkdir unknown && jq '.extraKey = 1' \ good/state.json > unknown/state.json NOMARCHY_PATH=$PWD/unknown python3 "$tool" validate \ | grep -q "unknown top-level key" cp good/state.json before.json if NOMARCHY_PATH=$PWD/good python3 "$tool" --quiet \ set ui.gapsOut '"12"' --no-switch 2>err; then echo "FAIL: invalid set was written"; exit 1; fi grep -q "refusing to write" err cmp good/state.json before.json touch $out ''; # The lifecycle "sweep before switch": with settings.autoCommit # on, nomarchy-pull/-rebuild/-home first commit every dirty file # in the machine flake (state-sync's own auto_commit stays # pathspec-limited to state.json). Exercises the real # helper binary against sandbox repos: flag on → one commit, # clean tree, fallback identity, file list in the body; clean # tree / flag off / no repo → untouched, never an error. lifecycle-autocommit = pkgs.runCommand "nomarchy-lifecycle-autocommit" { nativeBuildInputs = [ pkgs.git ]; } '' set -eu tool=${pkgs.nomarchy-lifecycle.autocommit}/bin/nomarchy-autocommit export HOME=$PWD # no global git config → fallback identity mkdir flake && git -C flake init -q printf '{ "settings": { "autoCommit": true } }' > flake/state.json echo base > flake/system.nix git -C flake add -A git -C flake -c user.name=T -c user.email=t@t commit -qm init echo edit >> flake/system.nix echo lock > flake/flake.lock NOMARCHY_PATH=$PWD/flake $tool rebuild [ -z "$(git -C flake status --porcelain)" ] \ || { echo "FAIL: tree still dirty after sweep"; exit 1; } git -C flake log -1 --format=%s \ | grep -qx 'nomarchy: auto-commit before rebuild' git -C flake log -1 --format=%ae | grep -qx 'nomarchy@localhost' git -C flake log -1 --format=%b | grep -q 'flake.lock' # Clean tree → no new commit NOMARCHY_PATH=$PWD/flake $tool rebuild [ "$(git -C flake rev-list --count HEAD)" = 2 ] # Flag off → dirty stays dirty, no commit printf '{ "settings": { "autoCommit": false } }' > flake/state.json echo more >> flake/system.nix NOMARCHY_PATH=$PWD/flake $tool rebuild [ "$(git -C flake rev-list --count HEAD)" = 2 ] [ -n "$(git -C flake status --porcelain)" ] # Not a git repo → silent no-op, exit 0 mkdir plain printf '{ "settings": { "autoCommit": true } }' > plain/state.json NOMARCHY_PATH=$PWD/plain $tool rebuild touch $out ''; # Installer hardware DB ↔ nixos-hardware (item 49): every module # name the DB table and the CPU/GPU/chassis autodetect reference # must exist in the PINNED nixos-hardware.nixosModules — a lock # bump can rename a module upstream (the X1 Carbon → x1-Nth-gen # churn) and break profiled installs on just the matching DMI, # invisible to any VM. The available-set is generated here from # attrNames — the same value fed to nomarchy-install — so it # tracks the lock automatically. hardware-db-modules = pkgs.runCommand "nomarchy-hardware-db-modules" { nativeBuildInputs = [ pkgs.python3 ]; } '' python3 ${./tools/check-hardware-db.py} \ ${./pkgs/nomarchy-install/hardware-db.sh} \ ${pkgs.writeText "nixos-hardware-modules.txt" (nixpkgs.lib.concatStringsSep "\n" (builtins.attrNames nixos-hardware.nixosModules))} touch $out ''; # compose-lock.py offline-composer contract (item 49): the lock # the installer writes on the target with no network must root at # nomarchy, carry every upstream node one level down with its # `follows` paths rebased, and fail closed on a missing narHash or # a pre-existing nomarchy node. Pure — runs the script on fixtures. installer-compose-lock = pkgs.runCommand "nomarchy-installer-compose-lock" { nativeBuildInputs = [ pkgs.python3 ]; } '' python3 ${./tools/check-compose-lock.py} \ ${./pkgs/nomarchy-install/compose-lock.py} touch $out ''; # Installer safety guards (item 54): offline compose-lock fail-closed # (no network flake lock), disk-signature warn regex, pre-activate # recovery hint path, password floors, disk-picker filter (#112). # Pure source+regex contract. installer-safety = pkgs.runCommand "nomarchy-installer-safety" { nativeBuildInputs = [ pkgs.python3 ]; } '' python3 ${./tools/check-install-safety.py} \ ${./pkgs/nomarchy-install/nomarchy-install.sh} \ ${./pkgs/nomarchy-install/patch-template.py} \ ${./templates/downstream} touch $out ''; # Menu Back/Left contract (#106): every internal rofi_menu list # emits ↩ Back (external modi / free-text keep Esc exceptions). menu-back = pkgs.runCommand "nomarchy-menu-back" { nativeBuildInputs = [ pkgs.python3 ]; } '' python3 ${./tools/check-menu-back.py} ${./modules/home/rofi.nix} touch $out ''; # Keybinds cheatsheet completeness + grouping (#108): every bind # from keybinds.nix has a group, and every desc appears in the # rendered cheatsheet (same source hyprland.nix binds from). keybinds-cheatsheet = let inherit (nixpkgs.lib) assertMsg concatLists concatMapStringsSep concatStringsSep elem filter hasInfix; kb = import ./modules/home/keybinds.nix; groups = [ "Window" "Workspace" "Menu" "Media" ]; allBinds = kb.binds ++ kb.multiLayoutBinds; badGroup = filter (b: !(elem (b.group or null) groups)) allBinds; extraBad = filter (e: !(elem (e.group or null) groups)) kb.extra; descs = map (b: b.desc) allBinds ++ map (e: e.desc) kb.extra; linesFor = g: let rows = filter (b: b.group == g) allBinds ++ filter (e: e.group == g) kb.extra; in if rows == [ ] then [ ] else [ ("── " + g + " ──") ] ++ map (r: r.desc) rows; sheet = concatStringsSep "\n" (concatLists (map linesFor groups)); missing = filter (d: !(hasInfix d sheet)) descs; ok = badGroup == [ ] && extraBad == [ ] && missing == [ ] && hasInfix "── Window ──" sheet && hasInfix "── Workspace ──" sheet && hasInfix "── Menu ──" sheet && hasInfix "── Media ──" sheet; msg = (if badGroup != [ ] then "binds missing/invalid group: " + concatMapStringsSep ", " (b: b.desc) badGroup + "\n" else "") + (if extraBad != [ ] then "extra missing/invalid group: " + concatMapStringsSep ", " (e: e.desc) extraBad + "\n" else "") + (if missing != [ ] then "descs missing from grouped sheet: " + concatStringsSep ", " missing + "\n" else ""); in assert assertMsg ok ("keybinds-cheatsheet: " + msg); pkgs.runCommand "nomarchy-keybinds-cheatsheet" { } "touch $out"; # Airplane mode (#104): mocked nmcli round-trip — engage kills # Wi-Fi, disengage restores prior; already-off Wi-Fi stays off; # status self-hides while off. Real radios are V3 (HARDWARE-QUEUE). airplane = pkgs.runCommand "nomarchy-airplane" { nativeBuildInputs = [ pkgs.bash pkgs.coreutils pkgs.gnugrep ]; } '' set -eu air=${pkgs.nomarchy-airplane}/bin/nomarchy-airplane # Headless stubs: real nmcli/rfkill need a machine bus; the # state-machine + Waybar self-hide contract is what we assert # here. Prior-restore against live radios is V3. # Shebang must be an absolute store bash — pure sandbox has # no /usr/bin/env. mock=$(mktemp -d) shebang='#!${pkgs.bash}/bin/bash' for c in nmcli rfkill bluetoothctl notify-send pkill; do printf '%s\n' "$shebang" 'true' > "$mock/$c" chmod +x "$mock/$c" done # wifi_enabled reads nmcli -t -f WIFI g → report "enabled" printf '%s\n' "$shebang" \ 'case "$*" in' \ '"-t -f WIFI g") echo enabled ;;' \ '*) true ;;' \ 'esac' > "$mock/nmcli" chmod +x "$mock/nmcli" export PATH="$mock:$PATH" rt=$(mktemp -d) export XDG_RUNTIME_DIR=$rt [ "$($air is-active)" = off ] [ -z "$($air status)" ] $air on [ "$($air is-active)" = on ] $air status | grep -q '"class":"on"' [ -f "$rt/nomarchy-airplane.state" ] grep -q 'wifi_prior=1' "$rt/nomarchy-airplane.state" $air off [ "$($air is-active)" = off ] [ -z "$($air status)" ] [ ! -f "$rt/nomarchy-airplane.state" ] grep -q 'airplane' ${./modules/home/rofi.nix} grep -q 'Airplane mode' ${./modules/home/rofi.nix} grep -q 'nomarchy-menu airplane' ${./modules/home/keybinds.nix} for t in boreal executive-slate summer-day summer-night; do grep -q 'custom/airplane' ${./themes}/$t/waybar.jsonc grep -q 'custom-airplane' ${./themes}/$t/waybar.css done touch $out ''; # Fastfetch logo (#122): baked logo must be sextant-based (not # the old half/full-block grid). Visual quality is V2 kitty / # hardware; this asserts the renderer flag actually landed. fastfetch-logo = let logo = pkgs.runCommand "nomarchy-fastfetch-logo-check" { nativeBuildInputs = [ pkgs.imagemagick pkgs.librsvg pkgs.chafa ]; } '' rsvg-convert -w 360 ${./modules/nixos/branding/logo.svg} > logo.png magick logo.png -fill "#B79BE8" -colorize 100 logo-c.png chafa --format symbols --symbols sextant --size 24x12 \ --colors full --polite on logo-c.png > $out ''; in pkgs.runCommand "nomarchy-fastfetch-logo" { nativeBuildInputs = [ pkgs.python3 ]; } '' set -eu grep -q 'symbols sextant' ${./modules/home/fastfetch.nix} grep -q 'size 24x12' ${./modules/home/fastfetch.nix} python3 - <<'PY' from pathlib import Path t = Path("${logo}").read_text(encoding="utf-8") sext = sum(1 for c in t if 0x1FB00 <= ord(c) <= 0x1FBFF) if sext < 20: raise SystemExit(f"too few sextant chars: {sext}") print(f"fastfetch-logo: {sext} sextant chars") PY touch $out ''; # Generation prune (#128): pure selection self-test (14d + keep ≥3 # past) baked into the script; assert timer+service on the distro. gen-prune = let node = self.nixosConfigurations.nomarchy; timerWanted = builtins.elem "timers.target" (node.config.systemd.timers.nomarchy-gen-prune.wantedBy or [ ]); execStart = node.config.systemd.services.nomarchy-gen-prune.serviceConfig.ExecStart or ""; gcOpts = node.config.nix.gc.options or ""; noAgeWipe = gcOpts == "" || !(nixpkgs.lib.hasInfix "delete-older-than" gcOpts); in assert nixpkgs.lib.assertMsg timerWanted "gen-prune: timer not wanted by timers.target"; assert nixpkgs.lib.assertMsg (execStart != "") "gen-prune: service ExecStart empty"; assert nixpkgs.lib.assertMsg noAgeWipe "gen-prune: nix.gc.options must not use --delete-older-than (got: ${gcOpts})"; pkgs.runCommand "nomarchy-gen-prune" { nativeBuildInputs = [ pkgs.bash ]; } '' set -eu ${pkgs.nomarchy-gen-prune}/bin/nomarchy-gen-prune --self-test ${pkgs.nomarchy-gen-prune}/bin/nomarchy-gen-prune --dry-run >/dev/null touch $out ''; # Installer keyboard no-variant regression (item 91): gum's `(none)` # row is display-only. Exercise it through the real template # patcher, then build the exact us + empty-variant console keymap # which previously failed as xkb-console-keymap.drv. installer-keyboard = let keyboardSystem = nixpkgs.lib.nixosSystem { inherit system; modules = [{ console.useXkbConfig = true; services.xserver.xkb.layout = "us"; services.xserver.xkb.variant = ""; system.stateVersion = "26.05"; }]; }; in pkgs.runCommand "nomarchy-installer-keyboard" { nativeBuildInputs = [ pkgs.python3 ]; } '' python3 ${./tools/check-install-safety.py} \ ${./pkgs/nomarchy-install/nomarchy-install.sh} \ ${./pkgs/nomarchy-install/patch-template.py} \ ${./templates/downstream} test -s ${keyboardSystem.config.console.keyMap} touch $out ''; # Installer share ↔ templates/downstream SoT (item 72): the four # files default.nix copies into share/.../template/ must stay # byte-identical to the checkout template (single machine-seed # SoT; no thinner second catalog). Cheap package build + cmp. template-sot = pkgs.runCommand "nomarchy-template-sot" { nativeBuildInputs = [ pkgs.python3 ]; } '' python3 ${./tools/check-template-sot.py} \ ${nomarchyInstall}/share/nomarchy-install/template \ ${./templates/downstream} touch $out ''; # The installer's disko arg contract (item 49): swapSize "0"/"0G" # (bare "0" is what the swap=0 install passes) must yield NO @swap # subvolume, a sized value a correctly-sized one, and withLuks must # gate the LUKS wrapper around the BTRFS root. Pure eval of # disko-config.nix (a plain function) — no disko, no VM. installer-disko = let diskoFor = args: import ./pkgs/nomarchy-install/disko-config.nix ({ mainDrive = "/dev/vda"; } // args); rootContent = args: (diskoFor args).disko.devices.disk.main.content.partitions.root.content; btrfsOf = c: if c.type == "luks" then c.content else c; subvols = args: (btrfsOf (rootContent args)).subvolumes; hasSwap = args: builtins.hasAttr "@swap" (subvols args); expect = cond: msg: nixpkgs.lib.assertMsg cond "installer-disko: ${msg}"; ok = expect (! hasSwap { swapSize = "0"; }) ''swapSize "0" produced an @swap subvol'' && expect (! hasSwap { swapSize = "0G"; }) ''swapSize "0G" produced an @swap subvol'' && expect (hasSwap { swapSize = "2G"; }) ''swapSize "2G" produced no @swap subvol'' && expect ((subvols { swapSize = "2G"; })."@swap".swap.swapfile.size == "2G") ''the @swap swapfile size is not "2G"'' && expect ((rootContent { withLuks = true; }).type == "luks") "withLuks=true did not wrap root in luks" && expect ((rootContent { withLuks = true; }).name == "crypted") "the luks device is not named 'crypted'" && expect ((rootContent { withLuks = false; }).type == "btrfs") "withLuks=false did not put btrfs directly on root" && expect (builtins.hasAttr "@" (subvols { withLuks = false; })) "the no-luks root lost its @ subvolume"; in assert ok; pkgs.runCommand "nomarchy-installer-disko" { } "touch $out"; # Hyprland windowrule dead-syntax guard (item 49): build the # GENERATED hyprland.conf (the ISO's HM output) and fail on a # regression to the pre-0.53 grammar — the `windowrulev2` keyword, # rule-first order (`float, class:^…$`), or a bare `:` # matcher. That syntax shipped a red config-error banner on every # boot (fixed in ed7fd93); this locks the correct form. Building # one config file is a pure derivation, not a VM. windowrule-syntax = let hyprconf = self.nixosConfigurations.nomarchy-live.config .home-manager.users.${username}.xdg.configFile."hypr/hyprland.conf".source; in pkgs.runCommand "nomarchy-windowrule-syntax" { nativeBuildInputs = [ pkgs.python3 ]; } '' python3 ${./tools/check-windowrule-syntax.py} ${hyprconf} touch $out ''; # Live-ISO baseline apps (ROADMAP § live-ISO baseline apps, #103) # plus the mime-default presence guard (#94 / #119). The live # session shipped no browser and no office for as long as it # existed — the thing a user judges the distro by before installing, # and boots to rescue a dead machine. Nothing in a build fails when # an app silently leaves the live profile (it just stops being in # the launcher), and a mime default that names a missing .desktop is # silently skipped by GIO (no handler at all) — so assert both: # binaries + launcher entries for the baseline suite, and that # every Default Applications key has at least one listed .desktop # present in the live profile or system path (thunar is system-side). # Same artifacts the ISO ships, no ISO build. live-baseline-apps = let liveHm = self.nixosConfigurations.nomarchy-live.config.home-manager.users.${username}; gen = liveHm.home.activationPackage; # system.path carries NixOS-side apps (thunar); HM-only # home-path does not. A mime default that only names a # system desktop must still resolve. sysPath = self.nixosConfigurations.nomarchy-live.config.system.path; # Template install path: same mime defaults, different package # set (vscode present, gnome-text-editor not). Guard both so # a live-only fix cannot re-break the installed machine. templateGen = downstream.homeConfigurations.me.activationPackage; templateSys = downstream.nixosConfigurations.default.config.system.path; in pkgs.runCommand "nomarchy-live-baseline-apps" { nativeBuildInputs = [ pkgs.gnugrep pkgs.findutils pkgs.python3 ]; } '' set -euo pipefail apps=${gen}/home-path/share/applications bins=${gen}/home-path/bin # Binary + launcher entry for each baseline app. The .desktop # is what makes it discoverable — VISION's "discoverable # without reading the README" — so a bin without one fails. for d in chromium-browser.desktop \ startcenter.desktop writer.desktop calc.desktop \ impress.desktop \ org.gnome.TextEditor.desktop \ io.bassi.Amberol.desktop \ mpv.desktop \ org.gnome.Snapshot.desktop; do test -f "$apps/$d" || { echo "live-baseline-apps: MISSING launcher entry $d"; exit 1; } done for b in chromium libreoffice gnome-text-editor amberol mpv snapshot; do test -x "$bins/$b" || { echo "live-baseline-apps: MISSING binary $b"; exit 1; } done # HTTP must still resolve to the browser that is actually # present (#94: mime named chromium while nothing shipped it, # so GIO silently skipped the entry and the live session had # no default browser at all). mimes=${gen}/home-files/.config/mimeapps.list grep -q '^x-scheme-handler/https=chromium-browser.desktop' "$mimes" \ || { echo "live-baseline-apps: https mime must name chromium-browser.desktop (#94)"; exit 1; } test -f "$apps/chromium-browser.desktop" # text/plain must prefer vscode but fall through to the live # editor (#119) — never a singleton that only names code. grep -qE '^text/plain=.*org\.gnome\.TextEditor\.desktop' "$mimes" \ || { echo "live-baseline-apps: text/plain must fall through to org.gnome.TextEditor.desktop (#119)"; exit 1; } test -f "$apps/org.gnome.TextEditor.desktop" # Every Default Applications key: at least one listed # .desktop must exist in home-path OR system path. A key # whose every entry is missing is the #94/#119 bug class. # Covers live *and* the default template install. python3 - "$mimes" "$apps" "${sysPath}/share/applications" \ "${templateGen}/home-files/.config/mimeapps.list" \ "${templateGen}/home-path/share/applications" \ "${templateSys}/share/applications" <<'PY' import sys from pathlib import Path def check(label, mimes, *app_dirs): text = Path(mimes).read_text() in_defaults = False bad = [] for line in text.splitlines(): if line.startswith("["): in_defaults = line.strip() == "[Default Applications]" continue if not in_defaults or not line.strip() or line.startswith("#"): continue if "=" not in line: continue key, val = line.split("=", 1) desktops = [d for d in val.split(";") if d.strip()] if not desktops: bad.append(f"{key}= (empty)") continue if not any( any((Path(d) / desk).is_file() for d in app_dirs) for desk in desktops ): bad.append(f"{key}={';'.join(desktops)} (none present)") if bad: print(f"live-baseline-apps: mime defaults missing on {label}:") for b in bad: print(f" {b}") sys.exit(1) print(f"live-baseline-apps: mime defaults ok on {label}") check("live", sys.argv[1], sys.argv[2], sys.argv[3]) check("template", sys.argv[4], sys.argv[5], sys.argv[6]) PY # Firefox is deliberately absent — a second browser is the one # item in #103's list that costs a real closure, and chromium # owns the mime default (Bernardo 2026-07-14). If it ever # ships here, that is a size decision, not a drive-by. test ! -f "$apps/firefox.desktop" || { echo "live-baseline-apps: firefox reappeared — size decision, see #103"; exit 1; } echo "live-baseline-apps: ok — browser, office, editor, music, video, camera + mime defaults resolve" touch $out ''; # Live-ISO install affordance (BACKLOG #57): desktop entry baked # into the live host's HM generation + installer on PATH + Tools # menu gate in nomarchy-menu. Builds the real nomarchy-live HM # package (not a full ISO) — V2 file-level smoke of the same # artifacts the ISO ships. live-install-entry = let liveHm = self.nixosConfigurations.nomarchy-live.config.home-manager.users.${username}; gen = liveHm.home.activationPackage; in pkgs.runCommand "nomarchy-live-install-entry" { nativeBuildInputs = [ pkgs.gnugrep pkgs.findutils ]; passAsFile = [ ]; } '' set -euo pipefail desk=$(find ${gen}/home-path/share/applications \ -name nomarchy-install.desktop | head -1) test -n "$desk" grep -q '^Name=Install Nomarchy$' "$desk" grep -q '^Terminal=true$' "$desk" grep -q '^Exec=nomarchy-install$' "$desk" test -x ${nomarchyInstall}/bin/nomarchy-install # Tools › Install Nomarchy self-gate lives in nomarchy-menu # (rofi.nix), which is on the live HM path. menu=$(find ${gen}/home-path/bin -name nomarchy-menu | head -1) test -n "$menu" grep -q 'Install Nomarchy' "$menu" echo "live-install-entry: ok desk=$desk menu=$menu" touch $out ''; # smartd self-gate (BACKLOG #118). Where no drive answers SMART, # smartd exits 17 and systemd marks the unit FAILED — so the doctor # honestly reported a failed unit and Waybar went red on every QEMU # guest and most live USBs. Both halves matter and they pull in # opposite directions, which is why both are asserted here: a # gate that never skips leaves the red icon, and a gate that always # skips silently disables drive-health monitoring on real hardware — # the failure nobody would notice until a disk died quietly. smartd-gate = pkgs.testers.runNixOSTest { name = "nomarchy-smartd-gate"; nodes = { # QEMU virtio: no SMART. The machine Bernardo saw. Imports the # REAL distro module, so this guards the shipped wiring rather # than a restatement of it — runNixOSTest supplies the node's # pkgs, so the module's own `nixpkgs.config` has to yield. nosmart = { lib, pkgs, ... }: { imports = [ ./modules/nixos/default.nix ]; nixpkgs.config = lib.mkForce { allowUnfree = true; }; nomarchy.system.greeter.enable = false; # For the test's own probing only — the gate calls smartctl by # store path, so this changes nothing about what is measured. environment.systemPackages = [ pkgs.smartmontools ]; }; }; testScript = '' nosmart.wait_for_unit("multi-user.target") # The gate itself agrees there is nothing to monitor. cond = nosmart.succeed( "systemctl show -p ExecCondition --value smartd.service" ) assert "smartd-any-smart-device" in cond, f"gate not wired: {cond}" nosmart.succeed("smartctl --scan | tee /dev/stderr | (! grep -q .)") # Inactive, NOT failed — the whole point. Before #118 this was # 'failed' with Result=exit-code / status=17. state = nosmart.succeed( "systemctl show -p ActiveState --value smartd.service" ).strip() assert state == "inactive", f"expected inactive, got {state}" nosmart.fail("systemctl is-failed --quiet smartd.service") # And therefore the thing the user actually sees: the doctor's # failed-unit check is what reddened the Waybar icon. failed = nosmart.succeed("systemctl --failed --no-legend --plain") assert "smartd" not in failed, f"smartd still failed: {failed}" # The other half: a machine WITH a SMART device must still run # smartd. QEMU cannot answer SMART, so drive the gate's logic # against a scan that finds one, rather than pretend otherwise. nosmart.succeed( "mkdir -p /tmp/fake && " "printf '#!/bin/sh\\necho \"/dev/sda -d scsi # /dev/sda, SCSI device\"\\n' " "> /tmp/fake/smartctl && chmod +x /tmp/fake/smartctl" ) gate = cond.split("argv[]=")[1].split(" ")[0].strip("; ]") nosmart.succeed( f"sed 's|/nix/store/[^ ]*/bin/smartctl|/tmp/fake/smartctl|' {gate} > /tmp/gate2" ) nosmart.succeed("chmod +x /tmp/gate2") nosmart.succeed("/tmp/gate2") # exit 0 => smartd would start ''; }; # nomarchy-doctor's contract: an induced failed unit flips the # sheet to ✖/exit-1 and names the unit; with the failure # cleared it reports healthy/exit-0. Minimal node (just the # package) — the disk/flake/snapper checks self-skip in a VM. doctor = pkgs.testers.runNixOSTest { name = "nomarchy-doctor"; nodes.machine = { ... }: { environment.systemPackages = [ pkgs.nomarchy-doctor ]; systemd.services.doomed = { description = "deliberately failing unit (doctor test)"; serviceConfig = { Type = "oneshot"; ExecStart = "${pkgs.coreutils}/bin/false"; }; wantedBy = [ "multi-user.target" ]; }; }; testScript = '' machine.wait_for_unit("multi-user.target") machine.wait_until_succeeds("systemctl is-failed doomed.service") out = machine.fail("nomarchy-doctor 2>&1") assert "doomed.service" in out, f"doctor did not name the failed unit:\n{out}" assert "journalctl" in out, f"doctor did not print a fix:\n{out}" machine.succeed("systemctl reset-failed") # The healthy run also exercises the #44 hardware section: in a # bare VM every probe (nmcli/wpctl/vainfo/fprintd/fwupd) is # absent, so each check must self-gate to a skip row and the # sheet must still exit 0 — i.e. the section runs without # erroring under `set -euo pipefail`. healthy = machine.succeed("nomarchy-doctor 2>&1") for probe in ["NetworkManager", "fingerprint", "fwupd", "VA-API", "hibernate", "battery health"]: assert probe in healthy, f"doctor hardware section missing a self-gated '{probe}' row:\n{healthy}" # #80 battery health: faked sysfs (test_power has no cycle/design). machine.succeed( "mkdir -p /tmp/fake-ps/BAT0 && " "echo Battery > /tmp/fake-ps/BAT0/type && " "echo System > /tmp/fake-ps/BAT0/scope && " "echo 150 > /tmp/fake-ps/BAT0/cycle_count && " "echo 4000000 > /tmp/fake-ps/BAT0/charge_full && " "echo 5000000 > /tmp/fake-ps/BAT0/charge_full_design" ) bat = machine.succeed( "NOMARCHY_POWER_SUPPLY_ROOT=/tmp/fake-ps nomarchy-doctor 2>&1" ) assert "BAT0 health" in bat, f"doctor missing battery health line:\n{bat}" assert "150 cycles" in bat, f"doctor missing cycle_count:\n{bat}" assert "80% of design capacity" in bat, f"doctor missing design %:\n{bat}" # Peripheral Device-scope batteries must not produce a health line. machine.succeed( "mkdir -p /tmp/fake-ps/MOUSE0 && " "echo Battery > /tmp/fake-ps/MOUSE0/type && " "echo Device > /tmp/fake-ps/MOUSE0/scope && " "echo 9 > /tmp/fake-ps/MOUSE0/cycle_count" ) bat2 = machine.succeed( "NOMARCHY_POWER_SUPPLY_ROOT=/tmp/fake-ps nomarchy-doctor 2>&1" ) assert "MOUSE0" not in bat2, f"doctor reported Device-scope battery:\n{bat2}" # #83 first-boot pre-activate fail flag: log present, no HM gen → ✖. machine.succeed( "mkdir -p /var/log && " "echo 'error: activation failed' > /var/log/nomarchy-hm-preactivate.log" ) # Bare test VM has no per-user HM profile for root. pre = machine.fail("nomarchy-doctor 2>&1") assert "pre-activat" in pre.lower() or "Home Manager generation" in pre, ( f"doctor missing pre-activate fail line:\n{pre}" ) assert "home-manager switch" in pre, f"doctor missing recovery one-liner:\n{pre}" # Once a generation exists, the flag clears (recovered). machine.succeed( "mkdir -p /nix/var/nix/profiles/per-user/root && " "ln -sfn /run/current-system " "/nix/var/nix/profiles/per-user/root/home-manager" ) pre_ok = machine.succeed("nomarchy-doctor 2>&1") assert "desktop generation is present" in pre_ok, ( f"doctor should ok when HM gen exists:\n{pre_ok}" ) ''; }; # Config-level VM assertions for the nomarchy.hardware.* toggles — # what they SET (kernel cmdline, fprintd, PAM). Real hardware # behaviour (firmware/driver/device) needs bare metal and is out of # scope here. Imports only hardware.nix, so the VM stays minimal. # Three nodes: pam=true wires the PARALLEL module by default # (pam_fprintd_grosshack in stock fprintd's rule slot, before # pam_unix) and password-only auth must still work with no # reader present — the lockout-safety invariant; parallel=false # wires stock sequential pam_fprintd; pam=false wires neither # (NixOS defaults fprintAuth from fprintd.enable — we force off). hardware-toggles = pkgs.testers.runNixOSTest { name = "nomarchy-hardware-toggles"; nodes = { machine = { ... }: { imports = [ ./modules/nixos/hardware.nix ]; nomarchy.hardware = { intel.enable = true; amd.enable = true; fingerprint = { enable = true; pam = true; }; npu.enable = true; }; users.users.alice = { isNormalUser = true; password = "t0ps3cret"; extraGroups = [ "wheel" ]; }; }; seqpam = { ... }: { imports = [ ./modules/nixos/hardware.nix ]; nomarchy.hardware.fingerprint = { enable = true; pam = true; parallel = false; }; }; nopam = { ... }: { imports = [ ./modules/nixos/hardware.nix ]; nomarchy.hardware.fingerprint = { enable = true; pam = false; }; }; }; testScript = '' machine.wait_for_unit("multi-user.target") cmdline = machine.succeed("cat /proc/cmdline") assert "amd_pstate=active" in cmdline, cmdline assert "i915.enable_guc=3" in cmdline, cmdline machine.succeed("systemctl cat fprintd.service") machine.succeed("grep -q pam_fprintd_grosshack /etc/pam.d/sudo") # The parallel rule must be an auth line ordered BEFORE # pam_unix — its typed password is only ferried to pam_unix. machine.succeed( "awk '/^auth/ && /pam_fprintd_grosshack/ {g=NR} " "/^auth/ && /pam_unix/ {u=NR} " "END {exit !(g && u && g < u)}' /etc/pam.d/sudo" ) # Lockout safety: this VM has no fingerprint reader, so the # grosshack rule can only fail through — the password alone # must still authenticate sudo (and a wrong one must not). machine.succeed("su - alice -c 'echo t0ps3cret | sudo -S -k true'") machine.fail("su - alice -c 'echo wrongpw | sudo -S -k true'") seqpam.wait_for_unit("multi-user.target") seqpam.succeed("grep -q pam_fprintd /etc/pam.d/sudo") seqpam.fail("grep -q pam_fprintd_grosshack /etc/pam.d/sudo") nopam.wait_for_unit("multi-user.target") nopam.succeed("systemctl cat fprintd.service") nopam.fail("grep -q pam_fprintd /etc/pam.d/sudo") nopam.fail("grep -q pam_fprintd /etc/pam.d/login") ''; }; # Dock audio sink priority (#87): WirePlumber rules prefer # HDMI/DP/USB sinks over built-in analog. Pure unit test of the # rule set + a minimal VM that the conf file is installed where # WirePlumber reads it (XDG_DATA_DIRS / share/wireplumber). dock-audio = let inherit (nixpkgs) lib; rules = import ./modules/nixos/dock-audio-rules.nix; alsa = rules."monitor.alsa.rules" or [ ]; expect = cond: msg: lib.assertMsg cond "dock-audio: ${msg}"; json = builtins.toJSON alsa; pureOk = expect (builtins.isList alsa && builtins.length alsa >= 2) "expected ≥2 alsa rule groups" && expect (lib.hasInfix "hdmi" json) "HDMI match missing from rules JSON" && expect (lib.hasInfix "usb-" json || lib.hasInfix "usb" json) "USB match missing from rules JSON" && expect (lib.hasInfix "priority.session" json) "priority.session missing" && expect (lib.hasInfix "1100" json) "HDMI session priority 1100 missing"; in assert pureOk; pkgs.testers.runNixOSTest { name = "nomarchy-dock-audio"; nodes.machine = { ... }: { services.pipewire = { enable = true; alsa.enable = true; pulse.enable = true; wireplumber.enable = true; wireplumber.extraConfig."90-nomarchy-dock-audio" = import ./modules/nixos/dock-audio-rules.nix; }; # WirePlumber is a user service; enable linger-less check via # the system data dir the package puts conf into. environment.systemPackages = [ pkgs.wireplumber ]; }; testScript = '' machine.wait_for_unit("multi-user.target") conf = machine.succeed( "find /nix/store -path " "'*wireplumber.conf.d/90-nomarchy-dock-audio.conf' " "2>/dev/null | head -1" ).strip() assert conf, "90-nomarchy-dock-audio.conf not in the store" body = machine.succeed(f"cat '{conf}'") assert "monitor.alsa.rules" in body, body assert "priority.session" in body, body assert "hdmi" in body.lower() or "HDMI" in body, body assert "usb" in body.lower(), body ''; }; # Clamshell / dock lid policy (#86): logind must ignore lid close # when docked (external display present). Real multi-monitor lid # events stay V3; this asserts the baked logind.conf + that # logind is alive to consume it. Imports options + power only. clamshell-logind = pkgs.testers.runNixOSTest { name = "nomarchy-clamshell-logind"; nodes.machine = { ... }: { imports = [ ./modules/nixos/options.nix ./modules/nixos/power.nix ]; nomarchy.system.power = { enable = true; laptop = true; }; }; testScript = '' machine.wait_for_unit("multi-user.target") machine.wait_for_unit("systemd-logind.service") conf = machine.succeed("cat /etc/systemd/logind.conf") assert "HandleLidSwitchDocked=ignore" in conf.replace(" ", ""), ( f"clamshell: logind.conf missing HandleLidSwitchDocked=ignore:\n{conf}" ) # logind must be running (policy is dead if the unit is down). machine.succeed("systemctl is-active systemd-logind.service") # busctl property: Docked lid policy is not always exported; # re-read conf after logind start is enough for the contract. ''; }; # Runtime VM check for the battery-charge-limit re-apply: the udev # rule must start the oneshot on an AC state change (firmware can # clear the threshold on unplug). The `test_power` module fakes a # Mains adapter we can toggle to emit a real power_supply uevent — # so this exercises the trigger, not just the config wiring. (The # sysfs *write* needs a real charge_control_end_threshold and stays # an on-hardware check.) Imports options.nix (where the power # options live) + power.nix, so the VM stays minimal. battery-charge-limit = pkgs.testers.runNixOSTest { name = "nomarchy-battery-charge-limit"; nodes.machine = { pkgs, ... }: { imports = [ ./modules/nixos/options.nix ./modules/nixos/power.nix ]; boot.kernelModules = [ "test_power" ]; # fake Mains + battery nomarchy.system.power = { enable = true; laptop = true; batteryChargeLimit = 80; }; # Keep the oneshot active long enough to prove that a burst of # Mains events coalesces instead of interrupting it. systemd.services.nomarchy-battery-charge-limit.serviceConfig.ExecStartPost = "${pkgs.coreutils}/bin/sleep 3"; }; testScript = '' machine.wait_for_unit("multi-user.target") # The oneshot exists and the fake Mains adapter is present. machine.succeed("systemctl cat nomarchy-battery-charge-limit.service") machine.succeed("udevadm settle") machine.succeed("grep -lx Mains /sys/class/power_supply/*/type") # Let boot/add-event starts and their delayed passes settle, # then sample the last successful invocation. machine.succeed("sleep 5") machine.wait_until_succeeds( 'test "$(systemctl show -p ActiveState --value ' 'nomarchy-battery-charge-limit.service)" = inactive', timeout=30, ) before = machine.succeed( "systemctl show -p InvocationID --value " "nomarchy-battery-charge-limit.service" ).strip() # Simulate an AC unplug, wait until the oneshot is in its # three-second active window, then emit a dock-like burst of # real power_supply change events during that same invocation. machine.succeed("echo off > /sys/module/test_power/parameters/ac_online") machine.succeed("udevadm settle") machine.wait_until_succeeds( 'test "$(systemctl show -p ActiveState --value ' 'nomarchy-battery-charge-limit.service)" = activating', timeout=10, ) mains = machine.succeed( "grep -lx Mains /sys/class/power_supply/*/type" ).strip().removesuffix("/type") machine.succeed( f"for i in $(seq 1 12); do " f"udevadm trigger --action=change {mains}; done; udevadm settle" ) # The delayed starts have all fired, the service returned to a # clean inactive state, and at least one new invocation ran. machine.succeed("sleep 8") machine.wait_until_succeeds( 'test "$(systemctl show -p ActiveState --value ' 'nomarchy-battery-charge-limit.service)" = inactive', timeout=30, ) machine.fail( "systemctl is-failed --quiet nomarchy-battery-charge-limit.service" ) machine.succeed( 'test "$(systemctl show -p Result --value ' 'nomarchy-battery-charge-limit.service)" = success' ) after = machine.succeed( "systemctl show -p InvocationID --value " "nomarchy-battery-charge-limit.service" ).strip() assert after != before, ( f"battery charge limit was not re-invoked: {before}" ) service_log = machine.succeed( "journalctl -b -u nomarchy-battery-charge-limit.service --no-pager" ) assert "start-limit-hit" not in service_log, service_log assert "Failed with result" not in service_log, service_log # SPACED storm (T14s, 2026-07-13): when each run finishes # before the next AC event arrives nothing coalesces, and 5 # successful starts inside systemd's default 10s window mark # the unit failed (start-limit-hit) although every run # succeeded. The unit must stay exempt from start rate # limiting — assert the exemption, then prove repeated # back-to-back starts leave a clean state. machine.succeed( 'test "$(systemctl show -p StartLimitIntervalUSec --value ' 'nomarchy-battery-charge-limit.service)" = 0' ) machine.succeed( "for i in $(seq 1 8); do " "systemctl start nomarchy-battery-charge-limit.service; done" ) machine.fail( "systemctl is-failed --quiet nomarchy-battery-charge-limit.service" ) service_log = machine.succeed( "journalctl -b -u nomarchy-battery-charge-limit.service --no-pager" ) assert "start-limit-hit" not in service_log, service_log ''; }; # Unit test of the monitor-rule overlay (modules/home/ # monitor-rules.nix — pure, imported directly, no HM eval): the # active profile must replace base entries whole by name, leave # unnamed base outputs alone, and the disable-guard must stop a # stale menu resolution pick (settings.monitors) resurrecting a # panel the profile disables. Junk in settings.displayProfile # degrades to base config, never an eval error. display-profiles = let inherit (nixpkgs) lib; monitorLib = import ./modules/home/monitor-rules.nix { inherit lib; }; entry = a: monitorLib.defaults // a; fixture = { base = [ (entry { name = "eDP-1"; position = "0x0"; }) (entry { name = "HDMI-A-1"; position = "auto-right"; }) ]; profiles.docked = [ (entry { name = "eDP-1"; resolution = "disable"; }) (entry { name = "DP-3"; position = "0x0"; }) ]; resOverrides."eDP-1" = "1920x1080@60"; # stale pick — must stay dead }; rulesFor = active: map monitorLib.rule (monitorLib.resolve (fixture // { inherit active; })); docked = rulesFor "docked"; junk = rulesFor 42; # hand-edited state (validator only warns) expect = cond: msg: lib.assertMsg cond "display-profiles: ${msg} (rendered: ${builtins.toJSON docked})"; ok = expect (lib.elem "eDP-1, disable" docked) "the profile did not disable eDP-1" && expect (! lib.any (lib.hasInfix "1920x1080") docked) "a stale resolution pick resurrected the disabled panel" && expect (lib.elem "DP-3, preferred, 0x0, 1" docked) "the profile-only output's rule is missing" && expect (lib.elem "HDMI-A-1, preferred, auto-right, 1" docked) "an unnamed base output lost its rule" && expect (lib.elem "eDP-1, 1920x1080@60, 0x0, 1" junk) "junk active profile must degrade to base config (with the pick applied)" && expect (monitorLib.workspaceRule "1" "DP-3" == "1, monitor:DP-3") "workspace pin did not render as a Hyprland workspace rule"; in assert ok; pkgs.runCommand "nomarchy-display-profiles" { } "touch $out"; # The layout indicator's gate, and the parity of a whole-swap bar # with the generated one. Both halves are pure — the interesting # inputs (a fresh runtime memory, junk device rows) are awkward to # stage in a VM and trivial to state here. waybar-language = let inherit (nixpkgs) lib; langLib = import ./modules/home/waybar-language.nix { inherit lib; }; expect = cond: msg: lib.assertMsg cond "waybar-language: ${msg}"; swap = builtins.fromJSON (builtins.readFile ./themes/boreal/waybar.jsonc); gatedOff = langLib.gate false swap; inSwap = bar: lib.any (n: builtins.isList bar.${n} && lib.elem "hyprland/language" bar.${n}) (lib.filter (lib.hasPrefix "modules-") (lib.attrNames bar)); ok = expect (! langLib.show { layout = "us"; }) "a plain single-layout session must not show the indicator" && expect (langLib.show { layout = "us,pt"; }) "two session layouts must show the indicator" && expect (langLib.show { layout = "us"; devices.dell-kb.layout = "pt"; }) "a declared per-device override must show the indicator" && expect (langLib.show { layout = "us"; remembered."by-tech-gaming-keyboard" = "pt"; }) "a freshly remembered device must show the indicator before it graduates" && expect (! langLib.show { layout = "us"; remembered = { "generic-benq-gw2790qt" = "us"; "by-tech-gaming-keyboard-consumer-control" = "us"; }; }) "devices remembered at the session layout add no second answer" && expect (! langLib.show { layout = "us"; devices.dell-kb.layout = "us"; }) "a declared override at the session layout adds no second answer" # The pool is picker candidates only (hyprland.nix never # merges it into kb_layout), so it cannot raise the indicator # by itself — a device actually using one of them does. && expect (! langLib.show { layout = "us"; }) "the candidate pool alone must not show the indicator" && expect (inSwap swap) "fixture drift: boreal's whole-swap no longer lists the language module" && expect (! inSwap gatedOff) "a whole-swap must hide the module wherever the generated bar does" && expect (langLib.gate true swap == swap) "gating on must leave a whole-swap untouched"; in assert ok; pkgs.runCommand "nomarchy-waybar-language" { } "touch $out"; # A whole-swap's commands must not read the session environment. # Waybar is spawned by Hyprland and never sources a login shell, so # `home.sessionVariables` (TERMINAL, EDITOR …) simply are not there: # the on-click expands to nothing, the click does nothing, and there # is no error to notice (#141 — it went unseen because only the # whole-swap themes hand-write these; the generated module bakes the # value at eval time and cannot regress). Guards the class: the next # hand-written jsonc reaching for an env var fails here instead. waybar-swap-env = let inherit (nixpkgs) lib; expect = cond: msg: lib.assertMsg cond "waybar-swap-env: ${msg}"; swaps = lib.filter (n: builtins.pathExists (./themes + "/${n}/waybar.jsonc")) (builtins.attrNames (builtins.readDir ./themes)); # `$1`/`$2` (awk, sh positionals) are fine — a *name* after the # sigil is what marks an environment read. readsEnv = s: builtins.match ".*\\$[A-Za-z_{].*" s != null; isCmdKey = k: lib.hasPrefix "on-click" k || lib.hasPrefix "on-scroll" k || k == "exec" || k == "exec-if"; offendersIn = name: let bar = builtins.fromJSON (builtins.readFile (./themes + "/${name}/waybar.jsonc")); in lib.concatMap (mod: let m = bar.${mod}; in if !builtins.isAttrs m then [ ] else lib.concatMap (k: let v = m.${k}; in if isCmdKey k && builtins.isString v && readsEnv v then [ "${name}: ${mod}.${k} = ${v}" ] else [ ]) (builtins.attrNames m)) (builtins.attrNames bar); offenders = lib.concatMap offendersIn swaps; ok = expect (swaps != [ ]) "fixture drift: no theme ships a waybar.jsonc — the guard is checking nothing" && expect (offenders == [ ]) ("a whole-swap command reads the environment, which Waybar does not have: " + lib.concatStringsSep "; " offenders); in assert ok; pkgs.runCommand "nomarchy-waybar-swap-env" { } "touch $out"; # A text menu must fit its longest label, which is a count of # characters — so the picker has to be sized in the theme's own font # (`ch`), never in % of the monitor or fixed px (#131: boreal pinned # 620px and truncated Recovery on a 2560 panel; 40% themes truncate at # 1366 while looking fine at 2560, and a 14pt mono theme needs ~25% # more room than an 11pt one on both). The `min` cap is the other half # — without it a big font on a small panel sprawls across the screen. # Grid views (theme picker, drun) legitimately size by icons and # override width per-invocation; this only governs the base theme. rofi-text-width = let inherit (nixpkgs) lib; expect = cond: msg: lib.assertMsg cond "rofi-text-width: ${msg}"; swaps = lib.filter (n: builtins.pathExists (./themes + "/${n}/rofi.rasi")) (builtins.attrNames (builtins.readDir ./themes)); widthOf = name: let text = builtins.readFile (./themes + "/${name}/rofi.rasi"); m = builtins.match ".*window[[:space:]]*\\{[^}]*width:[[:space:]]*([^;]+);.*" text; in if m == null then null else lib.head m; bad = lib.filter (name: let w = widthOf name; in w == null || builtins.match ".*ch.*" w == null || builtins.match ".*min.*" w == null) swaps; ok = expect (swaps != [ ]) "fixture drift: no theme ships a rofi.rasi — the guard is checking nothing" && expect (bad == [ ]) ("a whole-swap sizes its text menu without `ch` and a `min` cap, so it " + "cannot fit its own font's labels: " + lib.concatStringsSep ", " bad); in assert ok; pkgs.runCommand "nomarchy-rofi-text-width" { } "touch $out"; # Docking artifact guard: default/no-profile Home Manager output # must still launch both hotplug watchers, put Rofi on the focused # monitor, expose the manual keyboard picker, and never ship the # zero-output-prone "Laptop screen off" menu action again. docking-ux = let hm = self.nixosConfigurations.nomarchy-live.config .home-manager.users.${username}; gen = hm.home.activationPackage; hyprconf = hm.xdg.configFile."hypr/hyprland.conf".source; in assert hm.programs.rofi.extraConfig.monitor == -1; pkgs.runCommand "nomarchy-docking-ux" { nativeBuildInputs = [ pkgs.bash pkgs.findutils pkgs.gnugrep ]; } '' set -euo pipefail menu=$(find ${gen}/home-path/bin -name nomarchy-menu | head -1) keyboard=$(find ${gen}/home-path/bin -name nomarchy-keyboard-layout | head -1) kbwatch=$(find ${gen}/home-path/bin -name nomarchy-keyboard-watch | head -1) watcher=$(find ${gen}/home-path/bin -name nomarchy-display-profile-watch | head -1) transition=$(find ${gen}/home-path/bin -name nomarchy-display-transition | head -1) audio=$(find ${gen}/home-path/bin -name nomarchy-dock-audio | head -1) test -n "$menu" -a -n "$keyboard" -a -n "$watcher" \ -a -n "$transition" -a -n "$audio" -a -n "$kbwatch" bash -n "$menu" bash -n "$keyboard" bash -n "$kbwatch" bash -n "$watcher" bash -n "$transition" bash -n "$audio" "$keyboard" layouts > layouts.txt test "$(wc -l < layouts.txt)" -gt 50 grep -qx us layouts.txt grep -qx gb layouts.txt grep -qx pt layouts.txt grep -q 'nomarchy-keyboard-watch' ${hyprconf} grep -q 'nomarchy-menu keyboard' ${hyprconf} grep -q 'nomarchy-display-profile-watch' ${hyprconf} grep -q -- '--quiet wallpaper' "$watcher" grep -q 'Dock mode' "$menu" grep -q 'nomarchy-display-transition dock' "$menu" grep -q -- '--what=handle-lid-switch' "$watcher" grep -q 'systemd-inhibit' "$watcher" grep -q 'systemd-inhibit --list --json=short' "$watcher" grep -q 'lid-inhibitor=stale-cleaned' "$watcher" grep -q 'setsid.*systemd-inhibit' "$watcher" grep -q 'nomarchy-dock-lid-inhibitor' "$watcher" ! grep -q 'exec -a nomarchy-dock-lid-inhibitor' "$watcher" grep -Fq 'kill -TERM -"$pid"' "$watcher" grep -q 'exec 3< <(' "$watcher" grep -q '"$TRANSITION" undock' "$watcher" grep -q 'nomarchy-dock-audio reprobe monitoradded' "$watcher" # A dock plugged while the desktop sits idle must still be # seen: `socat -T` closed the IPC socket after a second of # quiet and lost the event in the reconnect gap, so the # output set — not the event — decides, on a read timeout. ! grep -q 'socat -T' "$watcher" grep -Fq 'read -r -t 1 line <&3' "$watcher" grep -q 'reconcile "$hint"' "$watcher" grep -q 'outputs-changed added=' "$watcher" # One physical keyboard exposes several key-capable evdev # nodes; only udev's own classification may raise a prompt. grep -q 'ID_INPUT_KEYBOARD=1' "$kbwatch" grep -q 'typing_keyboards' "$kbwatch" grep -q 'base_name' "$kbwatch" grep -q 'hyprctl --batch' "$transition" grep -q 'keyword monitor.*disable' "$transition" grep -q 'result=no-lid-inhibitor' "$transition" grep -q 'transition=undock.*action=enable\|keyword monitor.*preferred' "$transition" grep -q 'nomarchy-keyboard-layout' "$menu" ! grep -q 'Laptop screen off' "$menu" grep -q 'action=reprobe-start' "$audio" grep -q 'restart.*pipewire\|pipewire.service' "$audio" grep -q 'set-default-sink' "$audio" grep -q 'subscription-closed' "$audio" grep -q 'result=no-available-sink' "$audio" # A pinned default sink outranks every priority rule, so on # UCM cards (headphones = their own sink) the jack has to be # followed explicitly or plugging in stops switching output. grep -q 'headphone_sinks' "$audio" grep -Fq '"$0" headphones jack' "$audio" # pro-audio/off publish no routable sink, and the profile is # stored per card — so a monitor's audio stays unreachable # however it is picked until the card itself is repaired. grep -q 'repair_dock_cards' "$audio" grep -q 'set-card-profile' "$audio" grep -q 'repaired-card=' "$audio" # Auto-undock without auto-dock leaves the pair half manual. grep -q 'auto_dock' "$watcher" grep -q 'auto-dock internal=' "$watcher" grep -Fq '"$TRANSITION" dock' "$watcher" ! grep -q 'power_save' "$audio" echo "docking-ux: atomic display, lid inhibitor, and monitor-triggered audio reprobe present" touch $out ''; # Night-light geo mode: with BOTH lat/long set the backing unit # flips hyprsunset → wlsunset (location-computed sunrise/sunset) # and the live-state ExecCondition gate survives the swap (the # toggle script's unit= comes from the same let-binding, so the # gate landing on wlsunset covers the selection logic). Pure # eval over the rendered HM units — nothing is built. The # visible warm shift itself stays hardware-tier (HARDWARE-QUEUE), # exactly like the original night-light full cycle. nightlight-geo = let inherit (nixpkgs) lib; hm = home-manager.lib.homeManagerConfiguration { inherit pkgs; modules = [ self.homeModules.nomarchy { nomarchy.stateFile = ./templates/downstream/state.json; nomarchy.nightlight = { enable = true; latitude = "52.52"; longitude = "13.40"; }; home = { username = "geo"; homeDirectory = "/home/geo"; }; } ]; }; units = hm.config.systemd.user.services; exec = toString units.wlsunset.Service.ExecStart; cond = toString units.wlsunset.Service.ExecCondition; expect = c: msg: lib.assertMsg c "nightlight-geo: ${msg}"; ok = expect (lib.hasInfix "52.52" exec && lib.hasInfix "13.40" exec) "wlsunset ExecStart lacks the configured coordinates: ${exec}" && expect (lib.hasInfix "4000" exec) "wlsunset ExecStart lacks the night temperature: ${exec}" && expect (lib.hasInfix "should-start" cond) "wlsunset lost the live-state ExecCondition gate" && expect (!(hm.config.services.hyprsunset.enable or false)) "geo mode must not also run hyprsunset"; in assert ok; pkgs.runCommand "nomarchy-nightlight-geo" { } "touch $out"; # The low-battery watcher's crossing logic: one toast per downward # crossing (normal at 25%, critical at 10%), re-armed by charging. # test_power fakes a battery whose capacity/status we script; # notify-send is shimmed to a log via PATH (the package resolves # it from PATH for exactly this reason). What stays on-hardware: # the real toast rendering through swaync in a session. battery-notify = pkgs.testers.runNixOSTest { name = "nomarchy-battery-notify"; nodes.machine = { ... }: { boot.kernelModules = [ "test_power" ]; # fake battery + Mains environment.systemPackages = [ pkgs.nomarchy-battery-notify ]; }; testScript = '' machine.wait_for_unit("multi-user.target") # notify-send shim: log the calls instead of needing a session bus. machine.succeed( "mkdir -p /shim && printf '#!/bin/sh\necho \"$*\" >> /tmp/notifications\n'" " > /shim/notify-send && chmod +x /shim/notify-send" ) # Self-gate: with the fake battery gone, the watcher exits 0 at once. machine.succeed("modprobe -r test_power") machine.succeed("timeout 5 nomarchy-battery-notify 1") machine.succeed("modprobe test_power") # Healthy level while discharging: silence. machine.succeed("echo discharging > /sys/module/test_power/parameters/battery_status") machine.succeed("echo 80 > /sys/module/test_power/parameters/battery_capacity") machine.succeed( "systemd-run --unit=batwatch --setenv=PATH=/shim:/run/current-system/sw/bin" " /run/current-system/sw/bin/nomarchy-battery-notify 1" ) machine.sleep(3) machine.fail("test -s /tmp/notifications") # Crossing 25% fires the low toast exactly once, not per poll. machine.succeed("echo 20 > /sys/module/test_power/parameters/battery_capacity") machine.wait_until_succeeds("grep -q 'Battery low' /tmp/notifications") machine.succeed("echo 15 > /sys/module/test_power/parameters/battery_capacity") machine.sleep(3) assert machine.succeed("grep -c 'Battery low' /tmp/notifications").strip() == "1" # Crossing 10% escalates to a critical-urgency toast, once. machine.succeed("echo 5 > /sys/module/test_power/parameters/battery_capacity") machine.wait_until_succeeds("grep -q 'Battery critical' /tmp/notifications") assert "critical" in machine.succeed("grep 'Battery critical' /tmp/notifications") machine.sleep(3) assert machine.succeed("grep -c 'Battery critical' /tmp/notifications").strip() == "1" # Back on the charger → re-armed: the next drain notifies again. machine.succeed("echo charging > /sys/module/test_power/parameters/battery_status") machine.succeed("echo 80 > /sys/module/test_power/parameters/battery_capacity") machine.sleep(3) machine.succeed("echo discharging > /sys/module/test_power/parameters/battery_status") machine.succeed("echo 20 > /sys/module/test_power/parameters/battery_capacity") machine.wait_until_succeeds( "test \"$(grep -c 'Battery low' /tmp/notifications)\" = 2" ) ''; }; # First-session welcome toast gate (#81): fires once when # settings.firstBootShown is unset, writes the marker via # theme-sync, silent on re-run; skips hostname nomarchy-live. # notify-send + state-sync shimmed on PATH (same pattern as # battery-notify). Real toast rendering stays session/V3. first-boot = pkgs.testers.runNixOSTest { name = "nomarchy-first-boot"; nodes.machine = { ... }: { environment.systemPackages = [ pkgs.nomarchy-first-boot pkgs.nomarchy-state-sync pkgs.jq ]; }; testScript = '' machine.wait_for_unit("multi-user.target") # Writable flake checkout with a valid theme-state (template). machine.succeed( "mkdir -p /tmp/fake-nomarchy && " "cp ${./templates/downstream/state.json} " "/tmp/fake-nomarchy/state.json" ) machine.succeed( "mkdir -p /shim && " "printf '#!/bin/sh\\necho \"$*\" >> /tmp/notifications\\n' > /shim/notify-send && " "chmod +x /shim/notify-send" ) env = ( "PATH=/shim:/run/current-system/sw/bin " "NOMARCHY_PATH=/tmp/fake-nomarchy " "HOME=/root" ) # First run: toast + marker. machine.succeed(f"{env} nomarchy-first-boot") out = machine.succeed("cat /tmp/notifications") assert "You're set" in out, f"welcome toast missing:\n{out}" assert "SUPER+M" in out and "SUPER+T" in out, f"key pointers missing:\n{out}" assert "Network" in out or "Wi" in out, f"network pointer missing:\n{out}" shown = machine.succeed( f"{env} nomarchy-state-sync get settings.firstBootShown" ).strip() assert shown == "true", f"marker not written: {shown!r}" # Second run: silent (no second toast). machine.succeed("rm -f /tmp/notifications") machine.succeed(f"{env} nomarchy-first-boot") machine.fail("test -s /tmp/notifications") # Live ISO hostname: skip even with marker cleared. machine.succeed( f"{env} nomarchy-state-sync --quiet set settings.firstBootShown false --no-switch" ) machine.succeed("rm -f /tmp/notifications") machine.succeed(f"{env} hostname nomarchy-live || true") # uname -n is the kernel nodename — set via hostnamectl/hostname. machine.succeed("hostname nomarchy-live") machine.succeed(f"{env} nomarchy-first-boot") machine.fail("test -s /tmp/notifications") ''; }; # Generation readability (#82): nomarchy-what-changed turns an # nvd report into plain-language counts. Inject a fake nvd via # NOMARCHY_NVD so the VM stays minimal (no real store closures). what-changed = pkgs.testers.runNixOSTest { name = "nomarchy-what-changed"; nodes.machine = { ... }: { environment.systemPackages = [ pkgs.nomarchy-what-changed ]; }; testScript = '' machine.wait_for_unit("multi-user.target") # Fake nvd: args are `nvd --color never diff A B` — ignore paths. machine.succeed( "mkdir -p /shim && cat > /shim/fake-nvd <<'EOF'\n" "#!/bin/sh\n" "cat <<'R'\n" "<<< /nix/store/old\n" ">>> /nix/store/new\n" "Added packages:\n" "[A.] #1 hello 2.12\n" "[A.] #2 cowsay 3.04\n" "Removed packages:\n" "[R.] #1 fortune 1.0\n" "Version changes:\n" "[C.] #1 bash: 5.2 -> 5.3\n" "Closure size: 100 -> 105 (10 paths added, 5 paths removed, delta +5).\n" "R\n" "EOF\n" "chmod +x /shim/fake-nvd" ) # Dummy before/after paths (script only needs them to exist and differ). machine.succeed("mkdir -p /tmp/gen-a /tmp/gen-b && touch /tmp/gen-a/x /tmp/gen-b/y") out = machine.succeed( "NOMARCHY_NVD=/shim/fake-nvd nomarchy-what-changed --summary " "--diff /tmp/gen-a /tmp/gen-b" ) assert "2 added" in out, f"missing added count:\n{out}" assert "1 removed" in out, f"missing removed count:\n{out}" assert "1 updated" in out, f"missing updated count:\n{out}" # Identical paths → no package changes (no nvd call needed). out2 = machine.succeed( "NOMARCHY_NVD=/shim/fake-nvd nomarchy-what-changed --summary " "--diff /tmp/gen-a /tmp/gen-a" ) assert "no package changes" in out2, f"identical paths should short-circuit:\n{out2}" # Empty-ish nvd report. machine.succeed( "cat > /shim/fake-nvd-empty <<'EOF'\n" "#!/bin/sh\n" "echo 'No version or selection state changes.'\n" "echo 'Closure size: 1 -> 1 (0 paths added, 0 paths removed, delta +0).'\n" "EOF\n" "chmod +x /shim/fake-nvd-empty" ) out3 = machine.succeed( "NOMARCHY_NVD=/shim/fake-nvd-empty nomarchy-what-changed --summary " "--diff /tmp/gen-a /tmp/gen-b" ) assert "no package changes" in out3, f"empty report:\n{out3}" ''; }; # Memory-pressure protection (oom.nix): earlyoom must kill a # runaway allocator BEFORE the kernel thrash point — and must not # take anything else with it. A bystander unit survives the kill, # proving the process-level granularity that motivated earlyoom # over cgroup-level systemd-oomd (which would kill the whole # Hyprland session scope). Imports only oom.nix so the VM stays # minimal. oom-protection = pkgs.testers.runNixOSTest { name = "nomarchy-oom-protection"; nodes.machine = { ... }: { imports = [ ./modules/nixos/oom.nix ]; environment.systemPackages = [ pkgs.python3 ]; virtualisation.memorySize = 1024; }; testScript = '' machine.wait_for_unit("earlyoom.service") # One owner: oomd (nixpkgs default-on, inert) is disabled. machine.fail("systemctl is-active --quiet systemd-oomd.service") # The session avoid-list made it into the daemon invocation. machine.succeed("systemctl cat earlyoom.service | grep -q -- --avoid") # A bystander that must survive (stand-in for the session). machine.succeed("systemd-run --unit=bystander sleep infinity") # The hog: allocate in 20 MB chunks with a short sleep, so # earlyoom's poll wins the race against the kernel OOM killer. machine.succeed( "systemd-run --unit=hog python3 -c 'import time; " "exec(\"a=[]\\nwhile True:\\n a.append(bytearray(20*1024*1024)); time.sleep(0.05)\")'" ) # earlyoom (not the kernel) pulled the trigger… machine.wait_until_succeeds( "journalctl -u earlyoom.service | grep -Eiq 'sending SIG(TERM|KILL)'", timeout=120, ) # …the hog is gone… machine.wait_until_succeeds( 'test "$(systemctl show -p ActiveState --value hog.service)" != "active"', timeout=60, ) # …and the bystander is untouched. machine.succeed("systemctl is-active --quiet bystander.service") ''; }; # zram compressed-RAM swap (oom.nix, BACKLOG #76 slice): enabled # by default as the memory-pressure layer *before* earlyoom. Prove # the device comes up as an active swap, uses zstd, and carries the # high priority that keeps day-to-day paging off any disk swapfile # (which #76 reserves for the hibernate image). Imports oom.nix # directly so the check tracks the module, not a hand-copy. zram-swap = pkgs.testers.runNixOSTest { name = "nomarchy-zram-swap"; nodes.machine = { ... }: { imports = [ ./modules/nixos/oom.nix ]; virtualisation.memorySize = 1024; }; testScript = '' # zram-generator brings the device up as swap shortly after boot. machine.wait_until_succeeds("grep -q /dev/zram0 /proc/swaps", timeout=60) # Configured compression algorithm is the selected one ([zstd]). machine.succeed("grep -q '\\[zstd\\]' /sys/block/zram0/comp_algorithm") # High priority (100) so the kernel fills zram before any disk # swap — the ordering #76's hibernate image depends on. machine.succeed( "awk '$1==\"/dev/zram0\" && $5==100 {ok=1} END{exit !ok}' /proc/swaps" ) ''; }; # Hibernate → resume (BACKLOG #76): the load-bearing risk the # hibernation default introduces is coexistence with the zram # swap that oom.nix now enables by default. Prove, in the VM # harness, that (a) a machine hibernates and resumes for real — # a volatile ramfs marker survives resume but NOT a fresh boot — # and (b) zram (priority 100) does not swallow the hibernate # image: the disk swap (lower priority, explicit resumeDevice) # carries it. Modeled on nixpkgs' hibernate.nix (partition swap + # systemd-initrd resume). What QEMU genuinely can't do is feed a # boot-time `resume_offset` for a btrfs *swapfile* (the offset # only exists after the file is allocated on first boot) or drive # a LUKS-initrd unlock end-to-end — so the encrypted @swap # swapfile + offset path stays a V3 laptop check (HARDWARE-QUEUE), # which already runs that exact layout. This test imports oom.nix # so it tracks the real zram default, not a hand-copy. hibernate = pkgs.testers.runNixOSTest { name = "nomarchy-hibernate"; nodes.machine = { config, lib, pkgs, ... }: { imports = [ ./modules/nixos/oom.nix ]; # zram default-on (#76) virtualisation.memorySize = 2048; # Store + EFI must persist across the hibernate/resume/crash # boots; a disk swap ≥ 2×RAM holds the image. virtualisation.useNixStoreImage = true; virtualisation.useEFIBoot = true; virtualisation.emptyDiskImages = [ (2 * config.virtualisation.memorySize) ]; boot.initrd.systemd.enable = true; # systemd-initrd, like Nomarchy # Disk swap at a LOWER priority than zram (100); explicit # resumeDevice — exactly how the installer wires hibernation — # so the image goes to disk regardless of zram's priority. swapDevices = lib.mkOverride 0 [ { device = "/dev/vdc"; options = [ "x-systemd.makefs" ]; priority = 1; } ]; boot.resumeDevice = "/dev/vdc"; # Let the driver's shell reconnect after resume. systemd.services.backdoor.conflicts = [ "sleep.target" ]; powerManagement.resumeCommands = "systemctl --no-block restart backdoor.service"; }; testScript = '' machine.wait_for_unit("default.target") # zram (oom.nix) up at priority 100; disk swap present and lower # — so the hibernate image lands on disk, not volatile zram. machine.wait_until_succeeds("grep -q /dev/zram0 /proc/swaps", timeout=60) machine.succeed( "awk '$1==\"/dev/zram0\"&&$5==100{z=1} " "$1==\"/dev/vdc\"&&$5<100{d=1} END{exit !(z&&d)}' /proc/swaps" ) # Marker in volatile RAM: survives a resume, never a fresh boot. machine.succeed( "mkdir /run/test", "mount -t ramfs -o size=1m ramfs /run/test", "echo not persisted to disk > /run/test/suspended", ) # Hibernate, wait for real power-off, then resume. machine.execute("systemctl hibernate >&2 &", check_return=False) machine.wait_for_shutdown() machine.start() machine.succeed("grep 'not persisted to disk' /run/test/suspended") # Prove it was a genuine resume: a crash-boot must lose the marker. machine.crash() machine.wait_for_unit("default.target") machine.fail("grep 'not persisted to disk' /run/test/suspended") ''; }; # The installer's BTRFS @swap SWAPFILE setup (#76) — the parts the # partition hibernate test (checks.hibernate) can't reach. A full # hibernate→resume from a *runtime* swapfile isn't reachable # headlessly: systemd reports CanHibernate=no unless the swap is # declared in the boot config, and feeding a runtime-computed # resume_offset into the boot cmdline is a genuine chicken-and-egg. # So the power-cycle stays a real-hardware check (the dev box runs # exactly this layout — /proc/cmdline verified). What IS verified # here, bounded and deterministic, is that the swapfile is built # correctly and its resume offset is valid: NOCOW `mkswapfile`, a # good `map-swapfile -r` offset the kernel accepts, swapon as a file, # and zram (prio 100) above it so the image lands on disk, not zram. hibernate-swapfile = pkgs.testers.runNixOSTest { name = "nomarchy-hibernate-swapfile"; nodes.machine = { config, lib, pkgs, ... }: { imports = [ ./modules/nixos/oom.nix ]; # zram default-on (#76) virtualisation.memorySize = 2048; # A dedicated disk for the btrfs @swap subvolume. virtualisation.emptyDiskImages = [ (4 * config.virtualisation.memorySize) ]; environment.systemPackages = [ pkgs.btrfs-progs ]; }; testScript = '' machine.wait_for_unit("default.target") machine.wait_until_succeeds("grep -q /dev/zram0 /proc/swaps", timeout=60) # Reproduce the installer layout: btrfs, @swap subvolume, then a # NOCOW swapfile via `btrfs filesystem mkswapfile`. # The lone empty data disk (root is vda; no separate store disk # here since useNixStoreImage is off). machine.succeed("mkfs.btrfs -f /dev/vdb") machine.succeed( "mkdir -p /mnt-btrfs && mount /dev/vdb /mnt-btrfs " "&& btrfs subvolume create /mnt-btrfs/@swap && umount /mnt-btrfs" ) machine.succeed("mkdir -p /swap && mount -o subvol=@swap /dev/vdb /swap") machine.succeed("btrfs filesystem mkswapfile --size 3g --uuid clear /swap/swapfile") # NOCOW is mandatory — a copy-on-write swapfile corrupts on btrfs. machine.succeed("lsattr /swap/swapfile | grep -q C") machine.succeed("swapon /swap/swapfile") machine.succeed( "awk '$1==\"/swap/swapfile\" && $2==\"file\"{f=1} END{exit !f}' /proc/swaps" ) # The Nomarchy-specific offset math (patch-template bakes this # into resume_offset): a valid physical offset the kernel accepts. offset = machine.succeed( "btrfs inspect-internal map-swapfile -r /swap/swapfile" ).strip() assert offset.isdigit() and int(offset) > 0, f"bad resume offset: {offset!r}" machine.succeed(f"echo {offset} > /sys/power/resume_offset") # zram (prio 100) must sit above the disk swapfile, so a hibernate # image would land on disk, not volatile zram. machine.succeed( "awk '$1==\"/dev/zram0\"&&$5==100{z=1} " "$1==\"/swap/swapfile\"&&$5<100{d=1} END{exit !(z&&d)}' /proc/swaps" ) ''; }; # Auto time-of-day theme (#79, VISION § D): simulate crossing # sunset and sunrise by moving the VM clock, and assert # `nomarchy-state-sync auto` switches the active theme accordingly # — day stays day (no needless rebuild), sunset flips to night, # a later tick is idempotent, sunrise flips back. The actual # `home-manager switch` is stubbed via NOMARCHY_REBUILD (that # generic apply path is exercised by every manual theme change); # this isolates the auto decision + apply + rebuild-trigger. auto-theme = pkgs.testers.runNixOSTest { name = "nomarchy-auto-theme"; nodes.machine = { pkgs, ... }: { environment.systemPackages = [ pkgs.nomarchy-state-sync ]; time.timeZone = "UTC"; # naive HH:MM == the clock we set }; testScript = '' machine.wait_for_unit("multi-user.target") machine.succeed("timedatectl set-ntp false || true") # Writable flake state (start on the day theme) + a rebuild stub # that records each invocation instead of running home-manager. machine.succeed("mkdir -p /root/.nomarchy") machine.succeed( "echo '{\"slug\":\"summer-day\"}' > /root/.nomarchy/state.json" ) machine.succeed( "printf '#!/bin/sh\\necho switch >> /root/rebuilds\\n' > /root/rebuild" " && chmod +x /root/rebuild" ) env = ("NOMARCHY_PATH=/root/.nomarchy " "NOMARCHY_DEFAULT_THEMES=${./themes} " "NOMARCHY_REBUILD=/root/rebuild ") # Pair + schedule (setup writes only, no rebuild). for kv in ["settings.autoTheme.enable true", "settings.autoTheme.day summer-day", "settings.autoTheme.night summer-night", "settings.autoTheme.sunrise 07:00", "settings.autoTheme.sunset 20:00"]: machine.succeed(env + "nomarchy-state-sync --quiet set " + kv + " --no-switch") def slug(): return machine.succeed(env + "nomarchy-state-sync get slug").strip() def rebuilds(): return int(machine.succeed("wc -l < /root/rebuilds 2>/dev/null || echo 0").strip()) # Before sunset: already the day theme → no switch, no rebuild. machine.succeed("date -s '2026-01-02 19:55:00'") machine.succeed(env + "nomarchy-state-sync auto") assert slug() == "summer-day", f"pre-sunset should stay day, got {slug()}" assert rebuilds() == 0, f"no needless rebuild, got {rebuilds()}" # Cross sunset → night theme, exactly one rebuild triggered. machine.succeed("date -s '2026-01-02 20:05:00'") machine.succeed(env + "nomarchy-state-sync auto") assert slug() == "summer-night", f"post-sunset should be night, got {slug()}" assert rebuilds() == 1, f"sunset should trigger one rebuild, got {rebuilds()}" # Another tick still after sunset → idempotent, no extra rebuild. machine.succeed("date -s '2026-01-02 20:06:00'") machine.succeed(env + "nomarchy-state-sync auto") assert slug() == "summer-night" assert rebuilds() == 1, f"idempotent tick must not rebuild, got {rebuilds()}" # Cross sunrise the next morning → back to day, second rebuild. machine.succeed("date -s '2026-01-03 07:30:00'") machine.succeed(env + "nomarchy-state-sync auto") assert slug() == "summer-day", f"post-sunrise should be day, got {slug()}" assert rebuilds() == 2, f"sunrise should trigger a rebuild, got {rebuilds()}" ''; }; # Snapshot GUI canary: btrfs-assistant crashes in libbtrfsutil's # UNPRIVILEGED subvolume iteration (btrfs-progs 6.17.1, fixed # upstream after 6.17.1) but runs fine as root — which is how the # menu launches it (pkexec launcher). Guard the root path on a # real btrfs volume so a lock bump that breaks it fails here, not # on a user's machine. The GUI event loop is exercised offscreen # (survives a 5s timeout = no startup crash past the Btrfs scan). snapshot-gui = pkgs.testers.runNixOSTest { name = "nomarchy-snapshot-gui"; nodes.machine = { pkgs, ... }: { environment.systemPackages = [ pkgs.btrfs-assistant pkgs.btrfs-progs ]; virtualisation.emptyDiskImages = [ 512 ]; }; testScript = '' machine.wait_for_unit("multi-user.target") machine.succeed( "mkfs.btrfs /dev/vdb && mkdir -p /mnt && mount /dev/vdb /mnt " "&& btrfs subvolume create /mnt/sub1" ) # Root (the launcher's effective context): must work. machine.succeed("QT_QPA_PLATFORM=offscreen btrfs-assistant-bin --version") # The GUI proper survives startup (timeout kill = alive → 124). machine.succeed( "set +e; timeout 5 env QT_QPA_PLATFORM=offscreen btrfs-assistant-bin; " "[ $? -eq 124 ]" ) ''; }; # The distroId question (roadmap "Distro branding"): set # distroId = "nomarchy" so /etc/os-release is honest (ID=nomarchy, # ID_LIKE=nixos). The risk is switch-to-configuration's "is this # NixOS?" guard — but it builds the check from the *configured* # distroId (and /etc/NIXOS still exists), so a rebuild must still # work. This boots such a system and runs `switch-to-configuration # dry-activate` to prove the guard passes. Inline (not the full # module) so the VM stays minimal — the real wiring in default.nix # is covered by the downstream-template-system build. distro-id = pkgs.testers.runNixOSTest { name = "nomarchy-distro-id"; nodes.machine = { ... }: { system.nixos.distroName = "Nomarchy"; system.nixos.distroId = "nomarchy"; # Test base omits switch-to-configuration by default; we need it # to exercise the "is this NixOS?" guard. system.switch.enable = true; }; testScript = { nodes, ... }: '' machine.wait_for_unit("multi-user.target") # os-release reflects the rebrand, with the nixos lineage marker. machine.succeed("grep -q '^ID=nomarchy$' /etc/os-release") machine.succeed("grep -q '^ID_LIKE=nixos$' /etc/os-release") machine.succeed("grep -q '^NAME=Nomarchy$' /etc/os-release") # The rebuild path must still recognise this as a NixOS install # (the guard is built from the configured distroId, not "nixos"). machine.succeed( "${nodes.machine.system.build.toplevel}/bin/switch-to-configuration dry-activate" ) ''; }; }; # ─── Reference host ──────────────────────────────────────────── # Manually wired rather than via lib.mkFlake: the repo-root # 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; # this is what `nomarchy-state-sync apply` runs) # Theme changes never touch the system layer, so they never need # root and never rebuild the world. nixosConfigurations.nomarchy = nixpkgs.lib.nixosSystem { inherit system; specialArgs = { inherit username; }; modules = [ self.nixosModules.nomarchy { nomarchy.system.stateFile = ./state.json; } ./hosts/default/configuration.nix ]; }; homeConfigurations.${username} = home-manager.lib.homeManagerConfiguration { inherit pkgs; modules = [ self.homeModules.nomarchy { # The single source of truth — pure read: the file is part # of this flake's source. nomarchy.stateFile = ./state.json; home = { inherit username; homeDirectory = "/home/${username}"; }; } ]; }; # ─── Live ISO ────────────────────────────────────────────────── # Full desktop on a bootable stick, no installation: # nix build .#nixosConfigurations.nomarchy-live.config.system.build.isoImage # Test in QEMU with tools/test-live-iso.sh. See docs/TESTING.md. nixosConfigurations.nomarchy-live = nixpkgs.lib.nixosSystem { inherit system; specialArgs = { inherit username; nomarchySrc = self; }; modules = [ "${nixpkgs}/nixos/modules/installer/cd-dvd/installation-cd-minimal.nix" self.nixosModules.nomarchy ./hosts/live.nix # The ISO is a sealed artifact, so the desktop is baked in via # the HM NixOS module (the installed-system split into # nixos-rebuild vs home-manager switch doesn't apply here — # though the seeded ~/.nomarchy flake still allows standalone # `home-manager switch` for theme testing). home-manager.nixosModules.home-manager { home-manager = { useGlobalPkgs = true; useUserPackages = true; users.${username} = { pkgs, ... }: { imports = [ self.homeModules.nomarchy ]; nomarchy.stateFile = ./state.json; # Live-session baseline apps (ROADMAP § live-ISO baseline # apps, #103). Until now the # live desktop shipped the distro modules only — no browser, # no office — which is what a user judges the distro by before # installing, and what they boot to rescue a machine that # won't start. Terminal tooling (yazi, btop) plus zathura/imv # already ride along with the modules; this adds the GUI apps # a non-Nix user reaches for. # # These cost almost NOTHING on the ISO: system.extraDependencies # below already pins the template's HM closure for offline # installs, so chromium/libreoffice-fresh/amberol are in the # image's store either way — they were merely absent from the # live user's profile, so nothing put them on PATH or in the # launcher. Reuse the template's EXACT derivations and the # store paths are shared; a plain `chromium` (no override) # would be a second full browser build instead. # # Firefox is deliberately NOT here (Bernardo 2026-07-14): # Chromium already owns the HTTP mime default and ships in the # installed template, so it is the browser that matches what a # user gets after installing — and a second browser is the one # item in #103's list that would cost a real closure. It stays # a template opt-in for installed machines. home.packages = with pkgs; [ # Same override as templates/downstream/home.nix — must # match exactly to share the store path (Widevine DRM; # unfree CDM, allowUnfree is on for this system). (chromium.override { enableWideVine = true; }) libreoffice-fresh # documents/sheets/slides off a dying disk gnome-text-editor # the maintained gedit successor amberol # music player (also in the template) mpv # video (mime defaults → mpv.desktop; also # template — free on the ISO via the pin) snapshot # camera — the maintained Cheese successor ]; }; }; # The standalone CLI that `nomarchy-state-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. 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; # nixos-generate-config keys microcode off # enableRedistributableFirmware (now on): the # real machine enables exactly one vendor — # pin both, or the offline install builds # amd-ucode/intel-ucode from source (found the # hard way: the 2700s regression hang). hardware.cpu.amd.updateMicrocode = true; hardware.cpu.intel.updateMicrocode = true; 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 # The live session's `home-manager switch` builds the # REPO's standalone generation (the seeded ~/.nomarchy # flake) — not the template's, and not the NixOS-module # one baked into the ISO. It has drvs the other two # variants don't (user-dbus-services et al.), so pin it # and its direct build inputs too. self.homeConfigurations.${username}.activationPackage self.homeConfigurations.${username}.activationPackage.inputDerivation # home-files is rebuilt per theme and COPIES some of its # inputs (user-dbus-services) instead of referencing # them — the activation closure doesn't carry those, so # pin home-files' direct build inputs separately. self.homeConfigurations.${username}.config.home-files.inputDerivation # 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 # home-files COPIES user-dbus-services instead of # referencing it (same story as the self pin above) — # a custom-username install rebuilds home-files and # needs its direct inputs present. template.homeConfigurations.${username}.config.home-files.inputDerivation ] ++ [ # A theme switch re-renders stylix's base16 templates # (gtk.css, kvantum, …) on the machine: the renderer and # the stdenv its trivial drvs build with must be on # board, or an offline `apply ` cascades into # building mustache-go (and eventually stdenv) from # source. Found with tools/vm/gap-analysis.py. pkgs.mustache-go pkgs.stdenv ] ++ [ # …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 # system-path/initrd-bin-env are buildEnvs rebuilt per # machine; their shared builder.pl is build-time-only # (never in any runtime closure) — an empty env's # inputDerivation retains it. (pkgs.buildEnv { name = "nomarchy-pin-buildenv"; paths = [ ]; }).inputDerivation # /etc/dbus-1 reassembly wants this setup hook. pkgs.findXMLCatalogs # nixos-generate-config keys microcode off # enableRedistributableFirmware (now on): exactly one # vendor per real machine, so pin both ucode cpios. pkgs.microcode-amd pkgs.microcode-intel # …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"; }) ] ); } ]; }; }; }