Compare commits

104 Commits

Author SHA1 Message Date
dcc4efcc75 fix(rofi): migrate Home Manager options 2026-10-15 18:16:47 -04:00
a23a317335 build(nix): update flake inputs 2026-10-15 18:16:47 -04:00
6e545c0a15 build(pi): integrate vendored Pi 1.3.0 2026-10-15 18:05:57 -04:00
6081e3e325 chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
2026-10-15 17:58:48 -04:00
719370c8f7 refactor(bazel): inline the primary user 2026-10-15 14:23:18 -04:00
2b4e66a75c fix(palworld): configure the RCON port 2026-10-15 14:18:27 -04:00
c44769ff70 refactor(steamcmd): constrain service names 2026-10-15 14:13:07 -04:00
1397576538 refactor(reverse-proxy): simplify virtual host generation 2026-10-15 13:57:45 -04:00
5fbe1850e0 docs(tasks): organize task areas 2026-10-14 18:18:18 -04:00
57bb21af1a feat(vault): add task workflow 2026-10-14 01:24:22 -04:00
ffc40e547b feat(skills): add Conventional Commits skill 2026-10-13 22:49:44 -04:00
55f61f71d8 refactor(natu): remove custom Nix linter 2026-10-13 21:59:41 -04:00
08563c6769 refactor(nixos): remove unused direnv module 2026-10-13 21:50:37 -04:00
8462b3a5a8 refactor(copyparty): separate deployment and service policy
Move Copyparty rendering into shared dotfiles utilities, keep guest wiring focused on host/container plumbing, and let the service module derive effective volume policy.
2026-10-13 20:33:25 -04:00
d0872e7d8c fix(homepage): skip empty ordered sections 2026-10-12 11:50:27 -04:00
9be9ad7675 refactor(actualbudget): remove unused service 2026-10-12 11:40:05 -04:00
5ee328cdf8 refactor(flake): centralize custom packages 2026-10-12 11:34:18 -04:00
6532a822cd feat(userdirs): configure preferred directories 2026-10-11 22:54:11 -04:00
20a3990979 refactor(theming): simplify stylix module 2026-10-11 22:43:35 -04:00
b7ab518f58 refactor(screenshot): simplify capture bindings 2026-10-11 22:40:44 -04:00
3f1e34ef95 refactor(recording): signal status updates 2026-10-11 22:37:30 -04:00
d0abfc1625 refactor(osd): move volume bindings to audio 2026-10-11 22:33:32 -04:00
031090e4ea fix(waybar): use Lua dispatch build 2026-10-11 22:12:17 -04:00
2edad6c5df refactor(clipboard): simplify picker wiring 2026-10-11 21:36:54 -04:00
1d5acc09c6 refactor(audio): simplify media controls 2026-10-11 21:30:21 -04:00
ee29e53f7c refactor(waybar): register owned modules 2026-10-11 21:07:35 -04:00
3c9d0ec886 refactor(nix): share glyph decoding helper 2026-10-11 21:07:35 -04:00
f0c5544b10 refactor(waybar): register workspace window icons 2026-10-11 18:23:41 -04:00
4dd0a5a4f5 feat(rofi): increase launcher contrast 2026-10-11 17:49:05 -04:00
a4a795f7ee refactor(mako): use executable helper for history 2026-10-11 17:40:56 -04:00
ecdf877308 refactor(login): use configured uwsm executable 2026-10-11 17:39:37 -04:00
d1face10ec refactor(nix): apply executable helper rules 2026-10-11 17:38:15 -04:00
337d9a2014 docs(nix-style): add package command rules 2026-10-11 17:38:15 -04:00
f059068873 refactor(hyprlock): inline command paths 2026-10-11 17:25:18 -04:00
7662c04cda fix(hypridle): use Lua dpms dispatcher 2026-10-11 17:22:05 -04:00
33128fc3c7 feat(hyprland): migrate desktop utility bindings 2026-10-11 16:58:27 -04:00
68415ad47e fix(hyprland): place Zeus primary monitor at origin 2026-10-11 16:56:03 -04:00
6b7201e6fa feat(hyprland): migrate session feature bindings 2026-10-11 16:52:29 -04:00
4360f4863b feat(hyprland): migrate hardware key bindings 2026-10-11 16:40:07 -04:00
370cab8f83 feat(hyprland): render session env registry 2026-10-11 16:34:52 -04:00
e74a08b853 feat(dotfiles): add session env registry 2026-10-11 16:30:00 -04:00
250b0b71f9 feat(hyprland): migrate static Lua compositor settings 2026-10-11 16:24:48 -04:00
10299cec19 refactor(hyprland): place keybind definitions after config 2026-10-11 16:00:38 -04:00
91654b3068 feat(hyprland): add remaining window actions 2026-10-11 15:58:50 -04:00
be9c3c07a3 feat(hyprland): add window resize binds 2026-10-11 15:00:03 -04:00
a8bd4bf4cf feat(hyprland): add window movement binds 2026-10-11 14:52:39 -04:00
a2fc568704 feat(hyprland): add directional focus binds 2026-10-11 12:58:56 -04:00
0bf5ebae87 feat(hyprland): add workspace focus keybinds 2026-10-10 17:02:20 -04:00
fddec14128 refactor(hyprland): move terminal keybind into registry 2026-10-10 15:41:32 -04:00
85fadc5bfc feat(hyprland): start lua config migration 2026-10-10 13:57:33 -04:00
3992030949 docs: update notes 2026-10-10 11:06:05 -04:00
c8a596aa39 fix(hyprland): stop idle dpms poweroff 2026-10-10 08:29:01 -04:00
4908c4751f refactor(nixos): simplify Firefox configuration 2026-10-07 22:57:25 -04:00
79c56dfb1c refactor(nixos): remove Alacritty module 2026-10-07 22:53:09 -04:00
7140b6d258 refactor(nixos): simplify VPN client configuration 2026-10-07 22:44:45 -04:00
f173887228 refactor(nixos): consolidate VLAN networking 2026-10-07 21:29:52 -04:00
3a487050fd refactor(nixos): simplify SSH configuration 2026-10-07 20:27:48 -04:00
65c84f5b39 docs(nixos): clarify host module options 2026-10-07 18:20:53 -04:00
b3c4903c82 fix(fish): persist remote system deployments 2026-10-07 17:43:20 -04:00
ec1c0fba56 refactor(nixos): organize host modules 2026-10-07 16:59:03 -04:00
1f2f1a79ad refactor(nixos): derive ZFS host IDs 2026-10-07 16:51:56 -04:00
46a47fa061 feat(nixos): configure route body limits 2026-10-06 22:54:58 -04:00
8d357e960b feat(nixos): add unzip to host profiles 2026-10-06 22:45:49 -04:00
f48bac5ec1 refactor(nixos): inline module enable conditions 2026-10-06 22:26:47 -04:00
d53246caf0 docs(nixos): clarify guest option 2026-10-06 22:24:00 -04:00
92f7f0542d docs(nixos): clarify host option 2026-10-06 22:22:32 -04:00
4f7fbef35b feat(nixos): encapsulate CachyOS kernel configuration 2026-10-06 19:42:52 -04:00
c7c3257b9d docs(nixos): clarify primary user option 2026-10-06 19:12:58 -04:00
7d37b92e17 refactor(nixos): scope user profile to hosts 2026-10-05 23:15:01 -04:00
9ece182099 refactor(nixos): disable guest SSH by default 2026-10-05 23:05:55 -04:00
9aea13b8c2 refactor(dotfiles): inline module imports 2026-10-05 00:28:34 -04:00
369c7512c6 refactor(nixos): clarify stable UID validation 2026-10-05 00:26:28 -04:00
8c91a210b2 docs(nixos): clarify registry option descriptions 2026-10-05 00:13:31 -04:00
d602581a99 refactor(nixos): inline shell shortcut type 2026-10-05 00:11:51 -04:00
c3a6271582 refactor(nixos): model route protocols explicitly 2026-10-05 00:09:09 -04:00
a7db0b745c refactor(nixos): use Home Manager session variables 2026-10-04 22:22:55 -04:00
4a8e8920ea refactor(dotfiles): clarify registry validation 2026-10-04 22:01:03 -04:00
3e9295134e feat(natu): detect redundant let expressions 2026-10-04 21:23:35 -04:00
c91094234f refactor(natu): remove single-use binding rule 2026-10-04 14:08:13 -04:00
11b490d1e8 chore: apply generic updates 2026-10-04 13:56:23 -04:00
43b5d07a27 revert(nixos): undo bad system update
Restore the pre-update Pi packaging and Rofi option from PR 124 after the associated deployment exposed a performance regression.

Refs: 1b4dc7762
2026-10-04 13:50:10 -04:00
1b4dc77627 fix(nixos): build vendored pi independently 2026-10-03 07:57:41 -04:00
1cbdcee8ac feat(natu): ignore imported let bindings 2026-10-01 21:54:09 -04:00
3fbb0aca32 refactor(dotfiles): remove registry replication 2026-10-01 21:50:58 -04:00
87776c8994 chore(dotfiles): defer registry binding findings 2026-09-30 11:28:29 -04:00
17597526c5 refactor(dotfiles): inline MAC octet helper 2026-09-30 11:22:38 -04:00
14c9a418b6 feat(natu): support preceding suppressions 2026-09-30 11:03:32 -04:00
589e37f52e refactor(dotfiles): inline host configurations 2026-09-30 10:46:20 -04:00
78492739f3 feat(natu): detect single-use let bindings 2026-09-30 10:43:05 -04:00
2265dfc558 feat(natu): lint redundant parameter forwarding 2026-09-30 00:19:27 -04:00
becbb50491 docs(nix): clarify completed Nix prose 2026-09-29 22:59:14 -04:00
54335ead82 refactor(dotfiles): inline registry definitions 2026-09-29 20:16:15 -04:00
16c69e01e1 refactor(dotfiles): inline registry options 2026-09-29 00:27:15 -04:00
5af563d70f refactor(dotfiles): split registry responsibilities 2026-09-27 23:31:44 -04:00
0e5731145e feat(natu): lint direct listToAttrs maps 2026-09-27 18:05:16 -04:00
b6469567e1 fix(zeus): ignore power button presses 2026-09-27 14:47:00 -04:00
8f0837a4b5 feat(desktop): copy screenshots before annotation 2026-09-27 10:09:17 -04:00
0937f15260 fix(dotfiles): qualify stable UID helpers 2026-09-27 10:09:17 -04:00
61efef1a7d fix(dotfiles): stop passing unused guest libraries 2026-09-27 10:07:57 -04:00
8eabdee2a0 docs(nix-style): allow general basename use 2026-09-27 09:42:50 -04:00
43b3ff4b09 refactor(dotfiles): centralize Nix file names 2026-09-27 09:39:10 -04:00
3b2d16743e refactor(dotfiles): share Nix file discovery 2026-09-26 23:49:03 -04:00
11d11c7c02 feat(style-nix): require shared file discovery 2026-09-26 23:49:03 -04:00
869d6a2cbd feat(review): run natu for Nix targets 2026-09-26 23:49:03 -04:00
2959 changed files with 246487 additions and 489617 deletions

View File

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

View File

@@ -0,0 +1 @@
../../src/skills/conventional-commits

View File

