Compare commits

..

25 Commits

Author SHA1 Message Date
55fe9032f4 docs(xatu): note deprecated toPath resolution follow-up 2026-09-19 15:16:03 -04:00
f5148af377 test(xatu): clarify test names 2026-09-19 15:12:40 -04:00
1af843da11 test(xatu): return fixture load failures 2026-09-19 15:06:49 -04:00
e8e1261fc4 test(xatu): fully qualify googletest result 2026-09-19 15:05:19 -04:00
7c6a43cb5c test(xatu): simplify googletest result handling 2026-09-19 15:04:11 -04:00
60f9ac1087 test(xatu): use googletest matchers 2026-09-19 15:00:39 -04:00
f027ccc99f Merge branch 'main' into feat/xatu-deprecated-to-path 2026-09-19 14:56:03 -04:00
0cd7c4bfb7 test(xatu): inline finding assertions 2026-09-19 14:48:35 -04:00
798a6dc3a3 test(xatu): organize lint fixtures 2026-09-19 14:45:23 -04:00
575b0fde0d test(xatu): relax diagnostic count assertions 2026-09-19 11:10:16 -04:00
460e7e25ad test(xatu): simplify future sight tests 2026-09-19 11:06:14 -04:00
22e8c896c0 test(xatu): keep multiline fixtures in testdata 2026-09-19 11:02:12 -04:00
7b5559595e test(xatu): format inline fixtures 2026-09-19 10:59:29 -04:00
825a1b9773 test(xatu): use explicit path construction fixture 2026-09-19 10:58:28 -04:00
46c1ca65b7 test(xatu): inline short future sight fixtures 2026-09-19 10:53:57 -04:00
0485e5eb88 style(xatu): order rule dispatch list 2026-09-19 10:49:43 -04:00
8d4018c5dd refactor(xatu): order parser conversions by use 2026-09-19 10:43:07 -04:00
d455dcfb46 refactor(xatu): use conversion traits in parser 2026-09-19 10:36:57 -04:00
0b67331ca3 refactor(xatu): clarify parser conversions 2026-09-19 10:34:47 -04:00
d2fc9cb0af style(rust): drop declaration ordering rule 2026-09-19 10:30:53 -04:00
5190d1ed01 style(rust): document declaration ordering 2026-09-19 10:29:05 -04:00
efb2fd3234 style(xatu): order AST declarations by use 2026-09-19 10:23:09 -04:00
c790bf06fc style(xatu): order AST support declarations 2026-09-19 10:22:04 -04:00
731ad83ae9 style(xatu): order AST declarations 2026-09-19 10:18:24 -04:00
f8966fe2a8 feat(xatu): report deprecated toPath calls 2026-09-18 23:57:08 -04:00
139 changed files with 3572 additions and 5268 deletions

View File

@@ -17,15 +17,6 @@ If the target includes multiple matching file types, use all matching skills.
If the target is a diff, choose skills from the changed files.
Do not use a style skill when no reviewed file matches it.
For every reviewed `*.nix` file, run:
```bash
bazel run //src/natu:natu -- <reviewed-nix-paths>
```
Pass every reviewed Nix path to Natu.
Report every Natu diagnostic as an actionable finding.
Report only actionable findings.
Order findings by severity.
Do not report preferences unless a loaded rule states them.
@@ -45,7 +36,6 @@ Keep the code excerpt to one to three lines when possible.
Use a longer excerpt only when the violation needs surrounding context.
Use `...` to omit unrelated lines from a multi-line excerpt.
Use a style-skill rule citation in the form `skill::rule-file:line`.
Cite Natu diagnostics as `natu::<rule-id>`.
Make the fix direct.
Do not include alternatives when one fix best follows the cited rule.

View File

@@ -1 +0,0 @@
../../src/skills/craft-skill

View File

@@ -96,22 +96,11 @@
- `rules_rust` `rust_test` targets cannot set both `crate` and `srcs`.
Use one input style per test target.
- Rust tests in this monorepo live in separate `*_test.rs` files, not inline `#[cfg(test)]` modules.
- `rnix::ast::HasEntry::entries()` includes assignments and `inherit` statements.
Use `attrpath_values()` when code needs only assignment entries.
- Ariadne Rust tests are declared in Bazel `BUILD` files; do not add `[[test]]` entries to
`src/ariadne/Cargo.toml` unless explicitly requested.
- In this monorepo, "dotfiles" refers to the dotfiles/NixOS configuration under `src/dotfiles/` and `configs/nixos/`.
- The flake does not currently define `formatter.x86_64-linux`.
Do not use `nix fmt` as a validation command until a formatter is added.
- The `fix` app passes its current directory to Deadnix.
Run `nix run .#fix` from the repository root so path exclusions match.
- During the Nix refactor, address each issue that comes up by adding either a Natu rule or a new Nix style rule.
- Git pathspec searches started in a subdirectory default to that subtree.
Use `git -C <repository-root>` or top-anchored pathspecs for repository-wide history searches.
- A `**/*.nix` Git pathspec does not include Nix files directly in its starting directory.
Include both `*.nix` and `**/*.nix`, or use a recursive `*.nix` pathspec, when linting every Nix file.
- Nix `let` bindings are recursive; their source order does not determine binding availability.
Treat Natu `let_order` as a readability rule, not an evaluation dependency.
- Ariadne extraction must preserve the game-root season prefix for archived source.
Different game installs can contain compiled scripts with the same internal path.
- Clew owns Ren'Py extraction, preparation, and compiled-script AST export. Ariadne owns analysis over Clew-produced data.
@@ -125,4 +114,3 @@
Do not use `git subtree add` or `git subtree pull` for Pi PR branches.
Gitea rebases the synthetic upstream root commit without the `third_party/pi` prefix.
Preserve `git-subtree-dir` and `git-subtree-split` trailers on Pi vendor commits.
- The default Pi model for NixOS user configuration is set in `configs/nixos/modules/shell/pi/default.nix`, not in vendored Pi source.

24
Cargo.lock generated
View File

@@ -190,18 +190,6 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
name = "monorepo"
version = "0.1.0"
[[package]]
name = "natu"
version = "0.1.0"
dependencies = [
"clap",
"googletest",
"monorepo",
"rnix",
"rowan",
"status",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -405,6 +393,18 @@ dependencies = [
"windows-link",
]
[[package]]
name = "xatu"
version = "0.1.0"
dependencies = [
"clap",
"googletest",
"monorepo",
"rnix",
"rowan",
"status",
]
[[package]]
name = "zmij"
version = "1.0.23"

View File

@@ -4,7 +4,7 @@ members = [
"src/machoke",
"src/monorepo",
"src/status",
"src/natu",
"src/xatu",
]
resolver = "3"

View File

@@ -14,5 +14,4 @@ rust = use_extension("//bazel:rust.bzl", "rust")
use_repo(rust, "rust")
register_toolchains("@python//:python")
register_toolchains("@rust//:rust")

View File

@@ -2,10 +2,10 @@
## Desktop
- When adding a graphical application, register its workspace icon in `registry.window-icon`.
- TODO: Verify this path after the NixOS configuration is migrated.
When adding a graphical application, add a `window-rewrite` icon mapping in `modules/desktop/session/waybar.nix`.
The workspace indicator uses this mapping to show an application icon instead of the generic default glyph.
Match the mapping on the window class.
Store the icon as the rendered string.
Run `hyprctl clients -j | jq -r '.[].class' | sort -u` to list classes in the running session.
- TODO: Verify this path after the NixOS configuration is migrated.
Set Hyprland session environment variables in `modules/desktop/session/hyprland/default.nix` with `env = KEY,VALUE`.
@@ -40,8 +40,6 @@
## Gotchas
- `configs/nixos/registry/stable-uid.nix` receives library functions through `lib`.
Qualify its library calls with `lib.`.
- Guest modules are host-agnostic wiring, while host files own deployment-specific service configuration such as served volumes and host paths.
For example, `guests/copyparty.nix` should expose options and translate them into guest mounts/service settings, while `hosts/pikachu/default.nix` chooses `/inbox`, `/media`, and their backing paths.
Guest addresses and routes are host-level configuration because a guest module can be used on multiple hosts.
@@ -52,8 +50,8 @@
directories already exist inside the read-only source.
For aggregate Copyparty volumes such as `/srv` with nested `/srv/inbox`, keep the guest bind mount
writable and enforce read-only behavior through Copyparty access plus service `ReadWritePaths`.
- `deploy-to` builds from the source checkout, records the imported closure as the target system profile,
and activates it. Do not rely on a target-local monorepo checkout for remote deployment.
- `deploy-to` builds from the source checkout and activates an imported closure on the target.
Do not rely on a target-local monorepo checkout for remote deployment.
- On Pikachu, `nix copy --to ssh://pikachu` accepted a small `nix store add-file` probe,
but rejected locally built system paths that lacked trusted signatures.
Use a sudo `nix-store --import` path for unsigned deploy closures.
@@ -61,7 +59,3 @@
Do not describe NixOS route choices as public-DNS management.
- Do not hand-author `hardware-configuration.nix` for a host that has not been generated on that machine.
Generate it on the target system or live ISO, then stage it before evaluating the flake.
- Configure the Chaotic Nyx cache only on hosts that select `linuxPackages_cachyos` (currently Neogaia
and Zeus), not in the generic host module.
- Module-relative paths resolve from the module file. Update them when moving a module into a subdirectory.
- On Pikachu, `uptime` does not support `-s`. Use `who -b` to report the boot time.

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.guests.actualbudget; # NOLINT(let_order)
cfg = config.guests.actualbudget;
dataDir = "/data";
in
{

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.guests.copyparty; # NOLINT(let_order)
cfg = config.guests.copyparty;
volumeType = lib.types.submodule {
options = {

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.guests.homepage; # NOLINT(let_order)
cfg = config.guests.homepage;
environmentFile = "/run/secrets/homepage.env";
in
{

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.guests.palworld; # NOLINT(let_order)
cfg = config.guests.palworld;
steamDir = "/data/steamcmd";
installDir = "/data/server";
stateDir = "/data/state";

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.guests.valheim; # NOLINT(let_order)
cfg = config.guests.valheim;
steamDir = "/data/steamcmd";
installDir = "/data/server";
stateDir = "/data/state";

View File

@@ -1,6 +1,6 @@
{
inputs,
util,
pkgs,
...
}:
# neogaia — Dell XPS 13 9380 laptop.
@@ -13,9 +13,10 @@
./disk.nix
];
modules.host.cachyos.enable = true;
boot.kernelPackages = pkgs.linuxPackages_cachyos;
modules.network.enable = true;
# So wifi can be joined from the console.
networking.networkmanager.enable = true;
registry.address.neogaia.self = "10.23.50.146";
@@ -27,9 +28,15 @@
peer = {
publicKey = "sGEkSD+gEdIMXi4PBN3AhwHGc4tWMtcjOIey3s7yb3I=";
endpoint = "100.0.90.192:51820";
allowedIPs = [ "10.23.0.0/16" ];
};
};
registry.shell-shortcut.neogaia = {
vpn-on = "sudo systemctl start wireguard-wg-home.target";
vpn-off = "sudo systemctl stop 'wireguard-wg-home*.service' wireguard-wg-home.target";
};
modules.monorepo.enable = true;
modules.shell.herdr.enable = true;
modules.shell.gitea-axi.enable = true;
@@ -40,23 +47,4 @@
modules.desktop.apps.obsidian.enable = true;
modules.desktop.apps.steam.enable = true;
registry.waybar.neogaia.battery = {
order = 70;
settings = {
format = "{icon} {capacity}%";
format-charging = "${util.glyph "f0e7"} {capacity}%";
format-icons = [
(util.glyph "f244")
(util.glyph "f243")
(util.glyph "f242")
(util.glyph "f241")
(util.glyph "f240")
];
states = {
warning = 20;
critical = 10;
};
};
};
}

View File

@@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia

View File

@@ -15,15 +15,52 @@
hardware.cpu.intel.updateMicrocode = true;
modules.network.vlans = {
interface = "enp2s0";
ids = [ 20 ];
systemd.network = {
enable = true;
netdevs = {
"40-br-vlan20".netdevConfig = {
Name = "br-vlan20";
Kind = "bridge";
};
"40-vlan20" = {
netdevConfig = {
Name = "vlan20";
Kind = "vlan";
};
vlanConfig.Id = 20;
};
};
networks = {
"10-uplink" = {
matchConfig = {
Name = "enp2s0";
MACAddress = "78:55:36:07:af:49";
};
networkConfig.DHCP = "yes";
vlan = [ "vlan20" ];
linkConfig.RequiredForOnline = "routable";
};
"40-vlan20" = {
matchConfig.Name = "vlan20";
networkConfig.Bridge = "br-vlan20";
linkConfig.RequiredForOnline = "no";
};
"40-br-vlan20" = {
matchConfig.Name = "br-vlan20";
linkConfig.RequiredForOnline = "no";
};
};
};
networking.useDHCP = false;
modules.network.vlans.ids = [ 20 ];
boot.zfs.forceImportRoot = false;
modules.host.zfs = {
modules.zfs = {
enable = true;
hostId = "2346edbd";
pools.pikachu = { };
};
@@ -41,8 +78,7 @@
enable = true;
route = {
domain = "valheim.alexion.dev";
protocol = "udp";
ports = [
udp.ports = [
config.guests.valheim.port
(config.guests.valheim.port + 1)
];
@@ -65,8 +101,7 @@
enable = true;
route = {
domain = "palworld.alexion.dev";
protocol = "udp";
ports = [ config.guests.palworld.port ];
udp.ports = [ config.guests.palworld.port ];
};
};
};
@@ -81,7 +116,6 @@
enable = true;
route = {
domain = "budget.alexion.dev";
protocol = "https";
port = 5006;
};
};
@@ -98,7 +132,6 @@
enable = true;
route = {
domain = "music.alexion.dev";
protocol = "https";
port = 4533;
};
};
@@ -119,8 +152,7 @@
enable = true;
route = {
domain = "home";
protocol = "http";
port = 8082;
http.port = 8082;
};
};
};
@@ -135,9 +167,7 @@
enable = true;
route = {
domain = "files.alexion.dev";
protocol = "https";
port = 3923;
clientMaxBodySize = "1g";
};
trustedSources = [ "${registry.address.${config.networking.hostName}."reverse-proxy"}/32" ];
};
@@ -150,7 +180,7 @@
};
"/inbox" = {
hostPath = "/pikachu/__inbox";
path = "/pikachu/__inbox";
path = "/srv/inbox";
access.A = [ "alexion" ];
flags = [
"d2d"
@@ -160,7 +190,7 @@
};
"/media" = {
hostPath = "/pikachu/media";
path = "/pikachu/media";
path = "/srv/media";
access.A = [ "alexion" ];
flags = [
"d2d"
@@ -168,13 +198,6 @@
"dthumb"
];
};
"/opds" = {
hostPath = "/pikachu/data/opds";
path = "/pikachu/data/opds";
access.r = [ "alexion" ];
readOnly = true;
flags = [ "opds" ];
};
};
};

View File

@@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINNqJIC6VRXyvrNf3n9su9KdPCikC3CjK/QrCK2reHdB alexion@pikachu

View File

@@ -1,6 +1,7 @@
{
config,
lib,
pkgs,
...
}:
# zeus — Ryzen desktop with AMD graphics.
@@ -14,19 +15,14 @@
nixpkgs.hostPlatform = "x86_64-linux";
modules.host.cachyos.enable = true;
boot.kernelPackages = pkgs.linuxPackages_cachyos;
hardware.graphics = {
enable = true;
enable32Bit = true;
};
modules.network.enable = true;
services.logind.settings.Login = {
HandlePowerKey = "ignore";
HandlePowerKeyLongPress = "ignore";
};
networking.networkmanager.enable = true;
registry.address.zeus.self = "10.23.50.221";
@@ -43,25 +39,13 @@
modules.desktop.enable = true;
modules.desktop.apps.obsidian.enable = true;
modules.desktop.apps.r2modman.enable = true;
modules.desktop.apps.signal.enable = true;
modules.desktop.apps.steam.enable = true;
modules.desktop.apps.vencord.enable = true;
home-manager.users.${config.user.name}.wayland.windowManager.hyprland.settings.monitor = [
{
output = "HDMI-A-1";
mode = "1920x1080@120";
position = "-1080x0";
scale = 1;
transform = 3;
}
{
output = "DP-3";
mode = "2560x1440@239.96";
position = "0x0";
scale = 1;
}
"HDMI-A-1,1920x1080@120,0x0,1,transform,3"
"DP-3,2560x1440@239.96,1080x0,1"
];
}

View File

@@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIRBcHHuubptXCdvED/p2dR3MpjGN/AimqLasLY5984G alexion@zeus

View File

@@ -1,10 +1,14 @@
{
config,
lib,
pkgs,
inputs,
...
}:
# The shared foundation for every host and guest.
let
stateVersion = "26.05";
in
{
imports = [ inputs.home-manager.nixosModules.home-manager ];
@@ -13,14 +17,14 @@
type = lib.types.str;
default = "alexion";
description = ''
Names the primary user account.
Host configuration uses this name for the Home Manager user.
Primary interactive user for this system.
This drives the system account and Home Manager user together.
'';
};
};
config = {
system.stateVersion = "26.05";
system.stateVersion = stateVersion;
nixpkgs.config.allowUnfree = true;
nix.settings.experimental-features = [
@@ -38,7 +42,66 @@
users.groups.storage.gid = 10000;
security.sudo.extraConfig = ''
Defaults env_keep += "EDITOR VISUAL"
'';
time.timeZone = "America/New_York";
i18n.defaultLocale = "en_GB.UTF-8";
home-manager = {
useGlobalPkgs = true;
useUserPackages = true;
users.${config.user.name} = {
home.username = config.user.name;
home.homeDirectory = "/home/${config.user.name}";
home.stateVersion = stateVersion;
home.packages = with pkgs; [
eza
wget
];
};
};
registry.shell-shortcut.${config.networking.hostName} = {
ls = {
command = "eza -alg --color=always --group-directories-first --icons=always";
abbreviation = false;
};
ll = {
command = "eza -lg --color=always --group-directories-first --icons=always";
abbreviation = false;
};
lt = {
command = "eza -aT -I '.git' --color=always --group-directories-first --icons=always";
abbreviation = false;
};
".." = "cd ..";
"..." = "cd ../..";
"...." = "cd ../../..";
cp = "cp -v";
tarnow = "tar -acf ";
untar = "tar -zxvf ";
wget = "wget -c ";
grep = "grep --color=auto";
fgrep = "fgrep --color=auto";
egrep = "egrep --color=auto";
jctl = "journalctl -p 3 -xb";
update = "sudo nixos-rebuild switch --flake .#${config.networking.hostName}";
cleanup = "sudo nix-collect-garbage -d";
sops-host = {
command = "sudo --preserve-env=EDITOR,VISUAL env SOPS_AGE_KEY_FILE=/var/lib/sops-nix/key.txt nix shell nixpkgs#sops nixpkgs#neovim -c sops";
abbreviation = false;
};
};
modules.shell.fish.enable = lib.mkDefault true;
modules.shell.fish.defaultShell = lib.mkDefault true;
modules.shell.tmux.enable = lib.mkDefault true;
modules.shell.nvim.enable = lib.mkDefault true;
modules.shell.git.enable = lib.mkDefault true;
modules.shell.yazi.enable = lib.mkDefault true;
};
}

View File

@@ -0,0 +1,19 @@
{
config,
lib,
pkgs,
...
}:
# Alacritty as the desktop terminal.
let
cfg = config.modules.desktop.apps.alacritty;
user = config.user.name;
in
{
options.modules.desktop.apps.alacritty.enable =
lib.mkEnableOption "Alacritty as the desktop terminal";
config = lib.mkIf cfg.enable {
home-manager.users.${user}.home.packages = [ pkgs.alacritty ];
};
}

View File

