My Nix(OS) playground and configs
  • Nix 61.5%
  • Shell 19%
  • Lua 15.4%
  • CSS 4.1%
Find a file
Julius Zeidler e66e68ab1c
Some checks failed
Security scan / vulnix — CVE scan (push) Failing after 51m33s
Security scan / trivy — Go/Rust vuln scan (push) Successful in 22s
Security scan / Generate SBOM (push) Failing after 24m10s
fix(logiops): re-enable hires scroll
Revert hires: false; keep high-resolution scrolling on the wheel.
2026-07-09 14:17:08 +02:00
.claude refactor(bookokrat): use upstream flake input instead of local source build 2026-07-06 23:39:55 +02:00
.forgejo/workflows fix(ci): apt-get update before installing sudo (fresh container has stale package lists) 2026-07-06 23:16:48 +02:00
configs fix(logiops): re-enable hires scroll 2026-07-09 14:17:08 +02:00
hosts feat(tailscale): add trayscale GUI on tux 2026-07-08 21:55:46 +02:00
installer feat(installer): separate encrypted /home (200GiB root), reinstall-detect + keep/reformat prompt 2026-07-03 21:47:31 +02:00
modules feat(tailscale): add Tailscale client module, enable on tux 2026-07-08 21:50:21 +02:00
pkgs refactor(eilmeldung): use upstream flake input instead of local source build 2026-07-06 23:42:08 +02:00
scripts fix(mail): correct default bitwarden folder name to lowercase email 2026-07-05 14:37:27 +02:00
.gitignore feat(installer): install nixos-template + drop finish-setup.sh (per-machine host, nvidia autodetect) 2026-07-02 21:24:42 +02:00
.gitinore updates 2026-04-03 17:26:51 +02:00
.mcp.json feat(mcp): register mcp-nixos as project MCP server 2026-07-08 22:07:41 +02:00
CLAUDE.md docs(claude): add readme rule and fix formatting 2026-04-08 01:22:10 +02:00
flake.lock update 2026-07-08 11:32:29 +02:00
flake.nix refactor: restructure repository into hosts/<name>/ and modules/home/ layout 2026-07-07 16:16:56 +02:00
jz@chaos.li.pub.asc add pub key 2026-07-03 22:56:26 +02:00
README.md feat(mcp): register mcp-nixos as project MCP server 2026-07-08 22:07:41 +02:00
tools.md docs: add tools.md listing all TUI tools with descriptions and docs links 2026-07-06 22:51:27 +02:00

nixconfig — home-manager flake

First-time setup

1. Install Nix (Determinate Systems installer)

curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install

This installs Nix with flakes enabled by default. Restart your shell after.

2. Install home-manager

nix run home-manager/master -- init --switch

Or, once your flake is ready, pick your configuration:

Machine Command
tux — NixOS bare-metal (UEFI), x86_64 sudo nixos-rebuild switch --flake ~/nixconfigs#tux (or hms)
big-rig — CachyOS/Arch, x86_64, NVIDIA home-manager switch --flake ~/nixconfigs#big-rig
NixOS VM, x86_64 sudo nixos-rebuild switch --flake ~/nixconfigs#nixos-vm
Other NixOS bare-metal (UEFI), x86_64 Install via the installer ISOfinish-setup.sh; then sudo nixos-rebuild switch --flake ~/nixconfigs#<hostname> (or hms)
MacBook Intel (x86_64) home-manager switch --flake ~/nixconfigs#macbook-intel
MacBook M2 (aarch64) home-manager switch --flake ~/nixconfigs#macbook-silicon

3. Activate for the first time

# NixOS host (tux):
sudo nixos-rebuild switch --flake ~/nixconfigs#tux
# Arch/macOS host (home-manager only):
home-manager switch --flake ~/nixconfigs#big-rig

4. Subsequent updates

sudo nixos-rebuild switch --flake ~/nixconfigs#tux
# or add a shell alias: alias hms="sudo nixos-rebuild switch --flake ~/nixconfigs#tux"

One-time setup — logiops