@@ -3,7 +3,6 @@
## Commit convention
- Use Conventional Commits for all commit messages.
Before writing a commit message, read `docs/conventional-commits.md`.
- Keep commit messages about the Git history, not the forge workflow.
Include external references only when they are stable context for the change.
- When writing commit messages, NEVER auto-add your agent name as co-author.
@@ -105,9 +104,10 @@
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.
- 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.
@@ -118,7 +118,9 @@
- OMP does not discover project skills from `.pi/skills`.
Put skills shared by Pi and OMP under `.agents/skills`, or configure OMP's `skills.customDirectories`.
- Pi is vendored under `third_party/pi` as a linear vendor import.
`pkgs.alexion.pi-coding-agent` packages that source and does not use Nixpkgs Pi.
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.
- The `monorepo` and `status` libraries do not have `README.md` files.

154
Cargo.lock generated
View File

@@ -2,15 +2,6 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "1.0.0"
@@ -71,12 +62,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "clap"
version = "4.6.7"
@@ -108,7 +93,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.6",
"syn",
]
[[package]]
@@ -123,41 +108,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "countme"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "googletest"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b5e2f2b556b7b90297a5a35c8267dd43a537923d2b329beefdba2b4ec19d94"
dependencies = [
"googletest_macro",
"num-traits",
"regex",
"rustversion",
]
[[package]]
name = "googletest_macro"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ae6abc96141edd26bf5aeec0f119c129c44de3ced09e5073711a02cb74725d0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "heck"
version = "0.5.0"
@@ -190,27 +140,6 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
name = "monorepo"
version = "0.1.0"
[[package]]
name = "natu"
version = "0.1.0"
dependencies = [
"clap",
"googletest",
"monorepo",
"rnix",
"rowan",
"status",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
@@ -235,68 +164,6 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rnix"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c163bd17372eecdf10d351c34584b7de7c1a33be4e92a32f3fb3f5a7fe3f579b"
dependencies = [
"rowan",
]
[[package]]
name = "rowan"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21"
dependencies = [
"countme",
"hashbrown",
"rustc-hash",
"text-size",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "serde"
version = "1.0.229"
@@ -324,7 +191,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn",
]
[[package]]
@@ -350,17 +217,6 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
@@ -372,12 +228,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "text-size"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
[[package]]
name = "unicode-ident"
version = "1.0.26"

View File

@@ -4,16 +4,12 @@ members = [
"src/machoke",
"src/monorepo",
"src/status",
"src/natu",
]
resolver = "3"
[workspace.dependencies]
clap = "4"
googletest = "0.14"
monorepo = { path = "src/monorepo" }
rnix = "=0.14.0"
rowan = "0.16"
serde = "1.0"
serde_json = "1.0"
status = { path = "src/status" }

View File

@@ -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,10 @@
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.
- Evaluating a NixOS guest's inner `config.services.nginx` can force incomplete ACME values.
Evaluate the parent host's `config.system.build.toplevel` to validate an ACME-enabled guest.
- `pkgs.systemd` does not set `meta.mainProgram`. Use `lib.getExe' pkgs.systemd "systemctl"` instead of `lib.getExe pkgs.systemd`.

View File

@@ -1,51 +0,0 @@
{
config,
lib,
...
}:
let
cfg = config.guests.actualbudget; # NOLINT(let_order)
dataDir = "/data";
in
{
options.guests.actualbudget = {
dataPath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/actualbudget";
description = "Host path bind-mounted as Actual Budget's data directory.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.dataPath != null;
message = "guests.actualbudget.dataPath must point at the host directory that stores Actual Budget data.";
}
];
registry.homepage.${config.networking.hostName}.actualbudget = {
tab = "Apps";
section = "Other";
name = "Actual Budget";
description = "Finance";
icon = "actual-budget.png";
};
guests.actualbudget = {
interior = {
modules.services.actualbudget = {
enable = true;
inherit dataDir;
};
};
mounts.${dataDir}.directory = cfg.dataPath;
limits = {
memory = lib.mkDefault "2G";
cpu = lib.mkDefault "200%";
tasksMax = lib.mkDefault 512;
};
};
};
}

View File

@@ -4,58 +4,14 @@
...
}:
let
cfg = config.guests.copyparty; # NOLINT(let_order)
volumeType = lib.types.submodule {
options = {
hostPath = lib.mkOption {
type = lib.types.str;
description = "Host path bind-mounted into the Copyparty guest for this volume.";
};
path = lib.mkOption {
type = lib.types.str;
description = "Guest path served by this Copyparty volume.";
};
access = lib.mkOption {
type = lib.types.attrsOf (lib.types.listOf lib.types.str);
default = { };
description = "Copyparty access map from permission string to account names.";
};
readOnly = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Serve this Copyparty volume read-only.";
};
flags = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Copyparty volume flags.";
};
};
};
cfg = config.guests.copyparty;
in
{
options.guests.copyparty = {
accountName = lib.mkOption {
type = lib.types.str;
default = "alexion";
description = "Copyparty account name configured for password authentication.";
};
passwordSecret = lib.mkOption {
type = lib.types.str;
default = "services/copyparty/users/alexion/password";
description = "SOPS secret name containing the Copyparty account password.";
};
stateDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/copyparty";
description = "Guest path where Copyparty stores service state.";
default = "services/copyparty/users/${config.user.name}/password";
description = "SOPS secret name that contains the Copyparty account password.";
};
statePath = lib.mkOption {
@@ -72,7 +28,39 @@ in
};
volumes = lib.mkOption {
type = lib.types.attrsOf volumeType;
type = lib.types.attrsOf (
lib.types.submodule {
options = {
hostPath = lib.mkOption {
type = lib.types.str;
description = "Host path bind-mounted into the Copyparty guest for this volume.";
};
path = lib.mkOption {
type = lib.types.str;
description = "Guest path served by this Copyparty volume.";
};
access = lib.mkOption {
type = lib.types.attrsOf (lib.types.listOf lib.types.str);
default = { };
description = "Copyparty access map from permission string to account names.";
};
readOnly = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Serve this Copyparty volume read-only.";
};
flags = lib.mkOption {
type = lib.types.attrs;
default = { };
description = "Copyparty volume flags.";
};
};
}
);
default = { };
description = "Copyparty volumes keyed by their URL path.";
};
@@ -102,24 +90,26 @@ in
guests.copyparty = {
interior.modules.services.copyparty = {
enable = true;
inherit (cfg) accountName stateDir;
passwordFile = "/run/secrets/${cfg.passwordSecret}";
reverseProxy = {
enable = cfg.reverseProxy.enable;
inherit (cfg.reverseProxy) trustedSources;
};
volumes = lib.mapAttrs (_urlPath: volume: {
inherit (volume)
path
access
flags
readOnly
;
}) cfg.volumes;
volumes = lib.mapAttrs (
_urlPath: volume:
{
inherit (volume)
path
readOnly
;
}
// lib.optionalAttrs (volume.access != { }) { inherit (volume) access; }
// lib.optionalAttrs (volume.flags != { }) { inherit (volume) flags; }
) cfg.volumes;
};
mounts = {
${cfg.stateDir} = {
"/var/lib/copyparty" = {
directory = cfg.statePath;
owner = config.registry."stable-uid".${config.networking.hostName}.copyparty;
group = "storage";

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
inputs,
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;
};
};
};
}

View File

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

View File

@@ -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 = { };
};
@@ -73,12 +36,13 @@
steamPath = "/pikachu/data/steamcmd";
installPath = "/pikachu/data/servers/valheim/main";
statePath = "/pikachu/media/games/servers/valheim/main";
serverName = "butter-chicken";
serverName = "main";
reverseProxy = {
enable = true;
route = {
domain = "valheim.alexion.dev";
udp.ports = [
protocol = "udp";
ports = [
config.guests.valheim.port
(config.guests.valheim.port + 1)
];
@@ -101,22 +65,8 @@
enable = true;
route = {
domain = "palworld.alexion.dev";
udp.ports = [ config.guests.palworld.port ];
};
};
};
guests.actualbudget = {
enable = true;
vlan = 20;
mac = "BC:24:11:C1:CD:28";
dataPath = "/pikachu/data/actualbudget";
address = "10.23.20.42";
reverseProxy = {
enable = true;
route = {
domain = "budget.alexion.dev";
port = 5006;
protocol = "udp";
ports = [ config.guests.palworld.port ];
};
};
};
@@ -132,6 +82,7 @@
enable = true;
route = {
domain = "music.alexion.dev";
protocol = "https";
port = 4533;
};
};
@@ -152,7 +103,8 @@
enable = true;
route = {
domain = "home";
http.port = 8082;
protocol = "http";
port = 8082;
};
};
};
@@ -167,7 +119,9 @@
enable = true;
route = {
domain = "files.alexion.dev";
protocol = "https";
port = 3923;
clientMaxBodySize = "1g";
};
trustedSources = [ "${registry.address.${config.networking.hostName}."reverse-proxy"}/32" ];
};
@@ -175,35 +129,21 @@
"/" = {
hostPath = "/pikachu/data/copyparty/root";
path = "/srv";
access.r = [ "alexion" ];
readOnly = true;
};
"/inbox" = {
hostPath = "/pikachu/__inbox";
path = "/pikachu/__inbox";
access.A = [ "alexion" ];
flags = [
"d2d"
"d2t"
"dthumb"
];
};
"/media" = {
hostPath = "/pikachu/media";
path = "/pikachu/media";
access.A = [ "alexion" ];
flags = [
"d2d"
"d2t"
"dthumb"
];
};
"/opds" = {
hostPath = "/pikachu/data/opds";
path = "/pikachu/data/opds";
access.r = [ "alexion" ];
readOnly = true;
flags = [ "opds" ];
flags.opds = true;
};
};
};

View File

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

View File

@@ -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";
@@ -45,8 +49,19 @@
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;
}
];
}

View File

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

View File

@@ -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;
};
}

View File

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

View File

@@ -3,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; # NOLINT(let_order)
firefoxAddons = inputs.firefox-addons.packages.${pkgs.stdenv.hostPlatform.system};
user = config.user.name;
cfg = config.modules.desktop.apps.firefox;
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;
};
};

View File

@@ -2,17 +2,30 @@
config,
lib,
pkgs,
util,
...
}:
# Ghostty as the desktop terminal.
let
cfg = config.modules.desktop.apps.ghostty; # NOLINT(let_order)
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 ];
};
}

View File

@@ -2,18 +2,23 @@
config,
lib,
pkgs,
util,
...
}:
# Obsidian as the desktop note-taking vault.
let
cfg = config.modules.desktop.apps.obsidian; # NOLINT(let_order)
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";
};
};
}

View File

@@ -6,14 +6,13 @@
}:
# r2modman, a Thunderstore mod manager.
let
cfg = config.modules.desktop.apps.r2modman; # NOLINT(let_order)
user = config.user.name;
in
{
options.modules.desktop.apps.r2modman.enable =
lib.mkEnableOption "r2modman, a Thunderstore mod manager";
config = lib.mkIf cfg.enable {
config = lib.mkIf config.modules.desktop.apps.r2modman.enable {
home-manager.users.${user}.home.packages = [ pkgs.r2modman ];
};
}

View File

@@ -2,17 +2,22 @@
config,
lib,
pkgs,
util,
...
}:
# Signal desktop messenger.
let
cfg = config.modules.desktop.apps.signal; # NOLINT(let_order)
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";
};
};
}

View File

@@ -2,16 +2,14 @@
config,
lib,
pkgs,
util,
...
}:
# Steam game launcher and runtime integration.
let
cfg = config.modules.desktop.apps.steam; # NOLINT(let_order)
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";
};
};
}