@@ -3,12 +3,13 @@
inputs,
lib,
pkgs,
util,
...
}:
# Configures Firefox as the desktop browser.
# Firefox as the desktop browser: stock mainline, hardened and de-monetized by policy.
let
cfg = config.modules.desktop.apps.firefox; # NOLINT(let_order)
cfg = config.modules.desktop.apps.firefox;
user = config.user.name;
firefoxAddons = inputs.firefox-addons.packages.${pkgs.stdenv.hostPlatform.system};
in
{
options.modules.desktop.apps.firefox = {
@@ -18,25 +19,19 @@ in
type = lib.types.str;
default = "1.0";
description = ''
Scales Firefox UI and page content.
Sets `layout.css.devPixelsPerPx`.
Use a larger value for high-DPI displays.
Firefox UI and page scale, passed to `layout.css.devPixelsPerPx`.
Override it on hosts that need a larger browser scale, such as small
high-DPI laptop panels.
'';
};
};
config = lib.mkIf cfg.enable {
registry.window-icon.${config.networking.hostName}.firefox = {
class = ".*[Ff]irefox.*";
icon = util.glyph "f269";
};
home-manager.users.${config.user.name} = hm: {
home-manager.users.${user} = hm: {
programs.firefox = {
enable = true;
# Applies policies that users cannot change in Firefox.
# Enterprise policies: enforced and unchangeable from the browser UI.
policies = {
DisableTelemetry = true;
DisableFirefoxStudies = true;
@@ -45,7 +40,7 @@ in
DontCheckDefaultBrowser = true;
DisableFirefoxAccounts = true;
# Removes sponsored content from the new-tab page.
# Strip the monetized surfaces from the new-tab page.
FirefoxHome = {
SponsoredTopSites = false;
SponsoredPocket = false;
@@ -54,19 +49,23 @@ in
};
profiles.default = {
isDefault = true;
extensions = {
packages = with inputs.firefox-addons.packages.${pkgs.stdenv.hostPlatform.system}; [
packages = with firefoxAddons; [
ublock-origin
proton-pass
sponsorblock
];
# Home Manager overwrites Nord theme changes made in Firefox.
# The Nord chrome theme is a declared extension setting, so home-manager
# owns the extension-settings store, overwriting runtime changes to it.
force = true;
};
# Stylix uses a near-white selection background.
# Use the Nord selection color because its text stays readable.
# Stylix's Nord mapping paints the selected address-bar result a
# near-white grey, leaving its light text unreadable. Darken that one
# highlight to the Nord selection grey, from the same scheme.
extensions.settings."FirefoxColor@mozilla.com".settings.theme.colors.popup_highlight =
let
c = hm.config.lib.stylix.colors;
@@ -78,26 +77,32 @@ in
};
settings = {
# Scale the UI and page by a fixed factor.
# Left at auto (-1), Firefox reads the panel's 1.5x and inflates its
# whole chrome while point-sized apps stay put.
# A shade under that brings it into line without dropping to true
# 1:1, which reads too small at this DPI.
"layout.css.devPixelsPerPx" = cfg.uiScale;
# Enables Firefox Color after Firefox sideloads the add-on.
# Auto-enable the sideloaded Firefox Color add-on carrying the Nord
# chrome theme, which Firefox otherwise leaves disabled.
"extensions.autoDisableScopes" = 0;
# Removes sponsored content outside Firefox policies.
# Sponsored surfaces the policies above do not reach.
"browser.urlbar.suggest.quicksuggest.sponsored" = false;
"browser.newtabpage.activity-stream.showSponsored" = false;
"browser.newtabpage.activity-stream.showSponsoredTopSites" = false;
};
search = {
# Home Manager replaces the engine list when this setting is enabled.
# Engines added in Firefox do not survive a rebuild.
# Declaring search overwrites Firefox's own engine list wholesale,
# so engines added later in the UI do not survive a rebuild.
force = true;
default = "ddg";
# Hides commercial search engines.
# Bangs still reach these engines.
# Entries with only `metaData` configure Firefox built-in engines.
# The general-purpose commercial engines, hidden to leave a lean
# DuckDuckGo-and-Wikipedia list. They stay reachable through bangs.
# An engine carrying only metaData is treated as a builtin.
engines = {
google.metaData.hidden = true;
bing.metaData.hidden = true;
@@ -109,7 +114,8 @@ in
};
};
# Applies the Nord theme through Stylix's Firefox Color add-on.
# Nord chrome for the one profile, applied through the Stylix-managed
# Firefox Color add-on that colorTheme enables.
stylix.targets.firefox = {
enable = true;
profileNames = [ "default" ];
@@ -127,8 +133,8 @@ in
};
};
# Firefox creates `profiles.ini` on its first launch.
# Force replacement during Home Manager activation.
# Firefox writes profiles.ini itself on first launch, so home-manager is
# told to own the file rather than fail activation refusing to clobber it.
home.file."${hm.config.programs.firefox.configPath}/profiles.ini".force = true;
};
};

View File

@@ -2,30 +2,17 @@
config,
lib,
pkgs,
util,
...
}:
# Ghostty as the desktop terminal.
let
cfg = config.modules.desktop.apps.ghostty;
user = config.user.name;
in
{
options.modules.desktop.apps.ghostty.enable = lib.mkEnableOption "Ghostty as the desktop terminal";
config = lib.mkIf config.modules.desktop.apps.ghostty.enable {
registry.keybind.${config.networking.hostName}.ghostty = {
keys = "SUPER + Return";
action = {
kind = "exec";
command = lib.getExe pkgs.ghostty;
};
};
registry.window-icon.${config.networking.hostName}.ghostty = {
class = "com.mitchellh.ghostty";
icon = util.glyph "f120";
};
config = lib.mkIf cfg.enable {
home-manager.users.${user}.home.packages = [ pkgs.ghostty ];
};
}

View File

@@ -2,23 +2,18 @@
config,
lib,
pkgs,
util,
...
}:
# Obsidian as the desktop note-taking vault.
let
cfg = config.modules.desktop.apps.obsidian;
user = config.user.name;
in
{
options.modules.desktop.apps.obsidian.enable =
lib.mkEnableOption "Obsidian as the desktop note-taking vault";
config = lib.mkIf config.modules.desktop.apps.obsidian.enable {
config = lib.mkIf cfg.enable {
home-manager.users.${user}.home.packages = [ pkgs.obsidian ];
registry.window-icon.${config.networking.hostName}.obsidian = {
class = "obsidian";
icon = util.glyph "f02d";
};
};
}

View File

@@ -1,18 +0,0 @@
{
config,
lib,
pkgs,
...
}:
# r2modman, a Thunderstore mod manager.
let
user = config.user.name;
in
{
options.modules.desktop.apps.r2modman.enable =
lib.mkEnableOption "r2modman, a Thunderstore mod manager";
config = lib.mkIf config.modules.desktop.apps.r2modman.enable {
home-manager.users.${user}.home.packages = [ pkgs.r2modman ];
};
}

View File

@@ -2,22 +2,17 @@
config,
lib,
pkgs,
util,
...
}:
# Signal desktop messenger.
let
cfg = config.modules.desktop.apps.signal;
user = config.user.name;
in
{
options.modules.desktop.apps.signal.enable = lib.mkEnableOption "Signal desktop messenger";
config = lib.mkIf config.modules.desktop.apps.signal.enable {
config = lib.mkIf cfg.enable {
home-manager.users.${user}.home.packages = [ pkgs.signal-desktop ];
registry.window-icon.${config.networking.hostName}.signal = {
class = "[Ss]ignal";
icon = util.glyph "f4ad";
};
};
}

View File

@@ -2,14 +2,16 @@
config,
lib,
pkgs,
util,
...
}:
# Steam game launcher and runtime integration.
let
cfg = config.modules.desktop.apps.steam;
in
{
options.modules.desktop.apps.steam.enable = lib.mkEnableOption "Steam game launcher";
config = lib.mkIf config.modules.desktop.apps.steam.enable {
config = lib.mkIf cfg.enable {
programs.gamemode.enable = true;
programs.steam = {
@@ -24,10 +26,5 @@
environment.systemPackages = [
pkgs.gamescope
];
registry.window-icon.${config.networking.hostName}.steam = {
class = "[Ss]team";
icon = util.glyph "f1b6";
};
};
}

View File

@@ -2,23 +2,18 @@
config,
lib,
pkgs,
util,
...
}:
# Vesktop, a Discord client with Vencord built in.
let
cfg = config.modules.desktop.apps.vencord;
user = config.user.name;
in
{
options.modules.desktop.apps.vencord.enable =
lib.mkEnableOption "Vesktop, a Discord client with Vencord built in";
config = lib.mkIf config.modules.desktop.apps.vencord.enable {
config = lib.mkIf cfg.enable {
home-manager.users.${user}.home.packages = [ pkgs.vesktop ];
registry.window-icon.${config.networking.hostName}.vesktop = {
class = "[Vv]esktop";
icon = util.glyph "f1ff";
};
};
}

View File

@@ -1,11 +1,14 @@
{ config, lib, ... }:
# The desktop aggregator: one flag brings up the whole graphical session.
let
cfg = config.modules.desktop;
in
{
options.modules.desktop.enable = lib.mkEnableOption "the keyboard-driven Hyprland desktop";
# Each piece is turned on at default priority, so a host can still override
# any one of them while the single flag above enables the whole desktop.
config = lib.mkIf config.modules.desktop.enable {
config = lib.mkIf cfg.enable {
modules.desktop.apps.firefox.enable = lib.mkDefault true;
modules.desktop.apps.ghostty.enable = lib.mkDefault true;
modules.desktop.session.hyprland.enable = lib.mkDefault true;
@@ -23,6 +26,5 @@
modules.desktop.utilities.screenshot.enable = lib.mkDefault true;
modules.desktop.utilities.theming.enable = lib.mkDefault true;
modules.desktop.utilities.userdirs.enable = lib.mkDefault true;
registry.env.${config.networking.hostName}.NIXOS_OZONE_WL = "1";
};
}

View File

@@ -1,15 +1,41 @@
{
config,
lib,
util,
pkgs,
...
}:
# The Hyprland compositor.
let
cfg = config.modules.desktop.session.hyprland;
user = config.user.name;
cursor = config.stylix.cursor;
# Numbered-workspace switch and move for 1..9, the operator's i3 muscle memory.
workspaceBinds = lib.concatMap (n: [
"$mod, ${toString n}, workspace, ${toString n}"
"$mod SHIFT, ${toString n}, movetoworkspace, ${toString n}"
]) (lib.range 1 9);
# Flip the tiling strategy between the two built-in layouts, since neither a
# dispatcher nor a keyword toggles it on its own.
toggleLayout = pkgs.writeShellScript "hypr-toggle-layout" ''
if [ "$(hyprctl getoption -j general:layout | ${pkgs.jq}/bin/jq -r .str)" = dwindle ]; then
hyprctl keyword general:layout master
else
hyprctl keyword general:layout dwindle
fi
'';
in
{
options.modules.desktop.session.hyprland.enable = lib.mkEnableOption "the Hyprland compositor";
options.modules.desktop.session.hyprland = {
enable = lib.mkEnableOption "the Hyprland compositor";
config = lib.mkIf config.modules.desktop.session.hyprland.enable {
blur = lib.mkEnableOption ''
window blur. Off by default as the single biggest battery cost on a
laptop, left on for a host with the headroom to spend it'';
};
config = lib.mkIf cfg.enable {
# This program integration owns the session, portals, and polkit, launched
# through the universal Wayland session manager.
programs.hyprland = {
@@ -17,7 +43,7 @@
withUWSM = true;
};
home-manager.users.${config.user.name}.wayland.windowManager.hyprland = {
home-manager.users.${user}.wayland.windowManager.hyprland = {
enable = true;
# One package drives the whole session, so there is never a version split.
@@ -29,216 +55,135 @@
# uwsm owns the systemd graphical-session targets.
systemd.enable = false;
# Write the native Lua Hyprland config. Home Manager renders the Nix
# settings below as hl.* calls.
configType = "lua";
# Write the native hyprlang hyprland.conf, whose variable and bind syntax
# the settings below are expressed in.
configType = "hyprlang";
settings = {
config = {
input = {
kb_layout = config.services.xserver.xkb.layout;
repeat_delay = 250;
repeat_rate = 45;
accel_profile = "flat";
touchpad = {
natural_scroll = true;
tap_to_click = true;
disable_while_typing = true;
};
"$mod" = "SUPER";
"$terminal" = "ghostty";
# Session variables handed to the compositor directly.
# UWSM launches the session without the shell profile that would carry
# them, so a variable the compositor or its children must see is set
# here rather than through home-manager's sessionVariables.
env = [
# Chromium and Electron apps read this to select native Wayland;
# nixpkgs wrappers (Obsidian's included) gate their Wayland flags on
# it, so without it they run under XWayland and blur at this DPI.
"NIXOS_OZONE_WL,1"
]
# Bibata ships XCursor only.
# The hyprcursor variables name the same theme, which Hyprland
# resolves through its XCursor fallback.
++ lib.optionals (cursor != null) [
"XCURSOR_THEME,${cursor.name}"
"XCURSOR_SIZE,${toString cursor.size}"
"HYPRCURSOR_THEME,${cursor.name}"
"HYPRCURSOR_SIZE,${toString cursor.size}"
];
input = {
kb_layout = "us";
# Caps is a second Escape.
# Shift+Caps still toggles a real CapsLock.
kb_options = "caps:escape_shifted_capslock";
# Snappy key repeat.
repeat_delay = 250;
repeat_rate = 45;
accel_profile = "flat";
touchpad = {
natural_scroll = true;
tap-to-click = true;
disable_while_typing = true;
};
general = {
gaps_in = 4;
gaps_out = 8;
border_size = 2;
layout = "dwindle";
};
decoration = {
rounding = 6;
blur.enabled = false;
};
xwayland.force_zero_scaling = true;
dwindle.preserve_split = true;
};
env = lib.mapAttrsToList (name: value: {
_args = [
name
value
general = {
gaps_in = 4;
gaps_out = 8;
border_size = 2;
layout = "dwindle";
};
decoration = {
rounding = 6;
blur.enabled = cfg.blur;
};
# XWayland clients render at the panel's native resolution instead of
# being raster-scaled by the compositor at the fractional monitor scale.
xwayland.force_zero_scaling = true;
animations = {
enabled = true;
bezier = [ "ease, 0.25, 0.1, 0.25, 1.0" ];
# Durations are in centiseconds.
# Short values keep the motion subtle.
animation = [
"windows, 1, 3, ease"
"fade, 1, 3, ease"
# Layer surfaces like the launcher fade in a touch quicker than windows.
"layersIn, 1, 2, ease"
"fadeLayersIn, 1, 2, ease"
"workspaces, 1, 3, ease"
"border, 1, 3, ease"
];
}) (lib.attrByPath [ config.networking.hostName ] { } config.registry.env);
bind = lib.mapAttrsToList (_name: keybind: {
_args = [
keybind.keys
(lib.generators.mkLuaInline (
{
exec = "hl.dsp.exec_cmd(${lib.generators.toLua { } keybind.action.command})";
focus = "hl.dsp.focus(${
lib.generators.toLua { } { inherit (keybind.action) direction workspace; }
})";
"move-window" = "hl.dsp.window.move(${
lib.generators.toLua { } { inherit (keybind.action) direction workspace; }
})";
"resize-window" = "hl.dsp.window.resize(${
lib.generators.toLua { } (
{
down = {
x = 0;
y = 10;
relative = true;
};
left = {
x = -10;
y = 0;
relative = true;
};
right = {
x = 10;
y = 0;
relative = true;
};
up = {
x = 0;
y = -10;
relative = true;
};
}
.${keybind.action.direction}
)
})";
"toggle-floating" = ''hl.dsp.window.float({ action = "toggle" })'';
"toggle-fullscreen" = "hl.dsp.window.fullscreen()";
"close-window" = "hl.dsp.window.close()";
}
.${keybind.action.kind}
))
]
++ lib.optional keybind.repeating {
repeating = true;
}
++ lib.optional keybind.locked {
locked = true;
};
}) (lib.attrByPath [ config.networking.hostName ] { } config.registry.keybind);
};
};
registry.keybind.${config.networking.hostName} = lib.mkMerge [
# Generate numeric workspace keybinds.
(lib.listToAttrs (
lib.concatMap (workspace: [
{
name = "hyprland-workspace-${toString workspace}";
value = {
keys = "SUPER + ${toString workspace}";
action = {
kind = "focus";
inherit workspace;
};
};
}
{
name = "hyprland-move-to-workspace-${toString workspace}";
value = {
keys = "SUPER + SHIFT + ${toString workspace}";
action = {
kind = "move-window";
inherit workspace;
};
};
}
]) (lib.range 1 9)
))
# Generate directional keybinds.
(lib.listToAttrs (
lib.concatMap
(
{ direction, key }:
[
{
name = "hyprland-focus-${direction}";
value = {
keys = "SUPER + ${key}";
action = {
kind = "focus";
inherit direction;
};
};
}
{
name = "hyprland-move-${direction}";
value = {
keys = "SUPER + SHIFT + ${key}";
action = {
kind = "move-window";
inherit direction;
};
};
}
{
name = "hyprland-resize-${direction}";
value = {
keys = "SUPER + ALT + ${key}";
repeating = true;
action = {
kind = "resize-window";
inherit direction;
};
};
}
]
)
[
{
key = "H";
direction = "left";
}
{
key = "J";
direction = "down";
}
{
key = "K";
direction = "up";
}
{
key = "L";
direction = "right";
}
]
))
{
hyprland-toggle-floating = {
keys = "SUPER + Space";
action.kind = "toggle-floating";
};
hyprland-toggle-fullscreen = {
keys = "SUPER + F";
action.kind = "toggle-fullscreen";
dwindle = {
preserve_split = true;
};
hyprland-close-window = {
keys = "SUPER + SHIFT + Q";
action.kind = "close-window";
};
}
];
bind = [
"$mod, Return, exec, $terminal"
registry.waybar.${config.networking.hostName}."hyprland/workspaces" = {
position = "left";
order = 10;
settings = {
format = "{id} {windows}";
format-window-separator = " ";
window-rewrite-default = util.glyph "f2d0";
window-rewrite = lib.mapAttrs' (
_name: windowIcon: lib.nameValuePair "class<${windowIcon.class}>" windowIcon.icon
) (lib.attrByPath [ config.networking.hostName ] { } config.registry.window-icon);
# Move focus.
"$mod, H, movefocus, l"
"$mod, J, movefocus, d"
"$mod, K, movefocus, u"
"$mod, L, movefocus, r"
# Move the window within the layout.
"$mod SHIFT, H, movewindow, l"
"$mod SHIFT, J, movewindow, d"
"$mod SHIFT, K, movewindow, u"
"$mod SHIFT, L, movewindow, r"
# Resize the active window.
"$mod ALT, H, resizeactive, -40 0"
"$mod ALT, J, resizeactive, 0 40"
"$mod ALT, K, resizeactive, 0 -40"
"$mod ALT, L, resizeactive, 40 0"
"$mod, Space, togglefloating,"
"$mod, F, fullscreen,"
# togglesplit is a dwindle layout message, reached through layoutmsg.
"$mod, T, layoutmsg, togglesplit"
"$mod SHIFT, T, exec, ${toggleLayout}"
"$mod SHIFT, Q, killactive,"
"$mod CTRL, Q, forcekillactive,"
]
++ workspaceBinds;
# Volume and brightness keys repeat while held, each raising a popup
# through the OSD client.
# Volume is capped at 100 percent.
# The client floors brightness so a full hold cannot black the screen out.
binde = [
", XF86AudioRaiseVolume, exec, ${pkgs.swayosd}/bin/swayosd-client --output-volume raise --max-volume 100"
", XF86AudioLowerVolume, exec, ${pkgs.swayosd}/bin/swayosd-client --output-volume lower"
", XF86MonBrightnessUp, exec, ${pkgs.swayosd}/bin/swayosd-client --brightness raise"
", XF86MonBrightnessDown, exec, ${pkgs.swayosd}/bin/swayosd-client --brightness lower"
];
# Mute and media transport still fire while the session is locked.
bindl = [
", XF86AudioMute, exec, ${pkgs.swayosd}/bin/swayosd-client --output-volume mute-toggle"
", XF86AudioPlay, exec, ${pkgs.playerctl}/bin/playerctl play-pause"
", XF86AudioNext, exec, ${pkgs.playerctl}/bin/playerctl next"
", XF86AudioPrev, exec, ${pkgs.playerctl}/bin/playerctl previous"
];
};
};
};

View File

@@ -5,34 +5,62 @@
...
}:
# hypridle: idle-triggered locking and display power management.
{
options.modules.desktop.session.hyprland.hypridle.enable =
lib.mkEnableOption "hypridle idle management";
let
cfg = config.modules.desktop.session.hyprland.hypridle;
user = config.user.name;
config = lib.mkIf config.modules.desktop.session.hyprland.hypridle.enable {
hyprctl = "${config.programs.hyprland.package}/bin/hyprctl";
hyprlock = "${config.home-manager.users.${user}.programs.hyprlock.package}/bin/hyprlock";
# The guard drops the call when a locker is already up, so no idle trigger
# stacks a second hyprlock over the first.
lockCmd = "${pkgs.procps}/bin/pidof hyprlock || ${hyprlock}";
in
{
options.modules.desktop.session.hyprland.hypridle = {
enable = lib.mkEnableOption "hypridle idle management";
lockTimeout = lib.mkOption {
type = lib.types.ints.positive;
default = 300;
description = "Seconds of inactivity before the screen locks.";
};
screenOffTimeout = lib.mkOption {
type = lib.types.ints.positive;
default = 360;
description = "Seconds of inactivity before the displays are powered off.";
};
};
config = lib.mkIf cfg.enable {
# Closing the lid suspends, and every suspend locks first through the
# before_sleep_cmd below, so the lid always lands at a locked screen.
services.logind.settings.Login.HandleLidSwitch = "suspend";
home-manager.users.${config.user.name}.services.hypridle = {
home-manager.users.${user}.services.hypridle = {
enable = true;
settings = {
general = {
# The guard drops the call when a locker is already up, so no idle
# trigger stacks a second hyprlock over the first.
lock_cmd = "${lib.getExe' pkgs.procps "pidof"} hyprlock || ${lib.getExe pkgs.hyprlock}";
lock_cmd = lockCmd;
before_sleep_cmd = "loginctl lock-session";
# Waking pokes the compositor in case firmware leaves displays dark.
after_sleep_cmd = "${lib.getExe' config.programs.hyprland.package "hyprctl"} dispatch 'hl.dsp.dpms({ action = \"enable\" })'";
# Waking restores the displays the screen-off listener may have cut.
after_sleep_cmd = "${hyprctl} dispatch dpms on";
};
listener = [
# Lock on idle.
{
timeout = 300;
timeout = cfg.lockTimeout;
on-timeout = "loginctl lock-session";
}
# Power the displays off a little later, restoring them on any activity.
{
timeout = cfg.screenOffTimeout;
on-timeout = "${hyprctl} dispatch dpms off";
on-resume = "${hyprctl} dispatch dpms on";
}
];
};
};

View File

@@ -2,54 +2,45 @@
config,
lib,
pkgs,
util,
...
}:
# The lock screen: a hyprlock session-lock surface the compositor owns.
let
sleepIcon = util.glyph [
"db81"
"dcb2"
];
sleepOffIcon = util.glyph [
"db81"
"dcb3"
];
cfg = config.modules.desktop.session.hyprland.hyprlock;
user = config.user.name;
# The hyprlock this module installs, used by the lock keybind below.
hyprlock = "${config.home-manager.users.${user}.programs.hyprlock.package}/bin/hyprlock";
sleepIcon = builtins.fromJSON ''"\udb81\udcb2"'';
sleepOffIcon = builtins.fromJSON ''"\udb81\udcb3"'';
idleLockToggle = pkgs.writeShellScript "lockscreen-idle-inhibit-toggle" ''
unit=lockscreen-idle-inhibit.service
refresh_waybar() {
${lib.getExe' pkgs.procps "pkill"} -RTMIN+9 waybar >/dev/null 2>&1 || true
${pkgs.procps}/bin/pkill -RTMIN+9 waybar >/dev/null 2>&1 || true
}
if ${lib.getExe' pkgs.systemd "systemctl"} --user is-active --quiet "$unit"; then
${lib.getExe' pkgs.systemd "systemctl"} --user stop "$unit"
if ${pkgs.systemd}/bin/systemctl --user is-active --quiet "$unit"; then
${pkgs.systemd}/bin/systemctl --user stop "$unit"
refresh_waybar
${lib.getExe' pkgs.swayosd "swayosd-client"} --custom-message "${sleepIcon}"
${pkgs.swayosd}/bin/swayosd-client --custom-message "${sleepIcon}"
exit 0
fi
if ${lib.getExe' pkgs.systemd "systemd-run"} \
if ${pkgs.systemd}/bin/systemd-run \
--user \
--collect \
--unit=lockscreen-idle-inhibit \
--description="Disable idle-triggered screen locking" \
${lib.getExe' pkgs.systemd "systemd-inhibit"} \
${pkgs.systemd}/bin/systemd-inhibit \
--what=idle \
--who=lockscreen-hotkey \
--why="lock screen disabled by hotkey" \
${lib.getExe' pkgs.coreutils "sleep"} infinity >/dev/null; then
${pkgs.coreutils}/bin/sleep infinity >/dev/null; then
refresh_waybar
${lib.getExe' pkgs.swayosd "swayosd-client"} --custom-message "${sleepOffIcon}"
fi
'';
idleLockStatus = pkgs.writeShellScript "waybar-idle-lock-status" ''
if ${lib.getExe' pkgs.systemd "systemctl"} --user is-active --quiet lockscreen-idle-inhibit.service; then
printf '{"text":"${sleepOffIcon}","tooltip":"Idle locking inhibited","class":"inhibited"}\n'
else
printf '{"text":"","class":"idle"}\n'
${pkgs.swayosd}/bin/swayosd-client --custom-message "${sleepOffIcon}"
fi
'';
in
@@ -57,8 +48,8 @@ in
options.modules.desktop.session.hyprland.hyprlock.enable =
lib.mkEnableOption "the hyprlock lock screen";
config = lib.mkIf config.modules.desktop.session.hyprland.hyprlock.enable {
home-manager.users.${config.user.name} = {
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {
# Colors and the lock-screen background come from Stylix's hyprlock target,
# so only geometry and behaviour are set here.
programs.hyprlock = {
@@ -91,36 +82,14 @@ in
};
};
};
};
registry.keybind.${config.networking.hostName} = {
hyprlock-lock = {
keys = "SUPER + X";
action = {
kind = "exec";
# The guard drops the call when a locker is already up, so no manual
# trigger stacks a second hyprlock over the first.
command = "${lib.getExe' pkgs.procps "pidof"} hyprlock || ${lib.getExe pkgs.hyprlock}";
};
};
hyprlock-toggle-idle-inhibit = {
keys = "SUPER + CTRL + X";
action = {
kind = "exec";
command = "${idleLockToggle}";
};
};
};
registry.waybar.${config.networking.hostName}."custom/idle-lock" = {
order = 20;
settings = {
return-type = "json";
exec = "${idleLockStatus}";
interval = "once";
signal = 9;
};
# Lock on Super+X.
# The guard drops the keypress when a locker is already up, so a second
# hyprlock never stacks over the first.
wayland.windowManager.hyprland.settings.bind = [
"$mod, X, exec, ${pkgs.procps}/bin/pidof hyprlock || ${hyprlock}"
"$mod CTRL, X, exec, ${idleLockToggle}"
];
};
};
}

View File

@@ -6,20 +6,19 @@
}:
# Text login: greetd running the tuigreet greeter, which starts the session
# through the universal Wayland session manager.
let
cfg = config.modules.desktop.session.login;
in
{
options.modules.desktop.session.login.enable =
lib.mkEnableOption "greetd with the tuigreet text greeter";
config = lib.mkIf config.modules.desktop.session.login.enable {
config = lib.mkIf cfg.enable {
services.greetd = {
enable = true;
settings.default_session.command = lib.escapeShellArgs [
(lib.getExe pkgs.tuigreet)
"--time"
"--remember"
"--cmd"
"${lib.getExe config.programs.uwsm.package} start -e -D Hyprland hyprland.desktop"
];
settings.default_session.command =
"${lib.getExe pkgs.tuigreet} --time --remember "
+ "--cmd 'uwsm start -e -D Hyprland hyprland.desktop'";
};
};
}

View File

@@ -2,29 +2,20 @@
config,
lib,
pkgs,
util,
...
}:
# mako as the notification daemon: toasts, a do-not-disturb mode, and history recall.
let
dndStatus = pkgs.writeShellScript "waybar-dnd-status" ''
if ${lib.getExe' pkgs.mako "makoctl"} mode 2>/dev/null | ${lib.getExe pkgs.gnugrep} -qx dnd; then
printf '{"text":"${util.glyph "f1f6"}","tooltip":"Do not disturb: on","class":"dnd"}\n'
else
printf '{"text":"${util.glyph "f0f3"}","tooltip":"Notifications on","class":"active"}\n'
fi
'';
cfg = config.modules.desktop.session.mako;
user = config.user.name;
dndToggle = pkgs.writeShellScript "waybar-dnd-toggle" ''
${lib.getExe' pkgs.mako "makoctl"} mode -t dnd >/dev/null 2>&1
${lib.getExe' pkgs.procps "pkill"} -RTMIN+8 waybar
'';
makoctl = "${pkgs.mako}/bin/makoctl";
in
{
options.modules.desktop.session.mako.enable = lib.mkEnableOption "the mako notification daemon";
config = lib.mkIf config.modules.desktop.session.mako.enable {
home-manager.users.${config.user.name} = {
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {
# Colors and the popup font come from Stylix's mako target, so only
# behaviour is set here.
services.mako = {
@@ -41,25 +32,13 @@ in
"mode=dnd".invisible = true;
};
};
};
registry.keybind.${config.networking.hostName}.mako-history = {
keys = "SUPER + N";
action = {
kind = "exec";
command = "${lib.getExe' pkgs.mako "makoctl"} restore";
};
};
registry.waybar.${config.networking.hostName}."custom/dnd" = {
order = 80;
settings = {
return-type = "json";
exec = "${dndStatus}";
on-click = "${dndToggle}";
interval = "once";
signal = 8;
};
# Recall the last notification from history, keyboard-only like the rest
# of the session.
# $mod is defined by the compositor config these binds share.
wayland.windowManager.hyprland.settings.bind = [
"$mod, N, exec, ${makoctl} restore"
];
};
};
}

View File

@@ -1,10 +1,13 @@
{ config, lib, ... }:
# XDG desktop portal routing for in-app screen sharing and file dialogs.
let
cfg = config.modules.desktop.session.portals;
in
{
options.modules.desktop.session.portals.enable =
lib.mkEnableOption "XDG desktop portals for in-app screen sharing and file dialogs";
config = lib.mkIf config.modules.desktop.session.portals.enable {
config = lib.mkIf cfg.enable {
# The backend packages arrive with the compositor, so only the routing is set
# here.
# Screen sharing, screenshots, and global shortcuts need the compositor's

View File

@@ -6,29 +6,31 @@
}:
# rofi as the desktop launcher and dmenu-style menu frontend.
let
cfg = config.modules.desktop.session.rofi;
user = config.user.name;
# A session menu built on the same themed rofi, so the power actions look
# like every other menu the launcher drives.
powerMenu = pkgs.writeShellScript "rofi-power-menu" ''
chosen=$(printf '%s\n' Lock Suspend 'Log out' Reboot 'Shut down' \
| ${lib.getExe pkgs.rofi} -dmenu -i -p Power)
| ${pkgs.rofi}/bin/rofi -dmenu -i -p Power)
case "$chosen" in
Lock) ${lib.getExe' pkgs.systemd "loginctl"} lock-session ;;
Suspend) ${lib.getExe' pkgs.systemd "systemctl"} suspend ;;
'Log out') ${lib.getExe' pkgs.systemd "loginctl"} terminate-session "$XDG_SESSION_ID" ;;
Reboot) ${lib.getExe' pkgs.systemd "systemctl"} reboot ;;
'Shut down') ${lib.getExe' pkgs.systemd "systemctl"} poweroff ;;
Lock) ${pkgs.systemd}/bin/loginctl lock-session ;;
Suspend) ${pkgs.systemd}/bin/systemctl suspend ;;
'Log out') ${pkgs.systemd}/bin/loginctl terminate-session "$XDG_SESSION_ID" ;;
Reboot) ${pkgs.systemd}/bin/systemctl reboot ;;
'Shut down') ${pkgs.systemd}/bin/systemctl poweroff ;;
esac
'';
in
{
options.modules.desktop.session.rofi.enable = lib.mkEnableOption "the rofi launcher";
config = lib.mkIf config.modules.desktop.session.rofi.enable {
home-manager.users.${config.user.name} = hm: {
# Stylix supplies the Nord theme.
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {
# No theme is set here: Stylix's rofi target supplies the Nord one.
programs.rofi = {
enable = true;
font = lib.mkForce "JetBrains Mono 12";
# Application launch is the only mode, kept minimal so the prompt opens
# as fast as rofi can read the desktop entries.
@@ -37,31 +39,14 @@ in
modi = "drun";
show-icons = false;
};
theme.window = {
border = hm.config.lib.formats.rasi.mkLiteral "2px";
border-color = hm.config.lib.formats.rasi.mkLiteral "#FFFFFF";
border-radius = hm.config.lib.formats.rasi.mkLiteral "8px";
};
};
};
registry.keybind.${config.networking.hostName} = {
rofi-launcher = {
keys = "SUPER + R";
action = {
kind = "exec";
command = "${lib.getExe pkgs.rofi} -show drun";
};
};
rofi-power-menu = {
keys = "SUPER + SHIFT + X";
action = {
kind = "exec";
command = "${powerMenu}";
};
};
# The launcher and the session menu, kept next to the tool they drive.
# $mod is defined by the compositor config these binds share.
wayland.windowManager.hyprland.settings.bind = [
"$mod, R, exec, ${pkgs.rofi}/bin/rofi -show drun"
"$mod SHIFT, X, exec, ${powerMenu}"
];
};
};
}

View File

@@ -6,56 +6,165 @@
}:
# Waybar: a top status bar reading system state at a glance.
let
moduleNames =
position:
map (entry: entry.name) (
lib.sort (left: right: left.module.order < right.module.order) (
lib.filter (entry: entry.module.position == position) moduleEntries
)
);
cfg = config.modules.desktop.session.waybar;
vpnClient = config.modules.network.vpn-client;
user = config.user.name;
moduleEntries = lib.mapAttrsToList (name: module: {
inherit name module;
}) modules;
# A Nerd Font Private-Use-Area codepoint as a literal character, decoded
# through JSON so the glyph survives as bytes rather than an editor paste.
g = code: builtins.fromJSON ''"\u${code}"'';
sleepOffIcon = builtins.fromJSON ''"\udb81\udcb3"'';
modules = lib.attrByPath [ config.networking.hostName ] { } config.registry.waybar;
# Renders and toggles mako's do-not-disturb mode.
# With no mako daemon answering, the status reads "notifications on", keeping
# the widget inert rather than broken.
dndStatus = pkgs.writeShellScript "waybar-dnd-status" ''
if ${pkgs.mako}/bin/makoctl mode 2>/dev/null | ${pkgs.gnugrep}/bin/grep -qx dnd; then
printf '{"text":"${g "f1f6"}","tooltip":"Do not disturb: on","class":"dnd"}\n'
else
printf '{"text":"${g "f0f3"}","tooltip":"Notifications on","class":"active"}\n'
fi
'';
dndToggle = pkgs.writeShellScript "waybar-dnd-toggle" ''
${pkgs.mako}/bin/makoctl mode -t dnd >/dev/null 2>&1
${pkgs.procps}/bin/pkill -RTMIN+8 waybar
'';
# Lit while a wf-recorder capture is running.
# Empty text collapses the widget when idle, so it shows nothing until then.
recStatus = pkgs.writeShellScript "waybar-recording-status" ''
if ${pkgs.procps}/bin/pgrep -x wf-recorder >/dev/null; then
printf '{"text":"${g "f03d"}","tooltip":"Recording","class":"recording"}\n'
else
printf '{"text":"","class":"idle"}\n'
fi
'';
idleLockStatus = pkgs.writeShellScript "waybar-idle-lock-status" ''
if ${pkgs.systemd}/bin/systemctl --user is-active --quiet lockscreen-idle-inhibit.service; then
printf '{"text":"${sleepOffIcon}","tooltip":"Idle locking inhibited","class":"inhibited"}\n'
else
printf '{"text":"","class":"idle"}\n'
fi
'';
vpnStatus = pkgs.writeShellScript "waybar-vpn-status" ''
interface=${lib.escapeShellArg vpnClient.interface}
if ! ${pkgs.iproute2}/bin/ip link show dev "$interface" >/dev/null 2>&1; then
printf '{"text":"","class":"idle"}\n'
exit 0
fi
ipaddr=$(${pkgs.iproute2}/bin/ip -4 -o addr show dev "$interface" | ${pkgs.gawk}/bin/awk '{ print $4; exit }')
${pkgs.jq}/bin/jq -cn \
--arg text "󰲝" \
--arg tooltip "VPN $interface: ''${ipaddr:-no IPv4}" \
--arg class "active" \
'{ text: $text, tooltip: $tooltip, class: $class }'
'';
networkStatus = pkgs.writeShellScript "waybar-network-status" ''
active=$(
${pkgs.networkmanager}/bin/nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device status \
| ${pkgs.gawk}/bin/awk -F: '$3 == "connected" && $2 != "loopback" { print; exit }'
)
if [ -z "$active" ]; then
${pkgs.jq}/bin/jq -cn \
--arg text "${g "f127"} off" \
--arg tooltip "Network disconnected" \
--arg class "disconnected" \
'{ text: $text, tooltip: $tooltip, class: $class }'
exit 0
fi
IFS=: read -r device type _state connection <<< "$active"
case "$type" in
wifi) icon="${g "f1eb"}" ;;
ethernet) icon="LAN" ;;
*) icon="NET" ;;
esac
ipaddr=$(${pkgs.iproute2}/bin/ip -4 -o addr show dev "$device" | ${pkgs.gawk}/bin/awk '{ print $4; exit }')
${pkgs.jq}/bin/jq -cn \
--arg text "$icon $connection" \
--arg tooltip "$device: ''${ipaddr:-no IPv4}" \
--arg class "$type" \
'{ text: $text, tooltip: $tooltip, class: $class }'
'';
in
{
options.modules.desktop.session.waybar.enable = lib.mkEnableOption "the Waybar status bar";
config = lib.mkIf config.modules.desktop.session.waybar.enable {
config = lib.mkIf cfg.enable {
# A symbols font backs the module glyphs, so pango falls back to it for the
# codepoints Stylix's monospace font does not carry.
fonts.packages = [ pkgs.nerd-fonts.symbols-only ];
home-manager.users.${config.user.name}.programs.waybar = {
home-manager.users.${user}.programs.waybar = {
enable = true;
# A user service bound to graphical-session.target, which uwsm activates,
# so the bar comes up with the session and no compositor exec-once.
systemd.enable = true;
settings.mainBar = lib.mkMerge [
{
layer = "top";
position = "top";
height = 30;
spacing = 8;
margin-right = 8;
modules-left = moduleNames "left";
modules-center = moduleNames "center";
modules-right = moduleNames "right";
style = ''
.modules-right {
margin-right: 8px;
}
(lib.mapAttrs (_name: module: module.settings) modules)
];
};
registry.waybar.${config.networking.hostName} = {
clock = {
position = "center";
order = 10;
settings = {
#custom-dnd {
margin-right: 8px;
}
'';
settings.mainBar = {
layer = "top";
position = "top";
height = 30;
spacing = 8;
modules-left = [ "hyprland/workspaces" ];
modules-center = [ "clock" ];
modules-right = [
"custom/recording"
"custom/idle-lock"
"mpris"
"wireplumber"
]
++ lib.optional vpnClient.enable "custom/vpn"
++ [
"custom/network"
"battery"
"custom/dnd"
];
# Per-application icons for the windows on each workspace, mapped from
# window class with a neutral glyph for anything unlisted.
"hyprland/workspaces" = {
on-click = "activate";
format = "{id} {windows}";
format-window-separator = " ";
window-rewrite-default = g "f2d0";
window-rewrite = {
"class<Alacritty>" = g "f120";
"class<com.mitchellh.ghostty>" = g "f120";
"class<.*[Ff]irefox.*>" = g "f269";
"class<chromium.*>" = g "f268";
"class<[Cc]ode>" = g "f121";
"class<obsidian>" = g "f02d";
"class<[Ss]ignal>" = g "f4ad";
"class<[Ss]team>" = g "f1b6";
"class<[Vv]esktop>" = g "f1ff";
};
};
clock = {
format = "{:%a %d %b %H:%M}";
tooltip-format = "<tt>{calendar}</tt>";
calendar = {
@@ -64,6 +173,77 @@ in
format.today = "<b><u>{}</u></b>";
};
};
mpris = {
format = "${g "f001"} {title}";
format-paused = "${g "f04c"} {title}";
max-length = 40;
on-click = "play-pause";
on-scroll-up = "next";
on-scroll-down = "previous";
};
wireplumber = {
format = "{icon} {volume}%";
format-muted = "${g "f026"} muted";
format-icons = [
(g "f026")
(g "f027")
(g "f028")
];
on-click = "${pkgs.wireplumber}/bin/wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle";
};
"custom/vpn" = {
return-type = "json";
exec = "${vpnStatus}";
interval = 5;
};
"custom/network" = {
return-type = "json";
exec = "${networkStatus}";
interval = 5;
};
battery = {
format = "{icon} {capacity}%";
format-charging = "${g "f0e7"} {capacity}%";
format-icons = [
(g "f244")
(g "f243")
(g "f242")
(g "f241")
(g "f240")
];
states = {
warning = 20;
critical = 10;
};
};
"custom/dnd" = {
return-type = "json";
exec = "${dndStatus}";
on-click = "${dndToggle}";
interval = "once";
signal = 8;
};
# Recording state has no event to hook, so the widget samples the
# wf-recorder process once a second.
"custom/recording" = {
return-type = "json";
exec = "${recStatus}";
interval = 1;
};
"custom/idle-lock" = {
return-type = "json";
exec = "${idleLockStatus}";
interval = "once";
signal = 9;
};
};
};
};

View File

@@ -1,25 +1,12 @@
{
config,
lib,
pkgs,
util,
...
}:
{ config, lib, ... }:
# PipeWire as the desktop audio server.
let
mediaKey = keys: command: {
inherit keys;
locked = true;
action = {
kind = "exec";
command = "${lib.getExe pkgs.playerctl} ${command}";
};
};
cfg = config.modules.desktop.utilities.audio;
in
{
options.modules.desktop.utilities.audio.enable = lib.mkEnableOption "the PipeWire audio server";
config = lib.mkIf config.modules.desktop.utilities.audio.enable {
config = lib.mkIf cfg.enable {
# Realtime scheduling for the audio threads, so playback survives load.
security.rtkit.enable = true;
@@ -28,39 +15,5 @@ in
alsa.enable = true;
pulse.enable = true;
};
registry.keybind.${config.networking.hostName} = {
media-play = mediaKey "XF86AudioPlay" "play-pause";
media-next = mediaKey "XF86AudioNext" "next";
media-previous = mediaKey "XF86AudioPrev" "previous";
};
registry.waybar.${config.networking.hostName} = {
mpris = {
order = 30;
settings = {
format = "${util.glyph "f001"} {title}";
format-paused = "${util.glyph "f04c"} {title}";
max-length = 40;
on-click = "play-pause";
on-scroll-up = "next";
on-scroll-down = "previous";
};
};
wireplumber = {
order = 40;
settings = {
format = "{icon} {volume}%";
format-muted = "${util.glyph "f026"} muted";
format-icons = [
(util.glyph "f026")
(util.glyph "f027")
(util.glyph "f028")
];
on-click = "${lib.getExe' pkgs.wireplumber "wpctl"} set-mute @DEFAULT_AUDIO_SINK@ toggle";
};
};
};
};
}

View File

@@ -6,23 +6,30 @@
}:
# Clipboard history: cliphist records every copy, picked back through rofi.
let
cfg = config.modules.desktop.utilities.clipboard;
user = config.user.name;
cliphist = "${pkgs.cliphist}/bin/cliphist";
rofi = "${pkgs.rofi}/bin/rofi";
wl-copy = "${pkgs.wl-clipboard}/bin/wl-copy";
# The picker reuses the themed rofi, so history looks like every other menu
# the launcher drives.
# decode is needed because list emits id-prefixed lines rather than the copied
# bytes, so the chosen id has to be resolved back before it can be re-copied.
picker = pkgs.writeShellScript "clipboard-picker" ''
${lib.getExe pkgs.cliphist} list \
| ${lib.getExe pkgs.rofi} -dmenu -i -p Clipboard \
| ${lib.getExe pkgs.cliphist} decode \
| ${lib.getExe' pkgs.wl-clipboard "wl-copy"}
${cliphist} list \
| ${rofi} -dmenu -i -p Clipboard \
| ${cliphist} decode \
| ${wl-copy}
'';
in
{
options.modules.desktop.utilities.clipboard.enable =
lib.mkEnableOption "cliphist clipboard history";
config = lib.mkIf config.modules.desktop.utilities.clipboard.enable {
home-manager.users.${config.user.name} = {
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {
# wl-copy and wl-paste on PATH, so the shell can pipe into and out of the
# clipboard.
# The watchers and picker above reach wl-clipboard by store path, so this
@@ -32,14 +39,11 @@ in
# Two watchers record text and images to history, bound to the graphical
# session so uwsm starts and stops them with it.
services.cliphist.enable = true;
};
registry.keybind.${config.networking.hostName}.clipboard-history = {
keys = "SUPER + SHIFT + V";
action = {
kind = "exec";
command = "${picker}";
};
# $mod is defined by the compositor config these binds share.
wayland.windowManager.hyprland.settings.bind = [
"$mod SHIFT, V, exec, ${picker}"
];
};
};
}

View File

@@ -6,60 +6,14 @@
}:
# SwayOSD: a transient on-screen popup for volume and brightness changes.
let
cfg = config.modules.desktop.utilities.osd;
user = config.user.name;
in
{
options.modules.desktop.utilities.osd.enable =
lib.mkEnableOption "the SwayOSD volume and brightness popup";
config = lib.mkIf config.modules.desktop.utilities.osd.enable {
registry.keybind.${config.networking.hostName} = {
volume-up = {
keys = "XF86AudioRaiseVolume";
repeating = true;
action = {
kind = "exec";
command = "${pkgs.swayosd}/bin/swayosd-client --output-volume raise --max-volume 100";
};
};
volume-down = {
keys = "XF86AudioLowerVolume";
repeating = true;
action = {
kind = "exec";
command = "${pkgs.swayosd}/bin/swayosd-client --output-volume lower";
};
};
brightness-up = {
keys = "XF86MonBrightnessUp";
repeating = true;
action = {
kind = "exec";
command = "${pkgs.swayosd}/bin/swayosd-client --brightness raise";
};
};
brightness-down = {
keys = "XF86MonBrightnessDown";
repeating = true;
action = {
kind = "exec";
command = "${pkgs.swayosd}/bin/swayosd-client --brightness lower";
};
};
mute = {
keys = "XF86AudioMute";
locked = true;
action = {
kind = "exec";
command = "${pkgs.swayosd}/bin/swayosd-client --output-volume mute-toggle";
};
};
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [ pkgs.swayosd ];
# The udev rule chgrps each backlight's brightness node to `video` and adds

View File

@@ -2,11 +2,22 @@
config,
lib,
pkgs,
util,
...
}:
# Keybound screen recording: pick a region, then toggle a video-only capture.
let
cfg = config.modules.desktop.utilities.recording;
user = config.user.name;
slurp = "${pkgs.slurp}/bin/slurp";
wf-recorder = "${pkgs.wf-recorder}/bin/wf-recorder";
notify-send = "${pkgs.libnotify}/bin/notify-send";
pgrep = "${pkgs.procps}/bin/pgrep";
pkill = "${pkgs.procps}/bin/pkill";
date = "${pkgs.coreutils}/bin/date";
mkdir = "${pkgs.coreutils}/bin/mkdir";
xdgUserDir = "${pkgs.xdg-user-dirs}/bin/xdg-user-dir";
# A single key both starts and stops.
# A running wf-recorder is stopped with SIGINT so it finalises the file.
# Otherwise slurp picks a region and wf-recorder runs in the foreground until
@@ -14,51 +25,31 @@ let
# reports it saved.
# No -a means no audio is captured.
toggle = pkgs.writeShellScript "screen-record-toggle" ''
if ${lib.getExe' pkgs.procps "pgrep"} -x wf-recorder >/dev/null; then
${lib.getExe' pkgs.procps "pkill"} -INT -x wf-recorder
if ${pgrep} -x wf-recorder >/dev/null; then
${pkill} -INT -x wf-recorder
exit 0
fi
region=$(${lib.getExe pkgs.slurp}) || exit 0
dir="$(${lib.getExe' pkgs.xdg-user-dirs "xdg-user-dir"} VIDEOS)/Recordings"
${lib.getExe' pkgs.coreutils "mkdir"} -p "$dir"
file="$dir/recording-$(${lib.getExe' pkgs.coreutils "date"} +%Y%m%d-%H%M%S).mp4"
region=$(${slurp}) || exit 0
dir="$(${xdgUserDir} VIDEOS)/Recordings"
${mkdir} -p "$dir"
file="$dir/recording-$(${date} +%Y%m%d-%H%M%S).mp4"
${lib.getExe' pkgs.libnotify "notify-send"} -a "Screen recording" "Recording started" "Region capture, no audio."
${lib.getExe pkgs.wf-recorder} -g "$region" -f "$file"
${lib.getExe' pkgs.libnotify "notify-send"} -a "Screen recording" "Recording saved" "$file"
'';
# Lit while a wf-recorder capture is running.
# Empty text collapses the widget when idle, so it shows nothing until then.
recordingStatus = pkgs.writeShellScript "waybar-recording-status" ''
if ${lib.getExe' pkgs.procps "pgrep"} -x wf-recorder >/dev/null; then
printf '{"text":"${util.glyph "f03d"}","tooltip":"Recording","class":"recording"}\n'
else
printf '{"text":"","class":"idle"}\n'
fi
${notify-send} -a "Screen recording" "Recording started" "Region capture, no audio."
${wf-recorder} -g "$region" -f "$file"
${notify-send} -a "Screen recording" "Recording saved" "$file"
'';
in
{
options.modules.desktop.utilities.recording.enable =
lib.mkEnableOption "wf-recorder screen recording";
config = lib.mkIf config.modules.desktop.utilities.recording.enable {
registry.keybind.${config.networking.hostName}.recording-toggle = {
keys = "SUPER + SHIFT + R";
action = {
kind = "exec";
command = "${toggle}";
};
};
registry.waybar.${config.networking.hostName}."custom/recording" = {
order = 10;
settings = {
return-type = "json";
exec = "${recordingStatus}";
interval = 1;
};
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {
# $mod is defined by the compositor config these binds share.
wayland.windowManager.hyprland.settings.bind = [
"$mod SHIFT, R, exec, ${toggle}"
];
};
};
}

View File

@@ -4,26 +4,25 @@
pkgs,
...
}:
# Keyboard-driven screenshots copy to the clipboard before Satty opens for
# annotation. Satty copies annotated screenshots when it saves them.
# Keyboard-driven screenshots that open in satty for annotation, then land in
# both the clipboard and a dated file.
let
cfg = config.modules.desktop.utilities.screenshot;
user = config.user.name;
grimblast = "${pkgs.grimblast}/bin/grimblast";
satty = "${pkgs.satty}/bin/satty";
tee = "${pkgs.coreutils}/bin/tee";
wl-copy = "${pkgs.wl-clipboard}/bin/wl-copy";
xdgUserDir = "${pkgs.xdg-user-dirs}/bin/xdg-user-dir";
# Copy the original screenshot before Satty opens.
# Satty copies and saves annotated screenshots when the user presses Enter.
# satty's copy action also saves, so one keystroke lands the shot in both the
# clipboard and a file.
capture =
target:
pkgs.writeShellScript "screenshot-${target}" ''
dir="$(${xdgUserDir} PICTURES)/Screenshots"
mkdir -p "$dir"
${grimblast} save ${target} - \
| ${tee} >(${wl-copy}) \
| ${satty} --filename - \
--output-filename "$dir/screenshot-%Y%m%d-%H%M%S.png" \
--copy-command ${wl-copy} \
@@ -36,43 +35,24 @@ in
options.modules.desktop.utilities.screenshot.enable =
lib.mkEnableOption "screenshots via grimblast and satty";
config = lib.mkIf config.modules.desktop.utilities.screenshot.enable {
registry.keybind.${config.networking.hostName} = {
screenshot-area = {
keys = "Print";
action = {
kind = "exec";
command = "${capture "area"}";
};
};
screenshot-active = {
keys = "SHIFT + Print";
action = {
kind = "exec";
command = "${capture "active"}";
};
};
screenshot-screen = {
keys = "CTRL + Print";
action = {
kind = "exec";
command = "${capture "screen"}";
};
};
};
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {
# satty is a one-shot annotation surface, so float it rather than letting
# it claim a tile in the layout.
wayland.windowManager.hyprland.settings.window_rule = [
{
match.class = "^(com\\.gabm\\.satty)$";
float = true;
}
# windowrule v3 syntax: space-separated field/value tokens, comma
# separated, with matchers under a match: prefix and effects bare.
wayland.windowManager.hyprland.settings.windowrule = [
"float 1, match:class ^(com\\.gabm\\.satty)$"
];
# Print with plain/Shift/Ctrl for region/window/full.
# Super+L is already the hjkl focus and movement bind, so screenshots take
# the Print key instead.
wayland.windowManager.hyprland.settings.bind = [
", Print, exec, ${capture "area"}"
"SHIFT, Print, exec, ${capture "active"}"
"CTRL, Print, exec, ${capture "screen"}"
];
};
};
}

View File

@@ -6,6 +6,8 @@
}:
# Nord theming for the graphical layer, driven from one base16 scheme by Stylix.
let
cfg = config.modules.desktop.utilities.theming;
user = config.user.name;
# A single static Nord wallpaper, drawn as a vertical gradient across the
# Polar Night shades so it needs no committed binary and no network fetch.
@@ -14,13 +16,12 @@ let
-size 2560x1440 gradient:'#2E3440'-'#3B4252' \
"$out"
'';
user = config.user.name;
in
{
options.modules.desktop.utilities.theming.enable =
lib.mkEnableOption "Nord theming of the graphical layer via Stylix";
config = lib.mkIf config.modules.desktop.utilities.theming.enable {
config = lib.mkIf cfg.enable {
stylix = {
enable = true;
@@ -58,10 +59,6 @@ in
};
};
};
registry.env.${config.networking.hostName} = {
HYPRCURSOR_THEME = config.stylix.cursor.name;
HYPRCURSOR_SIZE = toString config.stylix.cursor.size;
};
home-manager.users.${user} = {
# Stylix drives the cursor through home-manager's pointer-cursor config,

View File

@@ -2,12 +2,13 @@
# Declares the XDG user directories, so a tool that queries them lands in a
# well-known folder a host can relocate from one place.
let
cfg = config.modules.desktop.utilities.userdirs;
user = config.user.name;
in
{
options.modules.desktop.utilities.userdirs.enable = lib.mkEnableOption "XDG user directories";
config = lib.mkIf config.modules.desktop.utilities.userdirs.enable {
config = lib.mkIf cfg.enable {
# Write ~/.config/user-dirs.dirs from the option defaults.
home-manager.users.${user}.xdg.userDirs.enable = true;
};

View File

@@ -1,9 +1,12 @@
{ config, lib, ... }:
let
cfg = config.modules.guest;
in
{
options.modules.guest.enable = lib.mkEnableOption "nested guest configuration";
options.modules.guest.enable = lib.mkEnableOption "nested-guest base configuration";
config = lib.mkIf config.modules.guest.enable {
modules.network.ssh.enable = lib.mkDefault false;
config = lib.mkIf cfg.enable {
modules.network.ssh.enable = lib.mkDefault true;
modules.network.ssh.hostKeys.restore = lib.mkDefault false;
};
}

View File

@@ -0,0 +1,52 @@
{
config,
lib,
...
}:
let
cfg = config.modules.host;
inherit (config) user;
hostSecrets = ../secrets + "/${config.networking.hostName}.yaml";
passwordSecret = "${user.name}-password";
in
{
options.modules.host.enable = lib.mkEnableOption "physical-host base configuration";
config = lib.mkIf cfg.enable {
nix.settings.extra-substituters = [ "https://nyx-cache.chaotic.cx/" ];
nix.settings.extra-trusted-public-keys = [
"nyx-cache.chaotic.cx:dJxTrgMC3V3cFfyIiBQDQorG6k1LsqurH/srpMSq7qk="
];
nix.gc.automatic = true;
nix.gc.dates = "Mon 03:15";
nix.gc.options = "--delete-older-than 30d";
nix.optimise.automatic = true;
nix.optimise.dates = [ "Thu 03:45" ];
boot.loader.systemd-boot.enable = true;
boot.loader.systemd-boot.configurationLimit = 15;
boot.loader.efi.canTouchEfiVariables = true;
hardware.enableRedistributableFirmware = true;
zramSwap.enable = true;
modules.network.ssh.enable = true;
modules.network.ssh.hostKeys.sopsFile = hostSecrets;
modules.network.ssh.userKey.sopsFile = hostSecrets;
services.xserver.xkb.layout = "us";
services.xserver.xkb.options = "caps:escape_shifted_capslock";
console.useXkbConfig = true;
sops.defaultSopsFile = ../secrets/shared.yaml;
sops.age.keyFile = "/var/lib/sops-nix/key.txt";
sops.age.sshKeyPaths = [ ];
sops.gnupg.sshKeyPaths = [ ];
users.mutableUsers = false;
sops.secrets.${passwordSecret}.neededForUsers = true;
users.users.${user.name}.hashedPasswordFile = config.sops.secrets.${passwordSecret}.path;
};
}

View File

@@ -1,18 +0,0 @@
{
config,
lib,
pkgs,
...
}:
{
options.modules.host.cachyos.enable = lib.mkEnableOption "the CachyOS kernel";
config = lib.mkIf config.modules.host.cachyos.enable {
boot.kernelPackages = pkgs.linuxPackages_cachyos;
nix.settings.extra-substituters = [ "https://nyx-cache.chaotic.cx/" ];
nix.settings.extra-trusted-public-keys = [
"nyx-cache.chaotic.cx:dJxTrgMC3V3cFfyIiBQDQorG6k1LsqurH/srpMSq7qk="
];
};
}

View File

@@ -1,106 +0,0 @@
{
config,
lib,
pkgs,
...
}:
let
hostSecrets = ../../secrets + "/${config.networking.hostName}.yaml";
in
{
options.modules.host.enable = lib.mkEnableOption "physical host configuration";
config = lib.mkIf config.modules.host.enable {
nix.gc.automatic = true;
nix.gc.dates = "Mon 03:15";
nix.gc.options = "--delete-older-than 30d";
nix.optimise.automatic = true;
nix.optimise.dates = [ "Thu 03:45" ];
boot.loader.systemd-boot.enable = true;
boot.loader.systemd-boot.configurationLimit = 15;
boot.loader.efi.canTouchEfiVariables = true;
hardware.enableRedistributableFirmware = true;
zramSwap.enable = true;
modules.network.ssh.enable = true;
modules.network.ssh.hostKeys.sopsFile = hostSecrets;
modules.network.ssh.userKey.sopsFile = hostSecrets;
services.xserver.xkb.layout = "us";
console.useXkbConfig = true;
sops.defaultSopsFile = ../../secrets/shared.yaml;
sops.age.keyFile = "/var/lib/sops-nix/key.txt";
sops.age.sshKeyPaths = [ ];
sops.gnupg.sshKeyPaths = [ ];
users.mutableUsers = false;
sops.secrets."${config.user.name}-password".neededForUsers = true;
users.users.${config.user.name}.hashedPasswordFile =
config.sops.secrets."${config.user.name}-password".path;
security.sudo.extraConfig = ''
Defaults env_keep += "EDITOR VISUAL"
'';
home-manager = {
useGlobalPkgs = true;
useUserPackages = true;
users.${config.user.name} = {
home.username = config.user.name;
home.homeDirectory = "/home/${config.user.name}";
home.stateVersion = config.system.stateVersion;
home.sessionVariables = lib.attrByPath [ config.networking.hostName ] { } config.registry.env;
home.packages = with pkgs; [
eza
unzip
wget
];
};
};
registry.shell-shortcut.${config.networking.hostName} = {
ls = {
command = "eza -alg --color=always --group-directories-first --icons=always";
abbreviation = false;
};
ll = {
command = "eza -lg --color=always --group-directories-first --icons=always";
abbreviation = false;
};
lt = {
command = "eza -aT -I '.git' --color=always --group-directories-first --icons=always";
abbreviation = false;
};
".." = "cd ..";
"..." = "cd ../..";
"...." = "cd ../../..";
cp = "cp -v";
tarnow = "tar -acf ";
untar = "tar -zxvf ";
wget = "wget -c ";
grep = "grep --color=auto";
fgrep = "fgrep --color=auto";
egrep = "egrep --color=auto";
jctl = "journalctl -p 3 -xb";
update = "sudo nixos-rebuild switch --flake .#${config.networking.hostName}";
cleanup = "sudo nix-collect-garbage -d";
sops-host = {
command = "sudo --preserve-env=EDITOR,VISUAL env SOPS_AGE_KEY_FILE=/var/lib/sops-nix/key.txt nix shell nixpkgs#sops nixpkgs#neovim -c sops";
abbreviation = false;
};
};
modules.shell.fish.enable = lib.mkDefault true;
modules.shell.fish.defaultShell = lib.mkDefault true;
modules.shell.tmux.enable = lib.mkDefault true;
modules.shell.nvim.enable = lib.mkDefault true;
modules.shell.git.enable = lib.mkDefault true;
modules.shell.yazi.enable = lib.mkDefault true;
};
}

View File

@@ -1,57 +0,0 @@
{
config,
lib,
...
}:
# Imports existing ZFS pools that hold durable service state.
let
cfg = config.modules.host.zfs; # NOLINT(let_order)
in
{
options.modules.host.zfs = {
enable = lib.mkEnableOption "ZFS pool imports for durable service state";
pools = lib.mkOption {
type = lib.types.attrsOf (lib.types.attrsOf lib.types.path);
default = { };
example = lib.literalExpression ''
{
tank = {
media = "/srv/media";
downloads = "/srv/downloads";
};
}
'';
description = ''
Lists pools that NixOS imports at boot.
Maps each dataset name to its mount point.
NixOS does not create or destroy these pools during rebuilds.
An empty map imports the pool without declaring file systems.
'';
};
};
config = lib.mkIf cfg.enable {
boot.supportedFilesystems = [ "zfs" ];
# This module derives a stable host ID from the configured hostname.
networking.hostId = builtins.substring 0 8 (
builtins.hashString "sha256" config.networking.hostName
);
boot.zfs.extraPools = lib.attrNames cfg.pools;
fileSystems = lib.mkMerge (
lib.mapAttrsToList (
pool: mounts:
lib.mapAttrs' (
dataset: mountpoint:
lib.nameValuePair mountpoint {
device = "${pool}/${dataset}";
fsType = "zfs";
}
) mounts
) cfg.pools
);
};
}

View File

@@ -4,10 +4,13 @@
...
}:
# Tools for developing this monorepo.
let
cfg = config.modules.monorepo;
in
{
options.modules.monorepo.enable = lib.mkEnableOption "tools for developing this monorepo";
config = lib.mkIf config.modules.monorepo.enable {
config = lib.mkIf cfg.enable {
modules.shell.bazel.enable = true;
modules.shell.python.enable = true;
modules.shell.rust.enable = true;

View File

@@ -1,57 +0,0 @@
{
config,
lib,
pkgs,
util,
...
}:
# NetworkManager-managed networking.
let
networkStatus = pkgs.writeShellScript "waybar-network-status" ''
active=$(
${lib.getExe' pkgs.networkmanager "nmcli"} -t -f DEVICE,TYPE,STATE,CONNECTION device status \
| ${lib.getExe' pkgs.gawk "awk"} -F: '$3 == "connected" && $2 != "loopback" { print; exit }'
)
if [ -z "$active" ]; then
${lib.getExe pkgs.jq} -cn \
--arg text "${util.glyph "f127"} off" \
--arg tooltip "Network disconnected" \
--arg class "disconnected" \
'{ text: $text, tooltip: $tooltip, class: $class }'
exit 0
fi
IFS=: read -r device type _state connection <<< "$active"
case "$type" in
wifi) icon="${util.glyph "f1eb"}" ;;
ethernet) icon="LAN" ;;
*) icon="NET" ;;
esac
ipaddr=$(${lib.getExe' pkgs.iproute2 "ip"} -4 -o addr show dev "$device" | ${lib.getExe' pkgs.gawk "awk"} '{ print $4; exit }')
${lib.getExe pkgs.jq} -cn \
--arg text "$icon $connection" \
--arg tooltip "$device: ''${ipaddr:-no IPv4}" \
--arg class "$type" \
'{ text: $text, tooltip: $tooltip, class: $class }'
'';
in
{
options.modules.network.enable = lib.mkEnableOption "NetworkManager-managed networking";
config = lib.mkIf config.modules.network.enable {
networking.networkmanager.enable = true;
registry.waybar.${config.networking.hostName}."custom/network" = {
order = 60;
settings = {
return-type = "json";
exec = "${networkStatus}";
interval = 5;
};
};
};
}

View File

@@ -4,208 +4,344 @@
registry,
...
}:
# Configures inbound and outbound SSH access.
# SSH on this machine, in both directions.
let
cfg = config.modules.network.ssh;
user = config.user.name; # NOLINT(let_order)
user = config.user.name;
# NOLINT(let_order)
workstationNames = [
"neogaia"
"zeus"
];
serverNames = [ "pikachu" ];
inherit (lib)
concatLists
concatStringsSep
elem
filter
genAttrs
hasAttr
imap0
listToAttrs
mapAttrs
mapAttrsToList
mkIf
mkMerge
mkOption
nameValuePair
optional
optionalAttrs
types
unique
;
isWorkstation = lib.elem config.networking.hostName workstationNames; # NOLINT(let_order)
isServer = lib.elem config.networking.hostName serverNames; # NOLINT(let_order)
hostKeySecret = type: "ssh-host-${type}-key";
userKeySecret = "ssh-user-ed25519-key";
# NOLINT(let_order)
clientKeyFor = host: builtins.readFile (../../hosts + "/${host}/ssh_user_ed25519_key.pub");
hostKeysFor =
host:
map builtins.readFile [
(../../hosts + "/${host}/ssh_host_ed25519_key.pub")
(../../hosts + "/${host}/ssh_host_rsa_key.pub")
];
targetType = types.submodule (
{ name, ... }:
{
options = {
hostName = mkOption {
type = types.str;
default = name;
description = ''
The network address OpenSSH connects to for this target.
'';
};
# NOLINT(let_order)
managedTargets = lib.genAttrs (workstationNames ++ serverNames) (name: {
hostName = registry.address.${name}.self;
clientKey = clientKeyFor name;
hostKeys = hostKeysFor name;
});
user = mkOption {
type = types.str;
default = config.user.name;
description = ''
The remote login name OpenSSH uses for this target.
'';
};
extraTargets = {
# TODO: Pin Gitea's host key after migrating it from Proxmox.
gitea = {
hostName = "git.alexion.dev";
port = 2022;
user = "gitea";
aliases = [
"gitea"
"git.alexion.dev"
];
};
};
port = mkOption {
type = types.port;
default = 22;
description = ''
The TCP port OpenSSH uses for this target.
'';
};
# NOLINT(let_order)
mkTarget = name: target: {
hostName = target.hostName or name;
user = target.user or user;
port = target.port or 22;
aliases = target.aliases or [ name ];
clientKey = target.clientKey or null;
hostKeys = target.hostKeys or [ ];
};
targets = lib.mapAttrs mkTarget (managedTargets // extraTargets); # NOLINT(let_order)
aliases = mkOption {
type = types.listOf types.str;
default = [ name ];
description = ''
Host patterns written into the generated OpenSSH client block.
'';
};
clientKeysFor = names: lib.filter (key: key != null) (map (name: targets.${name}.clientKey) names); # NOLINT(let_order)
# NOLINT(let_order)
defaultAuthorizedKeys = lib.flatten (
clientKeysFor workstationNames ++ lib.optional isServer (clientKeysFor serverNames)
clientKey = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
The public key this target offers when it connects outward.
Other machines admit this key according to the host groups below.
'';
};
hostKeys = mkOption {
type = types.listOf types.str;
default = [ ];
description = ''
The public keys this target presents when it accepts inbound SSH.
These keys generate system-wide known-host entries.
'';
};
};
}
);
outboundTargetNames = # NOLINT(let_order)
keyWithoutComment = key: concatStringsSep " " (lib.take 2 (lib.splitString " " key));
targetNamesIn = names: filter (name: hasAttr name cfg.targets) names;
workstationNames = targetNamesIn cfg.hosts.workstations;
serverNames = targetNamesIn cfg.hosts.servers;
clientKeysFor = names: filter (key: key != null) (map (name: cfg.targets.${name}.clientKey) names);
currentHost = config.networking.hostName;
isServer = elem currentHost cfg.hosts.servers;
isWorkstation = elem currentHost cfg.hosts.workstations;
defaultAuthorizedKeys = lib.flatten (
clientKeysFor workstationNames ++ optional isServer (clientKeysFor serverNames)
);
outboundTargetNames =
let
groupTargets =
if isServer then
builtins.attrNames extraTargets ++ serverNames
[ "gitea" ] ++ cfg.hosts.servers
else if isWorkstation then
builtins.attrNames extraTargets ++ serverNames ++ workstationNames
[ "gitea" ] ++ cfg.hosts.servers ++ cfg.hosts.workstations
else
builtins.attrNames extraTargets;
[ "gitea" ];
in
lib.filter (name: name != config.networking.hostName) groupTargets;
knownHosts = lib.listToAttrs (
lib.concatLists (
lib.mapAttrsToList (
targetName: target:
lib.imap0 (
i: key:
lib.nameValuePair "${targetName}-${toString i}" {
hostNames = knownHostNamesFor target;
publicKey = keyWithoutComment key;
}
) target.hostKeys
) targets
)
);
knownHostNamesFor =
target:
let
withPort = name: if target.port == 22 then name else "[${name}]:${toString target.port}";
names = lib.unique (target.aliases ++ [ target.hostName ]);
in
map withPort names;
keyWithoutComment = key: lib.concatStringsSep " " (lib.take 2 (lib.splitString " " key));
filter (name: name != currentHost) (targetNamesIn groupTargets);
sshSettingsFor =
name:
let
target = targets.${name};
target = cfg.targets.${name};
in
{
header = "Host ${lib.concatStringsSep " " target.aliases}";
header = "Host ${concatStringsSep " " target.aliases}";
HostName = target.hostName;
User = target.user;
}
// lib.optionalAttrs (target.port != 22) { Port = target.port; };
// optionalAttrs (target.port != 22) { Port = target.port; };
hostKeySecret = type: "ssh-host-${type}-key";
hostKeyTypes = [
"ed25519"
"rsa"
];
userKeySecret = "ssh-user-ed25519-key";
knownHostNamesFor =
target:
let
names = unique (target.aliases ++ [ target.hostName ]);
withPort = name: if target.port == 22 then name else "[${name}]:${toString target.port}";
in
map withPort names;
knownHosts = listToAttrs (
concatLists (
mapAttrsToList (
targetName: target:
imap0 (
i: key:
nameValuePair "${targetName}-${toString i}" {
hostNames = knownHostNamesFor target;
publicKey = keyWithoutComment key;
}
) target.hostKeys
) cfg.targets
)
);
in
{
options.modules.network.ssh = {
enable = lib.mkEnableOption "the OpenSSH daemon";
enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets";
hostKeys.restore = lib.mkOption {
type = lib.types.bool;
hosts = {
servers = mkOption {
type = types.listOf types.str;
default = [ "pikachu" ];
description = ''
Hosts that serve durable services.
They admit workstation keys and server keys, and they receive aliases for other servers and the forge.
'';
};
workstations = mkOption {
type = types.listOf types.str;
default = [
"neogaia"
"zeus"
];
description = ''
Hosts the operator works from.
Their keys are admitted by every host, and they receive aliases for the whole fleet and the forge.
'';
};
};
targets = mkOption {
type = types.attrsOf targetType;
default = {
gitea = {
hostName = "git.alexion.dev";
port = 2022;
user = "gitea";
aliases = [
"gitea"
"git.alexion.dev"
];
};
neogaia = {
hostName = registry.address.neogaia.self;
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia";
hostKeys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJS+wp7K123+4BT6G4f954R6WyrbWveY7VlpoBUf6I5p neogaia"
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCo2wWUKxyAS4J5TqbWf8glDhJvS5XmdRqFhMeJwG3pOB+4AccZ1T8LU7ZN+RjtRi3j2qXBJvIHuzhtQNtmT59TxocvfobYiqOgJpvVO5K6yD8ZoUJs6ziDkIduI9w9mdRIESoi+dBbVu8n24r61cKDVh+jWX+yjzkOcWcOzqDyQhhkjqblZ1WMAdujEMuEPvif1i2LCxStUaZqRGcx09m/ME2fYcaJrpuxxxvX2+CPJNicoo6Rx9i7ZjAoNuvH+jui4KT62DzlQtQtCl2CFUOM0gCPSa+MbNQ9elfHPvGzEcwOIMo2cuy9KURUkQu+sAgaG8S1PEniDDTecskHtuRdmPZawnQGpIhzo919Q6wUgjT8scK4mmSXRWmGmkMt0GNA2tfj5tDks6r5Q8XsYqtWs4rsOEvfmxVSdM771w+fqDBAil99Jsh0ksPK9+Bwgg8cMDzLLFDn8JA5y2G1HocMMom+u5DYKwPXEKnCILkasB8y24+O3PhSu1EuWw277w6EUEXvU03rCf0Ak/ULjxp9a00EGlloEwSmFI7Aub9XHDr87IdbGInEn+PMqyBYADiN+3h6nE2JO+nMa6i/CHdebmT+T7YJvuTKHD9sjFmQsYaghlq03DZrhHcm4hgUvE1dqGojHrhk/WgA3EWTWtK/+BP0Vy2jXaaz+qAx+EGnhQ== neogaia"
];
};
zeus = {
hostName = registry.address.zeus.self;
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIRBcHHuubptXCdvED/p2dR3MpjGN/AimqLasLY5984G alexion@zeus";
hostKeys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ4GBPt966LR4LpmXgiMMkepIqNIlXTUBh4zdNeek/lN root@zeus"
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC3xOxGAJCG9PE0FVufgJX9VRO64IUUlRGHSOfYfAI4Mit46xKsr7z/OvqPPE9z8rOIcVPbLkc/mkMuHbDgolleunAFCIeuIIYpkL4o4/TZECh9BtN2WdPhjcD7S0rV+rR7p24Qy5bTCSwZmsQq8QxHTVPjjBngtPGRSpKVrZAuHCcdCJ13hBWAvnaav5KysKC89nMj/7FIh02v8kipGfq5LGCZ20Qp2lmv2DS24NhBPXzcw8pLN47I5GYVkZh/0RWOByteo3eXtKCF+JCwfUPCTP+zHGD+1tbO8//B2fkt3Pe9lUrn5eM2ZnX1emXSWfQng3cMqkftzWqvBwv0sSDU+9YV6H2Ury+GrrfukpBtQVe9PQcBWqbRPhxuagncR+af/tx0ZohZS0t74m3WgLtZ4r2mpTZS4oBmRsfvWzMFGSgwtva+3Y+NsLjPPM1Cz5Dn05JJlidzcABfsUg2H23vkmO2otaQI01dUni+szlRn4w6sVyxNcvY2fQGjmt9k7jmVI5D2A3yl+OLJguAAlw3ia335X1tNUG8FoI/VyZNhOckIihmbTtihNIg37Ro4yiuPRhnUWFvg2hcBCa9hzNP7XDUPnoCRQyJ4adQHQA/x4Kh+HJ6pZqtGcdqU80fzmWtrLkWG6v5/NRKs6soYDup7oQIKbJo9TtXvqtzmVmovQ== root@zeus"
];
};
pikachu = {
hostName = registry.address.pikachu.self;
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINNqJIC6VRXyvrNf3n9su9KdPCikC3CjK/QrCK2reHdB alexion@pikachu";
hostKeys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu"
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy/riwm7dflA3mT+3a0/2CIoS2LbAsK/vn35kOoNeuzn0yhiF+imexP6tkB3S2t+H5ybRzkbbuNZcynFfeCqthFc8kvbdCnt8Diqoeg96fZ6ecvh5QE5yH9op8534EySetZ/exakFLnF+6EiWMuWUW3DFwsc2kcgDJObqSE8gTx/d7JK953MiTFmSJBFyg1RtQ3ZnMT+iCrvY2dyCLQai7VeF8koVKF2c0leAq2Hc75rb/L9md8MoJa64iPiz7hwTCin3xoFyaY/5hNVvyqFd5PivgR69gLdJkuVsUYO2mJzhur8cYmJD+pGjJ0U45hyE9TMrCFjeJHHuvSt3+2kph62wv95jLNk0WmMlwgyunISxENCSVVtNYdBMXhUh8VhEAW17QpVUg9EnPvxOdTKEjrvfOZYASWUa51JKbgBgexVgFbxdjDZR88DZa31AVBts/cx/59gXTUahFXMYLdZgssx+5uibZQWnvCyfUV9WLbfmK1lgL6hzReg1VkQ87iGr6skjtQYemJxRaFNA1+Q5f3kmG3KncuK/594a3qXYP4gC6A2blf8om1YZ4aXXh6f+GFKLjoEw1vvM2rJ+rjzfymwDX+pxVQ9L13OEtVZc9Ez76pOkbm1hqdbL0gY45+0cpxodhWV0wMQJBDXL1MHP8qcs+/vw0GxVK5l1SnWBGlw== root@pikachu"
];
};
};
description = ''
SSH targets known to the fleet.
The inventory holds connection details plus public keys used for authorization and host verification.
'';
};
extraAuthorizedKeys = mkOption {
type = types.listOf types.str;
default = [ ];
description = ''
Additional client public keys admitted by this host.
'';
};
authorizedKeys = mkOption {
type = types.nullOr (types.listOf types.str);
default = null;
description = ''
Complete override for client public keys admitted by this host.
Leave null to derive access from `modules.network.ssh.hosts` and `modules.network.ssh.targets`.
'';
};
extraSettings = mkOption {
type = types.attrsOf types.anything;
default = { };
description = ''
Additional OpenSSH client settings merged into the generated Home Manager configuration.
'';
};
hostKeys.restore = mkOption {
type = types.bool;
default = true;
description = ''
Restores host keys from secrets.
Preserves the fingerprint across reimages.
Guests disable this option and generate their own host keys.
Restore the host keys from secrets rather than letting the daemon generate its own.
A machine with its own identity keeps its fingerprint across a reimage by restoring committed keys.
A guest carries no host identity, so it turns this off and presents a self-generated key instead.
'';
};
hostKeys.sopsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
hostKeys.sopsFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Sets encrypted file that contains host private keys.
Names each secret `ssh-host-<type>-key`.
Requires a value when `restore` is enabled.
Encrypted file holding this host's SSH host private keys, one entry per key type, named `ssh-host-<type>-key`.
Required when `restore` is on.
'';
};
userKey.sopsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
hostKeys.types = mkOption {
type = types.listOf types.str;
default = [
"ed25519"
"rsa"
];
description = ''
Key types to restore, naming both the entries read from the encrypted file and the algorithms the daemon offers.
'';
};
userKey.sopsFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Sets encrypted file that contains the SSH client private key.
Names the secret `ssh-user-ed25519-key`.
Guests leave this unset because they do not authenticate to remote servers.
Encrypted file holding this machine's SSH client private key, under the entry `ssh-user-ed25519-key`.
Left unset on a machine that authenticates to no remote server, such as a guest.
'';
};
};
config = lib.mkIf cfg.enable (
lib.mkMerge [
{
services.openssh.enable = true;
config = mkIf cfg.enable (mkMerge [
{
services.openssh.enable = true;
users.users.${user}.openssh.authorizedKeys.keys = defaultAuthorizedKeys;
users.users.${user}.openssh.authorizedKeys.keys =
if cfg.authorizedKeys != null then
cfg.authorizedKeys
else
defaultAuthorizedKeys ++ cfg.extraAuthorizedKeys;
programs.ssh.knownHosts = knownHosts;
programs.ssh.knownHosts = knownHosts;
home-manager.users.${user}.programs.ssh = {
enable = true;
enableDefaultConfig = false;
settings = lib.mapAttrs (name: _: sshSettingsFor name) (
lib.genAttrs outboundTargetNames (name: name)
);
};
}
home-manager.users.${user}.programs.ssh = {
enable = true;
enableDefaultConfig = false;
settings =
mapAttrs (name: _: sshSettingsFor name) (genAttrs outboundTargetNames (name: name))
// cfg.extraSettings;
};
}
(lib.mkIf cfg.hostKeys.restore {
assertions = [
{
assertion = cfg.hostKeys.sopsFile != null;
message = "Set `modules.network.ssh.hostKeys.sopsFile` when host-key restoration is enabled.";
}
];
(mkIf cfg.hostKeys.restore {
assertions = [
{
assertion = cfg.hostKeys.sopsFile != null;
message = "modules.network.ssh.hostKeys.restore requires modules.network.ssh.hostKeys.sopsFile to name the encrypted host keys.";
}
];
sops.secrets = lib.genAttrs (map hostKeySecret hostKeyTypes) (_: {
inherit (cfg.hostKeys) sopsFile;
mode = "0400";
restartUnits = [ "sshd.service" ];
});
sops.secrets = genAttrs (map hostKeySecret cfg.hostKeys.types) (_: {
inherit (cfg.hostKeys) sopsFile;
mode = "0400";
restartUnits = [ "sshd.service" ];
});
services.openssh.hostKeys = [ ];
services.openssh.extraConfig = lib.concatStringsSep "" (
map (type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n") hostKeyTypes
);
})
services.openssh.hostKeys = [ ];
services.openssh.extraConfig = concatStringsSep "" (
map (type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n") cfg.hostKeys.types
);
})
(lib.mkIf (cfg.userKey.sopsFile != null) {
sops.secrets.${userKeySecret} = {
inherit (cfg.userKey) sopsFile;
mode = "0400";
owner = user;
};
(mkIf (cfg.userKey.sopsFile != null) {
sops.secrets.${userKeySecret} = {
inherit (cfg.userKey) sopsFile;
mode = "0400";
owner = user;
};
home-manager.users.${user}.programs.ssh.settings."*" = {
IdentityFile = config.sops.secrets.${userKeySecret}.path;
IdentitiesOnly = true;
};
})
]
);
home-manager.users.${user}.programs.ssh.settings."*".IdentityFile =
config.sops.secrets.${userKeySecret}.path;
})
]);
}

View File

@@ -4,17 +4,24 @@
util,
...
}:
# Configures bridges for tagged VLANs on a physical interface that uses DHCP.
# The host networking foundation: per-VLAN bridges over a tagged trunk.
let
cfg = config.modules.network.vlans; # NOLINT(let_order)
cfg = config.modules.network.vlans;
inherit (util) bridgeName;
vlanName = id: "${cfg.trunk}.${toString id}";
in
{
options.modules.network.vlans = {
interface = lib.mkOption {
enable = lib.mkEnableOption "host VLAN bridges over a tagged trunk";
trunk = lib.mkOption {
type = lib.types.str;
example = "enp1s0";
description = ''
Names the physical interface that carries untagged DHCP traffic and tagged VLANs.
Physical interface that carries 802.1Q-tagged traffic for every VLAN.
Each declared VLAN attaches to its own bridge.
'';
};
@@ -27,37 +34,67 @@ in
30
];
description = ''
Creates bridges for the specified tagged VLAN IDs.
VLAN ID N creates bridge `br-vlanN`.
Tagged VLAN ids to materialize.
Each id N creates one bridge named `br-vlanN`.
'';
};
management = {
vlan = lib.mkOption {
type = lib.types.nullOr (lib.types.ints.between 1 4094);
default = null;
description = ''
VLAN whose bridge carries the host's own management address.
The value must be one of `ids`.
Left null, the host takes no address on any bridge.
'';
};
address = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "10.0.10.2/24";
description = ''
Host static management address in CIDR form.
Left null, the host takes its management address by DHCP.
'';
};
};
};
config = lib.mkIf (cfg.ids != [ ]) {
networking = {
useDHCP = false;
networkmanager.unmanaged = [
"interface-name:${cfg.interface}"
]
++ map (id: "interface-name:${util.bridgeName id}") cfg.ids
++ map (id: "interface-name:vlan${toString id}") cfg.ids;
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.management.vlan == null || lib.elem cfg.management.vlan cfg.ids;
message = "modules.network.vlans.management.vlan (${toString cfg.management.vlan}) must name one of modules.network.vlans.ids.";
}
{
assertion = cfg.management.address == null || cfg.management.vlan != null;
message = "modules.network.vlans.management.address needs modules.network.vlans.management.vlan to say which bridge carries it.";
}
];
# networkd owns these interfaces so NetworkManager does not contend for them.
networking.networkmanager.unmanaged = [
"interface-name:${cfg.trunk}"
]
++ map (id: "interface-name:${bridgeName id}") cfg.ids
++ map (id: "interface-name:${vlanName id}") cfg.ids;
systemd.network = {
enable = true;
netdevs = lib.mkMerge (
map (id: {
"40-${util.bridgeName id}" = {
"40-${bridgeName id}" = {
netdevConfig = {
Name = util.bridgeName id;
Name = bridgeName id;
Kind = "bridge";
};
};
"40-vlan${toString id}" = {
"40-${vlanName id}" = {
netdevConfig = {
Name = "vlan${toString id}";
Name = vlanName id;
Kind = "vlan";
};
vlanConfig.Id = id;
@@ -67,25 +104,37 @@ in
networks = lib.mkMerge (
[
# The trunk carries only tagged frames and takes no address.
{
"10-${cfg.interface}" = {
matchConfig.Name = cfg.interface;
networkConfig.DHCP = "yes";
vlan = map (id: "vlan${toString id}") cfg.ids;
linkConfig.RequiredForOnline = "routable";
"30-${cfg.trunk}" = {
matchConfig.Name = cfg.trunk;
networkConfig.LinkLocalAddressing = "no";
linkConfig.RequiredForOnline = "no";
vlan = map vlanName cfg.ids;
};
}
]
++ map (id: {
"40-vlan${toString id}" = {
matchConfig.Name = "vlan${toString id}";
networkConfig.Bridge = util.bridgeName id;
linkConfig.RequiredForOnline = "no";
};
"40-${util.bridgeName id}" = {
matchConfig.Name = util.bridgeName id;
# Enslaved to its bridge, carrying no address itself.
"40-${vlanName id}" = {
matchConfig.Name = vlanName id;
networkConfig.Bridge = bridgeName id;
linkConfig.RequiredForOnline = "no";
};
# The management bridge carries the host address.
# Other bridges provide L2 segments for guests.
"40-${bridgeName id}" = lib.mkMerge [
{
matchConfig.Name = bridgeName id;
linkConfig.RequiredForOnline = "no";
}
(lib.mkIf (cfg.management.vlan == id) (
if cfg.management.address == null then
{ networkConfig.DHCP = "yes"; }
else
{ address = [ cfg.management.address ]; }
))
];
}) cfg.ids
);
};

View File

@@ -1,34 +1,10 @@
{
config,
lib,
pkgs,
util,
...
}:
let
cfg = config.modules.network.vpn-client; # NOLINT(let_order)
vpnIcon = util.glyph [
"db83"
"dc9d"
];
vpnStatus = pkgs.writeShellScript "waybar-vpn-status" ''
interface=${lib.escapeShellArg cfg.interface}
if ! ${lib.getExe' pkgs.iproute2 "ip"} link show dev "$interface" >/dev/null 2>&1; then
printf '{"text":"","class":"idle"}\n'
exit 0
fi
ipaddr=$(${lib.getExe' pkgs.iproute2 "ip"} -4 -o addr show dev "$interface" | ${lib.getExe' pkgs.gawk "awk"} '{ print $4; exit }')
${lib.getExe pkgs.jq} -cn \
--arg text "${vpnIcon}" \
--arg tooltip "VPN $interface: ''${ipaddr:-no IPv4}" \
--arg class "active" \
'{ text: $text, tooltip: $tooltip, class: $class }'
'';
cfg = config.modules.network.vpn-client;
in
{
options.modules.network.vpn-client = {
@@ -37,31 +13,61 @@ in
interface = lib.mkOption {
type = lib.types.str;
default = "wg-home";
description = "Names the WireGuard interface.";
description = "WireGuard interface name.";
};
address = lib.mkOption {
type = lib.types.str;
example = "10.23.100.3/32";
description = "Sets the client tunnel address in CIDR notation.";
description = "Client tunnel address in CIDR notation.";
};
privateKeySecret = lib.mkOption {
type = lib.types.str;
default = "wireguard-home-private-key";
description = "SOPS secret containing the WireGuard client private key.";
};
autoStart = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to start the VPN tunnel during boot.";
};
mtu = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = 1280;
description = "Tunnel MTU, or null to use the WireGuard default.";
};
sopsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Names the encrypted file that contains the WireGuard private key.";
description = "Encrypted secrets file that contains privateKeySecret.";
};
peer = {
publicKey = lib.mkOption {
type = lib.types.str;
description = "Sets the WireGuard public key for the remote peer.";
description = "WireGuard public key for the remote peer.";
};
endpoint = lib.mkOption {
type = lib.types.str;
example = "203.0.113.10:51820";
description = "Sets the WireGuard endpoint for the remote peer.";
description = "WireGuard endpoint for the remote peer.";
};
allowedIPs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "10.23.0.0/16" ];
description = "Routes to send through the VPN tunnel.";
};
persistentKeepalive = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = 25;
description = "Persistent keepalive interval in seconds, or null to disable it.";
};
};
};
@@ -70,39 +76,30 @@ in
assertions = [
{
assertion = cfg.sopsFile != null;
message = "Set modules.network.vpn-client.sopsFile when the VPN client is enabled.";
message = "modules.network.vpn-client.sopsFile must name the encrypted secrets file containing the WireGuard private key.";
}
];
sops.secrets.wireguard-home-private-key.sopsFile = cfg.sopsFile;
sops.secrets.${cfg.privateKeySecret}.sopsFile = cfg.sopsFile;
registry.shell-shortcut.${config.networking.hostName} = {
vpn-on = "sudo systemctl start wireguard-${cfg.interface}.target";
vpn-off = "sudo systemctl stop 'wireguard-${cfg.interface}*.service' wireguard-${cfg.interface}.target";
systemd.targets."wireguard-${cfg.interface}" = lib.mkIf (!cfg.autoStart) {
wantedBy = lib.mkForce [ ];
};
registry.waybar.${config.networking.hostName}."custom/vpn" = {
order = 50;
settings = {
return-type = "json";
exec = "${vpnStatus}";
interval = 5;
};
};
systemd.targets."wireguard-${cfg.interface}".wantedBy = lib.mkForce [ ];
networking.wireguard.interfaces.${cfg.interface} = {
ips = [ cfg.address ];
mtu = 1280;
privateKeyFile = config.sops.secrets.wireguard-home-private-key.path;
inherit (cfg) mtu;
privateKeyFile = config.sops.secrets.${cfg.privateKeySecret}.path;
peers = [
{
inherit (cfg.peer) publicKey endpoint;
allowedIPs = [ "10.23.0.0/16" ];
persistentKeepalive = 25;
}
(
{
inherit (cfg.peer) publicKey endpoint allowedIPs;
}
// lib.optionalAttrs (cfg.peer.persistentKeepalive != null) {
inherit (cfg.peer) persistentKeepalive;
}
)
];
};
};

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.modules.services.actualbudget; # NOLINT(let_order)
cfg = config.modules.services.actualbudget;
in
{
options.modules.services.actualbudget = {

View File

@@ -5,7 +5,7 @@
...
}:
let
cfg = config.modules.services.copyparty; # NOLINT(let_order)
cfg = config.modules.services.copyparty;
volumeType = lib.types.submodule {
options = {
@@ -38,31 +38,6 @@ let
};
};
configScript = pkgs.writeShellScript "copyparty-config" ''
set -eu
umask 077
password="$(${pkgs.coreutils}/bin/tr -d '\n' < "$CREDENTIALS_DIRECTORY/password")"
cat > /run/copyparty/copyparty.conf <<EOF
[global]
p: ${toString cfg.port}
usernames
http-only
no-crt
no-thumb
no-snap
no-rescan
ses-db: ${cfg.stateDir}/sessions.db
chpw-db: ${cfg.stateDir}/chpw.json
shr-db: ${cfg.stateDir}/shares.db
${renderedReverseProxy}
[accounts]
${cfg.accountName}: $password
${renderedVolumes}
EOF
'';
renderAccess = permission: accounts: " ${permission}: ${lib.concatStringsSep ", " accounts}";
hasFlag =
@@ -97,6 +72,31 @@ let
renderedVolumes = lib.concatStringsSep "\n" (lib.mapAttrsToList renderVolume cfg.volumes);
configScript = pkgs.writeShellScript "copyparty-config" ''
set -eu
umask 077
password="$(${pkgs.coreutils}/bin/tr -d '\n' < "$CREDENTIALS_DIRECTORY/password")"
cat > /run/copyparty/copyparty.conf <<EOF
[global]
p: ${toString cfg.port}
usernames
http-only
no-crt
no-thumb
no-snap
no-rescan
ses-db: ${cfg.stateDir}/sessions.db
chpw-db: ${cfg.stateDir}/chpw.json
shr-db: ${cfg.stateDir}/shares.db
${renderedReverseProxy}
[accounts]
${cfg.accountName}: $password
${renderedVolumes}
EOF
'';
in
{
options.modules.services.copyparty = {

View File

@@ -5,31 +5,44 @@
...
}:
let
cfg = config.modules.services.homepage; # NOLINT(let_order)
cfg = config.modules.services.homepage;
derivedAllowedHosts = lib.unique (
lib.optional (selfRoute != null) selfRoute.domain
++ lib.optionals (selfAddress != null) [
selfAddress
"${selfAddress}:${toString cfg.listenPort}"
]
mkRegistryPath =
registryName: host: entry:
"registry.${registryName}.${host}.${entry}";
homepageEntries = lib.concatLists (
lib.mapAttrsToList (
host: entries:
lib.mapAttrsToList (entry: homepage: {
inherit host entry homepage;
composite = registry.join host entry;
}) entries
) registry.homepage
);
selfRoute = selfEntry.route or null;
selfEntry = registry.join cfg.hostName cfg.entryName;
selfAddress = selfEntry.address or null;
homepageServices = map mkTab (lib.filter (tab: lib.hasAttr tab entriesByTab) orderedTabs);
mkHref =
item:
if item.homepage.href != null then
item.homepage.href
else if (item.composite.route or null) != null then
if item.composite.route.port != null then
"https://${item.composite.route.domain}"
else if item.composite.route.http.port != null then
"http://${item.composite.route.domain}"
else
throw "${mkRegistryPath "homepage" item.host item.entry} needs href or a matching TCP route entry"
else
throw "${mkRegistryPath "homepage" item.host item.entry} needs href or a matching route entry";
mkTab =
tab:
let
sections = entriesByTab.${tab};
in
{
${tab} = map (section: { ${section} = sections.${section}; }) (orderedSections tab sections);
mkServiceAttrs =
item:
lib.filterAttrs (_: value: value != null) {
href = mkHref item;
inherit (item.homepage) description icon;
};
entriesByTab = lib.foldl' insertEntry { } sortedEntries;
sortedEntries = lib.sort (left: right: left.homepage.name < right.homepage.name) homepageEntries;
insertEntry =
acc: item:
@@ -42,19 +55,7 @@ let
};
};
sortedEntries = lib.sort (left: right: left.homepage.name < right.homepage.name) homepageEntries;
homepageEntries = lib.concatLists (
lib.mapAttrsToList (
host: entries:
lib.mapAttrsToList (entry: homepage: {
inherit host entry homepage;
composite = registry.join host entry;
}) entries
) registry.homepage
);
orderedTabs = tabOrder ++ lib.filter (tab: !lib.elem tab tabOrder) (lib.attrNames entriesByTab);
entriesByTab = lib.foldl' insertEntry { } sortedEntries;
tabOrder = [
"Apps"
@@ -62,30 +63,7 @@ let
"System"
];
mkRegistryPath =
registryName: host: entry:
"registry.${registryName}.${host}.${entry}";
mkHref =
item:
if item.homepage.href != null then
item.homepage.href
else if (item.composite.route or null) != null then
if item.composite.route.protocol == "https" then
"https://${item.composite.route.domain}"
else if item.composite.route.protocol == "http" then
"http://${item.composite.route.domain}"
else
throw "${mkRegistryPath "homepage" item.host item.entry} needs href or a matching HTTP route entry"
else
throw "${mkRegistryPath "homepage" item.host item.entry} needs href or a matching route entry";
mkServiceAttrs =
item:
lib.filterAttrs (_: value: value != null) {
href = mkHref item;
inherit (item.homepage) description icon;
};
orderedTabs = tabOrder ++ lib.filter (tab: !lib.elem tab tabOrder) (lib.attrNames entriesByTab);
sectionOrderByTab = {
Apps = [
@@ -113,6 +91,28 @@ let
in
sectionOrder ++ lib.filter (section: !lib.elem section sectionOrder) (lib.attrNames sections);
mkTab =
tab:
let
sections = entriesByTab.${tab};
in
{
${tab} = map (section: { ${section} = sections.${section}; }) (orderedSections tab sections);
};
homepageServices = map mkTab (lib.filter (tab: lib.hasAttr tab entriesByTab) orderedTabs);
selfEntry = registry.join cfg.hostName cfg.entryName;
selfAddress = selfEntry.address or null;
selfRoute = selfEntry.route or null;
derivedAllowedHosts = lib.unique (
lib.optional (selfRoute != null) selfRoute.domain
++ lib.optionals (selfAddress != null) [
selfAddress
"${selfAddress}:${toString cfg.listenPort}"
]
);
in
{
options.modules.services.homepage = {

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.modules.services.navidrome; # NOLINT(let_order)
cfg = config.modules.services.navidrome;
in
{
options.modules.services.navidrome = {

View File

@@ -5,46 +5,63 @@
...
}:
let
cfg = config.modules.services.reverse-proxy;
generatedUdpStreams = lib.concatLists (
mkRegistryPath =
registryName: host: entry:
"registry.${registryName}.${host}.${entry}";
mkBackend = address: port: "http://${address}:${toString port}";
mkVirtualHost =
host: entry: mode:
let
compositeEntry = registry.join host entry;
inherit (compositeEntry) route;
address =
compositeEntry.address
or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}");
isHttp = mode == "http";
port = if isHttp then route.http.port else route.port;
in
{
inherit host entry;
registryName = "route";
name = route.domain;
value = {
forceSSL = !isHttp;
enableACME = !isHttp;
locations."/".proxyPass = mkBackend address port;
};
};
generatedHttpsVirtualHostList = lib.concatLists (
lib.mapAttrsToList (
host: routes:
lib.concatLists (
lib.mapAttrsToList (
entry: route: lib.optionals (route.protocol == "udp") (map (mkUdpStream host entry) route.ports)
) routes
lib.mapAttrsToList (entry: _route: mkVirtualHost host entry "https") (
lib.filterAttrs (_entry: route: route.port != null) routes
)
) registry.route
);
mkUdpStream =
host: entry: port:
let
address =
compositeEntry.address
or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}");
compositeEntry = registry.join host entry;
in
{
inherit
host
entry
port
address
;
};
generatedHttpVirtualHostList = lib.concatLists (
lib.mapAttrsToList (
host: routes:
lib.mapAttrsToList (entry: _route: mkVirtualHost host entry "http") (
lib.filterAttrs (_entry: route: route.http.port != null) routes
)
) registry.route
);
generatedStreamConfig = lib.concatMapStringsSep "\n" (stream: ''
server {
listen ${toString stream.port} udp;
proxy_pass ${stream.address}:${toString stream.port};
generatedVirtualHostList = generatedHttpsVirtualHostList ++ generatedHttpVirtualHostList;
virtualHostsByDomain = lib.foldl' (
acc: virtualHost:
acc
// {
${virtualHost.name} = (acc.${virtualHost.name} or [ ]) ++ [ virtualHost ];
}
'') generatedUdpStreams;
generatedVirtualHosts =
if duplicateDomainMessages != [ ] then
throw (builtins.head duplicateDomainMessages)
else
lib.listToAttrs generatedVirtualHostList;
) { } generatedVirtualHostList;
duplicateDomainMessages = lib.concatLists (
lib.mapAttrsToList (
@@ -62,72 +79,51 @@ let
) virtualHostsByDomain
);
virtualHostsByDomain = lib.foldl' (
acc: virtualHost:
acc
// {
${virtualHost.name} = (acc.${virtualHost.name} or [ ]) ++ [ virtualHost ];
}
) { } generatedVirtualHostList;
generatedVirtualHosts =
if duplicateDomainMessages != [ ] then
throw (builtins.head duplicateDomainMessages)
else
lib.listToAttrs generatedVirtualHostList;
generatedVirtualHostList = generatedHttpsVirtualHostList ++ generatedHttpVirtualHostList;
generatedHttpsVirtualHostList = lib.concatLists (
lib.mapAttrsToList (
host: routes:
lib.mapAttrsToList (entry: _route: mkVirtualHost host entry "https") (
lib.filterAttrs (_entry: route: route.protocol == "https") routes
)
) registry.route
);
mkVirtualHost =
host: entry: mode:
mkUdpStream =
host: entry: port:
let
isHttp = mode == "http";
compositeEntry = registry.join host entry;
address =
compositeEntry.address
or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}");
compositeEntry = registry.join host entry;
inherit (compositeEntry) route;
inherit (route) port;
in
{
inherit host entry;
registryName = "route";
name = route.domain;
value = {
forceSSL = !isHttp;
enableACME = !isHttp;
extraConfig = lib.optionalString (route.clientMaxBodySize != null) ''
client_max_body_size ${route.clientMaxBodySize};
'';
locations."/".proxyPass = mkBackend address port;
};
inherit
host
entry
port
address
;
};
mkBackend = address: port: "http://${address}:${toString port}";
generatedHttpVirtualHostList = lib.concatLists (
generatedUdpStreams = lib.concatLists (
lib.mapAttrsToList (
host: routes:
lib.mapAttrsToList (entry: _route: mkVirtualHost host entry "http") (
lib.filterAttrs (_entry: route: route.protocol == "http") routes
lib.concatLists (
lib.mapAttrsToList (entry: route: map (mkUdpStream host entry) route.udp.ports) routes
)
) registry.route
);
mkRegistryPath =
registryName: host: entry:
"registry.${registryName}.${host}.${entry}";
generatedStreamConfig = lib.concatMapStringsSep "\n" (stream: ''
server {
listen ${toString stream.port} udp;
proxy_pass ${stream.address}:${toString stream.port};
}
'') generatedUdpStreams;
in
{
options.modules.services.reverse-proxy = {
enable = lib.mkEnableOption "the public edge reverse proxy";
};
config = lib.mkIf config.modules.services.reverse-proxy.enable {
config = lib.mkIf cfg.enable {
networking.firewall.allowedTCPPorts = [
80
443

View File

@@ -80,10 +80,10 @@
(lib.mkIf config.modules.services.steamcmd.enable (
let
group = "storage";
service = config.modules.services.steamcmd;
user = "steamcmd";
group = "storage";
prepareScript = pkgs.writeShellScript "steamcmd-${service.serviceName}-prepare" ''
set -eu

View File

@@ -6,13 +6,14 @@
}:
# Bazel for the primary user's shell environment.
let
cfg = config.modules.shell.bazel;
user = config.user.name;
in
{
options.modules.shell.bazel.enable =
lib.mkEnableOption "Bazel for the primary user's shell environment";
config = lib.mkIf config.modules.shell.bazel.enable {
config = lib.mkIf cfg.enable {
home-manager.users.${user}.home.packages = [ pkgs.bazel ];
};
}

View File

@@ -5,13 +5,14 @@
}:
# direnv for the primary user, configured through home-manager.
let
cfg = config.modules.shell.direnv;
user = config.user.name;
in
{
options.modules.shell.direnv.enable =
lib.mkEnableOption "direnv for the primary user, with nix-direnv shell caching";
config = lib.mkIf config.modules.shell.direnv.enable {
config = lib.mkIf cfg.enable {
home-manager.users.${user}.programs.direnv = {
enable = true;
silent = true;

View File

@@ -6,10 +6,14 @@
}:
# fish for the primary user, configured through home-manager.
let
cfg = config.modules.shell.fish; # NOLINT(let_order)
commandsOf = shortcuts: lib.mapAttrs (_name: shortcut: shortcut.command) shortcuts;
shortcuts = lib.attrByPath [ config.networking.hostName ] { } config.registry.shell-shortcut;
cfg = config.modules.shell.fish;
user = config.user.name;
shortcuts = lib.attrByPath [ config.networking.hostName ] { } config.registry.shell-shortcut;
envVars = lib.attrByPath [ config.networking.hostName ] { } config.registry.env-var;
commandsOf = shortcuts: lib.mapAttrs (_name: shortcut: shortcut.command) shortcuts;
exportEnvVar = name: value: ''
set -gx ${name} ${lib.escapeShellArg value}
'';
in
{
options.modules.shell.fish = {
@@ -102,7 +106,9 @@ in
};
# Rendered by home-manager into ~/.config/fish/config.fish.
interactiveShellInit = builtins.readFile ./config.fish;
interactiveShellInit = lib.concatStringsSep "\n" (
(lib.mapAttrsToList exportEnvVar envVars) ++ [ (builtins.readFile ./config.fish) ]
);
};
};
};

View File

@@ -80,13 +80,6 @@ else
echo "deploy-to: $host already has the full closure for $system_path" >&2
end
echo "deploy-to: recording $system_path as the system profile on $host" >&2
ssh -t $host sudo nix-env --profile /nix/var/nix/profiles/system --set $system_path
or begin
echo "deploy-to: system profile update failed for $host" >&2
return $status
end
echo "deploy-to: activating $system_path on $host" >&2
ssh -t $host sudo "$system_path/bin/switch-to-configuration" switch
or begin

View File

@@ -5,13 +5,14 @@
}:
# git for the primary user, configured through home-manager.
let
cfg = config.modules.shell.git;
user = config.user.name;
in
{
options.modules.shell.git.enable =
lib.mkEnableOption "git for the primary user, carrying the operator's commit identity";
config = lib.mkIf config.modules.shell.git.enable {
config = lib.mkIf cfg.enable {
home-manager.users.${user}.programs.git = {
enable = true;

View File

@@ -6,13 +6,14 @@
}:
# gitea-axi for the primary user, installed through its own home-manager module.
let
cfg = config.modules.shell.gitea-axi;
user = config.user.name;
in
{
options.modules.shell.gitea-axi.enable =
lib.mkEnableOption "gitea-axi, an agent-ergonomic CLI for Gitea issues and pull requests";
config = lib.mkIf config.modules.shell.gitea-axi.enable {
config = lib.mkIf cfg.enable {
home-manager.sharedModules = [ inputs.gitea-axi.homeModules.default ];
home-manager.users.${user}.programs.gitea-axi.enable = true;
};

View File

@@ -6,13 +6,14 @@
}:
# Herdr, a terminal multiplexer for coding agents.
let
cfg = config.modules.shell.herdr;
user = config.user.name;
in
{
options.modules.shell.herdr.enable =
lib.mkEnableOption "Herdr, a terminal multiplexer for coding agents";
config = lib.mkIf config.modules.shell.herdr.enable {
config = lib.mkIf cfg.enable {
registry.shell-shortcut.${config.networking.hostName}.hs = "herdr --session";
home-manager.users.${user} = lib.mkMerge [

View File

@@ -7,13 +7,14 @@
}:
# Neovim for the primary user, configured declaratively through nixvim.
let
cfg = config.modules.shell.nvim;
user = config.user.name;
in
{
options.modules.shell.nvim.enable =
lib.mkEnableOption "Neovim, configured declaratively via nixvim";
config = lib.mkIf config.modules.shell.nvim.enable {
config = lib.mkIf cfg.enable {
registry.shell-shortcut.${config.networking.hostName} = {
vi = "nvim";
vim = "nvim";
@@ -31,7 +32,6 @@ in
ripgrep # backs the picker's live grep
fd # backs the picker's file finder
wl-clipboard # provides a non-blocking clipboard provider on Wayland
nixd # provides Nix language-server support
];
globals.mapleader = " ";
@@ -189,8 +189,6 @@ in
installRustc = false;
installRustfmt = false;
};
servers.nixd.enable = true;
};
gitsigns = {

View File

@@ -6,6 +6,7 @@
}:
# Oh My Pi, installed alongside upstream Pi as the `omp` command.
let
cfg = config.modules.shell.omp;
user = config.user.name;
in
{
@@ -13,7 +14,7 @@ in
enable = lib.mkEnableOption "Oh My Pi, a batteries-included fork of Pi";
};
config = lib.mkIf config.modules.shell.omp.enable {
config = lib.mkIf cfg.enable {
home-manager.users.${user}.home.packages = [ pkgs.omp ];
};
}

View File

@@ -8,26 +8,28 @@
# home-manager, which ships the package and manages global settings.
# The login credential is left unmanaged, so it survives rebuilds.
let
cfg = config.modules.shell.pi;
user = config.user.name;
piDir = "${config.users.users.${user}.home}/.pi/agent";
vendoredPi = pkgs.pi-coding-agent.overrideAttrs {
src = ../../../../../third_party/pi;
patches = [ ];
};
in
{
options.modules.shell.pi = {
enable = lib.mkEnableOption "Pi, a terminal coding agent, configured through home-manager";
};
config = lib.mkIf config.modules.shell.pi.enable {
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {
programs.pi-coding-agent = {
enable = true;
package = pkgs.pi-coding-agent.overrideAttrs {
src = ../../../../../third_party/pi;
patches = [ ];
};
package = vendoredPi;
settings = {
defaultProvider = "openai-codex";
defaultModel = "gpt-5.6-terra";
defaultModel = "gpt-5.5";
defaultThinkingLevel = "medium";
theme = "dark";
enableInstallTelemetry = false;

View File

@@ -6,14 +6,15 @@
}:
# Python tools for the primary user's shell environment.
let
cfg = config.modules.shell.python;
user = config.user.name;
in
{
options.modules.shell.python.enable =
lib.mkEnableOption "Python tools for the primary user's shell environment";
config = lib.mkIf config.modules.shell.python.enable {
registry.env.${config.networking.hostName}.PYTHON_TOOLCHAIN_ROOT = "${pkgs.python3}";
config = lib.mkIf cfg.enable {
registry.env-var.${config.networking.hostName}.PYTHON_TOOLCHAIN_ROOT = "${pkgs.python3}";
home-manager.users.${user}.home.packages = [ pkgs.python3 ];
};

View File

@@ -6,6 +6,8 @@
}:
# Rust tools for the primary user's shell environment.
let
cfg = config.modules.shell.rust;
user = config.user.name;
toolchain = pkgs.symlinkJoin {
name = "rust-toolchain";
paths = with pkgs; [
@@ -16,14 +18,13 @@ let
rustfmt
];
};
user = config.user.name;
in
{
options.modules.shell.rust.enable =
lib.mkEnableOption "Rust tools for the primary user's shell environment";
config = lib.mkIf config.modules.shell.rust.enable {
registry.env.${config.networking.hostName}.RUST_TOOLCHAIN_ROOT = "${toolchain}";
config = lib.mkIf cfg.enable {
registry.env-var.${config.networking.hostName}.RUST_TOOLCHAIN_ROOT = "${toolchain}";
home-manager.users.${user} = {
home.packages = [

View File

@@ -6,12 +6,13 @@
# tmux for the primary user, configured through home-manager.
# Settings without a home-manager option are read from ./extra.conf.
let
cfg = config.modules.shell.tmux;
user = config.user.name;
in
{
options.modules.shell.tmux.enable = lib.mkEnableOption "tmux, configured via home-manager";
config = lib.mkIf config.modules.shell.tmux.enable {
config = lib.mkIf cfg.enable {
registry.shell-shortcut.${config.networking.hostName}.tmx = "tmux new-session -A -s";
home-manager.users.${user}.programs.tmux = {

View File

@@ -6,12 +6,13 @@
}:
# Yazi, a terminal file manager.
let
cfg = config.modules.shell.yazi;
user = config.user.name;
in
{
options.modules.shell.yazi.enable = lib.mkEnableOption "Yazi, a terminal file manager";
config = lib.mkIf config.modules.shell.yazi.enable {
config = lib.mkIf cfg.enable {
home-manager.users.${user}.programs.yazi = {
enable = true;
enableFishIntegration = true;

View File

@@ -0,0 +1,72 @@
{
config,
lib,
...
}:
# The host-level ZFS pool import: durable service state a host mounts, never rebuilds.
let
cfg = config.modules.zfs;
in
{
options.modules.zfs = {
enable = lib.mkEnableOption "importing durable ZFS pools that hold service state";
hostId = lib.mkOption {
type = lib.types.strMatching "[0-9a-f]{8}";
example = "deadbeef";
description = ''
This host's 8-hex-digit ZFS host id, written to `networking.hostId`. ZFS
stamps an imported pool with the importing host's id, so a pool still
held by another machine is refused rather than silently dual-mounted. It
must be fixed for the machine and distinct across machines that can reach
the same pool.
'';
};
pools = lib.mkOption {
type = lib.types.attrsOf (lib.types.attrsOf lib.types.path);
default = { };
example = lib.literalExpression ''
{
tank = {
media = "/srv/media";
downloads = "/srv/downloads";
};
}
'';
description = ''
The ZFS pools to import at boot, keyed by pool name, each pool mapping a
dataset path relative to it to that dataset's mountpoint. A pool is
durable state imported as it stands, never created or destroyed by a
rebuild, so a service's data survives any rebuild or reimage. A pool
with an empty map is still imported, leaving each dataset to its own ZFS
`mountpoint` property.
'';
};
};
config = lib.mkIf cfg.enable {
# The ZFS stack in the kernel and boot, needed even where the root filesystem is another kind.
boot.supportedFilesystems = [ "zfs" ];
# ZFS refuses to import a pool without a host id to stamp its ownership onto.
networking.hostId = cfg.hostId;
# The declared pools are imported at boot, distinct from any pool backing the root filesystem.
boot.zfs.extraPools = lib.attrNames cfg.pools;
# Each declared dataset is mounted at its host path as a native ZFS filesystem.
fileSystems = lib.mkMerge (
lib.mapAttrsToList (
pool: mounts:
lib.mapAttrs' (
dataset: mountpoint:
lib.nameValuePair mountpoint {
device = "${pool}/${dataset}";
fsType = "zfs";
}
) mounts
) cfg.pools
);
};
}

View File

@@ -1,10 +1,10 @@
{ lib, util, ... }:
{ lib, ... }:
{
type = lib.types.str;
checks = [
{
assertion = util.registry.predicates.uniqueValue (entry: entry.value);
assertion = lib.registry.predicates.uniqueValue (entry: entry.value);
message = "Address registry values must be unique.";
}
];

View File

@@ -5,40 +5,40 @@
tab = lib.mkOption {
type = lib.types.str;
example = "Apps";
description = "Groups the entry in a Homepage tab.";
description = "Homepage tab that contains this entry.";
};
section = lib.mkOption {
type = lib.types.str;
example = "Media";
description = "Groups the entry in a Homepage section.";
description = "Homepage section that contains this entry.";
};
name = lib.mkOption {
type = lib.types.str;
example = "Navidrome";
description = "Sets the display name in Homepage.";
description = "Homepage display name for this entry.";
};
description = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "Music";
description = "Sets the description in Homepage.";
description = "Homepage description for this entry.";
};
icon = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "navidrome.png";
description = "Sets the icon in Homepage.";
description = "Homepage icon for this entry.";
};
href = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "https://music.alexion.dev";
description = "Overrides the generated Homepage link.";
description = "Homepage link override for this entry.";
};
};
};

View File

@@ -1,112 +0,0 @@
{ lib, util, ... }:
{
checks = [
{
assertion = util.registry.predicates.uniquePerHost (entry: entry.value.keys);
message = "Desktop keybinds must use unique key chords per host.";
}
{
assertion =
entries:
lib.all (
entry:
(entry.value.action.kind == "exec" && entry.value.action.command != null)
|| (
builtins.elem entry.value.action.kind [
"focus"
"move-window"
]
&& lib.xor (entry.value.action.direction != null) (entry.value.action.workspace != null)
)
|| (
entry.value.action.kind == "resize-window"
&& entry.value.action.direction != null
&& entry.value.action.workspace == null
)
|| (
builtins.elem entry.value.action.kind [
"toggle-floating"
"toggle-fullscreen"
"close-window"
]
&& entry.value.action.command == null
&& entry.value.action.direction == null
&& entry.value.action.workspace == null
)
) entries;
message = "Desktop keybind actions must set the fields their kind requires.";
}
];
type = lib.types.submodule {
options = {
keys = lib.mkOption {
description = "Sets the key chord for this desktop keybind.";
type = lib.types.str;
example = "SUPER + Return";
};
repeating = lib.mkOption {
description = "Repeats the keybind action while the key chord is held.";
type = lib.types.bool;
default = false;
example = true;
};
locked = lib.mkOption {
description = "Allows the keybind action while the session is locked.";
type = lib.types.bool;
default = false;
example = true;
};
action = lib.mkOption {
description = "Declares the action this keybind runs.";
type = lib.types.submodule {
options = {
kind = lib.mkOption {
description = "Selects the keybind action kind.";
type = lib.types.enum [
"exec"
"focus"
"move-window"
"resize-window"
"toggle-floating"
"toggle-fullscreen"
"close-window"
];
};
command = lib.mkOption {
description = "Command executed by an exec keybind.";
type = lib.types.nullOr lib.types.str;
default = null;
example = "ghostty";
};
direction = lib.mkOption {
description = "Direction targeted by a keybind action.";
type = lib.types.nullOr (
lib.types.enum [
"down"
"left"
"right"
"up"
]
);
default = null;
example = "left";
};
workspace = lib.mkOption {
description = "Workspace targeted by a keybind action.";
type = lib.types.nullOr lib.types.int;
default = null;
example = 1;
};
};
};
};
};
};
}

View File

@@ -1,39 +1,22 @@
{ lib, util, ... }:
{ lib, ... }:
{
checks = [
{
assertion = util.registry.predicates.uniqueValue (entry: entry.value.domain);
assertion = lib.registry.predicates.uniqueValue (entry: entry.value.domain);
message = "Route domains must be unique.";
}
{
assertion =
entries:
util.registry.predicates.uniqueValue (entry: entry.value) (
lib.concatMap (entry: map (port: entry // { value = port; }) entry.value.ports) entries
lib.registry.predicates.uniqueValue (entry: entry.value) (
lib.concatMap (entry: map (port: entry // { value = port; }) entry.value.udp.ports) entries
);
message = "Route UDP ports must be unique.";
}
{
assertion =
entries:
lib.all (entry: entry.value.port != null && entry.value.ports == [ ]) (
lib.filter (entry: entry.value.protocol != "udp") entries
);
message = "HTTP and HTTPS routes must set port and not ports.";
}
{
assertion =
entries:
lib.all (entry: entry.value.port == null && entry.value.ports != [ ]) (
lib.filter (entry: entry.value.protocol == "udp") entries
);
message = "UDP routes must set ports and not port.";
}
{
assertion =
entries:
lib.all (entry: entry.value.protocol != "udp" || entry.value.clientMaxBodySize == null) entries;
message = "UDP routes must not set clientMaxBodySize.";
entries: lib.all (entry: entry.value.port == null || entry.value.http.port == null) entries;
message = "Route entries must not set both port and http.port.";
}
];
@@ -42,40 +25,31 @@
domain = lib.mkOption {
type = lib.types.str;
example = "files.alexion.dev";
description = "Records the public domain for this route.";
};
protocol = lib.mkOption {
type = lib.types.enum [
"http"
"https"
"udp"
];
description = "Selects the public protocol for this route.";
description = "Domain served by this route entry.";
};
port = lib.mkOption {
type = lib.types.nullOr lib.types.port;
default = null;
example = 3923;
description = "Sets the HTTP backend port for an HTTP or HTTPS route.";
description = "HTTPS backend TCP port served by the matching address registry entry.";
};
ports = lib.mkOption {
http.port = lib.mkOption {
type = lib.types.nullOr lib.types.port;
default = null;
example = 8082;
description = "HTTP backend TCP port served by the matching address registry entry.";
};
udp.ports = lib.mkOption {
type = lib.types.listOf lib.types.port;
default = [ ];
example = [
2456
2457
];
description = "Sets the UDP ports for a UDP route.";
};
clientMaxBodySize = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "1g";
description = "Maximum HTTP request body the reverse proxy accepts.";
description = "UDP ports served by the matching address registry entry.";
};
};
};

View File

@@ -1,20 +1,21 @@
{ lib, ... }:
{
type = lib.types.coercedTo lib.types.str (command: { inherit command; }) (
lib.types.submodule {
options = {
command = lib.mkOption {
type = lib.types.str;
example = "nvim";
description = "Sets the command expanded by this shortcut.";
};
abbreviation = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Uses a Fish abbreviation instead of an alias.";
};
let
entryType = lib.types.submodule {
options = {
command = lib.mkOption {
type = lib.types.str;
example = "nvim";
description = "Command expanded by this shell shortcut.";
};
}
);
abbreviation = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether fish realizes this shell shortcut as an abbreviation instead of an alias.";
};
};
};
in
{
type = lib.types.coercedTo lib.types.str (command: { inherit command; }) entryType;
}

View File

@@ -1,25 +1,33 @@
{ lib, ... }:
let
inherit (lib)
filterAttrs
groupBy
mapAttrs
unique
;
serviceNamesByUid =
entries:
mapAttrs (_uid: uidEntries: unique (map (entry: entry.entry) uidEntries)) (
groupBy (entry: toString entry.value) entries
);
duplicateUids =
entries:
lib.filterAttrs (_uid: entryNames: builtins.length entryNames > 1) (entryNamesByUid entries);
entryNamesByUid =
entries:
lib.mapAttrs (_uid: uidEntries: lib.unique (map (entry: entry.entry) uidEntries)) (
lib.groupBy (entry: toString entry.value) entries
);
filterAttrs (_uid: serviceNames: builtins.length serviceNames > 1) (serviceNamesByUid entries);
in
{
# Next stable UID: 20005
replicate = true;
type = lib.types.ints.positive;
checks = [
{
assertion = entries: duplicateUids entries == { };
message = "Multiple entries use the same stable UID.";
message = "Stable UID allocations must be unique.";
}
];
}

View File

@@ -1,29 +0,0 @@
{ lib, ... }:
{
type = lib.types.submodule {
options = {
position = lib.mkOption {
type = lib.types.enum [
"left"
"center"
"right"
];
default = "right";
description = "Selects the Waybar section that shows the module.";
};
order = lib.mkOption {
type = lib.types.int;
default = 100;
example = 50;
description = "Sorts the module within its Waybar section.";
};
settings = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
default = { };
description = "Sets the Waybar module configuration.";
};
};
};
}

View File

@@ -1,19 +0,0 @@
{ lib, ... }:
{
type = lib.types.submodule {
options = {
class = lib.mkOption {
description = "Matches the window class reported by the compositor.";
type = lib.types.str;
example = ".*[Ff]irefox.*";
};
icon = lib.mkOption {
description = "Sets the icon shown for matching windows.";
type = lib.types.str;
example = "";
};
};
};
}

View File

@@ -4,21 +4,41 @@
"type": "split",
"children": [
{
"id": "6d9088d392cb8604",
"id": "e581539f950ca1bd",
"type": "tabs",
"children": [
{
"id": "22a4c1f201a1b00e",
"id": "0c587e5452c16787",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "00 INBOX/ai/valheim-items.md",
"mode": "preview",
"file": "TODO.md",
"mode": "source",
"source": true
},
"icon": "lucide-file",
"title": "valheim-items"
"title": "TODO"
}
}
]
},
{
"id": "72e6501450a46859",
"type": "tabs",
"children": [
{
"id": "06bc3d52246b600b",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "00 INBOX/ai/steamcmd servers.md",
"mode": "source",
"source": true
},
"icon": "lucide-file",
"title": "steamcmd servers"
}
}
]
@@ -91,7 +111,7 @@
"state": {
"type": "localgraph",
"state": {
"file": "00 INBOX/ai/valheim-items.md",
"file": "TODO.md",
"options": {
"collapse-filter": true,
"search": "",
@@ -114,7 +134,7 @@
"repelStrength": 10,
"linkStrength": 1,
"linkDistance": 250,
"scale": 0.6666666666666666,
"scale": 1,
"close": true
}
},
@@ -231,43 +251,43 @@
"table-editor-obsidian:Advanced Tables Toolbar": false
}
},
"active": "22a4c1f201a1b00e",
"active": "0c587e5452c16787",
"lastOpenFiles": [
"00 INBOX/ai/hyprland-lua-migration-plan.md",
"00 INBOX/ai/deadnix-architecture.md",
"00 INBOX/ai/valheim-items.md",
"TODO.md",
"AGENTS.md",
"00 INBOX/valheim progression.md",
"technical-writing.md",
"rust-module-prototype.md",
"00 INBOX/ai/statix-architecture.md",
"00 INBOX/ai/nix-statix-deadnix-rules.md",
"00 INBOX/let order name resolution.md~",
"00 INBOX/nix name resolution.md",
"00 INBOX/let order name resolution.md",
"00 INBOX/nix name resolution.md~",
"TODO.md~",
"00 INBOX/ai/xatu-rule-implementation-order.md",
"00 INBOX/ai/xatu-architecture-design.md",
"00 INBOX/ai/nix-style-linter-plan.md",
"00 INBOX/ai/nix-style-guide-agent-customization.md",
"00 INBOX/ai/game-guest-readiness-plan.md",
"00 INBOX/ai/xatu-node-rule-static-dispatch-plan.md",
"00 INBOX/ai/nix-style-linter-pr-1-vendor-rnix.md",
"00 INBOX/ai/nix-style-linter-pr-3-let-order.md",
"00 INBOX/ai/nix-style-linter-pr-2-scaffold-cli.md",
"00 INBOX/ai/homepage-pikachu-setup-plan.md",
"00 INBOX/ai/source-driven-remote-deploy-plan.md",
"00 INBOX/ai/monorepo-work-report.md",
"00 INBOX/ai/clew-ast-exploration.md",
"00 INBOX/how to setup a steamcmd server.md",
"00 INBOX/ai/steamcmd servers.md",
"00 INBOX/ai/clew-ast-exploration.md~",
"03 wrk/ariadne.md",
"00 INBOX/ai/ariadne-cli-skeleton.md",
"00 INBOX/ai/ariadne-project-goals.md",
"TODO.md",
"00 INBOX/ai/dictionary-cli.md",
"00 INBOX/ai/dictionary-skill.md",
"03 wrk/falinks.md",
"03 wrk/machoke.md",
"00 INBOX/home assistant agent integration.md",
"02 notes/falinks public surfaces.md",
"03 wrk/machoke.md~",
"02 notes/machoke public surface.md",
"03 wrk/falinks.md~",
"99 meta/templates/checkmark.md",
"AGENTS.md",
"99 meta/attachments/bases/unlinked mentions.base",
"99 meta/templates/note.md",
"AGENTS.md.md",
"00 INBOX/ai",
"03 wrk/ai",
"99 meta/Untitled"
"03 wrk/brain.md",
"01 sources/second brain setup for doing.md",
"00 INBOX/ai/hoopa design rewrite proposal 2.md",
"00 INBOX/ai/hoopa decisions.md",
"00 INBOX/ai/second brain setup for doing.md",
"00 INBOX/ai/hoopa design rewrite proposal.md",
"00 INBOX/ai/hoopa design review findings.md",
"00 INBOX/ai/hoopa follow up decisions.md",
"01 sources/obsidian and ai were made for each other.md",
"99 meta/Untitled",
"03 wrk",
"02 notes",
"01 sources",
"30 wrk"
]
}

View File

@@ -1,102 +0,0 @@
# Hyprland Lua Migration Plan
## Problem Statement
The Hyprland compositor config uses Home Manager's hyprlang output.
Hyprland deprecates hyprlang for the main compositor config.
The repository needs to generate a Lua config without changing desktop behavior.
## Solution
Keep the config owned by Nix and Home Manager.
Switch the Hyprland Home Manager config output to Lua.
Convert only the settings whose shape changes under Lua.
This is not a desktop redesign.
The change preserves the current monitors, binds, window rules, environment variables, and UWSM startup.
## User Stories
1. As the operator, I want Hyprland to load `hyprland.lua`, so that startup no longer warns about hyprlang deprecation.
2. As the operator, I want existing keyboard binds to keep working, so that the desktop behavior stays unchanged.
3. As the operator, I want host monitor layout to stay unchanged, so that the physical display setup does not move.
4. As the operator, I want session environment variables to stay in the compositor config, so that Wayland and cursor behavior stays unchanged.
5. As the operator, I want auxiliary module binds to keep working, so that launcher, lock, screenshot, clipboard, notifications, and recording flows stay usable.
## Implementation Decisions
1. Use Home Manager's Lua config generator.
Set the Hyprland config type to Lua and keep package ownership unchanged.
2. Keep ordinary compositor options declarative in Nix.
Move input, general, decoration, xwayland, animation, and layout settings into the Lua config structure.
3. Remove hyprlang-only variables from generated config.
Keep modifier and terminal values as Nix locals or helper inputs.
4. Add minimal local helpers for Lua calls.
Use helpers only for repeated bind, dispatcher, command, environment, and rule shapes.
5. Convert all bind strings to Lua bind call data.
Preserve key combinations, dispatchers, commands, repeat behavior, and locked-session behavior.
6. Convert contributed binds in their owning modules.
Keep each bind near the module that owns the tool it launches.
7. Convert monitor strings to Lua monitor records.
Preserve output names, modes, positions, scale values, and rotation.
8. Convert the screenshot window rule to the Lua rule shape.
Preserve the Satty class matcher and floating behavior.
9. Convert environment strings to Lua environment calls.
Preserve the Wayland flag and cursor variables.
10. Delete or update comments that describe hyprlang syntax.
Keep comments that explain behavior or non-obvious invariants.
## Testing Decisions
1. Run the repository Nix formatting and style checks for modified Nix files.
2. Evaluate or build the affected host configuration.
This proves Home Manager accepts the Lua settings structure.
3. Confirm the generated config file uses Lua.
The expected output is `hyprland.lua`, not `hyprland.conf`.
4. Verify Hyprland accepts the generated Lua config.
Use Hyprland config verification when the installed build supports it.
Otherwise reload the compositor and inspect errors.
5. Smoke test the changed desktop surface.
Test one bind from each source area after reload.
6. Check the explicit monitor layout on the host that declares it.
Confirm resolution, position, scale, and rotation stayed unchanged.
## Out of Scope
- Do not split the config into Lua modules.
- Do not hand-write a full Lua config.
- Do not add new Hyprland behavior.
- Do not change UWSM startup.
- Do not redesign binds, monitor layout, or visual settings.
- Do not change hyprlock, hypridle, waybar, mako, or rofi settings beyond their Hyprland binds.
## Further Notes
Home Manager documents `configType = "lua"` for Hyprland.
It maps settings to `hl.*` calls.
It supports `_args` for multi-argument calls.
It supports raw Lua expressions with `lib.generators.mkLuaInline`.
The riskiest parts are binds, monitor declarations, and window rules.
Those parts changed shape more than scalar config sections.
Assumption: the pinned Home Manager input contains the documented Lua Hyprland generator.
Validate this during the configuration build.

View File

@@ -0,0 +1,791 @@
# Nix Statix and Deadnix Rules
## Purpose
This note rewrites the current `statix` and `deadnix` findings as possible `nix-style` rules.
Use the `Decision` line on each rule for `keep`, `discard`, `update`, or `need-more-info`.
## Source Baseline
- `statix` rule names come from `nix run --inputs-from . nixpkgs#statix -- list`.
- `deadnix` checks come from `nix run --inputs-from . nixpkgs#deadnix -- --help`.
- `deadnix` reports categories instead of named rules.
- At collection time, `src/dotfiles/apps.nix` ran `statix fix .` and `deadnix --edit` in `nix run .#fix`.
- At collection time, `statix.toml` disabled `repeated_keys`.
## Statix Rules
### `bool_comparison`
Decision: keep
Source: `statix` `W01`.
Status at collection time: enabled.
Autofix: yes.
#### Rule
Do not compare a boolean expression to `true` or `false`.
Use the expression directly for true checks.
Use `!` for false checks.
Avoid:
```nix
x == true
x == false
```
Use:
```nix
x
!x
```
#### Rationale
Boolean literal comparisons add noise without changing behavior.
Direct predicates make conditions easier to scan.
### `empty_let_in`
Decision: keep
Source: `statix` `W02`.
Status at collection time: enabled.
Autofix: yes when the `let` body has no comments.
#### Rule
Do not keep a `let` expression that declares no bindings.
Replace it with its body.
Avoid:
```nix
let in value
```
Use:
```nix
value
```
#### Rationale
An empty `let` adds syntax without naming a dependency.
It usually remains after editing or debugging.
### `manual_inherit`
Decision: Keep
Source: `statix` `W03`.
Status at collection time: enabled.
Autofix: yes.
#### Rule
Use `inherit` when an attribute assignment repeats the same name as its value.
Avoid:
```nix
{
package = package;
}
```
Use:
```nix
{
inherit package;
}
```
#### Rationale
`inherit` states that the attribute comes from the current scope.
It removes duplicated names.
### `manual_inherit_from`
Decision:
Source: `statix` `W04`.
Status at collection time: enabled.
Autofix: yes.
#### Rule
Use `inherit (source)` when an assignment selects an attribute with the same name.
Avoid:
```nix
package = pkgs.package;
```
Use:
```nix
inherit (pkgs) package;
```
#### Rationale
`inherit (source)` states that the value comes from an attribute set.
It avoids repeated selector names.
### `legacy_let_syntax`
Decision: keep
Source: `statix` `W05`.
Status at collection time: enabled.
Autofix: yes.
#### Rule
Do not use legacy `let { ... }` syntax.
Use documented Nix expressions.
Avoid:
```nix
let {
body = x + y;
x = 2;
y = 3;
}
```
Use:
```nix
rec {
body = x + y;
x = 2;
y = 3;
}.body
```
#### Rationale
Legacy `let` syntax is undocumented.
Documented forms are clearer to tools and readers.
### `collapsible_let_in`
Decision: keep
Source: `statix` `W06`.
Status at collection time: enabled.
Autofix: yes.
#### Rule
Collapse a `let` expression when its body is another `let` expression.
Put both binding groups in one `let` when scope permits it.
Avoid:
```nix
let
a = 1;
in
let
b = 2;
in
a + b
```
Use:
```nix
let
a = 1;
b = 2;
in
a + b
```
#### Rationale
Nested `let` expressions add structure when they do not add a scope boundary.
A single binding group shows the local dependencies together.
### `eta_reduction`
Decision: keep
Source: `statix` `W07`.
Status at collection time: enabled.
Autofix: yes.
#### Rule
Remove a lambda that only passes its argument to another function.
Keep the target function name when that preserves behavior.
Avoid:
```nix
map (x: double x) values
```
Use:
```nix
map double values
```
#### Rationale
The wrapper lambda adds no behavior.
The direct function reference exposes the operation.
### `useless_parens`
Decision: keep
Source: `statix` `W08`.
Status at collection time: enabled.
Autofix: yes.
#### Rule
Remove parentheses around expressions when they do not affect parsing.
Avoid:
```nix
let
double = (x: x * 2);
in
(value)
```
Use:
```nix
let
double = x: x * 2;
in
value
```
#### Rationale
Redundant parentheses make expressions harder to scan.
Required precedence parentheses remain valid.
### `empty_pattern`
Decision: keep
Source: `statix` `W10`.
Status at collection time: enabled.
Autofix: yes.
#### Rule
Use `_` instead of an empty variadic function pattern when the argument is ignored.
Do not apply this rule to NixOS-style modules that declare `imports`.
Avoid:
```nix
{ ... }: value
```
Use:
```nix
_: value
```
#### Rationale
`_` directly marks an ignored argument.
An empty variadic pattern looks like partial destructuring.
### `redundant_pattern_bind`
Decision: keep
Source: `statix` `W11`.
Status at collection time: enabled.
Autofix: yes.
#### Rule
Do not bind a variadic pattern when the pattern captures no fields.
Use a single argument name instead.
Avoid:
```nix
args @ { ... }: args.nixpkgs
```
Use:
```nix
args: args.nixpkgs
```
#### Rationale
The empty pattern adds no destructuring value.
A named argument shows that the whole argument is used.
### `unquoted_uri`
Decision: keep
Source: `statix` `W12`.
Status at collection time: enabled.
Autofix: yes.
#### Rule
Quote URI literals.
Do not use Nix's special unquoted URI syntax.
Avoid:
```nix
url = github:owner/repository;
```
Use:
```nix
url = "github:owner/repository";
```
#### Rationale
Quoted strings work for all URI forms.
They avoid ambiguity with punctuation after the URI.
### `empty_inherit`
Decision: keep
Source: `statix` `W14`.
Status at collection time: enabled.
Autofix: yes.
#### Rule
Remove `inherit` statements that inherit no names.
Avoid:
```nix
inherit;
```
Use no replacement.
#### Rationale
An empty `inherit` has no effect.
It usually remains after a refactor.
### `deprecated_to_path`
Decision: keep
Source: `statix` `W17`.
Status at collection time: enabled.
Autofix: no.
#### Rule
Do not use `builtins.toPath` or unqualified `toPath`.
Use explicit path construction instead.
Avoid:
```nix
builtins.toPath "/path"
```
Use:
```nix
/. + "/path"
./. + "/relative-path"
```
#### Rationale
`toPath` is deprecated.
Explicit path construction states whether the result is absolute or relative.
### `bool_simplification`
Decision: keep
Source: `statix` `W18`.
Status at collection time: enabled.
Autofix: yes.
#### Rule
Use `!=` instead of negating an equality comparison.
Avoid:
```nix
!(x == y)
```
Use:
```nix
x != y
```
#### Rationale
The direct operator expresses inequality without nested boolean syntax.
### `useless_has_attr`
Decision: keep
Source: `statix` `W19`.
Status at collection time: enabled.
Autofix: yes.
#### Rule
Use `or` when an `if` expression only checks for an attribute before selecting it.
Avoid:
```nix
if x ? a then x.a else default
```
Use:
```nix
x.a or default
```
#### Rationale
The `or` form is the Nix operator for attribute defaults.
It avoids repeating the attribute path.
### `repeated_keys`
Decision: update so that it doesn't conflict with the custom module convention.
Source: `statix` `W20`.
Status at collection time: disabled in `statix.toml`.
Autofix: no.
#### Rule
Group repeated attribute path prefixes in a nested attribute set.
Apply this rule only when at least three sibling assignments share the same first key.
Do not apply it inside `rec` attribute sets.
Avoid:
```nix
{
services.a = 1;
services.b = 2;
services.c = 3;
}
```
Use:
```nix
{
services = {
a = 1;
b = 2;
c = 3;
};
}
```
#### Rationale
Grouping repeated prefixes reduces visual repetition.
The local config disables this rule, so it needs an explicit decision before promotion.
### `empty_list_concat`
Decision: keep
Source: `statix` `W23`.
Status at collection time: enabled.
Autofix: yes.
#### Rule
Remove list concatenation with an empty list.
Avoid:
```nix
[] ++ values
values ++ []
```
Use:
```nix
values
```
#### Rationale
Concatenating `[]` changes no values.
It usually remains after conditional list construction changed.
## Deadnix Checks
### Unused `let` bindings
Decision: keep
Source: `deadnix` default check.
Status at collection time: active in `nix run .#fix`.
Autofix: yes.
#### Rule
Remove `let` bindings when no live expression uses their names.
Do not keep a binding only because another dead binding uses it.
Use `# deadnix: skip` only for intentional unused declarations.
Avoid:
```nix
let
unused = 1;
in
value
```
Use:
```nix
value
```
#### Rationale
Unused bindings hide the dependencies that matter.
They also make refactors look unfinished.
### Unused `inherit` names
Decision: keep
Source: `deadnix` default check.
Status at collection time: active in `nix run .#fix`.
Autofix: yes.
#### Rule
Remove names from `inherit` statements when no live expression uses those names.
Remove the whole `inherit` statement when it becomes empty.
Avoid:
```nix
let
inherit (builtins) unused;
in
value
```
Use:
```nix
value
```
#### Rationale
Unused inherited names create the same noise as unused `let` bindings.
An empty result also overlaps with `statix` `empty_inherit`.
### Unused lambda arguments
Decision: keep
Source: `deadnix` default check.
Status at collection time: active in `nix run .#fix`.
Autofix: yes.
#### Rule
Mark an unused single lambda argument with `_`.
Do not keep a meaningful name for an ignored argument.
Avoid:
```nix
arg: value
```
Use:
```nix
_: value
```
#### Rationale
`_` states that the argument is intentionally ignored.
A named unused argument implies missing logic.
### Unused lambda pattern names
Decision: keep
Source: `deadnix` default check.
Status at collection time: active in `nix run .#fix`.
Autofix: yes.
#### Rule
Remove unused names from lambda attribute patterns.
Keep only names used by the body or defaults.
Use `...` when callers can pass extra attributes.
Avoid:
```nix
{ used, unused, ... }: used
```
Use:
```nix
{ used, ... }: used
```
#### Rationale
Pattern names usually declare dependencies.
Unused names overstate the function's inputs.
### Unused lambda pattern aliases
Decision: keep
Source: `deadnix` default check.
Status at collection time: active in `nix run .#fix`.
Autofix: yes.
#### Rule
Remove a lambda pattern alias when the body does not use the alias.
Keep the alias only when code reads the whole argument set.
Avoid:
```nix
args @ { used, ... }: used
```
Use:
```nix
{ used, ... }: used
```
#### Rationale
The alias says the function needs the complete input set.
Keeping it unused makes the function contract look wider than it is.
### Ignored underscore bindings
Decision: keep
Source: `deadnix` `--no-underscore` option.
Status at collection time: inactive; current fixer does not pass `--no-underscore`.
Autofix: no separate rule.
#### Rule
Treat bindings whose names start with `_` as intentionally ignored.
Optionally skip unused diagnostics for those names.
Avoid, when the skip policy is active:
```nix
let
_unused = expensiveValue;
in
value
```
Use only for intentional placeholders.
#### Rationale
An underscore prefix is a common marker for intentionally unused names.
Skipping all underscore bindings can hide real dead code.
### Used underscore bindings
Decision: keep
Source: `deadnix` `--warn-used-underscore` option.
Status at collection time: inactive; current fixer does not pass `--warn-used-underscore`.
Autofix: no.
#### Rule
Do not use a binding whose name starts with `_`.
Rename it to a normal name when code references it.
Avoid:
```nix
let
_package = pkgs.hello;
in
_package
```
Use:
```nix
let
package = pkgs.hello;
in
package
```
#### Rationale
The underscore prefix communicates that the name is intentionally unused.
Using it contradicts that signal.
## Notes for Decisions
- `statix` rules are mostly syntax rewrites.
- `deadnix` rules need scope analysis and dead-code reachability.
- `deadnix` does not report `rec` attribute-set bindings as removable findings.
- `statix` `repeated_keys` is present in the installed rule list but disabled locally.
- Rules that change module argument patterns need extra care around NixOS modules and `callPackage` functions.

View File

@@ -0,0 +1,261 @@
# Statix Architecture
## Purpose
This note explains how `statix` works as a Nix linter and fixer.
It focuses on architecture, not the value of each rule.
Use this note when deciding how complete our repository-local Nix linter should become.
## What Statix Is
`statix` is a syntax-tree linter for Nix.
It parses source text into a Nix AST, walks that tree, and runs rules on matching nodes.
It does not evaluate Nix.
It does not import files.
It does not know the final value of an attribute set.
It sees source structure, not runtime semantics.
That design makes `statix` fast and deterministic.
It also limits it to rules that the parser can prove from local syntax.
## Minimal Linter Vocabulary
- Source text is the bytes in a Nix file.
- A parser converts source text into a syntax tree.
- A syntax tree records nodes such as lambdas, lists, strings, and operators.
- A token is a leaf item such as an identifier, comment, or URI literal.
- A rule inspects one syntax element and optionally returns a diagnostic.
- A diagnostic reports a span and a message.
- A suggestion adds a replacement for that span.
- A fixer applies suggestions back to source text.
## Component Map
The upstream architecture document names four major components.
### CLI component
The CLI owns operator interaction.
It parses subcommands and options, loads configuration, discovers files, and prints output.
It supports these user flows:
- `check`: report diagnostics.
- `fix`: apply all safe suggestions.
- `single`: apply one suggestion at a position.
- `explain`: print the long explanation for a rule code.
- `dump`: print a sample configuration file.
- `list`: print all available rules.
### Library component
The library owns lint rules and the data structures that rules return.
It defines `Report`, `Diagnostic`, `Suggestion`, `Rule`, `Metadata`, `Explain`, and `Lint`.
Each rule is a small unit.
It receives one syntax element and returns zero or one report.
The library also contains helpers that build replacement syntax.
Those helpers parse small replacement fragments back into AST nodes.
This makes fixes use valid Nix syntax instead of raw string assembly.
### Virtual filesystem component
The virtual filesystem stores file paths and file contents in memory.
It assigns each file a small `FileId`.
The CLI reads files into this store before linting.
Diagnostics then carry `FileId` references instead of path strings.
The VFS supports sequential iteration and parallel iteration.
`statix check` uses parallel iteration across files.
### Macro component
The macro component reduces rule boilerplate.
A `lint` macro generates rule metadata, explanation support, and the static rule object.
A rule author writes the rule structure, metadata, documentation, and validation logic.
The macro wires that rule into the common linter interface.
## Check Pipeline
```mermaid
flowchart TD
A[Parse CLI options] --> B[Discover configuration]
B --> C[Build ignore set]
C --> D[Walk Nix files]
D --> E[Read files into VFS]
E --> F[Build active lint map]
F --> G[Parse each file with rnix]
G --> H[Walk syntax tree]
H --> I[Run rules matching each syntax kind]
I --> J[Collect reports]
J --> K[Render diagnostics]
K --> L[Exit 1 when reports exist]
```
The key optimization is the active lint map.
`statix` groups rules by `SyntaxKind` before it walks a file.
During traversal, it looks up only rules that declared interest in the current node kind.
This avoids running every rule on every node.
It also keeps each rule narrow.
## Rule Registration
Rules are built into the binary.
There is no dynamic rule loading.
A registry macro imports each rule module and builds a static `LINTS` vector.
The CLI filters that vector with configuration.
Disabled rule names disappear from the active lint map.
This model has a simple trust boundary:
- all rule code ships with the tool;
- configuration can disable rules;
- configuration cannot define new rules.
## Rule Execution Model
Each rule declares the syntax kinds it wants to inspect.
For example, one rule can ask for binary-operation nodes.
Another rule can ask for URI tokens.
The traversal visits tree entries with `preorder_with_tokens`.
That includes both nodes and tokens.
Rules can therefore inspect structured AST nodes and raw tokens.
A rule returns a `Report` when it finds a problem.
The report contains one or more diagnostics.
Each diagnostic has a source range and a message.
A diagnostic can also include a `Suggestion`.
Rules do not share state with each other.
They also do not run a whole-program analysis.
A rule sees the syntax element passed to it and whatever children or parents it chooses to inspect.
## Parse Errors
`statix` turns parser errors into reports.
Those reports use code `0` and severity `Error`.
Parse errors flow through the same output machinery as lint findings.
This lets the CLI render syntax errors and lint diagnostics with one reporter.
## Output Model
`statix` has several output formats.
The human format uses `ariadne` to render annotated source snippets.
The `errfmt` format prints one compact line per diagnostic.
The JSON format exists behind a feature flag.
The output layer receives reports and the VFS.
It converts byte ranges into line and column positions at render time.
This separates rule logic from display logic.
Rules do not need to know how the terminal or editor consumes diagnostics.
## Fix Pipeline
A fixable diagnostic contains a `Suggestion`.
A suggestion contains the range to replace and replacement syntax.
`statix fix` uses the same parser and rule walk as `check`, but filters for reports that contain suggestions.
It then sorts and filters suggestions to avoid overlapping edits.
The fixer applies selected reports to a copy of the source.
Then it parses and scans the changed source again.
It repeats this process until no more suggestions appear.
That iterative design matters because one edit can expose a later edit.
It also avoids applying stale ranges after source text changes.
## Single Fix Pipeline
The `single` command applies one fix at a requested line and column.
It converts the line and column to a byte offset.
Then it finds the first fixable report whose diagnostic range contains that offset.
This shape supports editor workflows.
An editor can request a fix for the diagnostic under the cursor.
## Configuration Model
Configuration is small.
A `statix.toml` file can disable rules by name.
It can also add ignore globs.
The CLI discovers configuration by walking ancestors from the configured path.
If no config exists, it uses defaults.
The command also respects `.gitignore` unless unrestricted mode is enabled.
Ignore handling happens before parsing.
## Architectural Strengths
`statix` has a clean separation between these concerns:
- file discovery;
- parsing;
- rule selection;
- rule execution;
- reporting;
- fixing.
Rules stay small because the framework handles traversal and output.
Fixes are safer because replacement syntax comes from parsed Nix fragments.
The lint map avoids unnecessary rule calls.
Parallel file traversal improves throughput for large trees.
The static rule registry is easy to audit.
It fits a repository-local linter well when we want deterministic behavior.
## Architectural Limits
`statix` does not evaluate Nix.
It cannot know imported module results, overlays, option merges, or generated attributes.
Most rules must remain local syntax rules.
A rule that needs semantic knowledge can easily become wrong.
Fixes rely on byte ranges in source text.
They must avoid overlapping ranges.
They must rerun after edits because previous ranges become stale.
The macro system reduces rule boilerplate, but it hides generated behavior.
That tradeoff is useful for many rules, but it can make debugging harder.
## Lessons for Our Linter
Start with a clear rule interface.
A rule should declare its name, matching syntax kinds, and diagnostic output.
Keep parsing and diagnostics separate from output.
The same diagnostic should support terminal output, editor output, and tests.
Use syntax-kind indexing when rules are local.
It prevents unnecessary calls and makes rule ownership explicit.
Treat autofix as a second contract.
A diagnostic can exist without a safe fix.
A fix must define exact replacement ranges and avoid overlap.
Rerun analysis after applying a batch of fixes.
Source ranges become invalid after edits.
Do not add Nix evaluation accidentally.
If a rule needs evaluated values, design that as a separate analysis layer.
## Open Design Questions for Our Linter
- Should our linter support fixes, or stay report-only for now?
- Should rules register by syntax kind like `statix`?
- Should our suppression model live in the parser layer or rule layer?
- Should parse errors use the same diagnostic type as style rules?
- Should editor-friendly output be part of the first complete architecture?
- Should future semantic rules use a separate pass from local syntax rules?

View File

@@ -1,939 +0,0 @@
# Valheim craft and build checklist
This checklist groups craftable inventory items and non-structural build pieces by biome progression.
It excludes floors, walls, roofs, beams, poles, stairs, doors, gates, windows, and generic structure pieces.
Biome placement uses the earliest ordinary unlock from recipe materials or required stations.
## Meadows
### Buildables
- [x] Workbench
- [x] Chopping Block
- [x] Tanning Rack
- [x] Cooking Station
- [x] Beehive
- [x] Bed
- [x] Chest
- [x] Sign
- [x] Campfire
- [x] Standing Wood Torch
- [x] Deer Rug
- [x] Mysterious Rock
- [x] Wood Stack
- [x] Stone Pile
- [x] Flint Pile
### Equipment and inventory
#### Weapons and tools
- [x] Stone Axe
- [x] Flint Axe
- [x] Early Axes
- [x] Club
- [x] Torch
- [x] Flint Spear
- [x] Flint Knife
- [x] Crude Bow
- [x] Hoe
- [x] Antler Pickaxe
#### Armor
- [x] Rag Tunic
- [x] Rag Pants
- [x] Leather Helmet
- [x] Leather Tunic
- [x] Leather Pants
- [x] Deer Hide Cape
#### Shields
- [x] Wood Shield
- [x] Wood Tower Shield
- [x] Bone Tower Shield
#### Ammunition
- [x] Wood Arrow
- [x] Fire Arrow
- [x] Flinthead Arrow
#### Utility
- [x] Tankard
#### Fireworks
- [ ] Red fireworks
- [ ] Yellow fireworks
### Food, mead, and bait
- [x] Grilled neck tail
- [ ] Cooked boar meat
- [x] Honey
- [x] Cooked deer meat
- [x] Whole roasted Meadow boar
## Black Forest
### Buildables
- [x] Adze
- [x] Cauldron
- [x] Mead Ketill
- [x] Fermenter
- [x] Forge
- [x] Forge Cooler
- [x] Anvils
- [x] Smelter
- [x] Charcoal Kiln
- [ ] Barrel
- [ ] Stool
- [ ] Wood Chair
- [ ] Sitting Log
- [ ] Wood Bench
- [x] Table
- [x] Item Stand — vertical
- [x] Item Stand — horizontal
- [ ] Cartography Table
- [ ] Barber Station
- [ ] Archery Target
- [ ] T.W.I.G.
- [ ] Wood Dragon Adornment
- [ ] Sconce
- [ ] Fey Lights
- [ ] Flower Garland
- [x] Bearskin Rug
- [ ] Black Banner
- [ ] Blue Banner
- [ ] White and Red Striped Banner
- [ ] Yellow Banner
- [ ] Purple Banner
- [ ] Finewood Stack
- [ ] Corewood Stack
- [x] Coal Pile
- [x] Bone Stack
- [x] Coin Stack
- [x] Coin Pile
- [x] Tin Stack
- [x] Copper Stack
- [x] Bronze Stack
- [x] Cart
- [x] Portal
- [ ] Sharp Stakes
- [ ] Ward
### Equipment and inventory
#### Weapons and tools
- [x] Bronze Axe
- [x] Bronze Pickaxe
- [x] Cultivator
- [x] Butcher Knife
- [x] Wooden Sword
- [x] Stagbreaker
- [x] Bronze Mace
- [x] Bronze Sword
- [x] Bronze Spear
- [x] Bronze Atgeir
- [x] Copper Knife
- [x] Paws of the Bear
- [x] Finewood Bow
#### Armor
- [x] Troll Leather Helmet
- [x] Troll Leather Tunic
- [x] Troll Leather Pants
- [x] Troll Hide Cape
- [x] Headdress of the Bear
- [x] Patterns of the Bear
- [x] Loincloth of the Bear
- [x] Bronze Helmet
- [x] Bronze Plate Cuirass
- [x] Bronze Plate Leggings
- [x] Celebratory Cap
#### Shield
- [x] Bronze Buckler
#### Ammunition
- [x] Bronzehead Arrow
#### Trinkets
- [x] Heart of the Forest
- [x] Bronze Pendant
#### Utility
- [x] Mead Horn of Odin
#### Fireworks
- [ ] Blue fireworks
- [ ] Cyan fireworks
### Food, mead, and bait
- [ ] Carrot
- [ ] Boar jerky
- [ ] Cooked bear meat
- [ ] Minced meat sauce
- [ ] Queen's jam
- [ ] Carrot soup
- [ ] Deer stew
- [ ] Pulled Bear
- [ ] Black Forest buffet platter
- [ ] Mead base: Minor healing `[INFERENCE]`
- [ ] Minor healing mead `[INFERENCE]`
- [ ] Mead base: Minor stamina `[INFERENCE]`
- [ ] Minor stamina mead `[INFERENCE]`
- [ ] Mead base: Poison resistance `[INFERENCE]`
- [ ] Poison resistance mead `[INFERENCE]`
- [ ] Mead base: Tasty `[INFERENCE]`
- [ ] Tasty mead `[INFERENCE]`
- [ ] Mead base: Vananidir `[INFERENCE]`
- [ ] Draught of Vananidir `[INFERENCE]`
- [ ] Mead base: Ratatosk `[INFERENCE]`
- [ ] Tonic of Ratatosk `[INFERENCE]`
- [ ] Mead base: Troll endurance `[INFERENCE]`
- [ ] Mead of Troll endurance `[INFERENCE]`
- [ ] Mossy fishing bait
## Ocean
### Buildables
- [ ] Raft
- [ ] Karve
- [ ] Longship
### Equipment and inventory
#### Weapons and tools
- [ ] Abyssal Razor
- [ ] Abyssal Harpoon
#### Trinket
- [ ] Fins of Destiny
### Food, mead, and bait
- [ ] Cooked serpent meat `[INFERENCE]`
- [ ] Serpent stew `[INFERENCE]`
- [ ] Sailor's bounty
- [ ] Heavy fishing bait
## Swamp
### Buildables
- [ ] Birds' Nest
- [ ] Spice Rack
- [ ] Stonecutter
- [ ] Food Preparation Table
- [ ] Iron Cooking Station
- [ ] Forge Bellows
- [ ] Grinding Wheel
- [ ] Smith's Anvil
- [ ] Forge Tool Rack
- [ ] Obliterator
- [ ] Bonfire
- [ ] Hearth
- [ ] Hanging Brazier
- [ ] Standing Iron Torch
- [ ] Standing Blue-burning Iron Torch
- [ ] Standing Green-burning Iron Torch
- [ ] Resin Candle
- [ ] Reinforced Chest
- [ ] Personal Chest
- [ ] Armour Stand
- [ ] Raven Throne
- [ ] Red Banner
- [ ] Green Banner
- [ ] Iron Stack
- [ ] Iron Fire Pit
### Equipment and inventory
#### Weapons and tools
- [ ] Iron Axe
- [ ] Iron Pickaxe
- [ ] Battleaxe
- [ ] Iron Mace
- [ ] Iron Sledge
- [ ] Iron Sword
- [ ] Ancient Bark Spear
- [ ] Iron Atgeir
- [ ] Huntsman Bow
#### Armor
- [ ] Root Mask
- [ ] Root Harnesk
- [ ] Root Leggings
- [ ] Iron Helmet
- [ ] Iron Scale Mail
- [ ] Iron Greaves
#### Shields
- [ ] Banded Shield
- [ ] Shield of Roots
- [ ] Iron Buckler
- [ ] Iron Tower Shield
- [ ] Serpent Scale Shield
#### Ammunition
- [ ] Ironhead Arrow
#### Bombs
- [ ] Ooze Bomb
- [ ] Blob Bomb: Poison
- [ ] Blob Bomb: Elite poison
#### Fishing bait
- [ ] Mossy Fishing Bait
- [ ] Sticky Fishing Bait
- [ ] Heavy Fishing Bait
#### Trinkets
- [ ] Iron Brooch
- [ ] Nimble Anklet
#### Utility
- [ ] Horn of Celebration
#### Fireworks
- [ ] Green fireworks
- [ ] Purple fireworks
### Food, mead, and bait
- [ ] Cooked fish
- [ ] Muckshake
- [ ] Turnip stew
- [ ] Sausages
- [ ] Black soup
- [ ] Swamp dweller's delight
- [ ] Mead base: Frost resistance `[INFERENCE]`
- [ ] Frost resistance mead `[INFERENCE]`
- [ ] Mead base: Medium healing `[INFERENCE]`
- [ ] Medium healing mead `[INFERENCE]`
- [ ] Sticky fishing bait
## Mountain
### Buildables
- [ ] Tool Shelf
- [ ] Butcher's Table
- [ ] Dragon Bed
- [ ] Stone Throne
- [ ] Standing Brazier
- [ ] Blue Standing Brazier
- [ ] Wolf Rug
- [ ] Treasure Chest
- [ ] Silver Stack
### Equipment and inventory
#### Weapons and tools
- [ ] Crystal Battleaxe
- [ ] Frostner
- [ ] Silver Sword
- [ ] Fang Spear
- [ ] Silver Knife
- [ ] Flesh Rippers
- [ ] Draugr Fang
- [ ] Scythe
#### Armor
- [ ] Fenris Hood
- [ ] Fenris Coat
- [ ] Fenris Leggings
- [ ] Drake Helmet
- [ ] Wolf Hide Chestpiece
- [ ] Wolf Hide Trousers
- [ ] Wolf Fur Cape
#### Shield
- [ ] Silver Shield
#### Ammunition
- [ ] Silver Arrow
- [ ] Obsidian Arrow
- [ ] Poison Arrow
- [ ] Frost Arrow
#### Bomb
- [ ] Blob Bomb: Frost
#### Fishing bait
- [ ] Cold Fishing Bait
- [ ] Frosty Fishing Bait
#### Trinkets
- [ ] Wolf Sight
- [ ] Crystal Heart
### Food, mead, and bait
- [ ] Onion
- [ ] Cooked wolf meat
- [ ] Wolf jerky
- [ ] Onion soup
- [ ] Wolf skewer
- [ ] Eyescream
- [ ] Hearty Mountain logger's stew
- [ ] Mead base: Animal whispers `[INFERENCE]`
- [ ] Brew of animal whispers `[INFERENCE]`
- [ ] Cold fishing bait
- [ ] Frosty fishing bait `[INFERENCE]`
## Plains
### Buildables
- [ ] Artisan Table
- [ ] Blast Furnace
- [ ] Spinning Wheel
- [ ] Windmill
- [ ] Stone Oven
- [ ] Pots and Pans
- [ ] Darkwood Chair
- [ ] Long Heavy Table
- [ ] Round Table
- [ ] Hot Tub
- [ ] Black Metal Chest
- [ ] Lox Rug
- [ ] Straw
- [ ] Red Jute Carpet
- [ ] Red Jute Curtain
- [ ] Raven Adornment
- [ ] Wolf Adornment
- [ ] Blue, Red and White Banner
- [ ] White and Blue Striped Banner
- [ ] Orange Banner
- [ ] White Banner
- [ ] Black Metal Stack
### Equipment and inventory
#### Weapons and tools
- [ ] Black Metal Axe
- [ ] Black Metal Battleaxe
- [ ] Porcupine
- [ ] Black Metal Sword
- [ ] Black Metal Atgeir
- [ ] Black Metal Knife
- [ ] Vilebone Maulclaws
#### Armor
- [ ] Padded Helmet
- [ ] Padded Cuirass
- [ ] Padded Greaves
- [ ] Linen Cape
- [ ] Lox Cape
- [ ] Lox Fur Hood
- [ ] Lox Fur Jacket
- [ ] Lox Fur Trousers
- [ ] Vilebone Visage
- [ ] Vilebone Cage
- [ ] Vilebone Drapes
#### Shields
- [ ] Black Metal Shield
- [ ] Black Metal Tower Shield
#### Ammunition
- [ ] Needle Arrow
- [ ] Wooden Missile
- [ ] Black Metal Missile
#### Bomb
- [ ] Blob Bomb: Tar
#### Fishing bait
- [ ] Stingy Fishing Bait
- [ ] Misty Fishing Bait
#### Trinkets
- [ ] Bracelets of the Brave
- [ ] Evasion Mantle
#### Accessories and utility
- [ ] Wisplight
- [ ] Lox Saddle
### Food, mead, and bait
- [ ] Cooked egg
- [ ] Cooked chicken meat
- [ ] Cooked lox meat
- [ ] Fish wraps
- [ ] Lox meat pie
- [ ] Blood pudding
- [ ] Bread
- [ ] Frosted sweetbread
- [ ] Plains pie picnic
- [ ] Barley wine base: Fire resistance `[INFERENCE]`
- [ ] Fire resistance barley wine `[INFERENCE]`
- [ ] Mead base: Medium stamina `[INFERENCE]`
- [ ] Medium stamina mead `[INFERENCE]`
- [ ] Mead base: Berserkir `[INFERENCE]`
- [ ] Berserkir mead `[INFERENCE]`
- [ ] Mead base: Anti-sting `[INFERENCE]`
- [ ] Anti-sting concoction `[INFERENCE]`
- [ ] Stingy fishing bait
- [ ] Misty fishing bait `[INFERENCE]`
## Mistlands
### Buildables
- [ ] Wisp Fountain
- [ ] Artisan Press
- [ ] Mortar and Pestle
- [ ] Black Forge
- [ ] Black Forge Cooler
- [ ] Vice
- [ ] Galdr Table
- [ ] Rune Table
- [ ] Unfading Candles
- [ ] Sap Extractor
- [ ] Eitr Refinery
- [ ] Dvergr Wall Lantern
- [ ] Dvergr Pole Lantern
- [ ] Wisp Torch
- [ ] Black Marble Bench
- [ ] Black Marble Table
- [ ] Black Marble Throne
- [ ] Hare Rug
- [ ] Blue Jute Carpet
- [ ] Blue Jute Curtain
- [ ] Blue Jute Drapes
- [ ] Yggdrasil Wood Stack
- [ ] Black Marble Pile
- [ ] Dvergr Sharp Stakes
- [ ] Trap
- [ ] Ballista
### Equipment and inventory
#### Weapons and tools
- [ ] Jotun Bane
- [ ] Skull Splittur
- [ ] Demolisher
- [ ] Mistwalker
- [ ] Krom
- [ ] Carapace Spear
- [ ] Himminafl
- [ ] Skoll and Hati
- [ ] Spinesnap
- [ ] Arbalest
- [ ] Staff of Frost
- [ ] Staff of Embers
- [ ] Staff of Protection
- [ ] Dead Raiser
- [ ] Black Metal Pickaxe
- [ ] Dvergr Lantern
#### Armor
- [ ] Carapace Helmet
- [ ] Carapace Breastplate
- [ ] Carapace Greaves
- [ ] Eitr-weave Hood
- [ ] Eitr-weave Robe
- [ ] Eitr-weave Trousers
- [ ] Feather Cape
#### Shields
- [ ] Carapace Shield
- [ ] Carapace Buckler
#### Ammunition
- [ ] Carapace Arrow
- [ ] Bone Bolt
- [ ] Iron Bolt
- [ ] Black Metal Bolt
- [ ] Carapace Bolt
#### Bomb
- [ ] Bile Bomb
#### Trinkets
- [ ] Pulsating Earrings
- [ ] Resounding Shackle
#### Utility
- [ ] Sealbreaker
### Food, mead, and bait
- [ ] Magecap
- [ ] Jotun puffs
- [ ] Cooked seeker meat
- [ ] Cooked hare meat
- [ ] Meat platter
- [ ] Honey glazed chicken
- [ ] Misthare supreme
- [ ] Salad
- [ ] Mushroom omelette
- [ ] Fish 'n' bread
- [ ] Stuffed mushroom
- [ ] Yggdrasil porridge
- [ ] Seeker aspic
- [ ] Mushrooms galore á la Mistlands
- [ ] Mead base: Major healing `[INFERENCE]`
- [ ] Major healing mead `[INFERENCE]`
- [ ] Mead base: Lingering stamina `[INFERENCE]`
- [ ] Lingering stamina mead `[INFERENCE]`
- [ ] Mead base: Minor eitr `[INFERENCE]`
- [ ] Minor eitr mead `[INFERENCE]`
- [ ] Mead base: Lightfoot `[INFERENCE]`
- [ ] Lightfoot mead `[INFERENCE]`
## Ashlands
### Buildables
- [ ] Rolling Pins and Cutting Boards
- [ ] Metal Cutter
- [ ] Gem Cutter
- [ ] Feathery Wreath
- [ ] Eternal Pyre
- [ ] Shield Generator
- [ ] Ashwood Bed
- [ ] Ashwood Bench
- [ ] Bone Throne
- [ ] Grausten Chest
- [ ] Small Green Pot
- [ ] Medium Green Pot
- [ ] Large Green Pot
- [ ] Lava Lantern
- [ ] Asksvin Rug
- [ ] Asksvin Skeleton
- [ ] Ashwood Stack
- [ ] Grausten Pile
- [ ] Flametal Stack
- [ ] Pile of Skulls
- [ ] Portal Stone
- [ ] Drakkar
- [ ] Battering Ram
- [ ] Catapult
### Equipment and inventory
#### Weapons
- [ ] Berserkir Axes
- [ ] Bleeding Berserkir Axes
- [ ] Thundering Berserkir Axes
- [ ] Primal Berserkir Axes
- [ ] Flametal Mace
- [ ] Bloodgeon
- [ ] Storm Star
- [ ] Klossen
- [ ] Nidhögg
- [ ] Nidhögg the Bleeding
- [ ] Nidhögg the Thundering
- [ ] Nidhögg the Primal
- [ ] Dyrnwyn
- [ ] Slayer
- [ ] Brutal Slayer
- [ ] Scourging Slayer
- [ ] Primal Slayer
- [ ] Splitnir
- [ ] Splitnir the Bleeding
- [ ] Splitnir the Storming
- [ ] Splitnir the Primal
- [ ] Ash Fang
- [ ] Blood Fang
- [ ] Storm Fang
- [ ] Root Fang
- [ ] Ripper
- [ ] Wound Ripper
- [ ] Storm Ripper
- [ ] Root Ripper
- [ ] Staff of Fracturing
- [ ] Dundr
- [ ] Staff of the Wild
- [ ] Trollstav
#### Armor
- [ ] Hood of Embla
- [ ] Robes of Embla
- [ ] Trousers of Embla
- [ ] Hood of Ask
- [ ] Breastplate of Ask
- [ ] Trousers of Ask
- [ ] Flametal Helmet
- [ ] Flametal Breastplate
- [ ] Flametal Greaves
- [ ] Asksvin Cloak
- [ ] Ashen Cape
#### Shields
- [ ] Flametal Shield
- [ ] Flametal Tower Shield
#### Ammunition
- [ ] Charred Arrow
- [ ] Charred Bolt
- [ ] Flametal Missile
- [ ] Grausten Payload
- [ ] Explosive Payload
#### Bombs
- [ ] Smoke Bomb
- [ ] Basalt Bomb
- [ ] Blob Bomb: Lava
#### Fishing bait
- [ ] Hot Fishing Bait
#### Trinkets
- [ ] Jörmundling
- [ ] Brimstone
#### Utility
- [ ] Asksvin Saddle
- [ ] Bell
### Food, mead, and bait
- [ ] Vineberry cluster
- [ ] Cooked asksvin tail
- [ ] Cooked volture meat
- [ ] Cooked bonemaw meat
- [ ] Fiery svinstew
- [ ] Mashed meat
- [ ] Piquant pie
- [ ] Spicy marmalade
- [ ] Scorching medley
- [ ] Roasted crust pie
- [ ] Sizzling berry broth
- [ ] Sparkling shroomshake
- [ ] Marinated greens
- [ ] Ashlands gourmet bowl
- [ ] Mead base: Lingering health `[INFERENCE]`
- [ ] Lingering healing mead `[INFERENCE]`
- [ ] Mead base: Lingering eitr `[INFERENCE]`
- [ ] Lingering eitr mead `[INFERENCE]`
- [ ] Hot fishing bait
## Deep North
### Buildables
- [ ] Eternal Pyre
- [ ] Frigid Kiln
- [ ] Frost Foundry
- [ ] Smith's Aprons
- [ ] Smoker
- [ ] Standing Loom
- [ ] Timberwood Drawbridge
- [ ] Antler Throne
- [ ] Carved Bench
- [ ] Carved Chair
- [ ] Hooded Lantern
- [ ] Long Carved Table
- [ ] Moose Hide Carpet
- [ ] Sealskin Rug
- [ ] Snow Lantern
- [ ] Square Carved Table
- [ ] Wardrobe
- [ ] Bloodgold Stack
- [ ] Timberwood Stack
- [ ] Oat Straw `[INFERENCE]`
- [ ] Seed Kale
- [ ] Timberwood Sapling
### Equipment and inventory
#### Equipment and inventory
- [ ] Nord Atgeir
- [ ] Nord Axe
- [ ] Nord Bow
- [ ] Nord Crossbow
- [ ] Nord Dagger
- [ ] Nord Greataxe
- [ ] Nord Greatsword
- [ ] Nord Knucklechains
- [ ] Nord Mace
- [ ] Nord Sledge
- [ ] Nord Spear
- [ ] Nord Sword
- [ ] Frostfire Atgeir
- [ ] Frostfire Axe
- [ ] Frostfire Bow
- [ ] Frostfire Crossbow
- [ ] Frostfire Dagger
- [ ] Frostfire Greataxe
- [ ] Frostfire Greatsword
- [ ] Frostfire Knucklechains
- [ ] Frostfire Mace
- [ ] Frostfire Sledge
- [ ] Frostfire Spear
- [ ] Frostfire Sword
- [ ] Thunderblood Atgeir
- [ ] Thunderblood Axe
- [ ] Thunderblood Bow
- [ ] Thunderblood Crossbow
- [ ] Thunderblood Dagger
- [ ] Thunderblood Greataxe
- [ ] Thunderblood Greatsword
- [ ] Thunderblood Knucklechains
- [ ] Thunderblood Mace
- [ ] Thunderblood Sledge
- [ ] Thunderblood Spear
- [ ] Thunderblood Sword
- [ ] Echo Spike
- [ ] Lightning Strike
- [ ] Northern Vengeance
- [ ] Spirit Caller
- [ ] Nord Buckler
- [ ] Nord Greatshield
- [ ] Nord Shield
- [ ] Helmet of the Protector
- [ ] Breastplate of the Protector
- [ ] Trousers of the Protector
- [ ] Hood of the Vanguard
- [ ] Chestpiece of the Vanguard
- [ ] Trousers of the Vanguard
- [ ] Headdress of the Caller
- [ ] Robes of the Caller
- [ ] Trousers of the Caller
- [ ] Cape of the Caller
- [ ] Moose Hide Cape
- [ ] Crown of Valheim `[INFERENCE]`
- [ ] Fishing Hat
- [ ] Neckstabber `[INFERENCE]`
- [ ] Witch Crown
- [ ] Snow Shovel
- [ ] Intricate Key
- [ ] Moose Saddle `[INFERENCE]`
- [ ] Bloodgold Arrow
- [ ] Bloodgold Bolt
- [ ] Bloodgold Missile
- [ ] Bloodgold Payload
- [ ] Blob Bomb: Pulp `[INFERENCE]`
- [ ] Ember Charge
- [ ] Snowball
### Food, mead, and bait
- [ ] Baked Poteitr
- [ ] Cooked Moose Meat
- [ ] Cooked Seal Blubber `[INFERENCE]`
- [ ] Fish Soup `[INFERENCE]`
- [ ] Kale *(also the cultivator crop piece; listed once)*
- [ ] Kale Chips `[INFERENCE]`
- [ ] Lingonberries
- [ ] Lingonberry Juice
- [ ] Luminous Larva
- [ ] Meat In Bread
- [ ] Meatballs and Poteitr
- [ ] Oat Milk `[INFERENCE]`
- [ ] Oatmeal
- [ ] Oats `[INFERENCE]`
- [ ] Oven Pancake
- [ ] Pancakes
- [ ] Poteitr `[INFERENCE]`
- [ ] Seal Meat Soup
- [ ] Smoked Fish
- [ ] Smoked Moose Meat
- [ ] Northern Morning Fare
- [ ] Oat Flour `[INFERENCE]`
- [ ] Oven Pancake Batter
- [ ] Raw Kale Chips `[INFERENCE]`
- [ ] Unbaked Poteitr
- [ ] Frosty fishing bait
## Seasonal
### Buildables
- [ ] Maypole
- [ ] Jack-o-turnip
- [ ] Mistletoe
- [ ] Yule Tree
- [ ] Yule Wreath
- [ ] Yule Garland
- [ ] Yuleklapp — small
- [ ] Yuleklapp — medium
- [ ] Yuleklapp — large
### Equipment and inventory
#### Armor
- [ ] Midsummer Crown
- [ ] Pointy Hat
## Sources
The Valheim Wiki gives most current lists and progression tables.
Deep North names use the 1.0 notes and game-derived catalogs when Wiki pages lag release data.
- https://valheim.wiki/w/Weapons
- https://valheim.wiki/w/Armor
- https://valheim.wiki/w/Tools
- https://valheim.wiki/w/Accessories
- https://valheim.wiki/w/Trinkets
- https://valheim.wiki/w/Food
- https://valheim.wiki/w/Mead
- https://valheim.wiki/w/Bait
- https://valheim.wiki/w/Building
- https://valheim.wiki/w/Deep_North
- https://www.valheimgame.com/news/valheim-1-0-has-arrived-/
- https://www.valheimplanner.com/recipes/
- https://www.valheimplanner.com/build/
- https://www.valheimplanner.com/food/

View File

@@ -1,267 +0,0 @@
---
date-created: 2026-09-23 07:13
---
# Valheim Progression
## Notation & Terms
| Notation | Meaning |
| --------- | ------------------------------------------------------------------ |
| A <- B | A can be OBTAINED from/through B |
| A <- B{x} | A can be purchased from B for x coins |
| A => B | A DISCOVERS/UNLOCKS B in-game |
| A -> B | A is REQUIRED to make/do B |
| A ~> B | A is an UPGRADE for B |
| ![B] A | Completion objective of B category |
| A @ B | A is FOUND/LOCATED at/in B |
| ^ A#B | A is an alternative method for B |
| ? A | Open question about A |
| x A | A is prohibited/illegal method |
| A + B | A AND B |
| A \| B | A OR B |
| \* A | A is available from the START / has no progression prerequisites |
| A[n] | A at QUALITY/LEVEL n |
| A$n | A with CAPABILITY tier n (higher tiers satisfy lower requirements) |
| | |
- [farm]: a deliberately constructed setup that repeatedly produces a resource or creature drop more efficiently/reliably than ordinary gathering, though it may require consumable inputs or periodic maintenance.
- [build]: furniture, whether functional or decorative only.
- [craft]: craft the item
## Notes
![farm] Greydwarf Farm
Hanging Brazier + Forge[1] + Greydwarf Nest -> Greydwarf Farm
Bronze + Coal + Chain + Forge[1] + Hammer => Hanging Brazier
Bronze{5} + Coal{2} + Chain{1} @ Forge[1] -> Hanging Brazier
Copper + Tin + Forge[1] => Bronze
Copper{2} + Tin{1} @ Forge[1] -> Bronze
Copper Ore{1} + Coal{2} @ Smelter -> Copper
Copper Ore <- Mine Copper Deposit
Copper Deposit + Pickaxe$0 -> Mine Copper Deposit
Copper Deposit @ Black Forest
* Black Forest
Antler Pickaxe -> Pickaxe$0
Wood + Hard Antler + Workbench[1] => Antler Pickaxe
Wood{10} + Hard Antler{1} @ Workbench[1] -> Antler Pickaxe
* Wood
Hard Antler <- Kill Eikthyr
Summon Eikthyr -> Kill Eikthyr
Deer Trophy{2} @ Eikthyr Forsaken Altar -> Summon Eikthyr
Deer Trophy <- Deer
Deer @ Meadows
Deer @ Black Forest
Eikthyr Forsaken Altar @ Meadows
Wood + Hammer => Workbench[1]
Wood{10} -> Workbench[1]
Wood + Stone => Hammer
Wood{3} + Stone{2} -> Hammer
* Stone
Wood @ Charcoal Kiln -> Coal
Stone + Surtling Core + Workbench[1] + Hammer => Charcoal Kiln
Stone{20} + Surtling Core{5} @ Workbench[1] -> Charcoal Kiln
Surtling Core @ Burial Chambers
Burial Chambers @ Black Forest
Stone + Surtling Core + Workbench[1] + Hammer => Smelter
Stone{20} + Surtling Core{5} @ Workbench[1] -> Smelter
Tin Ore{1} + Coal{2} @ Smelter -> Tin
Tin Ore <- Mine Tin Deposit
Tin Deposit + Pickaxe$0 -> Mine Tin Deposit
Tin Deposit @ Black Forest
Stone + Coal + Wood + Copper + Workbench[1] + Hammer => Forge[1]
Stone{4} + Coal{4} + Wood{10} + Copper{6} @ Workbench[1] -> Forge[1]
Chain @ Sunken Crypts
Swamp Key -> Sunken Crypts
Swamp Key <- Kill Elder
Summon Elder -> Kill Elder
Ancient Seed{3} @ Elder Forsaken Altar -> Summon Elder
Ancient Seed <- Greydwarf Nest
Elder Forsaken Altar @ Black Forest
Sunken Crypts @ Swamp
* Swamp
Greydwarf Nest @ Black Forest
![build] Hearth
Stone + Stonecutter[1] + Hammer => Hearth
Stone{15} @ Stonecutter[1] -> Hearth
Wood + Iron + Stone + Workbench[1] + Hammer => Stonecutter[1]
Wood{10} + Iron{2} + Stone{4} @ Workbench[1] -> Stonecutter[1]
Scrap Iron{1} + Coal{2} @ Smelter -> Iron
Scrap Iron <- Sunken Crypts
Scrap Iron <- Mine Muddy Scrap Pile
Muddy Scrap Pile + Pickaxe$0 -> Mine Muddy Scrap Pile
Muddy Scrap Pile @ Sunken Crypts
![build] Iron Cooking Station
Iron + Chain + Forge[1] + Hammer => Iron Cooking Station
Iron{3} + Chain{3} @ Forge[1] -> Iron Cooking Station
![build] Obliterator
Iron + Copper + Thunder Stone + Forge[1] + Hammer => Obliterator
Iron{8} + Copper{4} + Thunder Stone{1} @ Forge[1] -> Obliterator
Thunder Stone <- Haldor{50}
Haldor @ Black Forest
Coins <- Troll
Troll @ Black Forest
Coins @ Burial Chambers
Coins @ Troll Cave
Troll Cave @ Black Forest
Coins @ Sunken Crypts
![craft] Iron Greaves
Iron + Deer Hide + Forge[2] => Iron Greaves
Iron{20} + Deer Hide{2} @ Forge[2] -> Iron Greaves
![craft] Iron Scale Mail
Iron + Deer Hide + Forge[2] => Iron Scale Mail
Iron{20} + Deer Hide{2} @ Forge[2] -> Iron Scale Mail
![build] Forge Tool Rack
Forge Tool Rack ~> Forge
Iron + Wood + Workbench[1] + Hammer => Forge Tool Rack
Iron{15} + Wood{10} @ Workbench[1] -> Forge Tool Rack
![build] Cauldron
Tin + Forge[1] + Hammer => Cauldron
Tin{10} @ Forge[1] -> Cauldron
![build] Mysterious Rock
Rock + Coal + Hammer => Mysterious Rock
Rock{1} + Coal{1} -> Mysterious Rock
Rock @ Black Forest
Rock @ Meadows
Rock @ Swamp
![build] Chopping Block
Chopping Block ~> Workbench
Wood + Flint + Workbench[1] + Hammer => Chopping Block
Wood{10} + Flint{10} @ Workbench[1] -> Chopping Block
![build] Tanning Rack
Tanning Rack ~> Workbench
Wood + Flint + Leather Scraps + Deer Hide + Workbench[1] + Hammer => Tanning Rack
Wood{10} + Flint{15} + Leather Scraps{20} + Deer Hide{5} @ Workbench[1] -> Tanning Rack
* Flint
Leather Scraps <- Boar
Boar @ Meadows
Deer Hide <- Deer
![build] Adze
Adze ~> Workbench
Finewood + Bronze + Forge[1] + Hammer => Adze
Finewood{10} + Bronze{3} @ Forge[1] -> Adze
Finewood <- Chop Birch
Birch + Axe[2] -> Chop Birch
Birch @ Meadows
Bronze Axe -> Axe[2]
Finewood <- Chop Oak
Oak + Axe[2] -> Chop Oak
Oak @ Meadows
Wood + Bronze + Leather Scraps + Forge[1] => Bronze Axe
Wood{4} + Bronze{8} + Leather Scraps{2} @ Forge[1] -> Bronze Axe

View File

@@ -3,7 +3,7 @@
- machoke
- falinks
- dotfiles
- natu
- xatu
- nix-style / refactor
# project ideas
@@ -22,9 +22,6 @@
- dictionary cli tool
- kitchen.md
- Rust project creator
- random number generator utility
- reading material helper
- valheim progression DSL + graph
# migration
@@ -101,13 +98,6 @@
- https://www.youtube.com/watch?v=kzcI5F4tGiU
- https://www.youtube.com/watch?v=iKwPaB5TUdI
- https://www.youtube.com/watch?v=A-CtdQFRX64
- natu literature
- https://rust-analyzer.github.io/book/contributing/syntax.html?utm_source=chatgpt.com
- https://github.com/dotnet/roslyn/blob/main/docs/compilers/Design/Red-Green%20Trees.md?utm_source=chatgpt.com
- https://github.com/rust-analyzer/rowan/blob/master/examples/s_expressions.rs?utm_source=chatgpt.com
- https://dl.acm.org/doi/10.1145/964001.964011
- https://web.cecs.pdx.edu/~apt/esop15.pdf?utm_source=chatgpt.com
- https://www.researchgate.net/publication/2377179_Efficient_and_flexible_incremental_parsing?utm_source=chatgpt.com
# tech debt
@@ -123,36 +113,34 @@
Review Nix files in entrypoint order:
- [x] `flake.nix`
- [x] `src/flake/default.nix`
- [x] `src/flake/formatter.nix`
- [x] `src/flake/apps.nix`
- [x] `src/dotfiles/default.nix`
- [x] `src/dotfiles/util.nix`
- [x] `src/dotfiles/registry.nix`
- [x] `src/dotfiles/registry/schema.nix`
- [x] `src/dotfiles/registry/validation.nix`
- [x] `configs/nixos/registry/address.nix`
- [>] `configs/nixos/registry/homepage.nix`
- [x] `configs/nixos/registry/route.nix`
- [x] `configs/nixos/registry/shell-shortcut.nix`
- [x] `configs/nixos/registry/stable-uid.nix`
- [x] `configs/nixos/modules/default.nix`
- [x] `configs/nixos/modules/host.nix`
- [x] `configs/nixos/modules/guest.nix`
- [x] `configs/nixos/modules/monorepo.nix`
- [x] `configs/nixos/modules/zfs.nix`
- [x] `configs/nixos/modules/network/ssh.nix`
- [x] `configs/nixos/modules/network/vlans.nix`
- [x] `configs/nixos/modules/network/vpn-client.nix`
- [x] `configs/nixos/modules/desktop/default.nix`
- [x] `configs/nixos/modules/desktop/apps/alacritty.nix`
- [x] `configs/nixos/modules/desktop/apps/firefox.nix`
- [x] `configs/nixos/modules/desktop/apps/ghostty.nix`
- [x] `configs/nixos/modules/desktop/apps/obsidian.nix`
- [x] `configs/nixos/modules/desktop/apps/r2modman.nix`
- [x] `configs/nixos/modules/desktop/apps/signal.nix`
- [x] `configs/nixos/modules/desktop/apps/steam.nix`
- [x] `configs/nixos/modules/desktop/apps/vencord.nix`
- [x] `src/dotfiles/formatter.nix`
- [x] `src/dotfiles/apps.nix`
- [ ] `src/dotfiles/registry.nix`
- [ ] `configs/nixos/registry/address.nix`
- [ ] `configs/nixos/registry/env-var.nix`
- [ ] `configs/nixos/registry/homepage.nix`
- [ ] `configs/nixos/registry/route.nix`
- [ ] `configs/nixos/registry/shell-shortcut.nix`
- [ ] `configs/nixos/registry/stable-uid.nix`
- [ ] `src/dotfiles/modules.nix`
- [ ] `configs/nixos/modules/default.nix`
- [ ] `configs/nixos/modules/host.nix`
- [ ] `configs/nixos/modules/guest.nix`
- [ ] `configs/nixos/modules/monorepo.nix`
- [ ] `configs/nixos/modules/zfs.nix`
- [ ] `configs/nixos/modules/network/ssh.nix`
- [ ] `configs/nixos/modules/network/vlans.nix`
- [ ] `configs/nixos/modules/network/vpn-client.nix`
- [ ] `configs/nixos/modules/desktop/default.nix`
- [ ] `configs/nixos/modules/desktop/apps/alacritty.nix`
- [ ] `configs/nixos/modules/desktop/apps/firefox.nix`
- [ ] `configs/nixos/modules/desktop/apps/ghostty.nix`
- [ ] `configs/nixos/modules/desktop/apps/obsidian.nix`
- [ ] `configs/nixos/modules/desktop/apps/signal.nix`
- [ ] `configs/nixos/modules/desktop/apps/steam.nix`
- [ ] `configs/nixos/modules/desktop/apps/vencord.nix`
- [ ] `configs/nixos/modules/desktop/session/hyprland/default.nix`
- [ ] `configs/nixos/modules/desktop/session/hyprland/hypridle.nix`
- [ ] `configs/nixos/modules/desktop/session/hyprland/hyprlock.nix`

View File

@@ -2,13 +2,13 @@
description = "alexion.dev";
inputs = {
# Provides the base nixpkgs channel.
# Base nixpkgs channel.
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
# Provides newer packages through `unstable.<name>`.
# Fresher packages, reachable per-package as `unstable.<name>`.
nixpkgs-unstable.url = "github:nixos/nixpkgs/nixpkgs-unstable";
# Provides the latest stable release through `stable.<name>`.
# Latest stable release, reachable per-package as `stable.<name>`.
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
home-manager = {
@@ -16,71 +16,70 @@
inputs.nixpkgs.follows = "nixpkgs";
};
# Provides signed AMO extensions pinned by version and hash.
# Signed AMO extensions, pinned by version and hash.
firefox-addons = {
url = "gitlab:rycee/nur-expressions?dir=pkgs/firefox-addons";
inputs.nixpkgs.follows = "nixpkgs";
};
# Uses this flake's nixpkgs so plugins build against the same package set.
# Follows our nixpkgs so its plugins build against the same package set.
nixvim = {
url = "github:nix-community/nixvim";
inputs.nixpkgs.follows = "nixpkgs";
};
# Configures declarative disk partitioning.
# Each host declares its layout.
# Declarative disk partitioning.
# Each host declares its own layout.
disko = {
url = "github:nix-community/disko";
inputs.nixpkgs.follows = "nixpkgs";
};
# Provides upstream hardware profiles for each machine.
# Each host imports its profile.
# Upstream per-machine hardware profiles.
# Each host imports its own.
nixos-hardware = {
url = "github:NixOS/nixos-hardware";
inputs.nixpkgs.follows = "nixpkgs";
};
# Decrypts committed secrets at activation with an age identity on the host.
# Decrypts committed secrets at activation, from an age identity on the host.
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
# Themes the graphical layer with one base16 scheme.
# Uses this flake's nixpkgs so it themes the host package set.
# Themes the graphical layer from one base16 scheme.
# Follows our nixpkgs so it themes the same package set the host builds.
stylix = {
url = "github:danth/stylix";
inputs.nixpkgs.follows = "nixpkgs";
};
# TODO: Deprecate and remove this input.
# Provides a Gitea CLI for agents.
# Its Home Manager module configures agent context.
# TODO: deprecate / remove
# Agent-ergonomic CLI for Gitea, with a home-manager module for the agent context.
gitea-axi = {
url = "git+https://git.alexion.dev/alexion/gitea-axi";
inputs.nixpkgs.follows = "nixpkgs";
};
# Configures the repository formatter and Nix linter.
# Repository formatter and Nix lint wiring.
treefmt-nix = {
url = "github:numtide/treefmt-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
# Provides the CachyOS kernel and binary cache.
# Uses its pinned package set because Chaotic builds against it.
# Its cache serves the kernel only when the overlay uses the cache-friendly path.
# CachyOS kernel and binary cache.
# This must not follow this flake's nixpkgs because Chaotic builds against its own pinned package set.
# Its cache serves the kernel only when the overlay stays on the cache-friendly path.
chaotic.url = "github:chaotic-cx/nyx/nyxpkgs-unstable";
};
outputs =
{ self, nixpkgs, ... }@inputs:
import ./src/flake {
import ./src/dotfiles {
inherit inputs self;
inherit (nixpkgs) lib;
root = ./.;
root = ./configs/nixos;
system = "x86_64-linux";
};
}

View File

@@ -2,8 +2,6 @@
## Gotchas
- `src/dotfiles/guests.nix` does not accept a `lib` argument.
Do not pass `lib` when importing it.
- The registry collector reads only top-level host `config.registry` declarations.
Registry declarations inside guest interiors live under `config.containers.<guest>.config.registry`.
The collected registry does not include those guest-interior declarations.

View File

@@ -15,18 +15,13 @@ in
name = "fix";
runtimeInputs = [
formatter
pkgs.statix
pkgs.deadnix
];
text = ''
treefmt
deadnix --edit \
--exclude \
configs/nixos/hosts/neogaia/hardware-configuration.nix \
configs/nixos/hosts/pikachu/hardware-configuration.nix \
configs/nixos/hosts/zeus/hardware-configuration.nix \
src/natu/testdata \
third_party \
-- .
statix fix .
deadnix --edit --exclude configs/nixos/hosts/*/hardware-configuration.nix -- .
treefmt
'';
}

View File

@@ -3,8 +3,22 @@
inputs,
self,
root,
system,
}:
let
util = import ./util.nix { inherit lib; };
registry = import ./registry.nix {
inherit lib;
hosts = self.nixosConfigurations;
root = root + "/registry";
};
modules = import ./modules.nix {
inherit util;
root = root + "/modules";
};
specialArgs = {
inherit
inputs
@@ -14,18 +28,10 @@ let
registry = registry.collected;
};
util = import ./util.nix { inherit lib; };
registry = import ./registry.nix {
inherit lib util;
hosts = self.nixosConfigurations;
root = root + "/registry";
};
# TODO: Refactor guest-module composition.
guestModules = import ./guests.nix {
inherit
inputs
lib
registry
specialArgs
util
@@ -33,17 +39,31 @@ let
repositoryModules = modules;
root = root + "/guests";
};
modules.imports = util.collectNixFiles (root + "/modules");
in
import ./hosts.nix {
inherit
inputs
lib
specialArgs
guestModules
;
registryModule = registry.module;
repositoryModules = modules;
root = root + "/hosts";
{
formatter.${system} =
(import ./formatter.nix {
inherit inputs system;
}).config.build.wrapper;
apps.${system} = import ./apps.nix {
inherit
inputs
lib
system
;
formatter = self.formatter.${system};
};
nixosConfigurations = import ./hosts.nix {
inherit
inputs
lib
specialArgs
guestModules
;
registryModule = registry.module;
repositoryModules = modules;
root = root + "/hosts";
};
}

View File

@@ -6,7 +6,5 @@ inputs.treefmt-nix.lib.evalModule inputs.nixpkgs.legacyPackages.${system} {
settings.formatter.nixfmt.excludes = [
"configs/nixos/hosts/*/hardware-configuration.nix"
"src/natu/testdata/*.nix"
"third_party/**"
];
}

View File

@@ -1,5 +1,6 @@
{
inputs,
lib,
root,
specialArgs,
repositoryModules,
@@ -7,7 +8,6 @@
util,
}:
let
files = util.collectNixFiles root;
mkGuest =
name:
{ config, lib, ... }:
@@ -167,22 +167,22 @@ let
config = lib.mkIf cfg.enable {
assertions =
let
hasDirectorySettings = mount: mount.owner != null || mount.group != null || mount.mode != null;
hasCompleteDirectorySettings =
mount: mount.owner != null && mount.group != null && mount.mode != null;
hasOneSource = mount: (mount.directory != null) != (mount.file != null);
mountsWithInvalidSource = lib.attrNames (
lib.filterAttrs (_: mount: !hasOneSource mount) cfg.mounts
);
hasOneSource = mount: (mount.directory != null) != (mount.file != null);
fileMountsWithHostSettings = lib.attrNames (
lib.filterAttrs (_: mount: mount.file != null && hasDirectorySettings mount) cfg.mounts
);
hasDirectorySettings = mount: mount.owner != null || mount.group != null || mount.mode != null;
directoryMountsWithPartialHostSettings = lib.attrNames (
lib.filterAttrs (
_: mount:
mount.directory != null && hasDirectorySettings mount && !hasCompleteDirectorySettings mount
) cfg.mounts
);
hasCompleteDirectorySettings =
mount: mount.owner != null && mount.group != null && mount.mode != null;
in
[
{
@@ -275,7 +275,11 @@ let
};
};
names = map util.removeNixSuffix files;
files = util.collectNixFiles root;
guestFiles = lib.filterAttrs (file: type: type == "regular" && lib.hasSuffix ".nix" file) (
builtins.readDir root
);
names = map (file: lib.removeSuffix ".nix" file) (lib.attrNames guestFiles);
in
{
imports = files ++ map mkGuest names;

View File

@@ -9,8 +9,6 @@
registryModule,
}:
let
names = lib.attrNames directories;
directories = lib.filterAttrs (_name: type: type == "directory") (builtins.readDir root);
mkHost =
{
root,
@@ -38,5 +36,7 @@ let
];
};
directories = lib.filterAttrs (_name: type: type == "directory") (builtins.readDir root);
names = lib.attrNames directories;
in
lib.genAttrs names (name: mkHost { inherit root name; })

7
src/dotfiles/modules.nix Normal file
View File

@@ -0,0 +1,7 @@
{
root,
util,
}:
{
imports = util.collectNixFiles root;
}

View File

@@ -2,57 +2,222 @@
hosts,
lib,
root,
util,
}:
let
definitions = lib.genAttrs' (util.collectNixFiles root) (
path: lib.nameValuePair (util.removeNixSuffix path) (mkRegistry path)
);
checkType = lib.types.submodule {
options = {
assertion = lib.mkOption {
type = lib.types.functionTo lib.types.bool;
description = ''
Predicate run against this registry's flattened collected entries.
'';
};
message = lib.mkOption {
type = lib.types.str;
description = ''
Error message shown when this registry validation check fails.
'';
};
};
};
predicates = {
uniqueValue =
value: entries: builtins.length (lib.unique (map value entries)) == builtins.length entries;
};
registryLib = lib // {
registry = (lib.registry or { }) // {
inherit predicates;
};
};
registryDefinitionModule = {
options = {
type = lib.mkOption {
type = lib.types.raw;
description = ''
NixOS option type for one registry entry.
'';
};
replicate = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether collected entries are merged and published under every host key.
'';
};
checks = lib.mkOption {
type = lib.types.listOf checkType;
default = [ ];
description = ''
Validation checks for this registry's flattened collected entries.
'';
};
};
};
registryName = fileName: lib.removeSuffix ".nix" fileName;
registryOption =
registry:
lib.mkOption {
type = lib.types.attrsOf (lib.types.attrsOf registry.type);
default = { };
description = ''
Lazily collected fleet-visible registry entries, nested by host and entry name.
'';
};
mkRegistry =
path:
(lib.evalModules {
specialArgs = { inherit util; };
modules = [
(import ./registry/schema.nix { inherit lib; })
path
];
}).config;
let
evaluated = lib.evalModules {
specialArgs.lib = registryLib;
modules = [
registryDefinitionModule
path
];
};
in
{
inherit (evaluated.config) type replicate checks;
};
validation = import ./registry/validation.nix { inherit data definitions lib; };
definitions =
lib.mapAttrs'
(
name: _type:
let
path = root + "/${name}";
in
lib.nameValuePair (registryName name) (mkRegistry path)
)
(
lib.filterAttrs (name: type: type == "regular" && lib.hasSuffix ".nix" name) (builtins.readDir root)
);
data = lib.mapAttrs (
registryName: _registry:
lib.mapAttrs (hostName: host: host.config.registry.${registryName}.${hostName} or { }) hosts
) definitions;
flattenRegistryEntries =
registryName: data:
lib.concatLists (
lib.mapAttrsToList (
host: entries:
lib.mapAttrsToList (entry: value: {
path = "registry.${registryName}.${host}.${entry}";
inherit host entry value;
}) entries
) data
);
rawDeclarations =
registryName: hosts:
lib.mapAttrs (_hostName: host: host.config.registry.${registryName} or { }) hosts;
replicatedEntries =
registryName: raw:
let
entries = lib.concatLists (
lib.concatLists (
lib.mapAttrsToList (
_evalHost: hostDeclarations:
lib.mapAttrsToList (
hostName: declarations:
lib.mapAttrsToList (entryName: value: {
path = "registry.${registryName}.${hostName}.${entryName}";
inherit entryName value;
}) declarations
) hostDeclarations
) raw
)
);
in
lib.foldl' (
acc: entry:
if lib.hasAttr entry.entryName acc then
if acc.${entry.entryName}.value == entry.value then
acc
else
throw "conflicting replicated registry entry ${entry.path} does not match ${acc.${entry.entryName}.path}"
else
acc
// {
${entry.entryName} = entry;
}
) { } entries;
replicatedData =
registryName: hosts: raw:
let
entries = lib.mapAttrs (_entryName: entry: entry.value) (replicatedEntries registryName raw);
in
lib.mapAttrs (_hostName: _host: entries) hosts;
validationAssertions =
data:
lib.concatLists (
lib.mapAttrsToList (
registryName: registry:
let
entries = flattenRegistryEntries registryName data.${registryName};
in
map (check: {
assertion = check.assertion entries;
inherit (check) message;
}) registry.checks
) definitions
);
assertValid =
data:
let
assertions = validationAssertions data;
failures = lib.filter (assertion: !assertion.assertion) assertions;
in
assert lib.assertMsg (lib.all (assertion: assertion.assertion) assertions) (
lib.concatMapStringsSep "\n" (assertion: assertion.message) failures
);
data;
join =
registry: host: entry:
lib.filterAttrs (_name: value: value != null) (
lib.mapAttrs (
registryName: _registry: (registry.${registryName}.${host} or { }).${entry} or null
) definitions
);
collectData =
{ hosts }:
let
raw = lib.mapAttrs (registryName: _registry: rawDeclarations registryName hosts) definitions;
data = lib.mapAttrs (
registryName: registry:
if registry.replicate or false then
replicatedData registryName hosts raw.${registryName}
else
lib.mapAttrs (hostName: host: host.config.registry.${registryName}.${hostName} or { }) hosts
) definitions;
in
assertValid data;
withOperations =
registry:
registry
// {
join = host: entry: join registry host entry;
};
in
{
types = lib.mapAttrs (_name: definition: definition.type) definitions;
module = {
options.registry = lib.mapAttrs (
_name: registry:
lib.mkOption {
type = lib.types.attrsOf (lib.types.attrsOf registry.type);
default = { };
description = ''
Stores registry entries that the collector exposes to the fleet.
Nests entries by host and entry name.
'';
}
) definitions;
options.registry = lib.mapAttrs (_name: registryOption) definitions;
};
collected =
assert validation;
data
// {
join =
host: entry:
lib.filterAttrs (_name: value: value != null) (
lib.mapAttrs (
registryName: _registry: (data.${registryName}.${host} or { }).${entry} or null
) definitions
);
};
collected = withOperations (collectData {
inherit hosts;
});
}

View File

@@ -1,38 +0,0 @@
{ lib }:
let
checkType = lib.types.submodule {
options = {
assertion = lib.mkOption {
type = lib.types.functionTo lib.types.bool;
description = ''
Tests the registry's flattened entries.
'';
};
message = lib.mkOption {
type = lib.types.str;
description = ''
Describes the validation failure.
'';
};
};
};
in
{
options = {
type = lib.mkOption {
type = lib.types.raw;
description = ''
Defines the NixOS option type for a registry entry.
'';
};
checks = lib.mkOption {
type = lib.types.listOf checkType;
default = [ ];
description = ''
Lists validation checks for the registry's flattened entries.
'';
};
};
}

Some files were not shown because too many files have changed in this diff Show More