NixOS (tux)

Fully declarative via modules/nixos/logiops.nix — the service and config are managed by NixOS. No manual steps needed.

Arch (big-rig)

home-manager deploys ~/.config/logid.cfg from configs/logid.cfg. The logid service itself is managed manually via pacman. Install it, then point the service at the user config:

sudo pacman -S logiops
sudo systemctl edit logid

In the editor, add:

[Service]
ExecStart=
ExecStart=/usr/bin/logid -c /home/jz/.config/logid.cfg

After that, logid will pick up config changes on the next systemctl restart logid.


One-time setup — Tailscale

NixOS (tux)

Fully declarative via modules/nixos/tailscale.nix — the tailscaled daemon and tailscale CLI are managed by NixOS. Authenticate the machine once:

sudo tailscale up   # --ssh to expose Tailscale SSH, --accept-routes for subnet routes

GUI: Tailscale has no official Linux app, so tux's home profile installs Trayscale (pkgs.trayscale) — a GTK tray front-end that drives the same tailscaled. Its icon appears in Waybar's tray module (already enabled). Launch trayscale (or autostart it via Hyprland) to connect/disconnect, pick exit nodes, and copy peer IPs.

Arch (big-rig)

home-manager can't run a system daemon, so tailscaled is managed manually via pacman (same approach as logid). Install it, enable the daemon, then authenticate:

sudo pacman -S tailscale
sudo systemctl enable --now tailscaled
sudo tailscale up   # --ssh to expose Tailscale SSH, --accept-routes for subnet routes

The node reconnects automatically on every boot after the first tailscale up.


One-time setup — libvirt VM networking (CachyOS)