View File

@@ -2,18 +2,23 @@
config,
lib,
pkgs,
util,
...
}:
# Vesktop, a Discord client with Vencord built in.
let
cfg = config.modules.desktop.apps.vencord; # NOLINT(let_order)
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";
};
};
}

View File

@@ -1,14 +1,11 @@
{ config, lib, ... }:
# The desktop aggregator: one flag brings up the whole graphical session.
let
cfg = config.modules.desktop; # NOLINT(let_order)
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";
};
}

View File

@@ -1,42 +1,15 @@
{
config,
lib,
pkgs,
util,
...
}:
# The Hyprland compositor.
let
cfg = config.modules.desktop.session.hyprland; # NOLINT(let_order)
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);
user = config.user.name;
# 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 = {
@@ -44,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.
@@ -56,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);
};
};
};

View File

@@ -5,63 +5,34 @@
...
}:
# hypridle: idle-triggered locking and display power management.
let
cfg = config.modules.desktop.session.hyprland.hypridle; # NOLINT(let_order)
# 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}";
user = config.user.name;
hyprctl = "${config.programs.hyprland.package}/bin/hyprctl";
hyprlock = "${config.home-manager.users.${user}.programs.hyprlock.package}/bin/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";
}
];
};
};

View File

@@ -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; # NOLINT(let_order)
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;
};
};
};
}

View File

@@ -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; # NOLINT(let_order)
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"
];
};
};
}

View File

@@ -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; # NOLINT(let_order)
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;
};
};
};
}

View File

@@ -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; # NOLINT(let_order)
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

View File

@@ -6,47 +6,61 @@
}:
# rofi as the desktop launcher and dmenu-style menu frontend.
let
cfg = config.modules.desktop.session.rofi; # NOLINT(let_order)
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;
# Application launch is the only mode, kept minimal so the prompt opens
# as fast as rofi can read the desktop entries.
# Icons are off: resolving one per entry is the bulk of drun's startup.
extraConfig = {
settings = {
font = lib.mkForce "JetBrains Mono 12";
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}";
};
};
};
};
}

View File

@@ -6,166 +6,57 @@
}:
# Waybar: a top status bar reading system state at a glance.
let
cfg = config.modules.desktop.session.waybar; # NOLINT(let_order)
vpnClient = config.modules.network.vpn-client;
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}"'';
user = config.user.name;
moduleEntries = lib.mapAttrsToList (name: module: {
inherit name module;
}) modules;
sleepOffIcon = builtins.fromJSON ''"\udb81\udcb3"'';
# 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;
package = pkgs.alexion.waybar;
# 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 = {
@@ -174,77 +65,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;
};
};
};
};

View File

@@ -1,12 +1,34 @@
{ config, lib, ... }:
{
config,
lib,
pkgs,
util,
...
}:
# PipeWire as the desktop audio server.
let
cfg = config.modules.desktop.utilities.audio; # NOLINT(let_order)
volumeKey = keys: command: {
inherit keys;
repeating = true;
action = {
kind = "exec";
command = "${lib.getExe' pkgs.swayosd "swayosd-client"} --output-volume ${command}";
};
};
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 +37,50 @@ in
alsa.enable = true;
pulse.enable = true;
};
registry.keybind.${config.networking.hostName} = {
volume-up = volumeKey "XF86AudioRaiseVolume" "raise --max-volume 100";
volume-down = volumeKey "XF86AudioLowerVolume" "lower";
mute = {
keys = "XF86AudioMute";
locked = true;
action = {
kind = "exec";
command = "${lib.getExe' pkgs.swayosd "swayosd-client"} --output-volume mute-toggle";
};
};
media-play = mediaKey "XF86AudioPlay" "play-pause";
media-next = mediaKey "XF86AudioNext" "next";
media-previous = mediaKey "XF86AudioPrev" "previous";
};
registry.waybar.${config.networking.hostName} = {
mpris = {
order = 30;
settings = {
format = "${util.glyph "f001"} {title}";
format-paused = "${util.glyph "f04c"} {title}";
max-length = 40;
on-click = "play-pause";
on-scroll-up = "next";
on-scroll-down = "previous";
};
};
wireplumber = {
order = 40;
settings = {
format = "{icon} {volume}%";
format-muted = "${util.glyph "f026"} muted";
format-icons = [
(util.glyph "f026")
(util.glyph "f027")
(util.glyph "f028")
];
on-click = "${lib.getExe' pkgs.wireplumber "wpctl"} set-mute @DEFAULT_AUDIO_SINK@ toggle";
};
};
};
};
}

View File

@@ -6,30 +6,23 @@
}:
# Clipboard history: cliphist records every copy, picked back through rofi.
let
cfg = config.modules.desktop.utilities.clipboard; # NOLINT(let_order)
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}";
};
};
};
}

View File

@@ -5,36 +5,52 @@
...
}:
# SwayOSD: a transient on-screen popup for volume and brightness changes.
let
cfg = config.modules.desktop.utilities.osd; # NOLINT(let_order)
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 {
environment.systemPackages = [ pkgs.swayosd ];
# The udev rule chgrps each backlight's brightness node to `video` and adds
# group write, so the server dims the panel without root.
# Membership below grants the running session that access.
services.udev.packages = [ pkgs.swayosd ];
users.users.${user}.extraGroups = [ "video" ];
users.users.${config.user.name}.extraGroups = [ "video" ];
# The server draws the popups, so it runs for the whole graphical session.
# It binds to the target uwsm activates.
home-manager.users.${user}.systemd.user.services.swayosd = {
home-manager.users.${config.user.name}.systemd.user.services.swayosd = {
Unit = {
Description = "SwayOSD on-screen display server";
PartOf = [ "graphical-session.target" ];
After = [ "graphical-session.target" ];
};
Service = {
ExecStart = "${pkgs.swayosd}/bin/swayosd-server";
ExecStart = lib.getExe' pkgs.swayosd "swayosd-server";
Restart = "on-failure";
};
Install.WantedBy = [ "graphical-session.target" ];
};
registry.keybind.${config.networking.hostName} = {
brightness-up = {
keys = "XF86MonBrightnessUp";
repeating = true;
action = {
kind = "exec";
command = "${lib.getExe' pkgs.swayosd "swayosd-client"} --brightness raise";
};
};
brightness-down = {
keys = "XF86MonBrightnessDown";
repeating = true;
action = {
kind = "exec";
command = "${lib.getExe' pkgs.swayosd "swayosd-client"} --brightness lower";
};
};
};
};
}

View File

@@ -2,54 +2,67 @@
config,
lib,
pkgs,
util,
...
}:
# Keybound screen recording: pick a region, then toggle a video-only capture.
let
cfg = config.modules.desktop.utilities.recording; # NOLINT(let_order)
user = config.user.name;
# A single key starts and stops recording.
# Waybar refreshes on state changes instead of polling.
recordingToggle = pkgs.writeShellScript "screen-record-toggle" ''
refresh_waybar() {
${lib.getExe' pkgs.procps "pkill"} -RTMIN+10 waybar >/dev/null 2>&1 || true
}
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
# that stop arrives, so this instance lives for the whole recording and then
# 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" &
recorder=$!
refresh_waybar
wait "$recorder" || true
refresh_waybar
${lib.getExe' pkgs.libnotify "notify-send"} -a "Screen recording" "Recording saved" "$file"
'';
# Empty text collapses the widget when recording is idle.
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 = "${recordingToggle}";
};
};
registry.waybar.${config.networking.hostName}."custom/recording" = {
order = 10;
settings = {
return-type = "json";
exec = "${recordingStatus}";
interval = "once";
signal = 10;
};
};
};
}

View File

@@ -4,28 +4,29 @@
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; # NOLINT(let_order)
user = config.user.name;
screenshotKey = keys: target: {
inherit keys;
action = {
kind = "exec";
command = "${capture target}";
};
};
grimblast = "${pkgs.grimblast}/bin/grimblast";
satty = "${pkgs.satty}/bin/satty";
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} - \
| ${satty} --filename - \
dir="$(${lib.getExe' pkgs.xdg-user-dirs "xdg-user-dir"} PICTURES)/Screenshots"
${lib.getExe' pkgs.coreutils "mkdir"} -p "$dir"
${lib.getExe pkgs.grimblast} save ${target} - \
| ${lib.getExe' pkgs.coreutils "tee"} >(${lib.getExe' pkgs.wl-clipboard "wl-copy"}) \
| ${lib.getExe pkgs.satty} --filename - \
--output-filename "$dir/screenshot-%Y%m%d-%H%M%S.png" \
--copy-command ${wl-copy} \
--copy-command ${lib.getExe' pkgs.wl-clipboard "wl-copy"} \
--save-after-copy \
--early-exit \
--actions-on-enter save-to-clipboard
@@ -35,24 +36,22 @@ in
options.modules.desktop.utilities.screenshot.enable =
lib.mkEnableOption "screenshots via grimblast and satty";
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {
config = lib.mkIf config.modules.desktop.utilities.screenshot.enable {
home-manager.users.${config.user.name} = {
# 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"}"
];
registry.keybind.${config.networking.hostName} = {
screenshot-area = screenshotKey "Print" "area";
screenshot-active = screenshotKey "SHIFT + Print" "active";
screenshot-screen = screenshotKey "CTRL + Print" "screen";
};
};
}

View File

@@ -6,22 +6,19 @@
}:
# Nord theming for the graphical layer, driven from one base16 scheme by Stylix.
let
cfg = config.modules.desktop.utilities.theming; # NOLINT(let_order)
# 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.
wallpaper = pkgs.runCommand "nord-wallpaper.png" { } ''
${pkgs.imagemagick}/bin/magick \
${lib.getExe pkgs.imagemagick} \
-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;
@@ -60,7 +57,7 @@ in
};
};
home-manager.users.${user} = {
home-manager.users.${config.user.name} = {
# Stylix drives the cursor through home-manager's pointer-cursor config,
# whose generation must be switched on explicitly.
home.pointerCursor.enable = true;
@@ -69,5 +66,10 @@ in
# base16 mapping Stylix would apply, so its target stays off.
stylix.targets.nixvim.enable = false;
};
registry.env.${config.networking.hostName} = {
HYPRCURSOR_THEME = config.stylix.cursor.name;
HYPRCURSOR_SIZE = toString config.stylix.cursor.size;
};
};
}

View File

@@ -1,15 +1,27 @@
{ config, lib, ... }:
# Declares the XDG user directories, so a tool that queries them lands in a
# well-known folder a host can relocate from one place.
# Declares XDG user directories, so tools that query them use predictable
# folders a host can relocate from one place.
let
cfg = config.modules.desktop.utilities.userdirs; # NOLINT(let_order)
user = config.user.name;
homeDirectory = "/home/${config.user.name}";
in
{
options.modules.desktop.utilities.userdirs.enable = lib.mkEnableOption "XDG user directories";
config = lib.mkIf cfg.enable {
# Write ~/.config/user-dirs.dirs from the option defaults.
home-manager.users.${user}.xdg.userDirs.enable = true;
config = lib.mkIf config.modules.desktop.utilities.userdirs.enable {
home-manager.users.${config.user.name}.xdg.userDirs = {
enable = true;
createDirectories = true;
setSessionVariables = true;
desktop = "${homeDirectory}/.ignoreme";
documents = "${homeDirectory}/docs";
download = "${homeDirectory}/dwn";
music = "${homeDirectory}/mus";
pictures = "${homeDirectory}/pic";
publicShare = "${homeDirectory}/.ignoreme";
templates = "${homeDirectory}/.ignoreme";
videos = "${homeDirectory}/vid";
projects = "${homeDirectory}/.ignoreme";
};
};
}

View File

@@ -1,12 +1,9 @@
{ config, lib, ... }:
let
cfg = config.modules.guest; # NOLINT(let_order)
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;
};
}

