Compare commits
110 Commits
addcf7ca8e
...
fix/waybar
| Author | SHA1 | Date | |
|---|---|---|---|
| c053846adb | |||
| 2edad6c5df | |||
| 1d5acc09c6 | |||
| ee29e53f7c | |||
| 3c9d0ec886 | |||
| f0c5544b10 | |||
| 4dd0a5a4f5 | |||
| a4a795f7ee | |||
| ecdf877308 | |||
| d1face10ec | |||
| 337d9a2014 | |||
| f059068873 | |||
| 7662c04cda | |||
| 33128fc3c7 | |||
| 68415ad47e | |||
| 6b7201e6fa | |||
| 4360f4863b | |||
| 370cab8f83 | |||
| e74a08b853 | |||
| 250b0b71f9 | |||
| 10299cec19 | |||
| 91654b3068 | |||
| be9c3c07a3 | |||
| a8bd4bf4cf | |||
| a2fc568704 | |||
| 0bf5ebae87 | |||
| fddec14128 | |||
| 85fadc5bfc | |||
| 3992030949 | |||
| c8a596aa39 | |||
| 4908c4751f | |||
| 79c56dfb1c | |||
| 7140b6d258 | |||
| f173887228 | |||
| 3a487050fd | |||
| 65c84f5b39 | |||
| b3c4903c82 | |||
| ec1c0fba56 | |||
| 1f2f1a79ad | |||
| 46a47fa061 | |||
| 8d357e960b | |||
| f48bac5ec1 | |||
| d53246caf0 | |||
| 92f7f0542d | |||
| 4f7fbef35b | |||
| c7c3257b9d | |||
| 7d37b92e17 | |||
| 9ece182099 | |||
| 9aea13b8c2 | |||
| 369c7512c6 | |||
| 8c91a210b2 | |||
| d602581a99 | |||
| c3a6271582 | |||
| a7db0b745c | |||
| 4a8e8920ea | |||
| 3e9295134e | |||
| c91094234f | |||
| 11b490d1e8 | |||
| 43b5d07a27 | |||
| 1b4dc77627 | |||
| 1cbdcee8ac | |||
| 3fbb0aca32 | |||
| 87776c8994 | |||
| 17597526c5 | |||
| 14c9a418b6 | |||
| 589e37f52e | |||
| 78492739f3 | |||
| 2265dfc558 | |||
| becbb50491 | |||
| 54335ead82 | |||
| 16c69e01e1 | |||
| 5af563d70f | |||
| 0e5731145e | |||
| b6469567e1 | |||
| 8f0837a4b5 | |||
| 0937f15260 | |||
| 61efef1a7d | |||
| 8eabdee2a0 | |||
| 43b3ff4b09 | |||
| 3b2d16743e | |||
| 11d11c7c02 | |||
| 869d6a2cbd | |||
| e5b76515f1 | |||
| 324d40c85d | |||
| dd82367dcb | |||
| ac5cc8d659 | |||
| 5e5c3e2534 | |||
| 09a2093f90 | |||
| 8000367613 | |||
| 38dff04ece | |||
| 0c1a1c1ffa | |||
| 27d9194ac3 | |||
| c4ea12b3ce | |||
| 16ce11fc52 | |||
| a73636a84f | |||
| 80db2b61e6 | |||
| 7cc066b55d | |||
| 74e7d2d211 | |||
| 98d734e977 | |||
| dda05559a5 | |||
| 594877c4ba | |||
| f9aff6839e | |||
| 4b7d3834ab | |||
| 213cd84642 | |||
| 6e19169e11 | |||
| be8744795c | |||
| ad00301ac9 | |||
| 87dc93513b | |||
| 58f2350d5f | |||
| 4b9beae501 |
@@ -17,6 +17,15 @@ 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.
|
||||
@@ -36,6 +45,7 @@ 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.
|
||||
|
||||
|
||||
1
.pi/skills/craft-skill
Symbolic link
1
.pi/skills/craft-skill
Symbolic link
@@ -0,0 +1 @@
|
||||
../../src/skills/craft-skill
|
||||
12
AGENTS.md
12
AGENTS.md
@@ -96,11 +96,22 @@
|
||||
- `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.
|
||||
@@ -114,3 +125,4 @@
|
||||
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
24
Cargo.lock
generated
@@ -190,6 +190,18 @@ 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"
|
||||
@@ -393,18 +405,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xatu"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"googletest",
|
||||
"monorepo",
|
||||
"rnix",
|
||||
"rowan",
|
||||
"status",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
|
||||
@@ -4,7 +4,7 @@ members = [
|
||||
"src/machoke",
|
||||
"src/monorepo",
|
||||
"src/status",
|
||||
"src/xatu",
|
||||
"src/natu",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
|
||||
@@ -14,4 +14,5 @@ rust = use_extension("//bazel:rust.bzl", "rust")
|
||||
use_repo(rust, "rust")
|
||||
|
||||
register_toolchains("@python//:python")
|
||||
|
||||
register_toolchains("@rust//:rust")
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
## Desktop
|
||||
|
||||
- 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`.
|
||||
- When adding a graphical application, register its workspace icon in `registry.window-icon`.
|
||||
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,6 +40,8 @@
|
||||
|
||||
## 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.
|
||||
@@ -50,8 +52,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 and activates an imported closure on the target.
|
||||
Do not rely on a target-local monorepo checkout for remote deployment.
|
||||
- `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.
|
||||
- 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.
|
||||
@@ -59,3 +61,7 @@
|
||||
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.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.guests.actualbudget;
|
||||
cfg = config.guests.actualbudget; # NOLINT(let_order)
|
||||
dataDir = "/data";
|
||||
in
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.guests.copyparty;
|
||||
cfg = config.guests.copyparty; # NOLINT(let_order)
|
||||
|
||||
volumeType = lib.types.submodule {
|
||||
options = {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.guests.homepage;
|
||||
cfg = config.guests.homepage; # NOLINT(let_order)
|
||||
environmentFile = "/run/secrets/homepage.env";
|
||||
in
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.guests.palworld;
|
||||
cfg = config.guests.palworld; # NOLINT(let_order)
|
||||
steamDir = "/data/steamcmd";
|
||||
installDir = "/data/server";
|
||||
stateDir = "/data/state";
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.guests.valheim;
|
||||
cfg = config.guests.valheim; # NOLINT(let_order)
|
||||
steamDir = "/data/steamcmd";
|
||||
installDir = "/data/server";
|
||||
stateDir = "/data/state";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
inputs,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
# neogaia — Dell XPS 13 9380 laptop.
|
||||
@@ -13,10 +13,9 @@
|
||||
./disk.nix
|
||||
];
|
||||
|
||||
boot.kernelPackages = pkgs.linuxPackages_cachyos;
|
||||
modules.host.cachyos.enable = true;
|
||||
|
||||
# So wifi can be joined from the console.
|
||||
networking.networkmanager.enable = true;
|
||||
modules.network.enable = true;
|
||||
|
||||
registry.address.neogaia.self = "10.23.50.146";
|
||||
|
||||
@@ -28,15 +27,9 @@
|
||||
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;
|
||||
@@ -47,4 +40,23 @@
|
||||
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;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
1
configs/nixos/hosts/neogaia/ssh_user_ed25519_key.pub
Normal file
1
configs/nixos/hosts/neogaia/ssh_user_ed25519_key.pub
Normal file
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia
|
||||
@@ -15,52 +15,15 @@
|
||||
|
||||
hardware.cpu.intel.updateMicrocode = true;
|
||||
|
||||
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";
|
||||
};
|
||||
};
|
||||
modules.network.vlans = {
|
||||
interface = "enp2s0";
|
||||
ids = [ 20 ];
|
||||
};
|
||||
networking.useDHCP = false;
|
||||
|
||||
modules.network.vlans.ids = [ 20 ];
|
||||
|
||||
boot.zfs.forceImportRoot = false;
|
||||
|
||||
modules.zfs = {
|
||||
modules.host.zfs = {
|
||||
enable = true;
|
||||
hostId = "2346edbd";
|
||||
pools.pikachu = { };
|
||||
};
|
||||
|
||||
@@ -78,7 +41,8 @@
|
||||
enable = true;
|
||||
route = {
|
||||
domain = "valheim.alexion.dev";
|
||||
udp.ports = [
|
||||
protocol = "udp";
|
||||
ports = [
|
||||
config.guests.valheim.port
|
||||
(config.guests.valheim.port + 1)
|
||||
];
|
||||
@@ -101,7 +65,8 @@
|
||||
enable = true;
|
||||
route = {
|
||||
domain = "palworld.alexion.dev";
|
||||
udp.ports = [ config.guests.palworld.port ];
|
||||
protocol = "udp";
|
||||
ports = [ config.guests.palworld.port ];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -116,6 +81,7 @@
|
||||
enable = true;
|
||||
route = {
|
||||
domain = "budget.alexion.dev";
|
||||
protocol = "https";
|
||||
port = 5006;
|
||||
};
|
||||
};
|
||||
@@ -132,6 +98,7 @@
|
||||
enable = true;
|
||||
route = {
|
||||
domain = "music.alexion.dev";
|
||||
protocol = "https";
|
||||
port = 4533;
|
||||
};
|
||||
};
|
||||
@@ -152,7 +119,8 @@
|
||||
enable = true;
|
||||
route = {
|
||||
domain = "home";
|
||||
http.port = 8082;
|
||||
protocol = "http";
|
||||
port = 8082;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -167,7 +135,9 @@
|
||||
enable = true;
|
||||
route = {
|
||||
domain = "files.alexion.dev";
|
||||
protocol = "https";
|
||||
port = 3923;
|
||||
clientMaxBodySize = "1g";
|
||||
};
|
||||
trustedSources = [ "${registry.address.${config.networking.hostName}."reverse-proxy"}/32" ];
|
||||
};
|
||||
@@ -180,7 +150,7 @@
|
||||
};
|
||||
"/inbox" = {
|
||||
hostPath = "/pikachu/__inbox";
|
||||
path = "/srv/inbox";
|
||||
path = "/pikachu/__inbox";
|
||||
access.A = [ "alexion" ];
|
||||
flags = [
|
||||
"d2d"
|
||||
@@ -190,7 +160,7 @@
|
||||
};
|
||||
"/media" = {
|
||||
hostPath = "/pikachu/media";
|
||||
path = "/srv/media";
|
||||
path = "/pikachu/media";
|
||||
access.A = [ "alexion" ];
|
||||
flags = [
|
||||
"d2d"
|
||||
@@ -198,6 +168,13 @@
|
||||
"dthumb"
|
||||
];
|
||||
};
|
||||
"/opds" = {
|
||||
hostPath = "/pikachu/data/opds";
|
||||
path = "/pikachu/data/opds";
|
||||
access.r = [ "alexion" ];
|
||||
readOnly = true;
|
||||
flags = [ "opds" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
1
configs/nixos/hosts/pikachu/ssh_user_ed25519_key.pub
Normal file
1
configs/nixos/hosts/pikachu/ssh_user_ed25519_key.pub
Normal file
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINNqJIC6VRXyvrNf3n9su9KdPCikC3CjK/QrCK2reHdB alexion@pikachu
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# zeus — Ryzen desktop with AMD graphics.
|
||||
@@ -15,14 +14,19 @@
|
||||
|
||||
nixpkgs.hostPlatform = "x86_64-linux";
|
||||
|
||||
boot.kernelPackages = pkgs.linuxPackages_cachyos;
|
||||
modules.host.cachyos.enable = true;
|
||||
|
||||
hardware.graphics = {
|
||||
enable = true;
|
||||
enable32Bit = true;
|
||||
};
|
||||
|
||||
networking.networkmanager.enable = true;
|
||||
modules.network.enable = true;
|
||||
|
||||
services.logind.settings.Login = {
|
||||
HandlePowerKey = "ignore";
|
||||
HandlePowerKeyLongPress = "ignore";
|
||||
};
|
||||
|
||||
registry.address.zeus.self = "10.23.50.221";
|
||||
|
||||
@@ -39,13 +43,25 @@
|
||||
|
||||
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 = [
|
||||
"HDMI-A-1,1920x1080@120,0x0,1,transform,3"
|
||||
"DP-3,2560x1440@239.96,1080x0,1"
|
||||
{
|
||||
output = "HDMI-A-1";
|
||||
mode = "1920x1080@120";
|
||||
position = "-1080x0";
|
||||
scale = 1;
|
||||
transform = 3;
|
||||
}
|
||||
{
|
||||
output = "DP-3";
|
||||
mode = "2560x1440@239.96";
|
||||
position = "0x0";
|
||||
scale = 1;
|
||||
}
|
||||
];
|
||||
|
||||
}
|
||||
|
||||
1
configs/nixos/hosts/zeus/ssh_user_ed25519_key.pub
Normal file
1
configs/nixos/hosts/zeus/ssh_user_ed25519_key.pub
Normal file
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIRBcHHuubptXCdvED/p2dR3MpjGN/AimqLasLY5984G alexion@zeus
|
||||
@@ -1,14 +1,10 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
# The shared foundation for every host and guest.
|
||||
let
|
||||
stateVersion = "26.05";
|
||||
in
|
||||
{
|
||||
imports = [ inputs.home-manager.nixosModules.home-manager ];
|
||||
|
||||
@@ -17,14 +13,14 @@ in
|
||||
type = lib.types.str;
|
||||
default = "alexion";
|
||||
description = ''
|
||||
Primary interactive user for this system.
|
||||
This drives the system account and Home Manager user together.
|
||||
Names the primary user account.
|
||||
Host configuration uses this name for the Home Manager user.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = {
|
||||
system.stateVersion = stateVersion;
|
||||
system.stateVersion = "26.05";
|
||||
|
||||
nixpkgs.config.allowUnfree = true;
|
||||
nix.settings.experimental-features = [
|
||||
@@ -42,66 +38,7 @@ in
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
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 ];
|
||||
};
|
||||
}
|
||||
@@ -3,13 +3,12 @@
|
||||
inputs,
|
||||
lib,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
# Firefox as the desktop browser: stock mainline, hardened and de-monetized by policy.
|
||||
# Configures Firefox as the desktop browser.
|
||||
let
|
||||
cfg = config.modules.desktop.apps.firefox;
|
||||
user = config.user.name;
|
||||
firefoxAddons = inputs.firefox-addons.packages.${pkgs.stdenv.hostPlatform.system};
|
||||
cfg = config.modules.desktop.apps.firefox; # NOLINT(let_order)
|
||||
in
|
||||
{
|
||||
options.modules.desktop.apps.firefox = {
|
||||
@@ -19,19 +18,25 @@ in
|
||||
type = lib.types.str;
|
||||
default = "1.0";
|
||||
description = ''
|
||||
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.
|
||||
Scales Firefox UI and page content.
|
||||
|
||||
Sets `layout.css.devPixelsPerPx`.
|
||||
Use a larger value for high-DPI displays.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user} = hm: {
|
||||
registry.window-icon.${config.networking.hostName}.firefox = {
|
||||
class = ".*[Ff]irefox.*";
|
||||
icon = util.glyph "f269";
|
||||
};
|
||||
|
||||
home-manager.users.${config.user.name} = hm: {
|
||||
programs.firefox = {
|
||||
enable = true;
|
||||
|
||||
# Enterprise policies: enforced and unchangeable from the browser UI.
|
||||
# Applies policies that users cannot change in Firefox.
|
||||
policies = {
|
||||
DisableTelemetry = true;
|
||||
DisableFirefoxStudies = true;
|
||||
@@ -40,7 +45,7 @@ in
|
||||
DontCheckDefaultBrowser = true;
|
||||
DisableFirefoxAccounts = true;
|
||||
|
||||
# Strip the monetized surfaces from the new-tab page.
|
||||
# Removes sponsored content from the new-tab page.
|
||||
FirefoxHome = {
|
||||
SponsoredTopSites = false;
|
||||
SponsoredPocket = false;
|
||||
@@ -49,23 +54,19 @@ in
|
||||
};
|
||||
|
||||
profiles.default = {
|
||||
isDefault = true;
|
||||
|
||||
extensions = {
|
||||
packages = with firefoxAddons; [
|
||||
packages = with inputs.firefox-addons.packages.${pkgs.stdenv.hostPlatform.system}; [
|
||||
ublock-origin
|
||||
proton-pass
|
||||
sponsorblock
|
||||
];
|
||||
|
||||
# The Nord chrome theme is a declared extension setting, so home-manager
|
||||
# owns the extension-settings store, overwriting runtime changes to it.
|
||||
# Home Manager overwrites Nord theme changes made in Firefox.
|
||||
force = true;
|
||||
};
|
||||
|
||||
# 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.
|
||||
# Stylix uses a near-white selection background.
|
||||
# Use the Nord selection color because its text stays readable.
|
||||
extensions.settings."FirefoxColor@mozilla.com".settings.theme.colors.popup_highlight =
|
||||
let
|
||||
c = hm.config.lib.stylix.colors;
|
||||
@@ -77,32 +78,26 @@ 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;
|
||||
|
||||
# Auto-enable the sideloaded Firefox Color add-on carrying the Nord
|
||||
# chrome theme, which Firefox otherwise leaves disabled.
|
||||
# Enables Firefox Color after Firefox sideloads the add-on.
|
||||
"extensions.autoDisableScopes" = 0;
|
||||
|
||||
# Sponsored surfaces the policies above do not reach.
|
||||
# Removes sponsored content outside Firefox policies.
|
||||
"browser.urlbar.suggest.quicksuggest.sponsored" = false;
|
||||
"browser.newtabpage.activity-stream.showSponsored" = false;
|
||||
"browser.newtabpage.activity-stream.showSponsoredTopSites" = false;
|
||||
};
|
||||
|
||||
search = {
|
||||
# Declaring search overwrites Firefox's own engine list wholesale,
|
||||
# so engines added later in the UI do not survive a rebuild.
|
||||
# Home Manager replaces the engine list when this setting is enabled.
|
||||
# Engines added in Firefox do not survive a rebuild.
|
||||
force = true;
|
||||
default = "ddg";
|
||||
|
||||
# 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.
|
||||
# Hides commercial search engines.
|
||||
# Bangs still reach these engines.
|
||||
# Entries with only `metaData` configure Firefox built-in engines.
|
||||
engines = {
|
||||
google.metaData.hidden = true;
|
||||
bing.metaData.hidden = true;
|
||||
@@ -114,8 +109,7 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
# Nord chrome for the one profile, applied through the Stylix-managed
|
||||
# Firefox Color add-on that colorTheme enables.
|
||||
# Applies the Nord theme through Stylix's Firefox Color add-on.
|
||||
stylix.targets.firefox = {
|
||||
enable = true;
|
||||
profileNames = [ "default" ];
|
||||
@@ -133,8 +127,8 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
# 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.
|
||||
# Firefox creates `profiles.ini` on its first launch.
|
||||
# Force replacement during Home Manager activation.
|
||||
home.file."${hm.config.programs.firefox.configPath}/profiles.ini".force = true;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,17 +2,30 @@
|
||||
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 cfg.enable {
|
||||
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";
|
||||
};
|
||||
|
||||
home-manager.users.${user}.home.packages = [ pkgs.ghostty ];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
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 cfg.enable {
|
||||
config = lib.mkIf config.modules.desktop.apps.obsidian.enable {
|
||||
home-manager.users.${user}.home.packages = [ pkgs.obsidian ];
|
||||
|
||||
registry.window-icon.${config.networking.hostName}.obsidian = {
|
||||
class = "obsidian";
|
||||
icon = util.glyph "f02d";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
18
configs/nixos/modules/desktop/apps/r2modman.nix
Normal file
18
configs/nixos/modules/desktop/apps/r2modman.nix
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
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 ];
|
||||
};
|
||||
}
|
||||
@@ -2,17 +2,22 @@
|
||||
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 cfg.enable {
|
||||
config = lib.mkIf config.modules.desktop.apps.signal.enable {
|
||||
home-manager.users.${user}.home.packages = [ pkgs.signal-desktop ];
|
||||
|
||||
registry.window-icon.${config.networking.hostName}.signal = {
|
||||
class = "[Ss]ignal";
|
||||
icon = util.glyph "f4ad";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,16 +2,14 @@
|
||||
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 cfg.enable {
|
||||
config = lib.mkIf config.modules.desktop.apps.steam.enable {
|
||||
programs.gamemode.enable = true;
|
||||
|
||||
programs.steam = {
|
||||
@@ -26,5 +24,10 @@ in
|
||||
environment.systemPackages = [
|
||||
pkgs.gamescope
|
||||
];
|
||||
|
||||
registry.window-icon.${config.networking.hostName}.steam = {
|
||||
class = "[Ss]team";
|
||||
icon = util.glyph "f1b6";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
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 cfg.enable {
|
||||
config = lib.mkIf config.modules.desktop.apps.vencord.enable {
|
||||
home-manager.users.${user}.home.packages = [ pkgs.vesktop ];
|
||||
|
||||
registry.window-icon.${config.networking.hostName}.vesktop = {
|
||||
class = "[Vv]esktop";
|
||||
icon = util.glyph "f1ff";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
{ 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 cfg.enable {
|
||||
config = lib.mkIf config.modules.desktop.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;
|
||||
@@ -26,5 +23,6 @@ in
|
||||
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";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,41 +1,15 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
# 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";
|
||||
|
||||
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 config.modules.desktop.session.hyprland.enable {
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# This program integration owns the session, portals, and polkit, launched
|
||||
# through the universal Wayland session manager.
|
||||
programs.hyprland = {
|
||||
@@ -43,7 +17,7 @@ in
|
||||
withUWSM = true;
|
||||
};
|
||||
|
||||
home-manager.users.${user}.wayland.windowManager.hyprland = {
|
||||
home-manager.users.${config.user.name}.wayland.windowManager.hyprland = {
|
||||
enable = true;
|
||||
|
||||
# One package drives the whole session, so there is never a version split.
|
||||
@@ -55,135 +29,216 @@ in
|
||||
# uwsm owns the systemd graphical-session targets.
|
||||
systemd.enable = false;
|
||||
|
||||
# Write the native hyprlang hyprland.conf, whose variable and bind syntax
|
||||
# the settings below are expressed in.
|
||||
configType = "hyprlang";
|
||||
# Write the native Lua Hyprland config. Home Manager renders the Nix
|
||||
# settings below as hl.* calls.
|
||||
configType = "lua";
|
||||
|
||||
settings = {
|
||||
"$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;
|
||||
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;
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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"
|
||||
env = lib.mapAttrsToList (name: value: {
|
||||
_args = [
|
||||
name
|
||||
value
|
||||
];
|
||||
}) (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";
|
||||
};
|
||||
|
||||
dwindle = {
|
||||
preserve_split = true;
|
||||
hyprland-toggle-fullscreen = {
|
||||
keys = "SUPER + F";
|
||||
action.kind = "toggle-fullscreen";
|
||||
};
|
||||
|
||||
bind = [
|
||||
"$mod, Return, exec, $terminal"
|
||||
hyprland-close-window = {
|
||||
keys = "SUPER + SHIFT + Q";
|
||||
action.kind = "close-window";
|
||||
};
|
||||
}
|
||||
];
|
||||
|
||||
# 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"
|
||||
];
|
||||
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);
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,62 +5,34 @@
|
||||
...
|
||||
}:
|
||||
# hypridle: idle-triggered locking and display power management.
|
||||
let
|
||||
cfg = config.modules.desktop.session.hyprland.hypridle;
|
||||
user = config.user.name;
|
||||
|
||||
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";
|
||||
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 {
|
||||
config = lib.mkIf config.modules.desktop.session.hyprland.hypridle.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.${user}.services.hypridle = {
|
||||
home-manager.users.${config.user.name}.services.hypridle = {
|
||||
enable = true;
|
||||
|
||||
settings = {
|
||||
general = {
|
||||
lock_cmd = lockCmd;
|
||||
# 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}";
|
||||
before_sleep_cmd = "loginctl lock-session";
|
||||
# Waking restores the displays the screen-off listener may have cut.
|
||||
after_sleep_cmd = "${hyprctl} dispatch dpms on";
|
||||
# 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\" })'";
|
||||
};
|
||||
|
||||
listener = [
|
||||
# Lock on idle.
|
||||
{
|
||||
timeout = cfg.lockTimeout;
|
||||
timeout = 300;
|
||||
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";
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,45 +2,54 @@
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
# The lock screen: a hyprlock session-lock surface the compositor owns.
|
||||
let
|
||||
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"'';
|
||||
sleepIcon = util.glyph [
|
||||
"db81"
|
||||
"dcb2"
|
||||
];
|
||||
sleepOffIcon = util.glyph [
|
||||
"db81"
|
||||
"dcb3"
|
||||
];
|
||||
|
||||
idleLockToggle = pkgs.writeShellScript "lockscreen-idle-inhibit-toggle" ''
|
||||
unit=lockscreen-idle-inhibit.service
|
||||
|
||||
refresh_waybar() {
|
||||
${pkgs.procps}/bin/pkill -RTMIN+9 waybar >/dev/null 2>&1 || true
|
||||
${lib.getExe' pkgs.procps "pkill"} -RTMIN+9 waybar >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
if ${pkgs.systemd}/bin/systemctl --user is-active --quiet "$unit"; then
|
||||
${pkgs.systemd}/bin/systemctl --user stop "$unit"
|
||||
if ${lib.getExe' pkgs.systemd "systemctl"} --user is-active --quiet "$unit"; then
|
||||
${lib.getExe' pkgs.systemd "systemctl"} --user stop "$unit"
|
||||
refresh_waybar
|
||||
${pkgs.swayosd}/bin/swayosd-client --custom-message "${sleepIcon}"
|
||||
${lib.getExe' pkgs.swayosd "swayosd-client"} --custom-message "${sleepIcon}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ${pkgs.systemd}/bin/systemd-run \
|
||||
if ${lib.getExe' pkgs.systemd "systemd-run"} \
|
||||
--user \
|
||||
--collect \
|
||||
--unit=lockscreen-idle-inhibit \
|
||||
--description="Disable idle-triggered screen locking" \
|
||||
${pkgs.systemd}/bin/systemd-inhibit \
|
||||
${lib.getExe' pkgs.systemd "systemd-inhibit"} \
|
||||
--what=idle \
|
||||
--who=lockscreen-hotkey \
|
||||
--why="lock screen disabled by hotkey" \
|
||||
${pkgs.coreutils}/bin/sleep infinity >/dev/null; then
|
||||
${lib.getExe' pkgs.coreutils "sleep"} infinity >/dev/null; then
|
||||
refresh_waybar
|
||||
${pkgs.swayosd}/bin/swayosd-client --custom-message "${sleepOffIcon}"
|
||||
${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'
|
||||
fi
|
||||
'';
|
||||
in
|
||||
@@ -48,8 +57,8 @@ in
|
||||
options.modules.desktop.session.hyprland.hyprlock.enable =
|
||||
lib.mkEnableOption "the hyprlock lock screen";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user} = {
|
||||
config = lib.mkIf config.modules.desktop.session.hyprland.hyprlock.enable {
|
||||
home-manager.users.${config.user.name} = {
|
||||
# Colors and the lock-screen background come from Stylix's hyprlock target,
|
||||
# so only geometry and behaviour are set here.
|
||||
programs.hyprlock = {
|
||||
@@ -82,14 +91,36 @@ in
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# 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}"
|
||||
];
|
||||
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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,19 +6,20 @@
|
||||
}:
|
||||
# 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 cfg.enable {
|
||||
config = lib.mkIf config.modules.desktop.session.login.enable {
|
||||
services.greetd = {
|
||||
enable = true;
|
||||
settings.default_session.command =
|
||||
"${lib.getExe pkgs.tuigreet} --time --remember "
|
||||
+ "--cmd 'uwsm start -e -D Hyprland hyprland.desktop'";
|
||||
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"
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,20 +2,29 @@
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
# mako as the notification daemon: toasts, a do-not-disturb mode, and history recall.
|
||||
let
|
||||
cfg = config.modules.desktop.session.mako;
|
||||
user = config.user.name;
|
||||
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
|
||||
'';
|
||||
|
||||
makoctl = "${pkgs.mako}/bin/makoctl";
|
||||
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
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.modules.desktop.session.mako.enable = lib.mkEnableOption "the mako notification daemon";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user} = {
|
||||
config = lib.mkIf config.modules.desktop.session.mako.enable {
|
||||
home-manager.users.${config.user.name} = {
|
||||
# Colors and the popup font come from Stylix's mako target, so only
|
||||
# behaviour is set here.
|
||||
services.mako = {
|
||||
@@ -32,13 +41,25 @@ in
|
||||
"mode=dnd".invisible = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# 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"
|
||||
];
|
||||
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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
{ 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 cfg.enable {
|
||||
config = lib.mkIf config.modules.desktop.session.portals.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
|
||||
|
||||
@@ -6,31 +6,29 @@
|
||||
}:
|
||||
# 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' \
|
||||
| ${pkgs.rofi}/bin/rofi -dmenu -i -p Power)
|
||||
| ${lib.getExe pkgs.rofi} -dmenu -i -p Power)
|
||||
case "$chosen" in
|
||||
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 ;;
|
||||
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 ;;
|
||||
esac
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.modules.desktop.session.rofi.enable = lib.mkEnableOption "the rofi launcher";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user} = {
|
||||
# No theme is set here: Stylix's rofi target supplies the Nord one.
|
||||
config = lib.mkIf config.modules.desktop.session.rofi.enable {
|
||||
home-manager.users.${config.user.name} = hm: {
|
||||
# Stylix supplies the Nord theme.
|
||||
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.
|
||||
@@ -39,14 +37,31 @@ 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";
|
||||
};
|
||||
};
|
||||
|
||||
# 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}"
|
||||
];
|
||||
rofi-power-menu = {
|
||||
keys = "SUPER + SHIFT + X";
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = "${powerMenu}";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,165 +6,56 @@
|
||||
}:
|
||||
# Waybar: a top status bar reading system state at a glance.
|
||||
let
|
||||
cfg = config.modules.desktop.session.waybar;
|
||||
vpnClient = config.modules.network.vpn-client;
|
||||
user = config.user.name;
|
||||
moduleNames =
|
||||
position:
|
||||
map (entry: entry.name) (
|
||||
lib.sort (left: right: left.module.order < right.module.order) (
|
||||
lib.filter (entry: entry.module.position == position) moduleEntries
|
||||
)
|
||||
);
|
||||
|
||||
# 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"'';
|
||||
moduleEntries = lib.mapAttrsToList (name: module: {
|
||||
inherit name module;
|
||||
}) modules;
|
||||
|
||||
# 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 }'
|
||||
'';
|
||||
modules = lib.attrByPath [ config.networking.hostName ] { } config.registry.waybar;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.session.waybar.enable = lib.mkEnableOption "the Waybar status bar";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
config = lib.mkIf config.modules.desktop.session.waybar.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.${user}.programs.waybar = {
|
||||
home-manager.users.${config.user.name}.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;
|
||||
|
||||
style = ''
|
||||
.modules-right {
|
||||
margin-right: 8px;
|
||||
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";
|
||||
}
|
||||
(lib.mapAttrs (_name: module: module.settings) modules)
|
||||
];
|
||||
};
|
||||
|
||||
#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 = {
|
||||
registry.waybar.${config.networking.hostName} = {
|
||||
clock = {
|
||||
position = "center";
|
||||
order = 10;
|
||||
settings = {
|
||||
format = "{:%a %d %b %H:%M}";
|
||||
tooltip-format = "<tt>{calendar}</tt>";
|
||||
calendar = {
|
||||
@@ -173,77 +64,6 @@ 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;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
{ config, lib, ... }:
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
# PipeWire as the desktop audio server.
|
||||
let
|
||||
cfg = config.modules.desktop.utilities.audio;
|
||||
mediaKey = keys: command: {
|
||||
inherit keys;
|
||||
locked = true;
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = "${lib.getExe pkgs.playerctl} ${command}";
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
options.modules.desktop.utilities.audio.enable = lib.mkEnableOption "the PipeWire audio server";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
config = lib.mkIf config.modules.desktop.utilities.audio.enable {
|
||||
# Realtime scheduling for the audio threads, so playback survives load.
|
||||
security.rtkit.enable = true;
|
||||
|
||||
@@ -15,5 +28,39 @@ 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";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,30 +6,23 @@
|
||||
}:
|
||||
# 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" ''
|
||||
${cliphist} list \
|
||||
| ${rofi} -dmenu -i -p Clipboard \
|
||||
| ${cliphist} decode \
|
||||
| ${wl-copy}
|
||||
${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"}
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.modules.desktop.utilities.clipboard.enable =
|
||||
lib.mkEnableOption "cliphist clipboard history";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user} = {
|
||||
config = lib.mkIf config.modules.desktop.utilities.clipboard.enable {
|
||||
home-manager.users.${config.user.name} = {
|
||||
# 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
|
||||
@@ -39,11 +32,14 @@ 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;
|
||||
};
|
||||
|
||||
# $mod is defined by the compositor config these binds share.
|
||||
wayland.windowManager.hyprland.settings.bind = [
|
||||
"$mod SHIFT, V, exec, ${picker}"
|
||||
];
|
||||
registry.keybind.${config.networking.hostName}.clipboard-history = {
|
||||
keys = "SUPER + SHIFT + V";
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = "${picker}";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,14 +6,60 @@
|
||||
}:
|
||||
# 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 cfg.enable {
|
||||
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";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = [ pkgs.swayosd ];
|
||||
|
||||
# The udev rule chgrps each backlight's brightness node to `video` and adds
|
||||
|
||||
@@ -2,22 +2,11 @@
|
||||
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
|
||||
@@ -25,31 +14,51 @@ let
|
||||
# reports it saved.
|
||||
# No -a means no audio is captured.
|
||||
toggle = pkgs.writeShellScript "screen-record-toggle" ''
|
||||
if ${pgrep} -x wf-recorder >/dev/null; then
|
||||
${pkill} -INT -x wf-recorder
|
||||
if ${lib.getExe' pkgs.procps "pgrep"} -x wf-recorder >/dev/null; then
|
||||
${lib.getExe' pkgs.procps "pkill"} -INT -x wf-recorder
|
||||
exit 0
|
||||
fi
|
||||
|
||||
region=$(${slurp}) || exit 0
|
||||
dir="$(${xdgUserDir} VIDEOS)/Recordings"
|
||||
${mkdir} -p "$dir"
|
||||
file="$dir/recording-$(${date} +%Y%m%d-%H%M%S).mp4"
|
||||
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"
|
||||
|
||||
${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"
|
||||
${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
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.modules.desktop.utilities.recording.enable =
|
||||
lib.mkEnableOption "wf-recorder screen recording";
|
||||
|
||||
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}"
|
||||
];
|
||||
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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,25 +4,26 @@
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# Keyboard-driven screenshots that open in satty for annotation, then land in
|
||||
# both the clipboard and a dated file.
|
||||
# Keyboard-driven screenshots copy to the clipboard before Satty opens for
|
||||
# annotation. Satty copies annotated screenshots when it saves them.
|
||||
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";
|
||||
|
||||
# satty's copy action also saves, so one keystroke lands the shot in both the
|
||||
# clipboard and a file.
|
||||
# Copy the original screenshot before Satty opens.
|
||||
# Satty copies and saves annotated screenshots when the user presses Enter.
|
||||
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} \
|
||||
@@ -35,24 +36,43 @@ in
|
||||
options.modules.desktop.utilities.screenshot.enable =
|
||||
lib.mkEnableOption "screenshots via grimblast and satty";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
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"}";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
home-manager.users.${user} = {
|
||||
# satty is a one-shot annotation surface, so float it rather than letting
|
||||
# it claim a tile in the layout.
|
||||
# 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)$"
|
||||
wayland.windowManager.hyprland.settings.window_rule = [
|
||||
{
|
||||
match.class = "^(com\\.gabm\\.satty)$";
|
||||
float = true;
|
||||
}
|
||||
];
|
||||
|
||||
# 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"}"
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
}:
|
||||
# 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.
|
||||
@@ -16,12 +14,13 @@ 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 cfg.enable {
|
||||
config = lib.mkIf config.modules.desktop.utilities.theming.enable {
|
||||
stylix = {
|
||||
enable = true;
|
||||
|
||||
@@ -59,6 +58,10 @@ 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,
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
# 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 cfg.enable {
|
||||
config = lib.mkIf config.modules.desktop.utilities.userdirs.enable {
|
||||
# Write ~/.config/user-dirs.dirs from the option defaults.
|
||||
home-manager.users.${user}.xdg.userDirs.enable = true;
|
||||
};
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
cfg = config.modules.guest;
|
||||
in
|
||||
{
|
||||
options.modules.guest.enable = lib.mkEnableOption "nested-guest base configuration";
|
||||
options.modules.guest.enable = lib.mkEnableOption "nested guest configuration";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
modules.network.ssh.enable = lib.mkDefault true;
|
||||
config = lib.mkIf config.modules.guest.enable {
|
||||
modules.network.ssh.enable = lib.mkDefault false;
|
||||
modules.network.ssh.hostKeys.restore = lib.mkDefault false;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
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;
|
||||
};
|
||||
}
|
||||
18
configs/nixos/modules/host/cachyos.nix
Normal file
18
configs/nixos/modules/host/cachyos.nix
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
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="
|
||||
];
|
||||
};
|
||||
}
|
||||
106
configs/nixos/modules/host/default.nix
Normal file
106
configs/nixos/modules/host/default.nix
Normal file
@@ -0,0 +1,106 @@
|
||||
{
|
||||
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;
|
||||
};
|
||||
}
|
||||
57
configs/nixos/modules/host/zfs.nix
Normal file
57
configs/nixos/modules/host/zfs.nix
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
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
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -4,13 +4,10 @@
|
||||
...
|
||||
}:
|
||||
# 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 cfg.enable {
|
||||
config = lib.mkIf config.modules.monorepo.enable {
|
||||
modules.shell.bazel.enable = true;
|
||||
modules.shell.python.enable = true;
|
||||
modules.shell.rust.enable = true;
|
||||
|
||||
57
configs/nixos/modules/network/default.nix
Normal file
57
configs/nixos/modules/network/default.nix
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -4,344 +4,208 @@
|
||||
registry,
|
||||
...
|
||||
}:
|
||||
# SSH on this machine, in both directions.
|
||||
# Configures inbound and outbound SSH access.
|
||||
let
|
||||
cfg = config.modules.network.ssh;
|
||||
user = config.user.name;
|
||||
user = config.user.name; # NOLINT(let_order)
|
||||
|
||||
inherit (lib)
|
||||
concatLists
|
||||
concatStringsSep
|
||||
elem
|
||||
filter
|
||||
genAttrs
|
||||
hasAttr
|
||||
imap0
|
||||
listToAttrs
|
||||
mapAttrs
|
||||
mapAttrsToList
|
||||
mkIf
|
||||
mkMerge
|
||||
mkOption
|
||||
nameValuePair
|
||||
optional
|
||||
optionalAttrs
|
||||
types
|
||||
unique
|
||||
;
|
||||
# NOLINT(let_order)
|
||||
workstationNames = [
|
||||
"neogaia"
|
||||
"zeus"
|
||||
];
|
||||
serverNames = [ "pikachu" ];
|
||||
|
||||
hostKeySecret = type: "ssh-host-${type}-key";
|
||||
userKeySecret = "ssh-user-ed25519-key";
|
||||
isWorkstation = lib.elem config.networking.hostName workstationNames; # NOLINT(let_order)
|
||||
isServer = lib.elem config.networking.hostName serverNames; # NOLINT(let_order)
|
||||
|
||||
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)
|
||||
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")
|
||||
];
|
||||
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = config.user.name;
|
||||
description = ''
|
||||
The remote login name OpenSSH uses for this target.
|
||||
'';
|
||||
};
|
||||
# NOLINT(let_order)
|
||||
managedTargets = lib.genAttrs (workstationNames ++ serverNames) (name: {
|
||||
hostName = registry.address.${name}.self;
|
||||
clientKey = clientKeyFor name;
|
||||
hostKeys = hostKeysFor name;
|
||||
});
|
||||
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 22;
|
||||
description = ''
|
||||
The TCP port 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"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
aliases = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ name ];
|
||||
description = ''
|
||||
Host patterns written into the generated OpenSSH client block.
|
||||
'';
|
||||
};
|
||||
|
||||
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.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
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;
|
||||
# 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)
|
||||
|
||||
clientKeysFor = names: lib.filter (key: key != null) (map (name: targets.${name}.clientKey) names); # NOLINT(let_order)
|
||||
# NOLINT(let_order)
|
||||
defaultAuthorizedKeys = lib.flatten (
|
||||
clientKeysFor workstationNames ++ optional isServer (clientKeysFor serverNames)
|
||||
clientKeysFor workstationNames ++ lib.optional isServer (clientKeysFor serverNames)
|
||||
);
|
||||
|
||||
outboundTargetNames =
|
||||
outboundTargetNames = # NOLINT(let_order)
|
||||
let
|
||||
groupTargets =
|
||||
if isServer then
|
||||
[ "gitea" ] ++ cfg.hosts.servers
|
||||
builtins.attrNames extraTargets ++ serverNames
|
||||
else if isWorkstation then
|
||||
[ "gitea" ] ++ cfg.hosts.servers ++ cfg.hosts.workstations
|
||||
builtins.attrNames extraTargets ++ serverNames ++ workstationNames
|
||||
else
|
||||
[ "gitea" ];
|
||||
builtins.attrNames extraTargets;
|
||||
in
|
||||
filter (name: name != currentHost) (targetNamesIn groupTargets);
|
||||
lib.filter (name: name != config.networking.hostName) groupTargets;
|
||||
|
||||
sshSettingsFor =
|
||||
name:
|
||||
let
|
||||
target = cfg.targets.${name};
|
||||
in
|
||||
{
|
||||
header = "Host ${concatStringsSep " " target.aliases}";
|
||||
HostName = target.hostName;
|
||||
User = target.user;
|
||||
}
|
||||
// optionalAttrs (target.port != 22) { Port = target.port; };
|
||||
|
||||
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 (
|
||||
knownHosts = lib.listToAttrs (
|
||||
lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
targetName: target:
|
||||
imap0 (
|
||||
lib.imap0 (
|
||||
i: key:
|
||||
nameValuePair "${targetName}-${toString i}" {
|
||||
lib.nameValuePair "${targetName}-${toString i}" {
|
||||
hostNames = knownHostNamesFor target;
|
||||
publicKey = keyWithoutComment key;
|
||||
}
|
||||
) target.hostKeys
|
||||
) cfg.targets
|
||||
) 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));
|
||||
|
||||
sshSettingsFor =
|
||||
name:
|
||||
let
|
||||
target = targets.${name};
|
||||
in
|
||||
{
|
||||
header = "Host ${lib.concatStringsSep " " target.aliases}";
|
||||
HostName = target.hostName;
|
||||
User = target.user;
|
||||
}
|
||||
// lib.optionalAttrs (target.port != 22) { Port = target.port; };
|
||||
|
||||
hostKeySecret = type: "ssh-host-${type}-key";
|
||||
hostKeyTypes = [
|
||||
"ed25519"
|
||||
"rsa"
|
||||
];
|
||||
userKeySecret = "ssh-user-ed25519-key";
|
||||
|
||||
in
|
||||
{
|
||||
options.modules.network.ssh = {
|
||||
enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets";
|
||||
enable = lib.mkEnableOption "the OpenSSH daemon";
|
||||
|
||||
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;
|
||||
hostKeys.restore = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
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.
|
||||
Restores host keys from secrets.
|
||||
Preserves the fingerprint across reimages.
|
||||
Guests disable this option and generate their own host keys.
|
||||
'';
|
||||
};
|
||||
|
||||
hostKeys.sopsFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
hostKeys.sopsFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = ''
|
||||
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.
|
||||
Sets encrypted file that contains host private keys.
|
||||
Names each secret `ssh-host-<type>-key`.
|
||||
Requires a value when `restore` is enabled.
|
||||
'';
|
||||
};
|
||||
|
||||
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;
|
||||
userKey.sopsFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = ''
|
||||
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.
|
||||
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.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable (mkMerge [
|
||||
{
|
||||
services.openssh.enable = true;
|
||||
config = lib.mkIf cfg.enable (
|
||||
lib.mkMerge [
|
||||
{
|
||||
services.openssh.enable = true;
|
||||
|
||||
users.users.${user}.openssh.authorizedKeys.keys =
|
||||
if cfg.authorizedKeys != null then
|
||||
cfg.authorizedKeys
|
||||
else
|
||||
defaultAuthorizedKeys ++ cfg.extraAuthorizedKeys;
|
||||
users.users.${user}.openssh.authorizedKeys.keys = defaultAuthorizedKeys;
|
||||
|
||||
programs.ssh.knownHosts = knownHosts;
|
||||
programs.ssh.knownHosts = knownHosts;
|
||||
|
||||
home-manager.users.${user}.programs.ssh = {
|
||||
enable = true;
|
||||
enableDefaultConfig = false;
|
||||
settings =
|
||||
mapAttrs (name: _: sshSettingsFor name) (genAttrs outboundTargetNames (name: name))
|
||||
// cfg.extraSettings;
|
||||
};
|
||||
}
|
||||
home-manager.users.${user}.programs.ssh = {
|
||||
enable = true;
|
||||
enableDefaultConfig = false;
|
||||
settings = lib.mapAttrs (name: _: sshSettingsFor name) (
|
||||
lib.genAttrs outboundTargetNames (name: name)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
(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.";
|
||||
}
|
||||
];
|
||||
(lib.mkIf cfg.hostKeys.restore {
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.hostKeys.sopsFile != null;
|
||||
message = "Set `modules.network.ssh.hostKeys.sopsFile` when host-key restoration is enabled.";
|
||||
}
|
||||
];
|
||||
|
||||
sops.secrets = genAttrs (map hostKeySecret cfg.hostKeys.types) (_: {
|
||||
inherit (cfg.hostKeys) sopsFile;
|
||||
mode = "0400";
|
||||
restartUnits = [ "sshd.service" ];
|
||||
});
|
||||
sops.secrets = lib.genAttrs (map hostKeySecret hostKeyTypes) (_: {
|
||||
inherit (cfg.hostKeys) sopsFile;
|
||||
mode = "0400";
|
||||
restartUnits = [ "sshd.service" ];
|
||||
});
|
||||
|
||||
services.openssh.hostKeys = [ ];
|
||||
services.openssh.extraConfig = concatStringsSep "" (
|
||||
map (type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n") cfg.hostKeys.types
|
||||
);
|
||||
})
|
||||
services.openssh.hostKeys = [ ];
|
||||
services.openssh.extraConfig = lib.concatStringsSep "" (
|
||||
map (type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n") hostKeyTypes
|
||||
);
|
||||
})
|
||||
|
||||
(mkIf (cfg.userKey.sopsFile != null) {
|
||||
sops.secrets.${userKeySecret} = {
|
||||
inherit (cfg.userKey) sopsFile;
|
||||
mode = "0400";
|
||||
owner = user;
|
||||
};
|
||||
(lib.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;
|
||||
})
|
||||
]);
|
||||
home-manager.users.${user}.programs.ssh.settings."*" = {
|
||||
IdentityFile = config.sops.secrets.${userKeySecret}.path;
|
||||
IdentitiesOnly = true;
|
||||
};
|
||||
})
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,24 +4,17 @@
|
||||
util,
|
||||
...
|
||||
}:
|
||||
# The host networking foundation: per-VLAN bridges over a tagged trunk.
|
||||
# Configures bridges for tagged VLANs on a physical interface that uses DHCP.
|
||||
let
|
||||
cfg = config.modules.network.vlans;
|
||||
|
||||
inherit (util) bridgeName;
|
||||
|
||||
vlanName = id: "${cfg.trunk}.${toString id}";
|
||||
cfg = config.modules.network.vlans; # NOLINT(let_order)
|
||||
in
|
||||
{
|
||||
options.modules.network.vlans = {
|
||||
enable = lib.mkEnableOption "host VLAN bridges over a tagged trunk";
|
||||
|
||||
trunk = lib.mkOption {
|
||||
interface = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "enp1s0";
|
||||
description = ''
|
||||
Physical interface that carries 802.1Q-tagged traffic for every VLAN.
|
||||
Each declared VLAN attaches to its own bridge.
|
||||
Names the physical interface that carries untagged DHCP traffic and tagged VLANs.
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -34,67 +27,37 @@ in
|
||||
30
|
||||
];
|
||||
description = ''
|
||||
Tagged VLAN ids to materialize.
|
||||
Each id N creates one bridge named `br-vlanN`.
|
||||
Creates bridges for the specified tagged VLAN IDs.
|
||||
|
||||
VLAN ID N creates bridge `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.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;
|
||||
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;
|
||||
};
|
||||
|
||||
systemd.network = {
|
||||
enable = true;
|
||||
|
||||
netdevs = lib.mkMerge (
|
||||
map (id: {
|
||||
"40-${bridgeName id}" = {
|
||||
"40-${util.bridgeName id}" = {
|
||||
netdevConfig = {
|
||||
Name = bridgeName id;
|
||||
Name = util.bridgeName id;
|
||||
Kind = "bridge";
|
||||
};
|
||||
};
|
||||
"40-${vlanName id}" = {
|
||||
"40-vlan${toString id}" = {
|
||||
netdevConfig = {
|
||||
Name = vlanName id;
|
||||
Name = "vlan${toString id}";
|
||||
Kind = "vlan";
|
||||
};
|
||||
vlanConfig.Id = id;
|
||||
@@ -104,37 +67,25 @@ in
|
||||
|
||||
networks = lib.mkMerge (
|
||||
[
|
||||
# The trunk carries only tagged frames and takes no address.
|
||||
{
|
||||
"30-${cfg.trunk}" = {
|
||||
matchConfig.Name = cfg.trunk;
|
||||
networkConfig.LinkLocalAddressing = "no";
|
||||
linkConfig.RequiredForOnline = "no";
|
||||
vlan = map vlanName cfg.ids;
|
||||
"10-${cfg.interface}" = {
|
||||
matchConfig.Name = cfg.interface;
|
||||
networkConfig.DHCP = "yes";
|
||||
vlan = map (id: "vlan${toString id}") cfg.ids;
|
||||
linkConfig.RequiredForOnline = "routable";
|
||||
};
|
||||
}
|
||||
]
|
||||
++ map (id: {
|
||||
# Enslaved to its bridge, carrying no address itself.
|
||||
"40-${vlanName id}" = {
|
||||
matchConfig.Name = vlanName id;
|
||||
networkConfig.Bridge = bridgeName 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;
|
||||
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
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.modules.network.vpn-client;
|
||||
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 }'
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.modules.network.vpn-client = {
|
||||
@@ -13,61 +37,31 @@ in
|
||||
interface = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "wg-home";
|
||||
description = "WireGuard interface name.";
|
||||
description = "Names the WireGuard interface.";
|
||||
};
|
||||
|
||||
address = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "10.23.100.3/32";
|
||||
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.";
|
||||
description = "Sets the client tunnel address in CIDR notation.";
|
||||
};
|
||||
|
||||
sopsFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = "Encrypted secrets file that contains privateKeySecret.";
|
||||
description = "Names the encrypted file that contains the WireGuard private key.";
|
||||
};
|
||||
|
||||
peer = {
|
||||
publicKey = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "WireGuard public key for the remote peer.";
|
||||
description = "Sets the WireGuard public key for the remote peer.";
|
||||
};
|
||||
|
||||
endpoint = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "203.0.113.10:51820";
|
||||
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.";
|
||||
description = "Sets the WireGuard endpoint for the remote peer.";
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -76,30 +70,39 @@ in
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.sopsFile != null;
|
||||
message = "modules.network.vpn-client.sopsFile must name the encrypted secrets file containing the WireGuard private key.";
|
||||
message = "Set modules.network.vpn-client.sopsFile when the VPN client is enabled.";
|
||||
}
|
||||
];
|
||||
|
||||
sops.secrets.${cfg.privateKeySecret}.sopsFile = cfg.sopsFile;
|
||||
sops.secrets.wireguard-home-private-key.sopsFile = cfg.sopsFile;
|
||||
|
||||
systemd.targets."wireguard-${cfg.interface}" = lib.mkIf (!cfg.autoStart) {
|
||||
wantedBy = lib.mkForce [ ];
|
||||
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";
|
||||
};
|
||||
|
||||
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 ];
|
||||
inherit (cfg) mtu;
|
||||
privateKeyFile = config.sops.secrets.${cfg.privateKeySecret}.path;
|
||||
mtu = 1280;
|
||||
privateKeyFile = config.sops.secrets.wireguard-home-private-key.path;
|
||||
|
||||
peers = [
|
||||
(
|
||||
{
|
||||
inherit (cfg.peer) publicKey endpoint allowedIPs;
|
||||
}
|
||||
// lib.optionalAttrs (cfg.peer.persistentKeepalive != null) {
|
||||
inherit (cfg.peer) persistentKeepalive;
|
||||
}
|
||||
)
|
||||
{
|
||||
inherit (cfg.peer) publicKey endpoint;
|
||||
allowedIPs = [ "10.23.0.0/16" ];
|
||||
persistentKeepalive = 25;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.modules.services.actualbudget;
|
||||
cfg = config.modules.services.actualbudget; # NOLINT(let_order)
|
||||
in
|
||||
{
|
||||
options.modules.services.actualbudget = {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.modules.services.copyparty;
|
||||
cfg = config.modules.services.copyparty; # NOLINT(let_order)
|
||||
|
||||
volumeType = lib.types.submodule {
|
||||
options = {
|
||||
@@ -38,6 +38,31 @@ 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 =
|
||||
@@ -72,31 +97,6 @@ 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 = {
|
||||
|
||||
@@ -5,44 +5,31 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.modules.services.homepage;
|
||||
cfg = config.modules.services.homepage; # NOLINT(let_order)
|
||||
|
||||
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
|
||||
derivedAllowedHosts = lib.unique (
|
||||
lib.optional (selfRoute != null) selfRoute.domain
|
||||
++ lib.optionals (selfAddress != null) [
|
||||
selfAddress
|
||||
"${selfAddress}:${toString cfg.listenPort}"
|
||||
]
|
||||
);
|
||||
selfRoute = selfEntry.route or null;
|
||||
|
||||
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";
|
||||
selfEntry = registry.join cfg.hostName cfg.entryName;
|
||||
selfAddress = selfEntry.address or null;
|
||||
homepageServices = map mkTab (lib.filter (tab: lib.hasAttr tab entriesByTab) orderedTabs);
|
||||
|
||||
mkServiceAttrs =
|
||||
item:
|
||||
lib.filterAttrs (_: value: value != null) {
|
||||
href = mkHref item;
|
||||
inherit (item.homepage) description icon;
|
||||
mkTab =
|
||||
tab:
|
||||
let
|
||||
sections = entriesByTab.${tab};
|
||||
in
|
||||
{
|
||||
${tab} = map (section: { ${section} = sections.${section}; }) (orderedSections tab sections);
|
||||
};
|
||||
|
||||
sortedEntries = lib.sort (left: right: left.homepage.name < right.homepage.name) homepageEntries;
|
||||
entriesByTab = lib.foldl' insertEntry { } sortedEntries;
|
||||
|
||||
insertEntry =
|
||||
acc: item:
|
||||
@@ -55,7 +42,19 @@ let
|
||||
};
|
||||
};
|
||||
|
||||
entriesByTab = lib.foldl' insertEntry { } sortedEntries;
|
||||
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);
|
||||
|
||||
tabOrder = [
|
||||
"Apps"
|
||||
@@ -63,7 +62,30 @@ let
|
||||
"System"
|
||||
];
|
||||
|
||||
orderedTabs = tabOrder ++ lib.filter (tab: !lib.elem tab tabOrder) (lib.attrNames entriesByTab);
|
||||
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;
|
||||
};
|
||||
|
||||
sectionOrderByTab = {
|
||||
Apps = [
|
||||
@@ -91,28 +113,6 @@ 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 = {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.modules.services.navidrome;
|
||||
cfg = config.modules.services.navidrome; # NOLINT(let_order)
|
||||
in
|
||||
{
|
||||
options.modules.services.navidrome = {
|
||||
|
||||
@@ -5,63 +5,46 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.modules.services.reverse-proxy;
|
||||
|
||||
mkRegistryPath =
|
||||
registryName: host: entry:
|
||||
"registry.${registryName}.${host}.${entry}";
|
||||
generatedUdpStreams = lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
host: routes:
|
||||
lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
entry: route: lib.optionals (route.protocol == "udp") (map (mkUdpStream host entry) route.ports)
|
||||
) routes
|
||||
)
|
||||
) registry.route
|
||||
);
|
||||
|
||||
mkBackend = address: port: "http://${address}:${toString port}";
|
||||
|
||||
mkVirtualHost =
|
||||
host: entry: mode:
|
||||
mkUdpStream =
|
||||
host: entry: port:
|
||||
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;
|
||||
compositeEntry = registry.join host entry;
|
||||
in
|
||||
{
|
||||
inherit host entry;
|
||||
registryName = "route";
|
||||
name = route.domain;
|
||||
value = {
|
||||
forceSSL = !isHttp;
|
||||
enableACME = !isHttp;
|
||||
locations."/".proxyPass = mkBackend address port;
|
||||
};
|
||||
inherit
|
||||
host
|
||||
entry
|
||||
port
|
||||
address
|
||||
;
|
||||
};
|
||||
|
||||
generatedHttpsVirtualHostList = lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
host: routes:
|
||||
lib.mapAttrsToList (entry: _route: mkVirtualHost host entry "https") (
|
||||
lib.filterAttrs (_entry: route: route.port != null) routes
|
||||
)
|
||||
) registry.route
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
generatedVirtualHostList = generatedHttpsVirtualHostList ++ generatedHttpVirtualHostList;
|
||||
|
||||
virtualHostsByDomain = lib.foldl' (
|
||||
acc: virtualHost:
|
||||
acc
|
||||
// {
|
||||
${virtualHost.name} = (acc.${virtualHost.name} or [ ]) ++ [ virtualHost ];
|
||||
generatedStreamConfig = lib.concatMapStringsSep "\n" (stream: ''
|
||||
server {
|
||||
listen ${toString stream.port} udp;
|
||||
proxy_pass ${stream.address}:${toString stream.port};
|
||||
}
|
||||
) { } generatedVirtualHostList;
|
||||
'') generatedUdpStreams;
|
||||
generatedVirtualHosts =
|
||||
if duplicateDomainMessages != [ ] then
|
||||
throw (builtins.head duplicateDomainMessages)
|
||||
else
|
||||
lib.listToAttrs generatedVirtualHostList;
|
||||
|
||||
duplicateDomainMessages = lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
@@ -79,51 +62,72 @@ let
|
||||
) virtualHostsByDomain
|
||||
);
|
||||
|
||||
generatedVirtualHosts =
|
||||
if duplicateDomainMessages != [ ] then
|
||||
throw (builtins.head duplicateDomainMessages)
|
||||
else
|
||||
lib.listToAttrs generatedVirtualHostList;
|
||||
virtualHostsByDomain = lib.foldl' (
|
||||
acc: virtualHost:
|
||||
acc
|
||||
// {
|
||||
${virtualHost.name} = (acc.${virtualHost.name} or [ ]) ++ [ virtualHost ];
|
||||
}
|
||||
) { } generatedVirtualHostList;
|
||||
|
||||
mkUdpStream =
|
||||
host: entry: port:
|
||||
let
|
||||
compositeEntry = registry.join host entry;
|
||||
address =
|
||||
compositeEntry.address
|
||||
or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}");
|
||||
in
|
||||
{
|
||||
inherit
|
||||
host
|
||||
entry
|
||||
port
|
||||
address
|
||||
;
|
||||
};
|
||||
generatedVirtualHostList = generatedHttpsVirtualHostList ++ generatedHttpVirtualHostList;
|
||||
|
||||
generatedUdpStreams = lib.concatLists (
|
||||
generatedHttpsVirtualHostList = lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
host: routes:
|
||||
lib.concatLists (
|
||||
lib.mapAttrsToList (entry: route: map (mkUdpStream host entry) route.udp.ports) routes
|
||||
lib.mapAttrsToList (entry: _route: mkVirtualHost host entry "https") (
|
||||
lib.filterAttrs (_entry: route: route.protocol == "https") routes
|
||||
)
|
||||
) registry.route
|
||||
);
|
||||
|
||||
generatedStreamConfig = lib.concatMapStringsSep "\n" (stream: ''
|
||||
server {
|
||||
listen ${toString stream.port} udp;
|
||||
proxy_pass ${stream.address}:${toString stream.port};
|
||||
}
|
||||
'') generatedUdpStreams;
|
||||
mkVirtualHost =
|
||||
host: entry: mode:
|
||||
let
|
||||
isHttp = mode == "http";
|
||||
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;
|
||||
};
|
||||
};
|
||||
|
||||
mkBackend = address: port: "http://${address}:${toString port}";
|
||||
|
||||
generatedHttpVirtualHostList = lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
host: routes:
|
||||
lib.mapAttrsToList (entry: _route: mkVirtualHost host entry "http") (
|
||||
lib.filterAttrs (_entry: route: route.protocol == "http") routes
|
||||
)
|
||||
) registry.route
|
||||
);
|
||||
|
||||
mkRegistryPath =
|
||||
registryName: host: entry:
|
||||
"registry.${registryName}.${host}.${entry}";
|
||||
|
||||
in
|
||||
{
|
||||
options.modules.services.reverse-proxy = {
|
||||
enable = lib.mkEnableOption "the public edge reverse proxy";
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
config = lib.mkIf config.modules.services.reverse-proxy.enable {
|
||||
networking.firewall.allowedTCPPorts = [
|
||||
80
|
||||
443
|
||||
|
||||
@@ -80,10 +80,10 @@
|
||||
|
||||
(lib.mkIf config.modules.services.steamcmd.enable (
|
||||
let
|
||||
service = config.modules.services.steamcmd;
|
||||
user = "steamcmd";
|
||||
group = "storage";
|
||||
|
||||
service = config.modules.services.steamcmd;
|
||||
user = "steamcmd";
|
||||
prepareScript = pkgs.writeShellScript "steamcmd-${service.serviceName}-prepare" ''
|
||||
set -eu
|
||||
|
||||
|
||||
@@ -6,14 +6,13 @@
|
||||
}:
|
||||
# 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 cfg.enable {
|
||||
config = lib.mkIf config.modules.shell.bazel.enable {
|
||||
home-manager.users.${user}.home.packages = [ pkgs.bazel ];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,14 +5,13 @@
|
||||
}:
|
||||
# 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 cfg.enable {
|
||||
config = lib.mkIf config.modules.shell.direnv.enable {
|
||||
home-manager.users.${user}.programs.direnv = {
|
||||
enable = true;
|
||||
silent = true;
|
||||
|
||||
@@ -6,14 +6,10 @@
|
||||
}:
|
||||
# fish for the primary user, configured through home-manager.
|
||||
let
|
||||
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;
|
||||
cfg = config.modules.shell.fish; # NOLINT(let_order)
|
||||
commandsOf = shortcuts: lib.mapAttrs (_name: shortcut: shortcut.command) shortcuts;
|
||||
exportEnvVar = name: value: ''
|
||||
set -gx ${name} ${lib.escapeShellArg value}
|
||||
'';
|
||||
shortcuts = lib.attrByPath [ config.networking.hostName ] { } config.registry.shell-shortcut;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.shell.fish = {
|
||||
@@ -106,9 +102,7 @@ in
|
||||
};
|
||||
|
||||
# Rendered by home-manager into ~/.config/fish/config.fish.
|
||||
interactiveShellInit = lib.concatStringsSep "\n" (
|
||||
(lib.mapAttrsToList exportEnvVar envVars) ++ [ (builtins.readFile ./config.fish) ]
|
||||
);
|
||||
interactiveShellInit = builtins.readFile ./config.fish;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -80,6 +80,13 @@ 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
|
||||
|
||||
@@ -5,14 +5,13 @@
|
||||
}:
|
||||
# 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 cfg.enable {
|
||||
config = lib.mkIf config.modules.shell.git.enable {
|
||||
home-manager.users.${user}.programs.git = {
|
||||
enable = true;
|
||||
|
||||
|
||||
@@ -6,14 +6,13 @@
|
||||
}:
|
||||
# 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 cfg.enable {
|
||||
config = lib.mkIf config.modules.shell.gitea-axi.enable {
|
||||
home-manager.sharedModules = [ inputs.gitea-axi.homeModules.default ];
|
||||
home-manager.users.${user}.programs.gitea-axi.enable = true;
|
||||
};
|
||||
|
||||
@@ -6,14 +6,13 @@
|
||||
}:
|
||||
# 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 cfg.enable {
|
||||
config = lib.mkIf config.modules.shell.herdr.enable {
|
||||
registry.shell-shortcut.${config.networking.hostName}.hs = "herdr --session";
|
||||
|
||||
home-manager.users.${user} = lib.mkMerge [
|
||||
|
||||
@@ -7,14 +7,13 @@
|
||||
}:
|
||||
# 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 cfg.enable {
|
||||
config = lib.mkIf config.modules.shell.nvim.enable {
|
||||
registry.shell-shortcut.${config.networking.hostName} = {
|
||||
vi = "nvim";
|
||||
vim = "nvim";
|
||||
@@ -32,6 +31,7 @@ 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,6 +189,8 @@ in
|
||||
installRustc = false;
|
||||
installRustfmt = false;
|
||||
};
|
||||
|
||||
servers.nixd.enable = true;
|
||||
};
|
||||
|
||||
gitsigns = {
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
}:
|
||||
# Oh My Pi, installed alongside upstream Pi as the `omp` command.
|
||||
let
|
||||
cfg = config.modules.shell.omp;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
@@ -14,7 +13,7 @@ in
|
||||
enable = lib.mkEnableOption "Oh My Pi, a batteries-included fork of Pi";
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
config = lib.mkIf config.modules.shell.omp.enable {
|
||||
home-manager.users.${user}.home.packages = [ pkgs.omp ];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,28 +8,26 @@
|
||||
# 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 cfg.enable {
|
||||
config = lib.mkIf config.modules.shell.pi.enable {
|
||||
home-manager.users.${user} = {
|
||||
programs.pi-coding-agent = {
|
||||
enable = true;
|
||||
package = vendoredPi;
|
||||
package = pkgs.pi-coding-agent.overrideAttrs {
|
||||
src = ../../../../../third_party/pi;
|
||||
patches = [ ];
|
||||
};
|
||||
|
||||
settings = {
|
||||
defaultProvider = "openai-codex";
|
||||
defaultModel = "gpt-5.5";
|
||||
defaultModel = "gpt-5.6-terra";
|
||||
defaultThinkingLevel = "medium";
|
||||
theme = "dark";
|
||||
enableInstallTelemetry = false;
|
||||
|
||||
@@ -6,15 +6,14 @@
|
||||
}:
|
||||
# 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 cfg.enable {
|
||||
registry.env-var.${config.networking.hostName}.PYTHON_TOOLCHAIN_ROOT = "${pkgs.python3}";
|
||||
config = lib.mkIf config.modules.shell.python.enable {
|
||||
registry.env.${config.networking.hostName}.PYTHON_TOOLCHAIN_ROOT = "${pkgs.python3}";
|
||||
|
||||
home-manager.users.${user}.home.packages = [ pkgs.python3 ];
|
||||
};
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
}:
|
||||
# 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; [
|
||||
@@ -18,13 +16,14 @@ 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 cfg.enable {
|
||||
registry.env-var.${config.networking.hostName}.RUST_TOOLCHAIN_ROOT = "${toolchain}";
|
||||
config = lib.mkIf config.modules.shell.rust.enable {
|
||||
registry.env.${config.networking.hostName}.RUST_TOOLCHAIN_ROOT = "${toolchain}";
|
||||
|
||||
home-manager.users.${user} = {
|
||||
home.packages = [
|
||||
|
||||
@@ -6,13 +6,12 @@
|
||||
# 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 cfg.enable {
|
||||
config = lib.mkIf config.modules.shell.tmux.enable {
|
||||
registry.shell-shortcut.${config.networking.hostName}.tmx = "tmux new-session -A -s";
|
||||
|
||||
home-manager.users.${user}.programs.tmux = {
|
||||
|
||||
@@ -6,13 +6,12 @@
|
||||
}:
|
||||
# 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 cfg.enable {
|
||||
config = lib.mkIf config.modules.shell.yazi.enable {
|
||||
home-manager.users.${user}.programs.yazi = {
|
||||
enable = true;
|
||||
enableFishIntegration = true;
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
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
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
{ lib, ... }:
|
||||
{ lib, util, ... }:
|
||||
{
|
||||
type = lib.types.str;
|
||||
|
||||
checks = [
|
||||
{
|
||||
assertion = lib.registry.predicates.uniqueValue (entry: entry.value);
|
||||
assertion = util.registry.predicates.uniqueValue (entry: entry.value);
|
||||
message = "Address registry values must be unique.";
|
||||
}
|
||||
];
|
||||
|
||||
@@ -5,40 +5,40 @@
|
||||
tab = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "Apps";
|
||||
description = "Homepage tab that contains this entry.";
|
||||
description = "Groups the entry in a Homepage tab.";
|
||||
};
|
||||
|
||||
section = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "Media";
|
||||
description = "Homepage section that contains this entry.";
|
||||
description = "Groups the entry in a Homepage section.";
|
||||
};
|
||||
|
||||
name = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "Navidrome";
|
||||
description = "Homepage display name for this entry.";
|
||||
description = "Sets the display name in Homepage.";
|
||||
};
|
||||
|
||||
description = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "Music";
|
||||
description = "Homepage description for this entry.";
|
||||
description = "Sets the description in Homepage.";
|
||||
};
|
||||
|
||||
icon = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "navidrome.png";
|
||||
description = "Homepage icon for this entry.";
|
||||
description = "Sets the icon in Homepage.";
|
||||
};
|
||||
|
||||
href = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "https://music.alexion.dev";
|
||||
description = "Homepage link override for this entry.";
|
||||
description = "Overrides the generated Homepage link.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
112
configs/nixos/registry/keybind.nix
Normal file
112
configs/nixos/registry/keybind.nix
Normal file
@@ -0,0 +1,112 @@
|
||||
{ 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;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,22 +1,39 @@
|
||||
{ lib, ... }:
|
||||
{ lib, util, ... }:
|
||||
{
|
||||
checks = [
|
||||
{
|
||||
assertion = lib.registry.predicates.uniqueValue (entry: entry.value.domain);
|
||||
assertion = util.registry.predicates.uniqueValue (entry: entry.value.domain);
|
||||
message = "Route domains must be unique.";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
entries:
|
||||
lib.registry.predicates.uniqueValue (entry: entry.value) (
|
||||
lib.concatMap (entry: map (port: entry // { value = port; }) entry.value.udp.ports) entries
|
||||
util.registry.predicates.uniqueValue (entry: entry.value) (
|
||||
lib.concatMap (entry: map (port: entry // { value = port; }) entry.value.ports) entries
|
||||
);
|
||||
message = "Route UDP ports must be unique.";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
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.";
|
||||
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.";
|
||||
}
|
||||
];
|
||||
|
||||
@@ -25,31 +42,40 @@
|
||||
domain = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "files.alexion.dev";
|
||||
description = "Domain served by this route entry.";
|
||||
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.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.port;
|
||||
default = null;
|
||||
example = 3923;
|
||||
description = "HTTPS backend TCP port served by the matching address registry entry.";
|
||||
description = "Sets the HTTP backend port for an HTTP or HTTPS route.";
|
||||
};
|
||||
|
||||
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 {
|
||||
ports = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.port;
|
||||
default = [ ];
|
||||
example = [
|
||||
2456
|
||||
2457
|
||||
];
|
||||
description = "UDP ports served by the matching address registry entry.";
|
||||
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.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
{ lib, ... }:
|
||||
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;
|
||||
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.";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,25 @@
|
||||
{ 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:
|
||||
filterAttrs (_uid: serviceNames: builtins.length serviceNames > 1) (serviceNamesByUid 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
|
||||
);
|
||||
|
||||
in
|
||||
{
|
||||
# Next stable UID: 20005
|
||||
replicate = true;
|
||||
|
||||
type = lib.types.ints.positive;
|
||||
|
||||
checks = [
|
||||
{
|
||||
assertion = entries: duplicateUids entries == { };
|
||||
message = "Stable UID allocations must be unique.";
|
||||
message = "Multiple entries use the same stable UID.";
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
29
configs/nixos/registry/waybar.nix
Normal file
29
configs/nixos/registry/waybar.nix
Normal file
@@ -0,0 +1,29 @@
|
||||
{ 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.";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
19
configs/nixos/registry/window-icon.nix
Normal file
19
configs/nixos/registry/window-icon.nix
Normal file
@@ -0,0 +1,19 @@
|
||||
{ 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 = "";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
98
docs/.obsidian/workspace.json
vendored
98
docs/.obsidian/workspace.json
vendored
@@ -4,41 +4,21 @@
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "e581539f950ca1bd",
|
||||
"id": "6d9088d392cb8604",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "0c587e5452c16787",
|
||||
"id": "22a4c1f201a1b00e",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "TODO.md",
|
||||
"mode": "source",
|
||||
"file": "00 INBOX/ai/valheim-items.md",
|
||||
"mode": "preview",
|
||||
"source": true
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"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"
|
||||
"title": "valheim-items"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -111,7 +91,7 @@
|
||||
"state": {
|
||||
"type": "localgraph",
|
||||
"state": {
|
||||
"file": "TODO.md",
|
||||
"file": "00 INBOX/ai/valheim-items.md",
|
||||
"options": {
|
||||
"collapse-filter": true,
|
||||
"search": "",
|
||||
@@ -134,7 +114,7 @@
|
||||
"repelStrength": 10,
|
||||
"linkStrength": 1,
|
||||
"linkDistance": 250,
|
||||
"scale": 1,
|
||||
"scale": 0.6666666666666666,
|
||||
"close": true
|
||||
}
|
||||
},
|
||||
@@ -251,43 +231,43 @@
|
||||
"table-editor-obsidian:Advanced Tables Toolbar": false
|
||||
}
|
||||
},
|
||||
"active": "0c587e5452c16787",
|
||||
"active": "22a4c1f201a1b00e",
|
||||
"lastOpenFiles": [
|
||||
"00 INBOX/how to setup a steamcmd server.md",
|
||||
"00 INBOX/ai/steamcmd servers.md",
|
||||
"03 wrk/ariadne.md",
|
||||
"00 INBOX/ai/ariadne-cli-skeleton.md",
|
||||
"00 INBOX/ai/ariadne-project-goals.md",
|
||||
"00 INBOX/ai/hyprland-lua-migration-plan.md",
|
||||
"00 INBOX/ai/deadnix-architecture.md",
|
||||
"00 INBOX/ai/valheim-items.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",
|
||||
"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/ai/steamcmd servers.md",
|
||||
"00 INBOX/ai/clew-ast-exploration.md~",
|
||||
"03 wrk/machoke.md~",
|
||||
"03 wrk/falinks.md~",
|
||||
"99 meta/attachments/bases/unlinked mentions.base",
|
||||
"99 meta/templates/note.md",
|
||||
"AGENTS.md.md",
|
||||
"00 INBOX/ai",
|
||||
"03 wrk/ai",
|
||||
"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"
|
||||
"99 meta/Untitled"
|
||||
]
|
||||
}
|
||||
102
docs/00 INBOX/ai/hyprland-lua-migration-plan.md
Normal file
102
docs/00 INBOX/ai/hyprland-lua-migration-plan.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# 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.
|
||||
@@ -1,791 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,261 +0,0 @@
|
||||
# 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?
|
||||
939
docs/00 INBOX/ai/valheim-items.md
Normal file
939
docs/00 INBOX/ai/valheim-items.md
Normal file
@@ -0,0 +1,939 @@
|
||||
# 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/
|
||||
267
docs/00 INBOX/valheim progression.md
Normal file
267
docs/00 INBOX/valheim progression.md
Normal file
@@ -0,0 +1,267 @@
|
||||
---
|
||||
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
|
||||
66
docs/TODO.md
66
docs/TODO.md
@@ -3,7 +3,7 @@
|
||||
- machoke
|
||||
- falinks
|
||||
- dotfiles
|
||||
- xatu
|
||||
- natu
|
||||
- nix-style / refactor
|
||||
|
||||
# project ideas
|
||||
@@ -22,6 +22,9 @@
|
||||
- dictionary cli tool
|
||||
- kitchen.md
|
||||
- Rust project creator
|
||||
- random number generator utility
|
||||
- reading material helper
|
||||
- valheim progression DSL + graph
|
||||
|
||||
# migration
|
||||
|
||||
@@ -98,6 +101,13 @@
|
||||
- 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
|
||||
|
||||
@@ -113,34 +123,36 @@
|
||||
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/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`
|
||||
- [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`
|
||||
- [ ] `configs/nixos/modules/desktop/session/hyprland/default.nix`
|
||||
- [ ] `configs/nixos/modules/desktop/session/hyprland/hypridle.nix`
|
||||
- [ ] `configs/nixos/modules/desktop/session/hyprland/hyprlock.nix`
|
||||
|
||||
41
flake.nix
41
flake.nix
@@ -2,13 +2,13 @@
|
||||
description = "alexion.dev";
|
||||
|
||||
inputs = {
|
||||
# Base nixpkgs channel.
|
||||
# Provides the base nixpkgs channel.
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
|
||||
# Fresher packages, reachable per-package as `unstable.<name>`.
|
||||
# Provides newer packages through `unstable.<name>`.
|
||||
nixpkgs-unstable.url = "github:nixos/nixpkgs/nixpkgs-unstable";
|
||||
|
||||
# Latest stable release, reachable per-package as `stable.<name>`.
|
||||
# Provides the latest stable release through `stable.<name>`.
|
||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||
|
||||
home-manager = {
|
||||
@@ -16,70 +16,71 @@
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# Signed AMO extensions, pinned by version and hash.
|
||||
# Provides signed AMO extensions pinned by version and hash.
|
||||
firefox-addons = {
|
||||
url = "gitlab:rycee/nur-expressions?dir=pkgs/firefox-addons";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# Follows our nixpkgs so its plugins build against the same package set.
|
||||
# Uses this flake's nixpkgs so plugins build against the same package set.
|
||||
nixvim = {
|
||||
url = "github:nix-community/nixvim";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# Declarative disk partitioning.
|
||||
# Each host declares its own layout.
|
||||
# Configures declarative disk partitioning.
|
||||
# Each host declares its layout.
|
||||
disko = {
|
||||
url = "github:nix-community/disko";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# Upstream per-machine hardware profiles.
|
||||
# Each host imports its own.
|
||||
# Provides upstream hardware profiles for each machine.
|
||||
# Each host imports its profile.
|
||||
nixos-hardware = {
|
||||
url = "github:NixOS/nixos-hardware";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# Decrypts committed secrets at activation, from an age identity on the host.
|
||||
# Decrypts committed secrets at activation with an age identity on the host.
|
||||
sops-nix = {
|
||||
url = "github:Mic92/sops-nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# Themes the graphical layer from one base16 scheme.
|
||||
# Follows our nixpkgs so it themes the same package set the host builds.
|
||||
# Themes the graphical layer with one base16 scheme.
|
||||
# Uses this flake's nixpkgs so it themes the host package set.
|
||||
stylix = {
|
||||
url = "github:danth/stylix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# TODO: deprecate / remove
|
||||
# Agent-ergonomic CLI for Gitea, with a home-manager module for the agent context.
|
||||
# TODO: Deprecate and remove this input.
|
||||
# Provides a Gitea CLI for agents.
|
||||
# Its Home Manager module configures agent context.
|
||||
gitea-axi = {
|
||||
url = "git+https://git.alexion.dev/alexion/gitea-axi";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# Repository formatter and Nix lint wiring.
|
||||
# Configures the repository formatter and Nix linter.
|
||||
treefmt-nix = {
|
||||
url = "github:numtide/treefmt-nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
chaotic.url = "github:chaotic-cx/nyx/nyxpkgs-unstable";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ self, nixpkgs, ... }@inputs:
|
||||
import ./src/dotfiles {
|
||||
import ./src/flake {
|
||||
inherit inputs self;
|
||||
inherit (nixpkgs) lib;
|
||||
root = ./configs/nixos;
|
||||
root = ./.;
|
||||
system = "x86_64-linux";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -3,22 +3,8 @@
|
||||
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
|
||||
@@ -28,10 +14,18 @@ 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
|
||||
@@ -39,31 +33,17 @@ let
|
||||
repositoryModules = modules;
|
||||
root = root + "/guests";
|
||||
};
|
||||
modules.imports = util.collectNixFiles (root + "/modules");
|
||||
|
||||
in
|
||||
{
|
||||
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";
|
||||
};
|
||||
import ./hosts.nix {
|
||||
inherit
|
||||
inputs
|
||||
lib
|
||||
specialArgs
|
||||
guestModules
|
||||
;
|
||||
registryModule = registry.module;
|
||||
repositoryModules = modules;
|
||||
root = root + "/hosts";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
inputs,
|
||||
lib,
|
||||
root,
|
||||
specialArgs,
|
||||
repositoryModules,
|
||||
@@ -8,6 +7,7 @@
|
||||
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,11 +275,7 @@ let
|
||||
};
|
||||
};
|
||||
|
||||
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);
|
||||
names = map util.removeNixSuffix files;
|
||||
in
|
||||
{
|
||||
imports = files ++ map mkGuest names;
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
registryModule,
|
||||
}:
|
||||
let
|
||||
names = lib.attrNames directories;
|
||||
directories = lib.filterAttrs (_name: type: type == "directory") (builtins.readDir root);
|
||||
mkHost =
|
||||
{
|
||||
root,
|
||||
@@ -36,7 +38,5 @@ let
|
||||
];
|
||||
};
|
||||
|
||||
directories = lib.filterAttrs (_name: type: type == "directory") (builtins.readDir root);
|
||||
names = lib.attrNames directories;
|
||||
in
|
||||
lib.genAttrs names (name: mkHost { inherit root name; })
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
root,
|
||||
util,
|
||||
}:
|
||||
{
|
||||
imports = util.collectNixFiles root;
|
||||
}
|
||||
@@ -2,222 +2,57 @@
|
||||
hosts,
|
||||
lib,
|
||||
root,
|
||||
util,
|
||||
}:
|
||||
let
|
||||
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.
|
||||
'';
|
||||
};
|
||||
definitions = lib.genAttrs' (util.collectNixFiles root) (
|
||||
path: lib.nameValuePair (util.removeNixSuffix path) (mkRegistry path)
|
||||
);
|
||||
|
||||
mkRegistry =
|
||||
path:
|
||||
let
|
||||
evaluated = lib.evalModules {
|
||||
specialArgs.lib = registryLib;
|
||||
modules = [
|
||||
registryDefinitionModule
|
||||
path
|
||||
];
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit (evaluated.config) type replicate checks;
|
||||
};
|
||||
(lib.evalModules {
|
||||
specialArgs = { inherit util; };
|
||||
modules = [
|
||||
(import ./registry/schema.nix { inherit lib; })
|
||||
path
|
||||
];
|
||||
}).config;
|
||||
|
||||
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)
|
||||
);
|
||||
validation = import ./registry/validation.nix { inherit data definitions lib; };
|
||||
|
||||
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;
|
||||
};
|
||||
data = lib.mapAttrs (
|
||||
registryName: _registry:
|
||||
lib.mapAttrs (hostName: host: host.config.registry.${registryName}.${hostName} or { }) hosts
|
||||
) definitions;
|
||||
in
|
||||
{
|
||||
types = lib.mapAttrs (_name: definition: definition.type) definitions;
|
||||
|
||||
module = {
|
||||
options.registry = lib.mapAttrs (_name: registryOption) definitions;
|
||||
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;
|
||||
};
|
||||
|
||||
collected = withOperations (collectData {
|
||||
inherit hosts;
|
||||
});
|
||||
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
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
38
src/dotfiles/registry/schema.nix
Normal file
38
src/dotfiles/registry/schema.nix
Normal file
@@ -0,0 +1,38 @@
|
||||
{ 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.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
34
src/dotfiles/registry/validation.nix
Normal file
34
src/dotfiles/registry/validation.nix
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
data,
|
||||
definitions,
|
||||
lib,
|
||||
}:
|
||||
let
|
||||
checkResults = lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
registryName: definition:
|
||||
map (check: {
|
||||
assertion = check.assertion (entriesForRegistry registryName);
|
||||
inherit (check) message;
|
||||
}) definition.checks
|
||||
) definitions
|
||||
);
|
||||
|
||||
entriesForRegistry =
|
||||
registryName:
|
||||
lib.concatLists (
|
||||
lib.mapAttrsToList (host: entries: entriesForHost registryName host entries) data.${registryName}
|
||||
);
|
||||
|
||||
entriesForHost =
|
||||
registryName: host: entries:
|
||||
lib.mapAttrsToList (entry: value: {
|
||||
path = "registry.${registryName}.${host}.${entry}";
|
||||
inherit host entry value;
|
||||
}) entries;
|
||||
|
||||
failures = lib.filter (result: !result.assertion) checkResults;
|
||||
in
|
||||
lib.assertMsg (lib.all (result: result.assertion) checkResults) (
|
||||
lib.concatMapStringsSep "\n" (result: result.message) failures
|
||||
)
|
||||
@@ -16,21 +16,42 @@ let
|
||||
[ ]
|
||||
) (builtins.readDir dir)
|
||||
);
|
||||
|
||||
uniqueValue =
|
||||
value: entries: builtins.length (lib.unique (map value entries)) == builtins.length entries;
|
||||
in
|
||||
{
|
||||
inherit collectNixFiles;
|
||||
|
||||
removeNixSuffix = path: lib.removeSuffix ".nix" (builtins.baseNameOf path);
|
||||
|
||||
glyph =
|
||||
code:
|
||||
builtins.fromJSON (
|
||||
"\""
|
||||
+ (if builtins.isList code then lib.concatMapStrings (part: "\\u${part}") code else "\\u${code}")
|
||||
+ "\""
|
||||
);
|
||||
|
||||
registry.predicates.uniqueValue = uniqueValue;
|
||||
|
||||
registry.predicates.uniquePerHost =
|
||||
value:
|
||||
uniqueValue (entry: {
|
||||
inherit (entry) host;
|
||||
value = value entry;
|
||||
});
|
||||
|
||||
bridgeName = id: "br-vlan${toString id}";
|
||||
|
||||
deriveMac =
|
||||
name:
|
||||
let
|
||||
hash = builtins.hashString "sha256" name;
|
||||
octet = i: builtins.substring (i * 2) 2 hash;
|
||||
in
|
||||
lib.concatStringsSep ":" (
|
||||
[ "02" ]
|
||||
++ map octet [
|
||||
++ map (i: builtins.substring (i * 2) 2 hash) [
|
||||
0
|
||||
1
|
||||
2
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user