Docker sets the FORWARD chain's default policy to DROP and only allows traffic through its DOCKER-USER chain. This silently blocks libvirt VMs (on virbr0) from reaching the internet, even though libvirt's own NAT/forward rules are correct. Fixed with a systemd unit that re-inserts the missing accept rules every time docker.service starts (the rules don't survive a Docker restart otherwise):

printf '%s\n' \
'[Unit]' \
'Description=Allow virbr0 forwarding through Docker iptables FORWARD policy' \
'After=docker.service libvirtd.service' \
'Requires=docker.service' \
'' \
'[Service]' \
'Type=oneshot' \
'ExecStart=/bin/sh -c "/usr/bin/iptables -C DOCKER-USER -i virbr0 -j ACCEPT || /usr/bin/iptables -I DOCKER-USER -i virbr0 -j ACCEPT"' \
'ExecStart=/bin/sh -c "/usr/bin/iptables -C DOCKER-USER -o virbr0 -j ACCEPT || /usr/bin/iptables -I DOCKER-USER -o virbr0 -j ACCEPT"' \
'RemainAfterExit=yes' \
'' \
'[Install]' \
'WantedBy=multi-user.target' \
| sudo tee /etc/systemd/system/libvirt-docker-forward.service >/dev/null

sudo systemctl daemon-reload
sudo systemctl enable --now libvirt-docker-forward.service

Also ensure virsh talks to the system instance (not the empty per-user qemu:///session) — already set via LIBVIRT_DEFAULT_URI in modules/fish.nix.


NixOS VM (nixos-vm)

nixos-vm is a dev/test VM built from the same modules/nixos/common.nix and modules/nixos/desktop.nix as real machines, differing only in its bootloader — BIOS GRUB on a virtio disk (/dev/vda) instead of UEFI systemd-boot. Home-manager is a NixOS module, so one command applies system + dotfiles:

sudo nixos-rebuild switch --flake ~/nixconfigs#nixos-vm   # or: hms

The system-level Hyprland/Wayland pieces (compositor, pipewire, polkit, tty1 autologin, fish login shell) live in modules/nixos/desktop.nix; on tty1 login modules/fish.nix execs start-hyprland (custom.hyprlandLaunchCmd), the launcher binary nixpkgs' hyprland package ships (sets up the systemd user session before starting the compositor — running Hyprland directly prints a warning).


NixOS machines — the template model (nixos-template)

Every physical NixOS machine is provisioned from one generic host, nixos-template, then "graduated" into its own hosts/<hostname>/ entry on first boot. So a single installer image serves many machines (AMD/Intel CPUs, with or without NVIDIA), all sharing:

  • modules/nixos/common.nix — WiFi (wpa_supplicant), steam, bluetooth (blueman), podman (docker CLI shim, no daemon), docker-sbx (sbx, pkgs/docker-sbx/), openssh.
  • modules/nixos/desktop.nix — Hyprland, pipewire, Flatpak, tty1 autologin, upower. sbx login needs a Secret Service provider (services.gnome.gnome-keyring.enable) — since tty1 autologin skips the password prompt, set the first login keyring's password to blank so it unlocks without a passphrase from then on.
  • declarative Flatpak via nix-flatpak (modules/nixos/desktop.nix) — Flathub remote + Zen Browser (app.zen_browser.zen) + Bitwarden (com.bitwarden.desktop), auto-updated on rebuild. Dark mode for these needs xdg-desktop-portal-gtk in home-manager's own xdg.portal.extraPortals (modules/home/desktop/hyprland.nix) — Hyprland's home-manager module auto-enables its own portal dir via NIX_XDG_DESKTOP_PORTAL_DIR, which otherwise shadows the system portals and hides the Settings interface Flatpak apps query.
  • kanata keyboard remapper (modules/nixos/kanata.nix, imported per-host) — GACS home row mods + Caps Lock as tap-Esc/hold-Ctrl; config in configs/kanata.kbd (override per-host via custom.kanataConfig)
  • Tailscale mesh VPN — on NixOS declarative via modules/nixos/tailscale.nix (tailscaled daemon + CLI); on Arch (big-rig) managed via pacman like logid. Authenticate a machine once with sudo tailscale up; it reconnects on boot. See "One-time setup — Tailscale".
  • per-host home profile (hosts/<hostname>/home.nix) — imports modules/home/default.nix and modules/home/desktop; host-specific packages added inline
  • home-manager wired as a NixOS module, so nixos-install/nixos-rebuild applies system and dotfiles together (hms runs nixos-rebuild switch on NixOS hosts)

Hardware differences are handled per-host: CPU microcode (AMD/Intel) is written into each machine's hardware-configuration.nix by nixos-generate-config. NVIDIA env vars live in the host's home.nix via custom.hyprlandExtraLua. NixOS hosts are listed explicitly in flake.nix (nixosHosts); add a new machine by creating hosts/<name>/ and appending its name there.

Install → graduate

  1. Boot the installer ISO and run sudo bootstrap. It installs nixos-template, asks for this machine's hostname, and drops ~/finish-setup.sh (with the hostname baked in). First boot already has your dotfiles (home-manager is a NixOS module).
  2. Log in as jz and run:
    ./finish-setup.sh
    
    It clones the repo to ~/nixconfigs, creates hosts/<hostname>/ — the real hardware-configuration.nix from /etc/nixos/ plus a generated default.nix — commits it, then nixos-rebuild switch --flake ~/nixconfigs#<hostname>.
  3. Review and git push when happy.

From then on manage the machine with sudo nixos-rebuild switch --flake ~/nixconfigs#<hostname> (or hms). The nixos-template install artifact under /etc/nixconfig is leftover and can be deleted.

hosts/nixos-template/hardware-configuration.nix is a placeholder; each real host gets the genuine one via finish-setup.sh (flakes only see git-tracked files, so it's committed). For a BIOS machine, swap the boot.loader.systemd-boot/efi lines for boot.loader.grub.{enable,device} in the generated host config.


Custom installer ISO (installer)

Instead of the manual steps above, build a reusable installer ISO with this flake and a guided bootstrap script baked in. Boot it on any UEFI machine and run one command.

Build the image (on any machine with Nix + flakes):

nix build .#installer-iso

(short alias for .#nixosConfigurations.installer.config.system.build.isoImage.) This is an x86_64 Linux build. The ISO lands at result/iso/nixconfig-installer.iso.

Write it to a USB stick (Linux). This erases the stick — identify the device carefully; dd to the wrong disk is irreversible.

# 1. Find the stick by size/model (USB sticks show TRAN=usb).
#    Use the whole-disk name (/dev/sdX), NOT a partition (/dev/sdX1).
lsblk -dpno NAME,SIZE,MODEL,TRAN

# 2. Unmount anything auto-mounted from it, and clear stale signatures.
sudo umount /dev/sdX?*  2>/dev/null || true
sudo wipefs -a /dev/sdX

# 3. Write and flush. (Do NOT use oflag=direct — it can drop the final partial
#    block and produce a truncated ISO that boots to a bare `grub>` prompt.)
sudo dd if=result/iso/nixconfig-installer.iso of=/dev/sdX bs=4M status=progress conv=fsync
sync

# 4. Verify the stick matches the ISO byte-for-byte.
sudo cmp -n "$(stat -c%s result/iso/nixconfig-installer.iso)" \
           result/iso/nixconfig-installer.iso /dev/sdX && echo "OK: stick matches ISO"

Boot the stick, then get online firstnixos-install downloads nixpkgs and all packages, so the installer needs working internet before you run bootstrap:

# wired: automatic via DHCP — just plug in
# wifi:  the ISO ships NetworkManager + redistributable firmware (incl. Intel iwlwifi)
nmcli radio wifi on
nmtui                                              # TUI, or:
nmcli device wifi connect "<SSID>" password "<PW>"
ping -c1 nixos.org                                 # confirm connectivity

The ISO enables hardware.enableRedistributableFirmware (see installer/iso.nix) so WiFi adapters like Intel iwlwifi work in the live installer — the stock minimal ISO ships less firmware and leaves such cards unavailable. (enableAllFirmware is intentionally avoided: it pulls fragile blobs such as facetimehd-calibration that fail to build.) If nmcli device still shows the adapter unavailable, check rfkill list (run rfkill unblock wifi) or fall back to wired.

Then run the embedded bootstrap:

sudo bootstrap [DISK] [HOST]

# examples
sudo bootstrap                    # prompts for disk + hostname, installs nixos-template
sudo bootstrap /dev/nvme0n1       # installs the template onto that disk
sudo bootstrap /dev/sda nixos-vm  # different disk + a specific host

bootstrap (defined in installer/bootstrap.sh) first verifies it can reach cache.nixos.org (refusing to touch any disk if you're offline — override with SKIP_NET_CHECK=1), confirms before wiping, partitions the disk UEFI/GPT into ESP + LUKS root (200 GiB) + LUKS /home (rest) — a separate encrypted /home so you can reinstall the OS later without losing it. Root and /home get the same passphrase and the systemd initrd unlocks both with one prompt; nixos-generate-config captures both boot.initrd.luks.devices entries and the /home mount. Tunables: ENCRYPT=0 (no encryption), SEPARATE_HOME=0 (put /home on /), ROOT_END=<size> (root size, default 200GiB).

Reinstalls keep your data: on a disk that already has a /home partition, bootstrap detects it and asks whether to keep /home or reformat the whole disk (or force it with KEEP_HOME=1). Keeping it reformats only root + ESP and re-uses the existing encrypted /home untouched. It then copies the embedded flake to /mnt/etc/nixconfig, drops the generated hardware config into hosts/<HOST>/, and runs nixos-install. It also asks for this machine's hostname and drops a ~/finish-setup.sh (hostname baked in) that graduates the template into a real hosts/<hostname>/ on first boot (see the template model). After install it prompts to set the login user's password, generate an ed25519 SSH key for them, and reboot (all optional; the user defaults to jz, override with the USERNAME env var). On an encrypted machine you'll be prompted for the LUKS passphrase at every boot, before systemd-boot hands off to the kernel. The flake is shipped read-only inside the ISO at /etc/nixconfig (see installer/iso.nix), so the installer works fully offline for evaluation (package downloads still need network).

The ISO is reusable across machines and hostsHOST defaults to nixos-template but accepts any host under hosts/. Because the ISO embeds a snapshot of the flake, rebuild it after changing your config to pick up the changes.

After install, log in as jz, apply the home-manager profile, and commit the generated hardware config back to the repo (same final two steps as the manual install above).


Nitrokey SSH (OpenPGP smartcard)

NixOS hosts are prepared for Nitrokey SSH via modules/nixos/nitrokey.nix (in base.nix): hardware.nitrokey.enable (udev), services.pcscd.enable (smartcard daemon), and programs.gnupg.agent with enableSSHSupport — so gpg-agent is the SSH agent and the card's authentication subkey signs SSH challenges. modules/fish.nix already points SSH_AUTH_SOCK at gpg-agent's socket. (Terminal pinentry-curses by default — swap for pinentry-gnome3/-qt for a GUI prompt.)

You only ever export the public key; the private keys never leave the card.

Provisioning a key on a Nitrokey 3 (OpenPGP applet)

Always back up the secret key (step 3) before keytocard — it's a move. Losing the backup (and having no fetch URL / keyserver copy) means GPG can never use the card keys again.

# 1. Reset the OpenPGP applet (wipes old keys, unblocks PINs; other applets untouched)
gpg --card-edit → admin → factory-reset      # defaults: user 123456 / admin 12345678

# 2. Generate a certify-only primary + S/E/A subkeys (RSA 4096)
gpg --expert --full-generate-key             # (8) RSA set-own-caps -> Certify only, 4096
gpg --expert --edit-key <email>              # addkey ×3: sign-only, encrypt-only, auth-only; save

# 3. BACK UP FIRST (offline!)
gpg --export-secret-keys --armor <email> > gpg-secret-backup.asc
gpg --export             --armor <email> > gpg-public.asc

# 4. Move subkeys to the card
gpg --edit-key <email>                       # key 1/2/3 -> keytocard (Sig/Enc/Auth); save

# 5. Set a fetch URL (host gpg-public.asc somewhere) + change PINs
gpg --edit-card → admin → url / passwd

# 6. Export the SSH public key for your VCS / git server
gpg --export-ssh-key <email> > nitrokey.pub

Using it on another machine

Insert the card, then get the public key into that keyring:

gpg --edit-card → fetch      # if a URL is set (step 5); else: gpg --import gpg-public.asc
gpg --card-status            # "General key info" now shows your key
ssh-add -L                   # card's SSH key, served by gpg-agent

Optional — sign commits with it: git config --global commit.gpgsign true and git config --global user.signingkey <primary-keyid>.

Recovering an SSH pubkey when the GPG certificate is lost but the card still has the keys: read it straight off the card over PKCS#11 — gpgconf --kill scdaemon; ssh-keygen -D opensc-pkcs11.so.


Mail — local-first (mbsync + notmuch + aerc/meli) + KMail/Claws Mail/Betterbird

modules/mail.nix wires up a local-first mail stack to test against GUI clients: mbsync (isync) pulls IMAP down to ~/Mail/<account> on a 5-minute systemd timer, notmuch indexes it (Xapian full-text search, scales to huge mailboxes without the lag Betterbird hit), and aerc/meli are terminal clients on top. kdePackages.kmail is installed as a GUI alternative (home.nix); Claws Mail and Betterbird are installed declaratively via Flatpak (modules/nixos/desktop.nixservices.flatpak.packages). The waybar bar's leftmost right-side module (custom/mail, configs/waybar/mail.sh) shows the total notmuch count tag:unread across all accounts; clicking it opens aerc in a terminal.

Credentials come from a self-hosted Vaultwarden instance via rbw (an unofficial but scriptable CLI — unlike the official bw, it keeps an unlocked vault behind a background agent, so passwordCommand doesn't need to prompt on every sync). programs.rbw.settings.base_url in modules/mail.nix points at that instance, so rbw never talks to bitwarden.com.

Accounts (accounts.email.accounts in modules/mail.nix): julius@zeidlos.com (Gmail/Google Workspace, primary) plus several kasserver.com-hosted IMAP addresses (jz@chaos.li, banking@zeidlos.de, do7jz@chaos.li). Each account's passwordCommand pulls its password from a matching Bitwarden item — for Gmail, that item must hold an App Password (requires 2FA), not the account's normal login password.

One-time setup:

  1. rbw login and rbw unlock once, interactively (uses base_url/email already set in modules/mail.nix, so no rbw config set needed first).
  2. sudo nixos-rebuild switch --flake ~/nixconfigs#tux, then run mbsync --all once by hand to do the initial sync (the systemd timer only fires every 5 minutes after that).

mbsync's remove/expunge default to none, so nothing propagates deletions in either direction while this is just a test setup.

Adding another account: scripts/gen-mail-accounts.sh reads a Bitwarden folder (default email) via rbw and prints ready-to-paste accounts.email.accounts.<name> blocks matching the ones already in modules/mail.nix — it assumes item names look like <address>-password / <address>-app-password, that the item's Bitwarden username field holds the provider login (e.g. a kasserver.com account number, not the email address), and that every non-Gmail domain shares one IMAP/SMTP host. No password ever appears in its output; review the printed block before pasting it in.

rbw unlock
nix shell nixpkgs#jq nixpkgs#rbw -c ./scripts/gen-mail-accounts.sh

Neovim config

The LazyVim config lives at configs/nvim/ in this repo. modules/neovim.nix points ~/.config/nvim at it via an out-of-store symlink (not a store copy), so LazyVim can write lazy-lock.json back into the repo on :Lazy update/sync/clean — commit that file's changes like any other edit.


Claude Code — nixos MCP server

mcp-nixos (pkgs.mcp-nixos, in modules/home/default.nix, so every host has it) is a read-only MCP server that lets Claude Code query NixOS packages/options, Home Manager, nix-darwin, flakes, and package version history. It's registered project-scoped in .mcp.json:

{ "mcpServers": { "nixos": { "command": "mcp-nixos", "args": [] } } }

command resolves via the home-manager profile on PATH. On first launch in this repo, Claude Code prompts to approve the server; use /mcp to reconnect after config changes. It needs outbound network (search.nixos.org, FlakeHub, noogle.dev, cache.nixos.org) at runtime.


Known limitations / things still managed by pacman

Package Reason
zen-browser Not in nixpkgs — on Arch use flatpak/AUR; NixOS hosts get it via declarative Flatpak (modules/nixos/desktop.nixservices.flatpak.packages)
bitwarden-desktop Native nixpkgs build currently pulls EOL electron — NixOS hosts get it via declarative Flatpak (modules/nixos/desktop.nix)
betterbird Not in nixpkgs — on Arch use flatpak/AUR; NixOS hosts get it via declarative Flatpak (modules/nixos/desktop.nix)
hyprlock Nix build links against NixOS PAM path — use AUR
cachyos-fish-config CachyOS-specific — keep via pacman
keyd Needs system-level access — keep via pacman + systemd
krew kubectl plugin manager — install manually
virtualbox-host-dkms Kernel modules must be built against the running kernel — Nix can only install the userspace virtualbox binary; CachyOS uses a custom kernel so DKMS is required: sudo pacman -S virtualbox-host-dkms linux-cachyos-headers && sudo modprobe vboxdrv

System-level packages (kernel, drivers, firmware) always stay with CachyOS pacman.


Custom packages — flake outputs

All packages under pkgs/ are exposed as top-level flake outputs for direct builds and CI:

nix build .#aria2tui
nix build .#astroterm
nix build .#bookokrat    # resolves to upstream flake input (github:bugzmanov/bookokrat)
nix build .#eilmeldung
nix build .#herald
nix build .#nmrs

bookokrat and eilmeldung come from upstream flake inputs — update them with nix flake update bookokrat eilmeldung. No local packages.

herald is a source build (Go). On first build after a version bump, set both src.hash and vendorHash to lib.fakeHash in sequence, resolving each from the build error.


Supply chain security

CI workflows (.forgejo/workflows/)

Workflow Trigger What it does
security-scan.yml push to main, weekly Monday 06:00 UTC Builds all custom packages; runs vulnix (NVD/OSV CVE scan) and grype (fail on HIGH); generates CycloneDX SBOMs uploaded as 90-day CI artifacts
update-flake.yml weekly Monday 04:00 UTC Runs nix flake update, builds nixosConfigurations.tux to verify, opens a PR with a table of updated input revisions

The update workflow requires a FORGEJO_TOKEN secret (repo → Settings → Secrets) — a personal access token with repository write + pull-request write scope.

Ad-hoc scanning

# CVE scan a built package
OUT=$(nix build .#bookokrat --print-out-paths --no-link)
nix run nixpkgs#vulnix -- "$OUT"

# Generate a CycloneDX SBOM  (syft is installed via modules/common.nix)
syft scan dir:"$OUT" --output cyclonedx-json --file bookokrat.cdx.json

Structure

nixconfig/
├── flake.nix          # inputs: nixpkgs-unstable + home-manager
├── arch.nix           # CachyOS/Arch (x86_64-linux): all modules + Linux packages
├── nixos-vm.nix       # NixOS VM (x86_64-linux): trimmed desktop-essentials home-manager profile
├── macbook.nix        # macOS (x86_64-darwin + aarch64-darwin): shared mac config
├── hosts/
│   └── nixos-vm/
│       ├── configuration.nix       # NixOS system config: bootloader, networking, users
│       └── hardware-configuration.nix  # generated by nixos-generate-config, machine-specific
├── modules/
│   ├── fish.nix           # shell, aliases, abbreviations, functions
│   ├── git.nix            # git + lazygit
│   ├── tmux.nix           # tmux with nixpkgs plugins (replaces TPM)
│   ├── neovim.nix         # neovim binary + LSP deps (LazyVim manages plugins)
│   ├── starship.nix       # prompt
│   ├── terminal.nix       # ghostty
│   ├── waybar.nix         # waybar config + style — shared by arch.nix and home.nix (Linux only)
│   ├── mail.nix           # mbsync/notmuch/aerc/meli — local-first mail (see "Mail" section)
│   ├── nixos/
│   │   └── desktop.nix    # NixOS system module: hyprland, pipewire, polkit, tty1 autologin
│   └── arch/
│       ├── hyprland.nix   # hyprland + hyprpaper + hypridle  (Linux only)
│       ├── desktop.nix    # rofi, dunst, yazi, btop, xdg portals  (Linux only)
│       ├── logiops.nix    # MX Master 4 logiops config + systemd service  (Linux only)
│       └── media.nix      # mpd, ncspot                      (Linux only)
├── .forgejo/workflows/ # CI: security-scan.yml (vulnix/grype/sbom), update-flake.yml (weekly PR)
├── pkgs/              # custom packages not in nixpkgs, callPackage'd from modules
│   ├── adsb-tui/      # terminal ADS-B aircraft tracker, packaged from upstream release tarball
│   ├── aria2tui/      # Python TUI frontend for aria2c, packaged from PyPI
│   ├── astroterm/     # terminal planetarium, packaged from upstream release binary
│   # bookokrat — upstream flake input (github:bugzmanov/bookokrat); no local package
│   ├── docker-sbx/    # Docker Sandboxes CLI, packaged from upstream release tarball
│   # eilmeldung — upstream flake input (github:christo-auer/eilmeldung); no local package
│   ├── herald/        # terminal email client, built from source (needs hash update on first build)
│   └── nmrs/          # waybar network on-click GUI, built from networkmanager-rs/nmrs-gui
└── configs/           # raw config files referenced by modules
    ├── hyprland.lua
    ├── hyprlock.conf
    ├── dunstrc
    ├── logid.cfg
    ├── kanata.kbd
    ├── waybar-style.css
    └── waybar/
        ├── webcam.sh
        ├── timezones.sh
        ├── mx4-battery.sh
        ├── corne-battery.sh
        └── mail.sh