View File

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

View File

@@ -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="
];
};
}

View 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;
};
}

View File

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

View File

@@ -4,13 +4,10 @@
...
}:
# Tools for developing this monorepo.
let
cfg = config.modules.monorepo; # NOLINT(let_order)
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;

View 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;
};
};
};
}

View File

@@ -4,344 +4,203 @@
registry,
...
}:
# SSH on this machine, in both directions.
# Configures inbound and outbound SSH access.
let
cfg = config.modules.network.ssh; # 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.
'';
};
cfg = config.modules.network.ssh;
user = config.user.name;
user = mkOption {
type = types.str;
default = config.user.name;
description = ''
The remote login name OpenSSH uses for this target.
'';
};
workstationNames = [
"neogaia"
"zeus"
];
serverNames = [ "pikachu" ];
port = mkOption {
type = types.port;
default = 22;
description = ''
The TCP port OpenSSH uses for this target.
'';
};
isWorkstation = lib.elem config.networking.hostName workstationNames;
isServer = lib.elem config.networking.hostName serverNames;
aliases = mkOption {
type = types.listOf types.str;
default = [ name ];
description = ''
Host patterns written into the generated OpenSSH client block.
'';
};
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")
];
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.
'';
};
managedTargets = lib.genAttrs (workstationNames ++ serverNames) (name: {
hostName = registry.address.${name}.self;
clientKey = clientKeyFor name;
hostKeys = hostKeysFor name;
});
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.
'';
};
};
}
);
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"
];
};
};
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);
clientKeysFor = names: lib.filter (key: key != null) (map (name: targets.${name}.clientKey) names);
defaultAuthorizedKeys = lib.flatten (
clientKeysFor workstationNames ++ optional isServer (clientKeysFor serverNames)
clientKeysFor workstationNames ++ lib.optional isServer (clientKeysFor serverNames)
);
clientKeysFor = names: filter (key: key != null) (map (name: cfg.targets.${name}.clientKey) names);
outboundTargetNames =
let
groupTargets =
if isServer then
builtins.attrNames extraTargets ++ serverNames
else if isWorkstation then
builtins.attrNames extraTargets ++ serverNames ++ workstationNames
else
builtins.attrNames extraTargets;
in
lib.filter (name: name != config.networking.hostName) groupTargets;
workstationNames = targetNamesIn cfg.hosts.workstations;
targetNamesIn = names: filter (name: hasAttr name cfg.targets) names;
isServer = elem currentHost cfg.hosts.servers;
currentHost = config.networking.hostName;
serverNames = targetNamesIn cfg.hosts.servers;
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 = unique (target.aliases ++ [ target.hostName ]);
names = lib.unique (target.aliases ++ [ target.hostName ]);
in
map withPort names;
keyWithoutComment = key: concatStringsSep " " (lib.take 2 (lib.splitString " " key));
keyWithoutComment = key: lib.concatStringsSep " " (lib.take 2 (lib.splitString " " key));
sshSettingsFor =
name:
let
target = cfg.targets.${name};
target = targets.${name};
in
{
header = "Host ${concatStringsSep " " target.aliases}";
header = "Host ${lib.concatStringsSep " " target.aliases}";
HostName = target.hostName;
User = target.user;
}
// optionalAttrs (target.port != 22) { Port = target.port; };
outboundTargetNames =
let
groupTargets =
if isServer then
[ "gitea" ] ++ cfg.hosts.servers
else if isWorkstation then
[ "gitea" ] ++ cfg.hosts.servers ++ cfg.hosts.workstations
else
[ "gitea" ];
in
filter (name: name != currentHost) (targetNamesIn groupTargets);
isWorkstation = elem currentHost cfg.hosts.workstations;
// lib.optionalAttrs (target.port != 22) { Port = target.port; };
hostKeySecret = type: "ssh-host-${type}-key";
user = config.user.name;
inherit (lib)
concatLists
concatStringsSep
elem
filter
genAttrs
hasAttr
imap0
listToAttrs
mapAttrs
mapAttrsToList
mkIf
mkMerge
mkOption
nameValuePair
optional
optionalAttrs
types
unique
;
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;
};
})
]
);
}

View File

@@ -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; # NOLINT(let_order)
inherit (util) bridgeName;
vlanName = id: "${cfg.trunk}.${toString id}";
cfg = config.modules.network.vlans;
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
);
};

View File

@@ -1,10 +1,34 @@
{
config,
lib,
pkgs,
util,
...
}:
let
cfg = config.modules.network.vpn-client; # NOLINT(let_order)
cfg = config.modules.network.vpn-client;
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;
}
];
};
};

View File

@@ -1,62 +0,0 @@
{
config,
lib,
...
}:
let
cfg = config.modules.services.actualbudget; # NOLINT(let_order)
in
{
options.modules.services.actualbudget = {
enable = lib.mkEnableOption "Actual Budget";
port = lib.mkOption {
type = lib.types.port;
default = 5006;
description = "Port Actual Budget listens on.";
};
dataDir = lib.mkOption {
type = lib.types.str;
default = "/data";
description = "Directory where Actual Budget stores server and user files.";
};
user = lib.mkOption {
type = lib.types.str;
default = "actual";
description = "User account that runs Actual Budget.";
};
group = lib.mkOption {
type = lib.types.str;
default = "storage";
description = "Group account that owns Actual Budget's writable state.";
};
};
config = lib.mkMerge [
{
registry."stable-uid".${config.networking.hostName}.actualbudget = 20001;
}
(lib.mkIf cfg.enable {
users.users.${cfg.user} = {
isSystemUser = true;
uid = config.registry."stable-uid".${config.networking.hostName}.actualbudget;
inherit (cfg) group;
};
services.actual = {
enable = true;
openFirewall = true;
inherit (cfg) user;
inherit (cfg) group;
settings = {
inherit (cfg) port dataDir;
};
};
})
];
}

View File

@@ -2,147 +2,135 @@
config,
lib,
pkgs,
util,
...
}:
let
cfg = config.modules.services.copyparty; # NOLINT(let_order)
cfg = config.modules.services.copyparty;
volumeType = lib.types.submodule {
options = {
path = lib.mkOption {
type = lib.types.str;
description = "Filesystem path served by this volume.";
};
accountName = config.user.name;
access = lib.mkOption {
type = lib.types.attrsOf (lib.types.listOf lib.types.str);
default = { };
example = {
A = [ "alexion" ];
};
description = "Copyparty access map from permission string to account names.";
};
serviceGroup = "storage";
readOnly = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Serve this volume read-only.";
};
serviceStateDir = "/var/lib/copyparty";
flags = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "dthumb" ];
description = "Copyparty volume flags.";
};
};
serviceUser = "copyparty";
writableVolumes = lib.filter (volume: !volume.readOnly) (lib.attrValues cfg.volumes);
port = 3923;
defaultAccess = {
A = [ accountName ];
};
configScript = pkgs.writeShellScript "copyparty-config" ''
set -eu
defaultWritableFlags = {
d2d = true;
d2t = true;
dthumb = true;
};
umask 077
password="$(${pkgs.coreutils}/bin/tr -d '\n' < "$CREDENTIALS_DIRECTORY/password")"
credentialHash = builtins.substring 0 16 (builtins.hashString "sha256" accountName);
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
credentialName = "password-${credentialHash}";
${renderedVolumes}
EOF
'';
renderAccess = permission: accounts: " ${permission}: ${lib.concatStringsSep ", " accounts}";
passwordPlaceholder = "@COPYPARTY_PASSWORD_${credentialHash}@";
hasFlag =
name: flags: lib.any (flag: lib.hasPrefix "${name}:" flag || lib.hasPrefix "${name}=" flag) flags;
globalSettings = {
i = "0.0.0.0";
usernames = true;
"http-only" = true;
"no-crt" = true;
"no-thumb" = true;
"no-snap" = true;
"no-rescan" = true;
"ses-db" = "${serviceStateDir}/sessions.db";
"chpw-db" = "${serviceStateDir}/chpw.json";
"shr-db" = "${serviceStateDir}/shares.db";
}
// lib.optionalAttrs cfg.reverseProxy.enable {
rproxy = 1;
"xff-hdr" = "x-forwarded-for";
};
defaultPermissionFlags =
globalExtraConfig = lib.optionalString cfg.reverseProxy.enable (
lib.concatMapStringsSep "\n" (source: "xff-src: ${source}") cfg.reverseProxy.trustedSources
);
volumeFlags =
flags:
lib.optionals (!(hasFlag "chmod_d" flags)) [ "chmod_d: 2770" ]
++ lib.optionals (!(hasFlag "chmod_f" flags)) [ "chmod_f: 660" ];
lib.optionalAttrs (flags != { }) (
{
chmod_d = "2770";
chmod_f = "660";
}
// flags
);
renderFlags =
flags: lib.concatMapStringsSep "\n" (flag: " ${flag}") (flags ++ defaultPermissionFlags flags);
renderedVolumes = lib.mapAttrs (_urlPath: volume: {
inherit (volume) path;
access = if volume.readOnly then { r = [ accountName ]; } else defaultAccess // volume.access;
flags = volumeFlags (lib.optionalAttrs (!volume.readOnly) defaultWritableFlags // volume.flags);
}) cfg.volumes;
renderVolume = urlPath: volume: ''
[${urlPath}]
${volume.path}
accs:
${lib.concatStringsSep "\n" (lib.mapAttrsToList renderAccess volume.access)}
${lib.optionalString (volume.flags != [ ]) ''
flags:
${renderFlags volume.flags}
''}
configText = util.formats.copyparty.render {
inherit globalExtraConfig;
accounts.${accountName} = passwordPlaceholder;
global = globalSettings;
volumes = renderedVolumes;
};
configFile = pkgs.writeText "copyparty.conf" configText;
replacePassword = ''
${lib.getExe pkgs.replace-secret} ${lib.escapeShellArg passwordPlaceholder} "$CREDENTIALS_DIRECTORY/${credentialName}" /run/copyparty/copyparty.conf
'';
renderTrustedSource = source: " xff-src: ${source}";
renderedReverseProxy = lib.optionalString cfg.reverseProxy.enable ''
rproxy: 1
xff-hdr: x-forwarded-for
${lib.concatStringsSep "\n" (map renderTrustedSource cfg.reverseProxy.trustedSources)}
'';
renderedVolumes = lib.concatStringsSep "\n" (lib.mapAttrsToList renderVolume cfg.volumes);
in
{
options.modules.services.copyparty = {
enable = lib.mkEnableOption "Copyparty";
package = lib.mkPackageOption pkgs "copyparty-min" { };
port = lib.mkOption {
type = lib.types.port;
default = 3923;
description = "Port Copyparty listens on.";
};
user = lib.mkOption {
type = lib.types.str;
default = "copyparty";
description = "User account that runs Copyparty.";
};
group = lib.mkOption {
type = lib.types.str;
default = "storage";
description = "Group account that owns Copyparty's writable state and served files.";
};
accountName = lib.mkOption {
type = lib.types.str;
default = "alexion";
description = "Copyparty account name configured for password authentication.";
};
passwordFile = lib.mkOption {
type = lib.types.path;
description = "Runtime file containing the Copyparty account password.";
};
stateDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/copyparty";
description = "Directory where Copyparty stores service state.";
description = "Sets the runtime file that contains the Copyparty account password.";
};
volumes = lib.mkOption {
type = lib.types.attrsOf volumeType;
type = lib.types.attrsOf (
lib.types.submodule {
options = {
path = lib.mkOption {
type = lib.types.externalPath;
description = "Sets the filesystem path served by this volume.";
};
access = lib.mkOption {
type = lib.types.attrsOf (lib.types.listOf lib.types.str);
default = { };
example = {
A = [ "alexion" ];
};
description = "Maps Copyparty permission strings to account names.";
};
readOnly = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Serves this volume read-only.";
};
flags = lib.mkOption {
type = lib.types.attrs;
default = { };
example = {
dthumb = true;
};
description = "Sets Copyparty volume flags.";
};
};
}
);
default = { };
description = "Copyparty volumes keyed by their URL path.";
description = "Defines Copyparty volumes by URL path.";
};
reverseProxy = {
@@ -151,7 +139,7 @@ in
trustedSources = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "IP ranges that may supply Copyparty's forwarded client IP header.";
description = "Lists proxy sources that can set Copyparty's forwarded client IP header.";
};
};
};
@@ -173,46 +161,49 @@ in
}
];
users.users.${cfg.user} = {
users.users.${serviceUser} = {
isSystemUser = true;
uid = config.registry."stable-uid".${config.networking.hostName}.copyparty;
inherit (cfg) group;
home = cfg.stateDir;
group = serviceGroup;
home = serviceStateDir;
};
systemd.services.copyparty = {
description = "Copyparty file server";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
preStart = ''
${lib.getExe' pkgs.coreutils "install"} -m 600 ${configFile} /run/copyparty/copyparty.conf
${replacePassword}
'';
serviceConfig = {
ReloadSignal = "SIGUSR1";
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.stateDir;
User = serviceUser;
Group = serviceGroup;
WorkingDirectory = serviceStateDir;
Environment = [
"HOME=${cfg.stateDir}"
"XDG_CONFIG_HOME=${cfg.stateDir}"
"HOME=${serviceStateDir}"
"XDG_CONFIG_HOME=${serviceStateDir}"
];
StateDirectory = "copyparty";
RuntimeDirectory = "copyparty";
RuntimeDirectoryMode = "0700";
LoadCredential = [ "password:${cfg.passwordFile}" ];
ExecStartPre = configScript;
ExecStart = "${lib.getExe cfg.package} -c /run/copyparty/copyparty.conf";
LoadCredential = [ "${credentialName}:${cfg.passwordFile}" ];
ExecStart = "${lib.getExe pkgs.copyparty-min} -c /run/copyparty/copyparty.conf";
Restart = "on-failure";
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [
cfg.stateDir
serviceStateDir
]
++ map (volume: volume.path) (lib.filter (volume: !volume.readOnly) (lib.attrValues cfg.volumes));
++ map (volume: volume.path) writableVolumes;
};
};
networking.firewall.allowedTCPPorts = [ cfg.port ];
networking.firewall.allowedTCPPorts = [ port ];
})
];
}

View File

@@ -92,33 +92,33 @@
startScript = pkgs.writeShellScript "palworld-start" ''
set -eu
${pkgs.coreutils}/bin/install -d \
${lib.getExe' pkgs.coreutils "install"} -d \
-m 0770 \
${lib.escapeShellArg service.stateDir} \
${lib.escapeShellArg (builtins.dirOf savedDir)}
if [ -L ${lib.escapeShellArg savedDir} ]; then
current_target="$(${pkgs.coreutils}/bin/readlink ${lib.escapeShellArg savedDir})"
current_target="$(${lib.getExe' pkgs.coreutils "readlink"} ${lib.escapeShellArg savedDir})"
if [ "$current_target" != ${lib.escapeShellArg service.stateDir} ]; then
${pkgs.coreutils}/bin/rm ${lib.escapeShellArg savedDir}
${lib.getExe' pkgs.coreutils "rm"} ${lib.escapeShellArg savedDir}
fi
fi
if [ -d ${lib.escapeShellArg savedDir} ] && [ ! -L ${lib.escapeShellArg savedDir} ]; then
if ${pkgs.findutils}/bin/find ${lib.escapeShellArg service.stateDir} -mindepth 1 -maxdepth 1 | ${pkgs.gnugrep}/bin/grep -q .; then
${pkgs.coreutils}/bin/rm -rf ${lib.escapeShellArg savedDir}
if ${lib.getExe' pkgs.findutils "find"} ${lib.escapeShellArg service.stateDir} -mindepth 1 -maxdepth 1 | ${lib.getExe' pkgs.gnugrep "grep"} -q .; then
${lib.getExe' pkgs.coreutils "rm"} -rf ${lib.escapeShellArg savedDir}
else
${pkgs.coreutils}/bin/cp -a ${lib.escapeShellArg savedDir}/. ${lib.escapeShellArg service.stateDir}/
${pkgs.coreutils}/bin/rm -rf ${lib.escapeShellArg savedDir}
${lib.getExe' pkgs.coreutils "cp"} -a ${lib.escapeShellArg savedDir}/. ${lib.escapeShellArg service.stateDir}/
${lib.getExe' pkgs.coreutils "rm"} -rf ${lib.escapeShellArg savedDir}
fi
fi
if [ ! -e ${lib.escapeShellArg savedDir} ]; then
${pkgs.coreutils}/bin/ln -s ${lib.escapeShellArg service.stateDir} ${lib.escapeShellArg savedDir}
${lib.getExe' pkgs.coreutils "ln"} -s ${lib.escapeShellArg service.stateDir} ${lib.escapeShellArg savedDir}
fi
password="$(${pkgs.coreutils}/bin/tr -d '\n' < ${lib.escapeShellArg service.passwordFile})"
admin_password="$(${pkgs.coreutils}/bin/tr -d '\n' < ${lib.escapeShellArg service.adminPasswordFile})"
password="$(${lib.getExe' pkgs.coreutils "tr"} -d '\n' < ${lib.escapeShellArg service.passwordFile})"
admin_password="$(${lib.getExe' pkgs.coreutils "tr"} -d '\n' < ${lib.escapeShellArg service.adminPasswordFile})"
exec ./PalServer.sh \
"$@" \
@@ -145,9 +145,12 @@
]
++ lib.optional service.publicLobby "-publiclobby"
++ lib.optionals (service.publicIP != null) [ "-publicip=${service.publicIP}" ]
++ lib.optional service.rcon.enable "-rcon";
++ lib.optionals service.rcon.enable [
"-rcon"
"-RCONPort=${toString service.rcon.port}"
];
udpPorts = [ service.port ];
tcpPorts = [ service.port ];
tcpPorts = lib.optional service.rcon.enable service.rcon.port;
};
}
);

View File

@@ -51,7 +51,7 @@
startScript = pkgs.writeShellScript "valheim-start" ''
set -eu
password="$(${pkgs.coreutils}/bin/tr -d '\n' < ${lib.escapeShellArg service.passwordFile})"
password="$(${lib.getExe' pkgs.coreutils "tr"} -d '\n' < ${lib.escapeShellArg service.passwordFile})"
exec ./valheim_server.x86_64 "$@" -password "$password"
'';
in

View File

@@ -5,7 +5,7 @@
...
}:
let
cfg = config.modules.services.homepage; # NOLINT(let_order)
cfg = config.modules.services.homepage;
derivedAllowedHosts = lib.unique (
lib.optional (selfRoute != null) selfRoute.domain
@@ -71,12 +71,12 @@ let
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
if item.composite.route.protocol == "https" then
"https://${item.composite.route.domain}"
else if item.composite.route.http.port != null then
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 TCP route entry"
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";
@@ -111,7 +111,8 @@ let
let
sectionOrder = sectionOrderByTab.${tab} or [ ];
in
sectionOrder ++ lib.filter (section: !lib.elem section sectionOrder) (lib.attrNames sections);
lib.filter (section: lib.hasAttr section sections) sectionOrder
++ lib.filter (section: !lib.elem section sectionOrder) (lib.attrNames sections);
in
{

View File

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

View File

@@ -5,13 +5,14 @@
...
}:
let
cfg = config.modules.services.reverse-proxy; # NOLINT(let_order)
generatedUdpStreams = lib.concatLists (
lib.mapAttrsToList (
host: routes:
lib.concatLists (
lib.mapAttrsToList (entry: route: map (mkUdpStream host entry) route.udp.ports) routes
lib.mapAttrsToList (
entry: route: lib.optionals (route.protocol == "udp") (map (mkUdpStream host entry) route.ports)
) routes
)
) registry.route
);
@@ -39,43 +40,14 @@ let
proxy_pass ${stream.address}:${toString stream.port};
}
'') generatedUdpStreams;
generatedVirtualHosts =
if duplicateDomainMessages != [ ] then
throw (builtins.head duplicateDomainMessages)
else
lib.listToAttrs generatedVirtualHostList;
generatedVirtualHosts = lib.listToAttrs generatedVirtualHostList;
duplicateDomainMessages = lib.concatLists (
lib.mapAttrsToList (
domain: virtualHosts:
if lib.length virtualHosts > 1 then
[
"duplicate route domain ${domain} in ${
lib.concatMapStringsSep " and " (
virtualHost: mkRegistryPath virtualHost.registryName virtualHost.host virtualHost.entry
) virtualHosts
}"
]
else
[ ]
) virtualHostsByDomain
);
virtualHostsByDomain = lib.foldl' (
acc: virtualHost:
acc
// {
${virtualHost.name} = (acc.${virtualHost.name} or [ ]) ++ [ virtualHost ];
}
) { } generatedVirtualHostList;
generatedVirtualHostList = generatedHttpsVirtualHostList ++ generatedHttpVirtualHostList;
generatedHttpsVirtualHostList = lib.concatLists (
# Route registry validation already requires unique domains.
generatedVirtualHostList = lib.concatLists (
lib.mapAttrsToList (
host: routes:
lib.mapAttrsToList (entry: _route: mkVirtualHost host entry "https") (
lib.filterAttrs (_entry: route: route.port != null) routes
lib.mapAttrsToList (entry: route: mkVirtualHost host entry route.protocol) (
lib.filterAttrs (_entry: route: route.protocol != "udp") routes
)
) registry.route
);
@@ -89,30 +61,22 @@ let
or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}");
compositeEntry = registry.join host entry;
inherit (compositeEntry) route;
port = if isHttp then route.http.port else route.port;
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.http.port != null) routes
)
) registry.route
);
mkRegistryPath =
registryName: host: entry:
"registry.${registryName}.${host}.${entry}";
@@ -123,7 +87,7 @@ in
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

View File

@@ -15,9 +15,9 @@
};
serviceName = lib.mkOption {
type = lib.types.str;
type = lib.types.strMatching "[a-z0-9][a-z0-9-]*";
example = "valheim";
description = "Name of the systemd service that runs the game server.";
description = "Lowercase systemd service basename for the game server.";
};
appId = lib.mkOption {
@@ -87,7 +87,7 @@
prepareScript = pkgs.writeShellScript "steamcmd-${service.serviceName}-prepare" ''
set -eu
${pkgs.coreutils}/bin/install -d \
${lib.getExe' pkgs.coreutils "install"} -d \
-o ${user} \
-g ${group} \
-m 0770 \
@@ -155,7 +155,7 @@
wantedBy = lib.mkIf service.autoStart [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.systemd}/bin/systemctl --no-block start ${service.serviceName}.service";
ExecStart = "${lib.getExe' pkgs.systemd "systemctl"} --no-block start ${service.serviceName}.service";
};
};

View File

@@ -5,15 +5,11 @@
...
}:
# Bazel for the primary user's shell environment.
let
cfg = config.modules.shell.bazel; # NOLINT(let_order)
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 {
home-manager.users.${user}.home.packages = [ pkgs.bazel ];
config = lib.mkIf config.modules.shell.bazel.enable {
home-manager.users.${config.user.name}.home.packages = [ pkgs.bazel ];
};
}

View File

@@ -1,26 +0,0 @@
{
config,
lib,
...
}:
# direnv for the primary user, configured through home-manager.
let
cfg = config.modules.shell.direnv; # NOLINT(let_order)
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 {
home-manager.users.${user}.programs.direnv = {
enable = true;
silent = true;
# nix-direnv caches the evaluated shell so re-entering a directory is instant
# instead of re-running `nix develop`, and adds the `use flake` stdlib helper.
nix-direnv.enable = true;
stdlib = builtins.readFile ./repo-flake.sh;
};
};
}

View File

@@ -1,33 +0,0 @@
if declare -f use_flake >/dev/null; then
__dotfiles_use_flake_definition="$(declare -f use_flake)"
eval "${__dotfiles_use_flake_definition/use_flake/__dotfiles_nix_direnv_use_flake}"
unset __dotfiles_use_flake_definition
use_flake() {
local flake_expr="${1:-.}"
if [[ "$flake_expr" == //* ]]; then
local git_root
if ! git_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
log_error 'use flake // requires a Git repository'
return 1
fi
local flake_suffix="${flake_expr#//}"
if [[ -n "$flake_suffix" && "$flake_suffix" != \#* && "$flake_suffix" != /* ]]; then
flake_expr="path:${git_root}/${flake_suffix}"
else
flake_expr="path:${git_root}${flake_suffix}"
fi
shift
__dotfiles_nix_direnv_use_flake "$flake_expr" "$@"
return
fi
__dotfiles_nix_direnv_use_flake "$@"
}
else
log_error 'nix-direnv must define use_flake before the // wrapper loads'
return 1
fi

View File

@@ -6,13 +6,9 @@
}:
# fish for the primary user, configured through home-manager.
let
cfg = config.modules.shell.fish; # NOLINT(let_order)
cfg = config.modules.shell.fish;
commandsOf = shortcuts: lib.mapAttrs (_name: shortcut: shortcut.command) shortcuts;
shortcuts = lib.attrByPath [ config.networking.hostName ] { } config.registry.shell-shortcut;
exportEnvVar = name: value: ''
set -gx ${name} ${lib.escapeShellArg value}
'';
envVars = lib.attrByPath [ config.networking.hostName ] { } config.registry.env-var;
user = config.user.name;
in
{
@@ -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;
};
};
};

View File

@@ -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

View File

@@ -5,14 +5,13 @@
}:
# git for the primary user, configured through home-manager.
let
cfg = config.modules.shell.git; # NOLINT(let_order)
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;

View File

@@ -6,14 +6,13 @@
}:
# gitea-axi for the primary user, installed through its own home-manager module.
let
cfg = config.modules.shell.gitea-axi; # NOLINT(let_order)
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;
};

View File

@@ -6,14 +6,13 @@
}:
# Herdr, a terminal multiplexer for coding agents.
let
cfg = config.modules.shell.herdr; # NOLINT(let_order)
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 [

View File

@@ -7,14 +7,13 @@
}:
# Neovim for the primary user, configured declaratively through nixvim.
let
cfg = config.modules.shell.nvim; # NOLINT(let_order)
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";

View File

@@ -6,7 +6,6 @@
}:
# Oh My Pi, installed alongside upstream Pi as the `omp` command.
let
cfg = config.modules.shell.omp; # NOLINT(let_order)
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 ];
};
}

View File

@@ -8,24 +8,18 @@
# 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; # NOLINT(let_order)
vendoredPi = pkgs.pi-coding-agent.overrideAttrs {
src = ../../../../../third_party/pi;
patches = [ ];
};
user = config.user.name;
piDir = "${config.users.users.${user}.home}/.pi/agent";
in
{
options.modules.shell.pi = {
enable = lib.mkEnableOption "Pi, a terminal coding agent, configured through home-manager";
};
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.alexion.pi-coding-agent;
settings = {
defaultProvider = "openai-codex";

View File

@@ -6,15 +6,14 @@
}:
# Python tools for the primary user's shell environment.
let
cfg = config.modules.shell.python; # NOLINT(let_order)
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 ];
};

View File

@@ -6,7 +6,6 @@
}:
# Rust tools for the primary user's shell environment.
let
cfg = config.modules.shell.rust; # NOLINT(let_order)
toolchain = pkgs.symlinkJoin {
name = "rust-toolchain";
paths = with pkgs; [
@@ -23,8 +22,8 @@ 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 = [

View File

@@ -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; # NOLINT(let_order)
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 = {

View File

@@ -6,13 +6,12 @@
}:
# Yazi, a terminal file manager.
let
cfg = config.modules.shell.yazi; # NOLINT(let_order)
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;

View File

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

View File

@@ -1,10 +1,10 @@
{ lib, ... }:
{ 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.";
}
];

View File

@@ -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.";
};
};
};

View 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;
};
};
};
};
};
};
}

View File

@@ -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.";
};
};
};

View File

@@ -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.";
};
};
}
);
}

View File

@@ -2,25 +2,24 @@
let
duplicateUids =
entries:
filterAttrs (_uid: serviceNames: builtins.length serviceNames > 1) (serviceNamesByUid entries);
lib.filterAttrs (_uid: entryNames: builtins.length entryNames > 1) (entryNamesByUid entries);
serviceNamesByUid =
entryNamesByUid =
entries:
mapAttrs (_uid: uidEntries: unique (map (entry: entry.entry) uidEntries)) (
groupBy (entry: toString entry.value) 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.";
}
];
}

View 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.";
};
};
};
}

View 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 = "";
};
};
};
}

View File

@@ -5,7 +5,7 @@
"livePreview": false,
"readableLineLength": false,
"strictLineBreaks": true,
"propertiesInDocument": "hidden",
"propertiesInDocument": "visible",
"showLineNumber": true,
"autoPairMarkdown": false,
"useTab": false,

View File

@@ -5,5 +5,6 @@
"obsidian-style-settings",
"custom-font-loader",
"obsidian-icon-folder",
"table-editor-obsidian"
"table-editor-obsidian",
"obsidian-linter"
]

View File

@@ -28,6 +28,20 @@
"rgb": 5366249
}
},
{
"query": "path:\"03 wrk/tasks\"[\"status\":open]",
"color": {
"a": 1,
"rgb": 14725458
}
},
{
"query": "path:\"03 wrk/tasks\" [\"status\":closed]",
"color": {
"a": 1,
"rgb": 11657298
}
},
{
"query": "path:\"03 wrk\" ",
"color": {
@@ -60,6 +74,6 @@
"repelStrength": 20,
"linkStrength": 1,
"linkDistance": 250,
"scale": 0.3645607001522557,
"scale": 0.24304046676817048,
"close": false
}

View File

@@ -0,0 +1,326 @@
{
"ruleConfigs": {
"add-blank-line-after-yaml": {
"enabled": true
},
"dedupe-yaml-array-values": {
"enabled": true,
"dedupe-alias-key": true,
"dedupe-tag-key": true,
"dedupe-array-keys": true,
"ignore-keys": []
},
"escape-yaml-special-characters": {
"enabled": true,
"try-to-escape-single-line-arrays": true
},
"force-yaml-escape": {
"enabled": true,
"force-yaml-escape-keys": []
},
"format-tags-in-yaml": {
"enabled": true
},
"format-yaml-array": {
"enabled": true,
"alias-key": true,
"tag-key": true,
"default-array-keys": true,
"force-single-line-array-style": [],
"force-multi-line-array-style": []
},
"insert-yaml-attributes": {
"enabled": true,
"text-to-insert": []
},
"move-inline-fields-to-yaml": {
"enabled": true,
"how-to-handle-full-line-fields": "Move and remove",
"how-to-handle-bracketed-fields": "Leave in place",
"how-to-handle-existing-keys": "Skip",
"inline-keys-to-ignore": []
},
"move-tags-to-yaml": {
"enabled": true,
"how-to-handle-existing-tags": "Nothing",
"tags-to-ignore": []
},
"remove-yaml-keys": {
"enabled": true,
"yaml-keys-to-remove": []
},
"sort-yaml-array-values": {
"enabled": true,
"sort-alias-key": true,
"sort-tag-key": true,
"sort-array-keys": true,
"ignore-keys": [],
"sort-order": "Ascending Alphabetical"
},
"yaml-key-sort": {
"enabled": true,
"yaml-key-priority-sort-order": [
"aliases",
"date-created",
"date-modified"
],
"priority-keys-at-start-of-yaml": true,
"yaml-sort-order-for-other-keys": "None"
},
"yaml-timestamp": {
"enabled": true,
"date-created": true,
"date-created-key": "date-created",
"date-created-source-of-truth": "file system",
"date-modified": true,
"date-modified-key": "date-modified",
"date-modified-source-of-truth": "file system",
"format": "YYYY-MM-DD HH:mm",
"convert-to-utc": false,
"update-on-file-contents-updated": "after 5 seconds"
},
"yaml-title": {
"enabled": false,
"title-key": "title",
"mode": "first-h1-or-filename-if-h1-missing"
},
"yaml-title-alias": {
"enabled": true,
"preserve-existing-alias-section-style": true,
"keep-alias-that-matches-the-filename": false,
"use-yaml-key-to-keep-track-of-old-filename-or-heading": false,
"alias-helper-key": "linter-yaml-title-alias",
"remove-alias-if-empty": true
},
"capitalize-headings": {
"enabled": true,
"style": "Title Case",
"ignore-case-words": [],
"lowercase-words": [],
"starting-word-ignore-characters": "'\"(‘“-",
"ending-word-ignore-characters": ".?!,:;'\")”’0123456789-",
"ignore-words": []
},
"file-name-heading": {
"enabled": true
},
"header-increment": {
"enabled": true,
"start-at-h2": false
},
"headings-start-line": {
"enabled": true
},
"remove-trailing-punctuation-in-heading": {
"enabled": true,
"punctuation-to-remove": ".,;:!。,;:!"
},
"footnote-after-punctuation": {
"enabled": true
},
"move-footnotes-to-the-bottom": {
"enabled": true,
"include-blank-line-between-footnotes": false
},
"re-index-footnotes": {
"enabled": true
},
"auto-correct-common-misspellings": {
"enabled": true,
"ignore-words": [],
"skip-words-with-multiple-capitals": true,
"extra-auto-correct-files": []
},
"blockquote-style": {
"enabled": true,
"style": "space"
},
"convert-bullet-list-markers": {
"enabled": true
},
"default-language-for-code-fences": {
"enabled": true,
"default-language": ""
},
"emphasis-style": {
"enabled": true,
"style": "consistent"
},
"no-bare-urls": {
"enabled": true,
"no-bare-uris": true
},
"ordered-list-style": {
"enabled": true,
"number-style": "ascending",
"list-end-style": ".",
"preserve-start": false
},
"proper-ellipsis": {
"enabled": true
},
"quote-style": {
"enabled": true,
"single-quote-enabled": true,
"single-quote-style": "''",
"double-quote-enabled": true,
"double-quote-style": "\"\""
},
"remove-consecutive-list-markers": {
"enabled": true
},
"remove-empty-list-markers": {
"enabled": true
},
"remove-hyphenated-line-breaks": {
"enabled": true
},
"remove-multiple-spaces": {
"enabled": true
},
"strong-style": {
"enabled": true,
"style": "consistent"
},
"two-spaces-between-lines-with-content": {
"enabled": false,
"line-break-indicator": " "
},
"unordered-list-style": {
"enabled": true,
"list-style": "consistent"
},
"align-table-columns": {
"enabled": true
},
"compact-yaml": {
"enabled": true,
"inner-new-lines": false
},
"consecutive-blank-lines": {
"enabled": true
},
"convert-spaces-to-tabs": {
"enabled": true,
"tabsize": 4
},
"empty-line-around-blockquotes": {
"enabled": true
},
"empty-line-around-code-fences": {
"enabled": true
},
"empty-line-around-horizontal-rules": {
"enabled": true
},
"empty-line-around-math-blocks": {
"enabled": true
},
"empty-line-around-tables": {
"enabled": true
},
"heading-blank-lines": {
"enabled": true,
"bottom": true,
"empty-line-after-yaml": true
},
"line-break-at-document-end": {
"enabled": true
},
"move-math-block-indicators-to-their-own-line": {
"enabled": true
},
"paragraph-blank-lines": {
"enabled": true
},
"remove-empty-lines-between-list-markers-and-checklists": {
"enabled": true
},
"remove-link-spacing": {
"enabled": true
},
"remove-space-around-characters": {
"enabled": true,
"include-fullwidth-forms": true,
"include-cjk-symbols-and-punctuation": true,
"include-dashes": true,
"other-symbols": ""
},
"remove-space-before-or-after-characters": {
"enabled": true,
"characters-to-remove-space-before": ",!?;:).’”]",
"characters-to-remove-space-after": "¿¡‘“(["
},
"space-after-list-markers": {
"enabled": true
},
"space-between-chinese-japanese-or-korean-and-english-or-numbers": {
"enabled": true,
"english-symbols-punctuation-before": "-+;:'\"°%$)]",
"english-symbols-punctuation-after": "-+'\"([¥$"
},
"trailing-spaces": {
"enabled": true,
"two-space-line-break": false
},
"add-blockquote-indentation-on-paste": {
"enabled": true
},
"prevent-double-checklist-indicator-on-paste": {
"enabled": true
},
"prevent-double-list-item-indicator-on-paste": {
"enabled": true
},
"proper-ellipsis-on-paste": {
"enabled": true
},
"remove-hyphens-on-paste": {
"enabled": true
},
"remove-leading-or-trailing-whitespace-on-paste": {
"enabled": true
},
"remove-leftover-footnotes-from-quote-on-paste": {
"enabled": true
},
"remove-multiple-blank-lines-on-paste": {
"enabled": true
}
},
"lintOnSave": true,
"recordLintOnSaveLogs": false,
"displayChanged": true,
"suppressMessageWhenNoChange": true,
"enableDiffPreviewView": true,
"suppressLintAllFilesConfirmationModal": false,
"suppressLintAllFilesInFolderConfirmationModal": false,
"lintOnFileChange": false,
"displayLintOnFileChangeNotice": false,
"settingsConvertedToConfigKeyValues": true,
"textAreaSettingsConvertedToListItemSettings": true,
"defaultArrayStyleMovedToCommonStyles": true,
"additionalFileExtensions": [],
"foldersToIgnore": [
"99 meta/templates"
],
"filesToIgnore": [
{
"label": "todo",
"match": "TODO.md",
"flags": "i"
}
],
"linterLocale": "system-default",
"logLevel": "ERROR",
"lintCommands": [],
"customRegexes": [],
"commonStyles": {
"aliasArrayStyle": "multi-line",
"tagArrayStyle": "single-line",
"defaultArrayStyle": "multi-line",
"minimumNumberOfDollarSignsToBeAMathBlock": 2,
"escapeCharacter": "\"",
"removeUnnecessaryEscapeCharsForMultiLineArrays": true
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,11 @@
{
"id": "obsidian-linter",
"name": "Linter",
"version": "1.34.1",
"minAppVersion": "1.13.0",
"description": "Formats and styles your notes. It can be used to format YAML tags, aliases, arrays, and metadata; footnotes; headings; spacing; math blocks; regular markdown contents like list, italics, and bold styles; and more with the use of custom rule options as well.",
"author": "Victor Tao",
"authorUrl": "https://github.com/platers",
"helpUrl": "https://platers.github.io/obsidian-linter/",
"isDesktopOnly": false
}

View File

@@ -0,0 +1 @@
.linter-no-border{border:none}.linter-border-bottom{border-bottom:1px solid var(--background-modifier-border);border-top:0;margin-bottom:.75em}.custom-row-description{margin-top:0}.modal-warn,.search-zero-state{font-weight:700}.modal-heading,.search-zero-state{text-align:center}.confirm-modal-checkbox-container{align-items:center;display:flex;gap:6px;margin-top:8px}.disabled-list-entry{color:var(--text-faint)}.linter-diff-preview-view{display:flex;flex-direction:column;height:100%;min-width:0;overflow:hidden}.linter-diff-preview-empty{color:var(--text-muted);padding:16px}.linter-diff-preview-header{align-items:flex-start;border-bottom:1px solid var(--background-modifier-border);display:flex;flex:0 0 auto;gap:12px;justify-content:space-between;padding:12px 14px}.linter-diff-preview-title-group{min-width:0}.linter-diff-preview-header h3{font-size:var(--font-ui-medium);line-height:1.3;margin:0 0 6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.linter-diff-preview-actions{display:flex;flex:0 0 auto;gap:8px}.linter-diff-preview-body{flex:1 1 auto;min-height:0;overflow:auto;padding:12px 14px 16px}.linter-diff-preview-view .linter-diff-summary{color:var(--text-normal);display:inline-block;font-size:var(--font-ui-small);margin:0 0 10px;padding:5px 8px}.linter-diff-preview-view .linter-diff,.linter-diff-preview-view .linter-diff-summary{background:var(--background-secondary);border:1px solid var(--background-modifier-border);border-radius:6px}.linter-diff-preview-view .linter-diff{font-family:var(--font-monospace);font-size:var(--font-smaller);line-height:1.45;margin:0;overflow:hidden;padding:6px 0}.linter-diff-preview-view .linter-diff-line{display:grid;grid-template-columns:26px minmax(0,1fr);min-height:20px;padding:1px 10px 1px 0}.linter-diff-preview-view .linter-diff-marker{color:var(--text-muted);padding-right:8px;text-align:right;user-select:none}.linter-diff-preview-view .linter-diff-text{min-width:0;overflow-wrap:anywhere;white-space:pre-wrap}.linter-diff-preview-view .linter-diff-skip{border-bottom:1px solid var(--background-modifier-border);border-top:1px solid var(--background-modifier-border);color:var(--text-faint);font-family:var(--font-interface);font-size:var(--font-ui-smaller);padding:4px 12px;text-align:center}.linter-diff-preview-view .linter-diff-added{background:rgba(var(--color-green-rgb),.12);color:var(--text-normal)}.linter-diff-preview-view .linter-diff-removed{background:rgba(var(--color-red-rgb),.12);color:var(--text-normal)}.linter-diff-preview-view .linter-diff-context{color:var(--text-muted)}

View File

@@ -4,21 +4,21 @@
"type": "split",
"children": [
{
"id": "6d9088d392cb8604",
"id": "81c4860f495113bf",
"type": "tabs",
"children": [
{
"id": "22a4c1f201a1b00e",
"id": "94a7b5a014888797",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "00 INBOX/valheim progression.md",
"file": "03 wrk/tasks/0002-nixos-refactor.md",
"mode": "source",
"source": true
},
"icon": "lucide-file",
"title": "valheim progression"
"title": "0002-nixos-refactor"
}
}
]
@@ -91,7 +91,7 @@
"state": {
"type": "localgraph",
"state": {
"file": "00 INBOX/valheim progression.md",
"file": "03 wrk/tasks/0002-nixos-refactor.md",
"options": {
"collapse-filter": true,
"search": "",
@@ -115,7 +115,7 @@
"linkStrength": 1,
"linkDistance": 250,
"scale": 0.6666666666666666,
"close": true
"close": false
}
},
"icon": "lucide-git-fork",
@@ -184,20 +184,6 @@
"title": "tag"
}
},
{
"id": "8918bf52a32e4a8e",
"type": "leaf",
"state": {
"type": "all-properties",
"state": {
"sortOrder": "frequency",
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-ghost",
"title": "all-properties"
}
},
{
"id": "c10908b3bd97f5fa",
"type": "leaf",
@@ -231,42 +217,43 @@
"table-editor-obsidian:Advanced Tables Toolbar": false
}
},
"active": "22a4c1f201a1b00e",
"active": "94a7b5a014888797",
"lastOpenFiles": [
"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~",
"00 INBOX/valheim progression.md",
"TODO.md",
"00 INBOX/ai/deadnix-architecture.md",
"03 wrk/productivity.md",
"03 wrk/tasks/0003-whisper-dictation.md",
"03 wrk/tasks/0001-todo-cli.md",
"03 wrk/tasks/0002-nixos-refactor.md",
"03 wrk/dotfiles.md",
"03 wrk/machoke.md",
"03 wrk/falinks.md",
"03 wrk/ariadne.md",
"99 meta/templates/task.md",
"03 wrk/tasks/0005-modules-services-navidrome.md",
"03 wrk/tasks/0003-registry-homepage.md",
"03 wrk/tasks/0039-hosts-zeus-hardware-configuration.md",
"03 wrk/tasks/0030-dotfiles-hosts.md",
"03 wrk/tasks/0038-hosts-zeus-disk.md",
"03 wrk/tasks/0037-hosts-zeus-default.md",
"03 wrk/tasks/0036-hosts-pikachu-hardware-configuration.md",
"03 wrk/tasks/0035-hosts-pikachu-disk.md",
"03 wrk/tasks/0034-hosts-pikachu-default.md",
"03 wrk/tasks/0033-hosts-neogaia-hardware-configuration.md",
"03 wrk/tasks/0032-hosts-neogaia-disk.md",
"03 wrk/tasks/0031-hosts-neogaia-default.md",
"03 wrk/tasks/0029-guests-valheim.md",
"03 wrk/tasks/0028-guests-reverse-proxy.md",
"03 wrk/tasks/0027-guests-palworld.md",
"03 wrk/tasks/0026-guests-navidrome.md",
"03 wrk/tasks",
"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/ariadne-pipeline-stage-boundary.md",
"00 INBOX/ai/ariadne-project-goals.md",
"02 notes/steamcmd server setup.md",
"00 INBOX/how to setup a steamcmd server.md",
"00 INBOX/ai/clew-ast-exploration.md~",
"03 wrk/machoke.md~",
"03 wrk/falinks.md~",
"99 meta/attachments/bases/unlinked mentions.base",
"00 INBOX/ai",
"03 wrk/ai",
"99 meta/Untitled"
"03 wrk/ai"
]
}

View File

@@ -1,304 +0,0 @@
# Deadnix Architecture
## Purpose
This note explains how `deadnix` finds unused Nix code.
It focuses on architecture, not whether each check belongs in our linter.
Use this note when deciding whether our linter needs scope analysis.
## What Deadnix Is
`deadnix` is a dead-code scanner for Nix.
It finds bindings that introduce names which no live code uses.
It is not a general style linter.
It does not have many independent rules.
It implements one analysis family: unused declarations.
Like `statix`, it parses source text with `rnix`.
It does not evaluate Nix.
It does not import files.
It reasons over local syntax and local scope only.
## Minimal Dead-Code Vocabulary
- A binding introduces a name.
- A scope defines where introduced names are visible.
- A use is an identifier occurrence that refers to a binding.
- Shadowing happens when an inner scope reuses an outer name.
- Dead code is a binding that no live code uses.
- A fixed point is the final result after repeated analysis no longer changes.
Dead-code analysis needs more state than a simple syntax lint.
It must know which names a scope introduces and where those names can be used.
## Component Map
`deadnix` has fewer layers than `statix`.
The command-line entry point, file walk, parsing, analysis, reporting, and editing live in one small tool.
The important concepts are separate modules:
- settings control analysis options;
- scopes describe where names are introduced;
- bindings describe individual declared names;
- usage search answers whether a name appears in a live body;
- dead-code results connect a binding to its scope;
- reports render findings;
- edits remove or rename unused declarations.
## Main Pipeline
```mermaid
flowchart TD
A[Parse CLI options] --> B[Collect input files]
B --> C[Read each file]
C --> D[Parse with rnix]
D --> E[Skip file on parse errors]
D --> F[Run dead-code analysis]
F --> G[Print reports unless quiet]
G --> H[Apply edits when --edit is set]
H --> I[Exit 1 when --fail and findings exist]
```
The CLI accepts files or directories.
Directory mode walks `.nix` files.
Hidden files require an explicit option.
Excludes filter paths before analysis.
The tool skips dead-code diagnostics when a file has parse errors.
It reports parse errors to the user and continues with other files.
## Analysis Settings
The analysis has four important switches.
`no_lambda_arg` disables checks for single lambda arguments.
This can avoid noise when an API requires an argument name.
`no_lambda_pattern_names` disables checks for names in attribute-set function patterns.
This matters for `callPackage`-style code, where argument names can be external API.
`no_underscore` ignores bindings that start with `_`.
This treats `_name` as an intentional unused placeholder.
`warn_used_underscore` reports underscore-prefixed bindings that code actually uses.
This enforces the convention that `_name` means unused.
These settings are architecture-relevant because dead-code policy depends on API context.
A repository linter needs a way to express that context.
## Scope Model
`deadnix` recognizes four scope types.
### Lambda pattern scope
A lambda pattern is a function argument written as an attribute set pattern.
It can introduce field names and an optional alias for the whole argument set.
Example shape:
```nix
args @ { pkgs, lib, ... }: body
```
This scope can introduce `args`, `pkgs`, and `lib`.
### Lambda argument scope
A single lambda argument introduces one name.
Example shape:
```nix
pkgs: body
```
This scope introduces `pkgs`.
### Let scope
A `let` expression introduces bindings and inherited names.
Those names are available in binding expressions and in the body.
Example shape:
```nix
let
inherit (pkgs) hello;
value = hello;
in
value
```
This scope introduces `hello` and `value`.
### Recursive attribute-set scope
A `rec` attribute set introduces names that sibling attributes can reference.
`deadnix` models this scope, but treats its bindings as non-removable.
That conservative choice avoids deleting public-looking attributes from recursive sets.
## Binding Model
A binding stores three facts.
It stores the identifier name.
It stores the syntax node that declared the name.
It stores whether the binding is mortal.
A mortal binding can become a dead-code finding.
A non-mortal binding participates in name lookup but will not be removed.
Single lambda arguments that already start with `_` are non-mortal.
Recursive attribute-set bindings are also non-mortal.
A binding also checks for the `deadnix: skip` pragma.
The pragma applies to a nearby following declaration.
The scanner searches backward from the declaration and stops after a blank-line boundary.
## Usage Search
Usage search asks whether a given name appears inside a syntax subtree.
The search is scope-aware.
When it enters a child scope that binds the same name, it stops following that branch.
That prevents an inner binding from counting as a use of an outer binding.
Attribute paths get special handling.
Ordinary attribute keys do not count as uses.
Dynamic or string attribute names can contain expressions, so the search looks inside those parts.
`inherit` also gets special handling.
A scope can inherit names from an outer scope.
The analysis checks whether the inherited expression uses the searched name.
## Fixed-Point Analysis
`deadnix` repeats the scan until the result set stops changing.
This matters because dead code can depend on other dead code.
A binding can appear used during the first pass because another binding references it.
If the referring binding is dead, the referenced binding is also dead.
The loop tracks dead declaration nodes.
During later scans, uses inside already-dead bodies do not keep other bindings alive.
This fixed-point loop is the central architectural difference from simple local syntax lints.
It makes dead-code results transitive.
## Reporting Model
A result stores the scope and binding for one finding.
The display text combines both parts.
Examples include unused lambda pattern, unused lambda argument, and unused let binding.
Human output uses `ariadne`.
The reporter groups ranges by line and labels the exact binding names.
It uses different colors for different scope kinds.
JSON output exists behind a feature flag.
It emits file, message, line, column, and end column.
`NO_COLOR` disables colored human output.
## Edit Model
`deadnix --edit` converts findings into text edits.
Each edit has a start offset, end offset, and replacement string.
The edit behavior depends on scope kind.
For unused lambda pattern names, it removes the name and nearby comma or whitespace.
For unused pattern aliases, it removes the alias syntax.
For unused lambda arguments, it renames the argument with an underscore prefix.
For unused `let` assignments, it deletes the assignment.
For unused inherited names, it deletes only the inherited name.
After those edits, it parses the edited source again.
Then it removes empty `let in` constructs and empty `inherit` statements.
This cleanup pass prevents the first edit batch from leaving invalid or useless scaffolding.
## Error Handling
If a path cannot be stated or read, the CLI reports the file error and continues.
If a file has parse errors, the CLI reports those errors and skips dead-code analysis for that file.
The `--fail` flag changes exit status when findings exist.
Without `--fail`, findings do not make the command fail.
This design separates report generation from CI policy.
## Architectural Strengths
The scope model is explicit.
Each scope type declares which bindings it introduces and where those bindings are visible.
The fixed-point loop catches transitive dead code.
A binding used only by dead code also becomes dead.
The usage search handles shadowing.
That prevents common false positives in nested functions and nested `let` expressions.
The edit model is tied to analysis results.
It knows whether a binding came from a lambda, pattern, `let`, or `inherit`.
The tool has few moving parts.
That makes the analysis easy to follow compared to a general multi-rule linter.
## Architectural Limits
`deadnix` does not evaluate Nix.
It cannot know which names external callers depend on.
This matters for module arguments, package function arguments, and public attribute sets.
It has global switches rather than project-specific rule contexts.
For example, all lambda pattern names can be checked or skipped.
The tool does not know that one directory contains `callPackage` functions.
The analysis is local to one file.
It cannot see that another file imports a function and relies on its argument names.
The fixer performs text edits with byte ranges.
It must handle punctuation and whitespace carefully.
Pattern edits are harder than simple assignment deletion.
The tool has one analysis family.
It is not a framework for many unrelated rules.
## Lessons for Our Linter
Dead-code checks need a scope pass.
A per-node rule interface is not enough when a diagnostic depends on name visibility.
Represent scopes explicitly.
Each scope type should provide introduced bindings and visible bodies.
Track shadowing before reporting unused names.
A text search for an identifier is not sufficient.
Use fixed-point analysis for transitive dead code.
One pass misses names used only by dead declarations.
Separate policy from mechanics.
The mechanics can find unused names.
Repository policy decides whether module arguments, package arguments, or underscore names count.
Treat autofix as scope-specific.
Removing a pattern name is not the same operation as deleting a `let` assignment.
Run cleanup after edits.
Dead-code fixes can create empty `let` expressions and empty `inherit` statements.
## Open Design Questions for Our Linter
- Should dead-code analysis be part of `nix-style-linter`, or a separate command?
- Should policy vary by path, file kind, or suppression comment?
- Should recursive attribute-set members ever become removable findings?
- Should underscore names mean ignored, or should used underscore names be errors?
- Should the linter report unused names without offering autofix first?
- Should cleanup rules run as ordinary rules or as a post-fix pass?

View 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/

View File

@@ -5,22 +5,28 @@ date-created: 2026-09-23 07:13
# Valheim Progression
## Notation & Terms
| Notation | Meaning |
| -------- | ---------------------------------------------------------------- |
| A <- B | A can be OBTAINED from/through B |
| A => B | A DISCOVERS/UNLOCKS B in-game |
| A -> B | A is REQUIRED to make/do 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 with game-defined LEVEL/TIER n |
| 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
@@ -40,13 +46,13 @@ Copper Ore{1} + Coal{2} @ Smelter -> Copper
Copper Ore <- Mine Copper Deposit
Copper Deposit + Pickaxe[0] -> Mine Copper Deposit
Copper Deposit + Pickaxe$0 -> Mine Copper Deposit
Copper Deposit @ Black Forest
* Black Forest
Antler Pickaxe -> Pickaxe[0]
Antler Pickaxe -> Pickaxe$0
Wood + Hard Antler + Workbench[1] => Antler Pickaxe
@@ -96,7 +102,7 @@ Tin Ore{1} + Coal{2} @ Smelter -> Tin
Tin Ore <- Mine Tin Deposit
Tin Deposit + Pickaxe[0] -> Mine Tin Deposit
Tin Deposit + Pickaxe$0 -> Mine Tin Deposit
Tin Deposit @ Black Forest
@@ -104,9 +110,9 @@ Stone + Coal + Wood + Copper + Workbench[1] + Hammer => Forge[1]
Stone{4} + Coal{4} + Wood{10} + Copper{6} @ Workbench[1] -> Forge[1]
Chain @ Sunken Crypt
Chain @ Sunken Crypts
Swamp Key -> Sunken Crypt
Swamp Key -> Sunken Crypts
Swamp Key <- Kill Elder
@@ -118,8 +124,144 @@ Ancient Seed <- Greydwarf Nest
Elder Forsaken Altar @ Black Forest
Sunken Crypt @ Swamp
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

8
docs/03 wrk/dotfiles.md Normal file
View File

@@ -0,0 +1,8 @@
---
aliases:
- Dotfiles
date-created: 2026-10-14 16:27
date-modified: 2026-10-14 16:28
---
# Dotfiles

View File

@@ -0,0 +1,8 @@
---
aliases:
- Productivity
date-created: 2026-10-14 16:32
date-modified: 2026-10-14 16:33
---
# Productivity

View File

@@ -0,0 +1,15 @@
---
aliases:
- "0001"
- TODO CLI
date-created: 2026-10-14 00:49
date-modified: 2026-10-14 16:33
status: open
area: "[[productivity]]"
blocked-by: []
subtasks: []
---
# TODO CLI
Write a TODO CLI that enforces the task workflow in this vault deterministically. This allows agents to participate in task management with minimal cost instead of relying on markdown generation and parsing arbitrary metadata while maintaining human readability.

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