Compare commits

254 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
e5b76515f1 refactor(nix): order let bindings 2026-09-26 23:43:45 -04:00
324d40c85d style(nixos): suppress cfg order findings 2026-09-26 23:32:34 -04:00
dd82367dcb feat(natu): support inline suppressions 2026-09-26 23:30:01 -04:00
ac5cc8d659 fix(natu): preserve NixOS cfg bindings 2026-09-26 23:24:54 -04:00
5e5c3e2534 feat(natu): report expected let order 2026-09-26 23:20:00 -04:00
09a2093f90 feat(nvim): enable nixd 2026-09-26 22:36:03 -04:00
8000367613 refactor(dotfiles): order let bindings 2026-09-26 21:04:57 -04:00
38dff04ece fix(natu): resolve inherited attributes 2026-09-26 20:27:48 -04:00
0c1a1c1ffa refactor(natu): rename xatu 2026-09-26 20:12:57 -04:00
27d9194ac3 feat(xatu): check let binding order 2026-09-26 20:06:14 -04:00
c4ea12b3ce feat(xatu): add scope graph 2026-09-26 20:05:59 -04:00
16ce11fc52 chore(valheim): select butter-chicken world 2026-09-26 13:40:57 -04:00
a73636a84f feat(zeus): install r2modman 2026-09-25 13:21:40 -04:00
80db2b61e6 chore: update 2026-09-24 23:11:47 -04:00
7cc066b55d chore(nixos): update Pi default model 2026-09-24 23:10:12 -04:00
74e7d2d211 feat(skills): enable craft skill repo-wide 2026-09-23 23:38:30 -04:00
98d734e977 docs: add valheim progression note 2026-09-23 09:51:55 -04:00
dda05559a5 feat(nixos): add copyparty opds volume 2026-09-20 20:38:15 -04:00
594877c4ba fix(nixos): mirror copyparty volume paths 2026-09-20 12:14:45 -04:00
f9aff6839e feat(xatu): add manual inherit from lint 2026-09-19 23:30:56 -04:00
4b7d3834ab feat(xatu): add manual inherit lint 2026-09-19 21:27:23 -04:00
213cd84642 refactor(xatu): narrow lint rule set 2026-09-19 20:17:24 -04:00
6e19169e11 feat(xatu): report empty let expressions 2026-09-19 18:25:25 -04:00
be8744795c feat(xatu): report unquoted URI literals 2026-09-19 17:51:46 -04:00
ad00301ac9 docs(rust-style): order declarations by dependency 2026-09-19 17:38:09 -04:00
87dc93513b feat(xatu): set CLI exit statuses 2026-09-19 17:06:11 -04:00
58f2350d5f fix(xatu): ignore testdata directories 2026-09-19 16:49:50 -04:00
4b9beae501 refactor(flake): split root output wiring 2026-09-19 16:19:55 -04:00
addcf7ca8e feat(xatu): report deprecated toPath calls 2026-09-19 15:17:38 -04:00
6887b104d6 build(rust): vendor googletest crate 2026-09-19 14:53:22 -04:00
a6fe84190e test(xatu): cover future sight findings 2026-09-18 23:25:00 -04:00
1fa3962fed refactor(xatu): return structured reports 2026-09-18 22:43:17 -04:00
113018a62c feat(xatu): reject files with parse errors 2026-09-18 21:14:00 -04:00
11f1eba26f feat(xatu): add lint architecture 2026-09-18 20:57:39 -04:00
d974a46211 chore(vendor): remove unused anyhow crate 2026-09-17 15:14:18 -04:00
6b29f2c2b9 feat(status): add error context 2026-09-17 15:07:14 -04:00
49cf043035 feat(status): add status type 2026-09-17 14:57:50 -04:00
37539682d4 chore(vendor): add anyhow crate 2026-09-17 14:13:23 -04:00
7a3a3d6b90 feat(xatu): scaffold source collection 2026-09-17 14:09:47 -04:00
87916a8e17 docs(skills): add build style skill 2026-09-17 12:45:29 -04:00
498d0dd467 docs: update project todo list 2026-09-17 09:57:17 -04:00
b4b77faaa9 refactor(rust): align workspace with style rules 2026-09-17 09:57:11 -04:00
cf9a671fb8 feat(skills): add style review workflow 2026-09-17 09:56:58 -04:00
247712582a chore: remove unused Rust projects 2026-09-17 01:14:58 -04:00
ddc7952399 feat(xatu): scaffold project
Add the Xatu Rust package as a minimal hello-world skeleton with Cargo workspace metadata and Bazel library, binary, and test targets.

Include the Statix and Deadnix evaluation notes so the new linter work starts with the prior rule and architecture research.
2026-09-17 00:26:12 -04:00
885124c240 feat(nix): lint let binding order 2026-09-16 18:13:35 -04:00
7b09cea3af feat(nix): add style linter scaffold 2026-09-16 16:03:59 -04:00
82ad65a3a4 build(rust): vendor rnix parser crate 2026-09-16 15:51:43 -04:00
cbdf482422 refactor(dotfiles): order default let bindings 2026-09-16 14:50:19 -04:00
82b727e437 refactor(dotfiles): use formatter output in fix app 2026-09-16 14:19:28 -04:00
652b92d4f4 docs(skills): add nix let review rule 2026-09-16 13:44:35 -04:00
8154feba5a feat(skills): add nix style guidance 2026-09-16 11:27:24 -04:00
e15ce7f16b style(nix): apply style fixes 2026-09-16 00:16:08 -04:00
d24f58a4c9 feat(nix): add style tooling 2026-09-16 00:13:28 -04:00
5d40b43dd9 fix(nixos): avoid redundant deploy closure transfers 2026-09-15 21:16:30 -04:00
9a038d9821 feat(nixos): add pikachu homepage guest 2026-09-15 20:44:56 -04:00
fca37666d3 fix(nixos): keep palworld server stopped by default 2026-09-15 19:03:00 -04:00
aa1a330ef3 fix(nixos): decouple game startup from guest readiness 2026-09-15 18:04:51 -04:00
7be45ac44c fix(nixos): deploy from source checkout 2026-09-15 17:08:49 -04:00
4b3ca27637 docs(obsidian): process inbox notes 2026-09-15 09:44:36 -04:00
a73b59aa0a feat(copyparty): add read-only root volume 2026-09-14 22:50:45 -04:00
6159d87d10 fix(valheim): disable raids 2026-09-14 21:21:00 -04:00
e65eb8389f chore(pi): drop obsolete tool validation patch 2026-09-14 20:29:37 -04:00
212ed506a2 fix(pi): reject broken managed tool binaries 2026-09-14 10:57:23 -04:00
a8a18d27e9 fix(pi): discover ancestor project resources 2026-09-14 10:57:18 -04:00
aac222bee1 revert(pi): reset carried vendored source deltas 2026-09-14 10:57:14 -04:00
b6613b3a04 docs(pi): document linear vendor workflow 2026-09-14 10:49:19 -04:00
9e7759c9f5 feat(nixos): build vendored pi 2026-09-14 10:48:47 -04:00
4def357d09 chore(vendor): import pi 0.85.1
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: d981de1229ef899957bbe968bc8dcda02a21f477
2026-09-14 10:48:47 -04:00
5ebc7018c3 fix(nixos): add Steam game performance helpers 2026-09-13 21:45:00 -04:00
25ecd986b5 fix(nixos): skip herdr onboarding 2026-09-13 06:55:31 -04:00
b3bda4a739 feat(shell): manage herdr agent integrations 2026-09-13 06:47:25 -04:00
8fe2e6f844 feat(nixos): add OMP module 2026-09-13 00:16:30 -04:00
7625b031ec build: update flake inputs 2026-09-12 23:12:14 -04:00
6a364f5ce9 feat(nixos): migrate navidrome to pikachu 2026-09-12 21:17:36 -04:00
1a5edc1a5f fix(nixos): avoid blocking guest startup on SteamCMD 2026-09-12 20:00:20 -04:00
cd220b8030 feat(ariadne): deserialize Clew AST JSON 2026-09-12 11:55:06 -04:00
ec96a948b6 test(ariadne): cover pipeline prerequisites 2026-09-11 20:58:00 -04:00
a3ed3b0b9d feat(skills): add document drafting skill 2026-09-10 18:28:37 -04:00
9e7469796d feat(nixos): add Palworld Tracion guest 2026-09-10 14:37:50 -04:00
8bd9ff166e feat(nixos): run Valheim in a guest 2026-09-10 10:16:27 -04:00
5758a781d8 feat(nixos): add UDP route ingress 2026-09-10 09:08:47 -04:00
88762118b9 feat(nixos): add Valheim service module 2026-09-10 08:23:42 -04:00
baca50ceeb feat(nixos): add SteamCMD server support 2026-09-10 07:59:57 -04:00
c159115847 fix(nixos): deploy local worktree changes 2026-09-09 18:46:21 -04:00
3538297991 feat(ariadne): add CLI skeleton 2026-09-09 18:23:29 -04:00
01f47b419d chore(ariadne): remove obsolete extraction script 2026-09-08 23:03:03 -04:00
4f0512832b feat(nvim): add Rust LSP support 2026-09-08 22:59:57 -04:00
8855b6d379 build(ariadne): vendor clap 2026-09-08 19:48:37 -04:00
e6af135702 docs: update task inventory 2026-09-08 18:50:01 -04:00
e40652b1a0 refactor(nixos): simplify shared defaults 2026-09-08 18:41:23 -04:00
3a3c612a83 refactor(nixos): centralize physical host defaults 2026-09-08 16:58:20 -04:00
a7bc7c7667 refactor(nixos): centralize universal defaults 2026-09-08 16:35:35 -04:00
9cac3efe21 chore(shell): remove devtree helper 2026-09-08 15:40:21 -04:00
af244d1064 docs: add ariadne planning notes 2026-09-08 15:38:36 -04:00
c03becfbb9 docs: license monorepo 2026-09-08 15:25:19 -04:00
13d07efa1f feat(skills): add grill skill
Add a monorepo-owned grill skill and enable it at the repository root.

Remove stale craft-skill commit guidance.
2026-09-08 14:57:13 -04:00
db7da0e9c6 fix(pi): discover ancestor project resources 2026-09-07 22:46:14 -04:00
f940fc3fd2 docs: require pull request branches 2026-09-07 22:13:50 -04:00
115fdc570f fix(nixos): drop stale Pi layout patch 2026-09-07 22:11:29 -04:00
557be040f8 chore: update flake inputs 2026-09-07 21:35:17 -04:00
3e36db4d89 feat(clew): extract Ren'Py ASTs 2026-09-07 20:02:06 -04:00
df03bfbcbb feat(clew): extract Ren'Py game files 2026-09-07 15:39:34 -04:00
2463b7e787 feat(nixos): add neogaia WireGuard client 2026-09-07 12:18:52 -04:00
aab32198d1 build(python): simplify rpycdec vendor layout 2026-09-06 17:22:16 -04:00
884789d676 build(python): align toolchain with monorepo pattern 2026-09-06 17:18:14 -04:00
c85993c196 build(python): vendor rpycdec 2026-09-06 15:06:03 -04:00
64773e0948 build(ariadne): vendor serde crates 2026-09-05 20:21:54 -04:00
90f46026f0 feat(ariadne): add Rust binary skeleton 2026-09-05 20:02:28 -04:00
8d91550937 fix(ariadne): preserve season paths during extraction 2026-09-05 19:36:38 -04:00
675576bff6 feat(ariadne): add extraction preparation script 2026-09-05 18:49:10 -04:00
564f946471 feat(skills): add craft-skill 2026-09-05 18:02:24 -04:00
1c8d38a807 fix(nixos): deploy Pi extensions globally 2026-09-05 17:56:15 -04:00
205db4b367 feat(shell): add devtree worktree helper 2026-09-05 10:48:44 -04:00
1f49f1d63e fix(nixos): make Firefox scale host-specific 2026-09-05 07:35:30 -04:00
4a3eaea150 docs(obsidian): update vault workspace 2026-09-05 07:35:05 -04:00
b768f96e0b feat(nixos): add Signal and Vesktop to Zeus 2026-09-04 23:15:34 -04:00
55c5fdf1bc fix(nixos): improve waybar network display 2026-09-04 22:58:20 -04:00
ff6495d50a feat(nixos): configure zeus monitor layout 2026-09-04 22:46:29 -04:00
829f32d01c feat(nixos): add zeus SSH configuration 2026-09-04 21:02:39 -04:00
7fd639e3d2 feat(nixos): span zeus root across nvme disks 2026-09-04 18:34:18 -04:00
40688acc0d refactor(nixos): align zeus with neogaia 2026-09-03 19:53:03 -04:00
c87c8319c0 fix(nixos): defer zeus hardware detection 2026-09-03 19:42:14 -04:00
5f23518f81 feat(nixos): add zeus desktop host 2026-09-03 19:21:19 -04:00
da7d36c54a fix(fish): allow hosts without env vars 2026-09-03 16:29:04 -04:00
9d48dd6c16 fix(nixos): deploy from monorepo checkout 2026-09-03 16:24:28 -04:00
6af09b1ccb fix(copyparty): preserve shared volume access 2026-09-03 16:21:09 -04:00
494fecff76 refactor(agents): scope customization to monorepo
Move Pi runtime resources into project-local .pi directories and keep shell modules responsible for installing Pi, Herdr, gitea-axi, and Pi package patches.

Remove legacy global agent context, Claude Code customization, the global skills bridge, and the external skills flake input.
2026-09-02 22:07:06 -04:00
974d987dd5 chore(repo): update local workflow state 2026-09-02 11:44:11 -04:00
232ca72b69 chore(repo): ignore direnv state 2026-09-02 10:57:54 -04:00
c47e313a50 fix(fish): complete Bazel labels without query 2026-09-02 10:57:54 -04:00
cfbd1dbebe feat(machoke): add in-memory store 2026-09-02 08:47:42 -04:00
17d69e51e6 fix(fish): export Rust toolchain root 2026-09-02 08:47:42 -04:00
d4d554205a docs(machoke): add public surface note 2026-09-02 08:06:57 -04:00
ab39ad5f0e docs(falinks): add public surfaces note 2026-09-01 18:32:23 -04:00
29999ad0cd docs: migrate Obsidian vault 2026-08-31 20:13:11 -04:00
31582d0502 fix(nvim): use Wayland clipboard provider 2026-08-31 20:02:28 -04:00
50f36e4a09 docs: simplify project structure overview 2026-08-31 18:47:32 -04:00
fae75849be docs: limit structure overview to directories 2026-08-31 18:47:03 -04:00
0100c8e648 docs: document Rust crate vendoring 2026-08-31 18:46:42 -04:00
5917ceecad build: remove unused anyhow dependency 2026-08-31 18:42:15 -04:00
b20e9c90ce build: vendor Rust crate dependencies 2026-08-31 18:38:07 -04:00
97cca46f4e build: add Bazel Rust workflow 2026-08-31 17:50:12 -04:00
ba4fbb984a feat(dotfiles): migrate flake scaffolding 2026-08-30 19:50:47 -04:00
7a6dc4d6f3 feat(nixos): migrate system configuration 2026-08-30 19:40:21 -04:00
c5f34e9460 docs: add dotfiles vocabulary 2026-08-30 19:30:31 -04:00
8aba9b65c5 docs(nixos): migrate host provisioning guide 2026-08-30 19:23:39 -04:00
99aaf3cf58 docs: migrate dotfiles agent instructions 2026-08-30 18:13:54 -04:00
b9f8f6a626 docs(repo): add technical writing guide 2026-08-30 08:32:16 -04:00
d05bb4857e build(flake): align nixpkgs with dotfiles 2026-08-29 23:19:58 -04:00
6847 changed files with 1129063 additions and 35 deletions

1
.agents Symbolic link
View File

@@ -0,0 +1 @@
.pi

8
.gitignore vendored
View File

@@ -4,8 +4,8 @@
# local environment overrides
.env
.env.*
.envrc
!.env.example
!.envrc
# editor and OS noise
.DS_Store
@@ -17,6 +17,12 @@ Thumbs.db
# logs
*.log
# Bazel convenience symlinks
/bazel-*
# local generated output
/output/
# language package-manager caches and outputs
node_modules/
dist/

View File

@@ -0,0 +1,254 @@
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, join } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
type QuotaState =
| { status: "idle" | "loading" }
| { status: "ok"; detail: string; refreshedAt: number; weeklyRemaining?: number; shortRemaining?: number }
| { status: "missing" | "error"; detail: string; refreshedAt?: number };
const CODEX_USAGE_ENDPOINTS = [
"https://chatgpt.com/backend-api/wham/usage",
"https://chatgpt.com/backend-api/codex/usage",
];
const QUOTA_REFRESH_MS = 5 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 10_000;
let quotaState: QuotaState = { status: "idle" };
let quotaRefreshPromise: Promise<void> | null = null;
function shortCwd(cwd: string): string {
const home = process.env.HOME;
if (home && cwd.startsWith(`${home}/`)) return `~/${basename(cwd)}`;
return basename(cwd) || cwd;
}
function gitBranch(cwd: string): string | null {
try {
const out = execFileSync("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return out || null;
} catch {
return null;
}
}
function authPath(): string {
return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "auth.json");
}
function readCodexCredentials(): { access: string; accountId?: string } | null {
const file = authPath();
if (!existsSync(file)) return null;
try {
const auth = JSON.parse(readFileSync(file, "utf8"));
const credential = auth?.["openai-codex"];
if (credential?.type !== "oauth" || typeof credential.access !== "string") return null;
if (typeof credential.expires === "number" && credential.expires <= Date.now() + 30_000) return null;
return {
access: credential.access,
accountId: typeof credential.accountId === "string" ? credential.accountId : undefined,
};
} catch {
return null;
}
}
function numberValue(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function objectValue(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
}
function windowSeconds(raw: Record<string, unknown>): number | undefined {
const seconds = numberValue(raw.limit_window_seconds ?? raw.windowSeconds);
if (seconds !== undefined) return seconds;
const mins = numberValue(raw.windowDurationMins ?? raw.window_duration_mins);
return mins === undefined ? undefined : mins * 60;
}
function usedPercent(raw: Record<string, unknown>): number | undefined {
const value = numberValue(raw.used_percent ?? raw.usedPercent);
if (value === undefined) return undefined;
return Math.max(0, Math.min(100, value));
}
function collectWindows(raw: unknown, out: Array<{ seconds?: number; used: number; key: string }> = [], key = "root") {
if (Array.isArray(raw)) {
raw.forEach((item, index) => collectWindows(item, out, `${key}.${index}`));
return out;
}
const obj = objectValue(raw);
if (!obj) return out;
const used = usedPercent(obj);
if (used !== undefined) out.push({ seconds: windowSeconds(obj), used, key });
for (const [childKey, value] of Object.entries(obj)) {
if (value && typeof value === "object") collectWindows(value, out, `${key}.${childKey}`);
}
return out;
}
function pickQuotaWindows(raw: unknown): { weeklyRemaining?: number; shortRemaining?: number } | null {
const windows = collectWindows(raw);
if (windows.length === 0) return null;
const weekly = windows.find((window) => window.seconds !== undefined && Math.abs(window.seconds - 604_800) <= 60 * 60)
?? windows.find((window) => /week|weekly|secondary/i.test(window.key));
const short = windows.find((window) => window.seconds !== undefined && Math.abs(window.seconds - 18_000) <= 60 * 30)
?? windows.find((window) => /five|session|primary|short/i.test(window.key));
return {
weeklyRemaining: weekly ? Math.max(0, Math.min(100, 100 - weekly.used)) : undefined,
shortRemaining: short ? Math.max(0, Math.min(100, 100 - short.used)) : undefined,
};
}
function safeFg(theme: any, color: string, text: string): string {
try {
return theme.fg(color, text);
} catch {
return theme.fg("accent", text);
}
}
function contextColor(percent: number): string {
if (percent >= 90) return "error";
if (percent >= 70) return "warning";
return "success";
}
function quotaColor(percent: number): string {
if (percent >= 80) return "error";
if (percent >= 50) return "warning";
return "border";
}
function bar(theme: any, width: number, percent: number | null, glyph: string, colorForPercent: (percent: number) => string): string {
const barWidth = Math.max(12, width);
if (percent === null) return theme.fg("muted", glyph.repeat(barWidth));
const clamped = Math.max(0, Math.min(100, percent));
const filled = Math.max(0, Math.min(barWidth, Math.round((clamped / 100) * barWidth)));
const empty = Math.max(0, barWidth - filled);
return safeFg(theme, colorForPercent(clamped), glyph.repeat(filled)) + theme.fg("dim", glyph.repeat(empty));
}
async function fetchCodexQuota(force = false): Promise<void> {
const fresh = quotaState.status === "ok" && Date.now() - quotaState.refreshedAt < QUOTA_REFRESH_MS;
if (!force && fresh) return;
if (quotaRefreshPromise) return quotaRefreshPromise;
quotaState = { status: "loading" };
quotaRefreshPromise = (async () => {
const credentials = readCodexCredentials();
if (!credentials) {
quotaState = { status: "missing", detail: "OpenAI Codex OAuth credentials were not found or are expired" };
return;
}
let lastError = "quota unavailable";
for (const endpoint of CODEX_USAGE_ENDPOINTS) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const headers: Record<string, string> = { Authorization: `Bearer ${credentials.access}` };
if (credentials.accountId) headers["ChatGPT-Account-Id"] = credentials.accountId;
const response = await fetch(endpoint, { headers, signal: controller.signal });
if (!response.ok) {
lastError = `${response.status} ${response.statusText}`;
continue;
}
const windows = pickQuotaWindows(await response.json());
if (!windows || (windows.weeklyRemaining === undefined && windows.shortRemaining === undefined)) {
lastError = "response had no recognized quota windows";
continue;
}
const details = [];
if (windows.weeklyRemaining !== undefined) details.push(`weekly ${Math.round(windows.weeklyRemaining)}%`);
if (windows.shortRemaining !== undefined) details.push(`short ${Math.round(windows.shortRemaining)}%`);
quotaState = {
status: "ok",
detail: `Codex quota remaining: ${details.join(", ")}`,
weeklyRemaining: windows.weeklyRemaining,
shortRemaining: windows.shortRemaining,
refreshedAt: Date.now(),
};
return;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
} finally {
clearTimeout(timeout);
}
}
quotaState = { status: "error", detail: `Codex quota failed: ${lastError}`, refreshedAt: Date.now() };
})().finally(() => {
quotaRefreshPromise = null;
});
return quotaRefreshPromise;
}
function statusLines(ctx: any, theme: any, width: number): string[] {
const cwd = ctx.sessionManager?.getCwd?.() ?? ctx.cwd ?? process.cwd();
const branch = gitBranch(cwd);
const where = branch ? `󰉋 ${shortCwd(cwd)} 󰘬 ${branch}` : `󰉋 ${shortCwd(cwd)}`;
const model = ctx.model?.id ?? process.env.PI_MODEL ?? "no-model";
const thinking = ctx.thinkingLevel ?? process.env.PI_REASONING_LEVEL ?? "off";
const left = theme.fg("accent", where);
const right = theme.fg("dim", `${model} • ${thinking}`);
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
const contextPercentRaw = ctx.getContextUsage?.()?.percent;
const contextPercent = typeof contextPercentRaw === "number" && Number.isFinite(contextPercentRaw) ? contextPercentRaw : null;
const quotaConsumed = quotaState.status === "ok" && quotaState.weeklyRemaining !== undefined
? 100 - quotaState.weeklyRemaining
: null;
return [
truncateToWidth(left + pad + right, width),
bar(theme, width, contextPercent, "▃", contextColor),
bar(theme, width, quotaConsumed, "▔", quotaColor),
];
}
function setCompactStatusUi(ctx: any) {
if (!ctx.hasUI) return;
ctx.ui.setWidget("compact-status", (_tui: any, theme: any) => ({
invalidate() {},
render(width: number) {
return statusLines(ctx, theme, width);
},
}));
ctx.ui.setFooter(() => ({ invalidate() {}, render: () => [] }));
}
export default function compactStatus(pi: ExtensionAPI) {
function refreshUi(ctx: any) {
setCompactStatusUi(ctx);
}
pi.on("session_start", (_event, ctx) => {
refreshUi(ctx);
void fetchCodexQuota(false).then(() => refreshUi(ctx));
});
pi.on("model_select", (_event, ctx) => refreshUi(ctx));
pi.on("agent_settled", (_event, ctx) => refreshUi(ctx));
pi.registerCommand("codex-quota", {
description: "Refresh and show ChatGPT Codex quota",
handler: async (_args, ctx) => {
refreshUi(ctx);
await fetchCodexQuota(true);
refreshUi(ctx);
const level = quotaState.status === "ok" ? "info" : quotaState.status === "missing" ? "warning" : "error";
ctx.ui.notify(quotaState.status === "idle" || quotaState.status === "loading" ? "Codex quota refresh in progress" : quotaState.detail, level);
},
});
}

0
.pi/prompts/.gitkeep Normal file
View File

42
.pi/prompts/review.md Normal file
View File

@@ -0,0 +1,42 @@
---
description: Review changes against repository style rules
argument-hint: "[target]"
---
Review target: `${ARGUMENTS:-the staged diff}`.
Audit the target for violations of the repository style rules.
Choose skills from the reviewed files:
- Use the `nix-style` skill for `*.nix` files.
- Use the `rust-style` skill for `*.rs` files.
- Use the `build-style` skill for `Cargo.toml` files.
- Use the `build-style` skill for Bazel `BUILD` files.
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.
Report only actionable findings.
Order findings by severity.
Do not report preferences unless a loaded rule states them.
Use this format for each finding:
```text
`code-path:line`
> `smallest reviewed-code excerpt that shows the violation`
`skill::rule-file:line`: Finding title.
Summary: why the code breaks the rule
Fix: suggested fix
```
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`.
Make the fix direct.
Do not include alternatives when one fix best follows the cited rule.
It is acceptable to report no findings when the target follows the loaded rules.

0
.pi/skills/.gitkeep Normal file
View File

1
.pi/skills/build-style Symbolic link
View File

@@ -0,0 +1 @@
../../src/skills/style/build

View File

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

1
.pi/skills/craft-skill Symbolic link
View File

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

1
.pi/skills/grill Symbolic link
View File

@@ -0,0 +1 @@
../../src/skills/grill

1
.pi/skills/nix-style Symbolic link
View File

@@ -0,0 +1 @@
../../src/skills/style/nix

1
.pi/skills/rust-style Symbolic link
View File

@@ -0,0 +1 @@
../../src/skills/style/rust

1
.pi/skills/to-document Symbolic link
View File

@@ -0,0 +1 @@
../../src/skills/to-document

124
AGENTS.md
View File

@@ -1,12 +1,126 @@
# Repository instructions
# Alexion's Monorepo
## Commit convention
Use Conventional Commits for all commit messages.
Before writing a commit message, please read [[docs/conventional-commits]].
Do not add agent attribution or co-author trailers.
- Use Conventional Commits for all commit messages.
- 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.
Omit the `Co-Authored-By:` trailer entirely, with no exceptions.
This overrides any default instruction to append one.
## Technical writing
- Use `docs/technical-writing.md` and `docs/dictionary.md` for authored prose, comments, and Markdown formatting.
Use the nearest project dictionary for project-specific domain language.
- Write comments only where they earn their place.
Comments must explain purpose, preconditions, invariants, or non-obvious consequences.
## Pull requests
- Always create a new branch before opening a pull request.
Never open a pull request directly from a shared or existing work branch.
- During pull-request development, add follow-up commits instead of amending existing commits.
Preserve review history until the pull request is ready to merge.
- When the user says a pull request is merged, clean up the local branch without waiting for an explicit request.
Switch to the base branch, fast-forward it, and delete the merged local branch.
- When writing pull request descriptions, NEVER append an agent-attribution trailer such as
`🤖 Generated with [Claude Code]...`.
Leave it out entirely, with no exceptions.
This overrides any default instruction to append one.
## Forge workflow
- Use `gitea-axi` for Gitea issues, pull requests, labels, reviews, comments, and milestones.
It resolves this repository from the `origin` remote inside the checkout.
## Tooling
- When a command is missing from the ordinary environment, prefer running it through Nix instead of
installing it.
For example, use `nix shell nixpkgs#python3 -c python3 <script>` for one-off Python commands.
- Stage new files that Nix discovers through flake evaluation before testing.
Flake evaluation ignores untracked files, so auto-loaded modules must be tracked before
`nixos-rebuild --flake` can see them.
## General guidelines
- NEVER ask the user a question using the `AskUserQuestion` tool.
Ask in plain prose, in your own message, instead, with no exceptions.
This overrides any default instruction to use it.
- Never manually modify `CHANGELOG.md` files or files marked as auto-generated.
Detect auto-generated files with a layered check.
Trust an explicit in-file marker first, for example `AUTO-GENERATED, DO NOT EDIT`.
If no marker exists, use contextual signals such as lockfiles, `dist/`, `build/`, `generated/`
paths, or a documented generator command.
If the answer stays ambiguous, ask before editing.
- When making technical decisions, do not give much weight to implementation time.
Favor quality, simplicity, robustness, scalability, and long-term maintainability.
Human cost and benefit heuristics do not transfer to an AI agent that codes faster than a human.
Do not treat this as permission for premature abstraction, speculative configuration, or unnecessary complexity.
Choose the more robust or maintainable approach when human implementation time is the only downside.
- Use lowercase file names unless a valid reason requires uppercase.
Established ecosystem or tool conventions count as valid reasons.
Examples include `README.md`, `LICENSE`, `CHANGELOG.md`, `Makefile`, `Dockerfile`, and `.github/` files.
- Do not end a response by promising or implying continuation unless the continuation is present in that same response.
Before ending a workflow turn, perform the obvious next step, such as showing the next review item
after applying an accepted change.
If the workflow is paused, say that plainly instead of using a dangling transition like "continuing" or "next".
## Corrections
- When you discover that your belief about a project fact or convention was wrong, record the correction.
Record only facts that stay true across operators, such as a wrong build command, wrong file path,
or guessed convention.
Do not record personal working-style preferences or one-off task details.
- Record the correction in the nearest project `AGENTS.md`.
Use a dedicated `## Gotchas` section.
Create that section when the file lacks one.
- If no project `AGENTS.md` exists, ask before creating one.
Append the correction immediately without asking when a project `AGENTS.md` exists.
Briefly mention the edit in your response.
If an existing entry is wrong or stale, correct or remove it the same way.
## Gotchas
- When materializing an external specification, standard, or project-maintained convention, use the upstream source instead of reconstructing it from memory.
- When materializing an external specification, standard, or project-maintained convention, use the
upstream source instead of reconstructing it from memory.
Preserve local policy separately from copied upstream text.
- After an explicit squash or rewrite request on a pull request, subsequent pull-request updates still
use follow-up commits unless the user explicitly asks for another rewrite.
- Starlark top-level rule and extension declarations cannot reference implementation functions before
those functions are assigned.
Keep executable public interface declarations after their implementation symbols, or validate before
committing a top-down rewrite.
- `rules_rust` `rust_test` targets cannot set both `crate` and `srcs`.
Use one input style per test target.
- Rust tests in this monorepo live in separate `*_test.rs` files, not inline `#[cfg(test)]` modules.
- `rnix::ast::HasEntry::entries()` includes assignments and `inherit` statements.
Use `attrpath_values()` when code needs only assignment entries.
- Ariadne Rust tests are declared in Bazel `BUILD` files; do not add `[[test]]` entries to
`src/ariadne/Cargo.toml` unless explicitly requested.
- In this monorepo, "dotfiles" refers to the dotfiles/NixOS configuration under `src/dotfiles/` and `configs/nixos/`.
- The flake does not currently define `formatter.x86_64-linux`.
Do not use `nix fmt` as a validation command until a formatter is added.
- The `fix` app passes its current directory to Deadnix.
Run `nix run .#fix` from the repository root so path exclusions match.
- 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.
- Bazel Python targets require `PYTHON_TOOLCHAIN_ROOT` to point at a Python installation root.
- When checking whether a package exists in nixpkgs, check both this repository's pinned nixpkgs inputs
and the relevant current upstream channel. A package can exist on current `nixos-unstable` while being
absent from the pinned revision or from a different branch such as `nixpkgs-unstable`.
- 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.

4
BUILD Normal file
View File

@@ -0,0 +1,4 @@
exports_files([
"Cargo.lock",
"Cargo.toml",
])

262
Cargo.lock generated Normal file
View File

@@ -0,0 +1,262 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "ariadne"
version = "0.1.0"
dependencies = [
"clap",
"monorepo",
"serde",
"serde_json",
]
[[package]]
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "machoke"
version = "0.1.0"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "monorepo"
version = "0.1.0"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "status"
version = "0.1.0"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"

15
Cargo.toml Normal file
View File

@@ -0,0 +1,15 @@
[workspace]
members = [
"src/ariadne",
"src/machoke",
"src/monorepo",
"src/status",
]
resolver = "3"
[workspace.dependencies]
clap = "4"
monorepo = { path = "src/monorepo" }
serde = "1.0"
serde_json = "1.0"
status = { path = "src/status" }

251
LICENSE Normal file
View File

@@ -0,0 +1,251 @@
Repository license policy
=========================
Except as noted below, repository-owned content in this monorepo is
Copyright (C) 2026 Alexion and licensed under the GNU Affero General Public
License, version 3 or any later version.
Exceptions and exclusions:
- src/skills/ is licensed separately under the MIT license. See
src/skills/LICENSE.
- third_party/ is not covered by this license. Third-party content keeps its
upstream license.
The canonical AGPL-3.0 license text follows.
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software.
A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public.
The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version.
An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based on the Program.
To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work.
A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices".
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements.
You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see <http://www.gnu.org/licenses/>.

18
MODULE.bazel Normal file
View File

@@ -0,0 +1,18 @@
module(
name = "alexion",
version = "0.1.0",
)
bazel_dep(name = "bazel_skylib", version = "1.8.2")
bazel_dep(name = "rules_python", version = "1.6.3")
bazel_dep(name = "rules_rust", version = "0.74.0")
python = use_extension("//bazel:python.bzl", "python")
use_repo(python, "python")
rust = use_extension("//bazel:rust.bzl", "rust")
use_repo(rust, "rust")
register_toolchains("@python//:python")
register_toolchains("@rust//:rust")

1487
MODULE.bazel.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,14 +2,88 @@
This is my monorepo, started in 2026.
## Monorepo Structure
```
dev/ Monorepo root
├── .archive/ Archived projects
├── docs/ Obsidian vault / Second brain
├── src/ Projects
│ └── tools/ Projects that are tools
└── third_party/ Open source tools, libraries or otherwise
## Monorepo structure
```text
dev/ Monorepo root
├── bazel/ Bazel support files
├── configs/ Declarative configuration
│ └── nixos/ NixOS hosts, modules, guests, registry, and secrets
├── docs/ Repository documentation
├── src/ Projects
│ └── dotfiles/ Home and system configuration
└── third_party/ External source and vendoring support
├── crates/ Generated Rust crate sources and Bazel targets
└── pi/ Vendored Pi source
```
## Rust dependencies
The repository root contains one Cargo dependency graph.
Each Rust project declares its dependencies in its local `Cargo.toml` file.
Add each Rust project to the `members` list in the root `Cargo.toml` file.
Use explicit member paths because the Bazel generator does not expand workspace globs.
Update the shared lockfile after changing a project manifest:
```sh
cargo generate-lockfile
```
Regenerate local crate sources and Bazel targets:
```sh
bazel run //third_party:vendor_crates
```
Reference each dependency with an explicit local Bazel label:
```starlark
deps = ["//third_party/crates:crate_name"]
```
Commit the generated files under `third_party/crates/`.
Do not edit generated files manually.
## Vendored Pi
Pi source lives in `third_party/pi/`.
The directory is a linear vendor import from upstream Pi.
Local fork changes are carried directly in that vendored tree.
Use the upstream tag that matches the target CLI release.
The CLI version lives in `packages/coding-agent/package.json`.
Do not use `git subtree add` or `git subtree pull` for Pi PR branches.
Those commands create a synthetic upstream root commit.
Gitea rebase applies that root commit without the `third_party/pi` prefix.
That causes add/add conflicts on repository root files.
Update the vendor tree with a linear commit:
```sh
git fetch https://github.com/earendil-works/pi.git refs/tags/v<version>
split="$(git rev-parse FETCH_HEAD)"
rm -rf third_party/pi
mkdir -p third_party/pi
git archive --format=tar "$split" | tar -C third_party/pi -xf -
git add third_party/pi
git commit \
-m "chore(vendor): import pi <version>" \
-m "Imported as a linear vendor commit so forge rebases do not replay the upstream root tree." \
-m "git-subtree-dir: third_party/pi" \
-m "git-subtree-split: $split"
```
Keep the vendor import or update commit separate from local edits.
Do not remove the `git-subtree-dir` and `git-subtree-split` trailers.
They record the upstream source commit for future updates.
After the upstream update, resolve conflicts in `third_party/pi/`.
Keep local fork changes as ordinary commits after the vendor update.
Then verify the package:
```sh
nix build .#nixosConfigurations.zeus.config.home-manager.users.alexion.programs.pi-coding-agent.package --no-link --print-out-paths
```

7
bazel/BUILD Normal file
View File

@@ -0,0 +1,7 @@
exports_files([
"python.BUILD.template",
"python.bzl",
"rust.BUILD.template",
"rust.bzl",
"utils.bzl",
])

View File

@@ -0,0 +1,25 @@
load("@rules_python//python:py_runtime.bzl", "py_runtime")
load("@rules_python//python:py_runtime_pair.bzl", "py_runtime_pair")
py_runtime(
name = "runtime",
implementation_name = "cpython",
interpreter_path = "{interpreter_path}",
interpreter_version_info = {
"major": "{major}",
"minor": "{minor}",
"micro": "{micro}",
},
python_version = "PY3",
)
py_runtime_pair(
name = "runtime_pair",
py3_runtime = ":runtime",
)
toolchain(
name = "python",
toolchain = ":runtime_pair",
toolchain_type = "@rules_python//python:toolchain_type",
)

46
bazel/python.bzl Normal file
View File

@@ -0,0 +1,46 @@
load("//bazel:utils.bzl", "execute", "require_env")
def _python_repository_impl(repository_ctx):
root = repository_ctx.path(require_env(repository_ctx, "PYTHON_TOOLCHAIN_ROOT"))
python3 = root.get_child("bin/python3")
version = execute(
repository_ctx,
[
str(python3),
"-c",
"import sys; print('{}.{}.{}'.format(*sys.version_info[:3]))",
],
)
parts = version.split(".")
if len(parts) != 3:
fail("python3 did not report a major.minor.micro version")
repository_ctx.file("WORKSPACE.bazel", "")
repository_ctx.template(
"BUILD.bazel",
Label("//bazel:python.BUILD.template"),
{
"{interpreter_path}": str(python3),
"{major}": parts[0],
"{minor}": parts[1],
"{micro}": parts[2],
},
)
python_repository = repository_rule(
implementation = _python_repository_impl,
local = True,
environ = ["PYTHON_TOOLCHAIN_ROOT"],
)
def _python_impl(module_ctx):
python_repository(name = "python")
python = module_extension(
implementation = _python_impl,
)

117
bazel/rust.BUILD.template Normal file
View File

@@ -0,0 +1,117 @@
filegroup(
name = "rustc",
srcs = ["toolchain/bin/rustc"],
visibility = ["//visibility:public"],
)
filegroup(
name = "rustdoc",
srcs = ["toolchain/bin/rustdoc"],
visibility = ["//visibility:public"],
)
filegroup(
name = "cargo",
srcs = ["toolchain/bin/cargo"],
visibility = ["//visibility:public"],
)
filegroup(
name = "cargo_clippy",
srcs = ["toolchain/bin/cargo-clippy"],
visibility = ["//visibility:public"],
)
filegroup(
name = "clippy_driver",
srcs = ["toolchain/bin/clippy-driver"],
visibility = ["//visibility:public"],
)
filegroup(
name = "rustfmt",
srcs = ["toolchain/bin/rustfmt"],
visibility = ["//visibility:public"],
)
filegroup(
name = "cc",
srcs = ["toolchain/bin/cc"],
visibility = ["//visibility:public"],
)
filegroup(
name = "rustc_lib",
srcs = glob(
[
"rust/lib/*.so*",
"rust/lib/rustlib/{host_triple}/codegen-backends/*.so",
"rust/lib/rustlib/{host_triple}/lib/*.so*",
"rust/lib/rustlib/{host_triple}/lib/*.rmeta",
],
allow_empty = True,
),
visibility = ["//visibility:public"],
)
filegroup(
name = "rust_objcopy",
srcs = glob(
["rust/lib/rustlib/{host_triple}/bin/rust-objcopy"],
allow_empty = True,
),
visibility = ["//visibility:public"],
)
load("@rules_rust//rust:toolchain.bzl", "rust_stdlib_filegroup")
rust_stdlib_filegroup(
name = "rust_std",
srcs = glob(
[
"rust/lib/rustlib/{host_triple}/lib/*.rlib",
"rust/lib/rustlib/{host_triple}/lib/*.rmeta",
"rust/lib/rustlib/{host_triple}/lib/*.so*",
"rust/lib/rustlib/{host_triple}/lib/*.a",
"rust/lib/rustlib/{host_triple}/lib/self-contained/**",
],
allow_empty = True,
),
visibility = ["//visibility:public"],
)
load("@rules_rust//rust:toolchain.bzl", "rust_toolchain")
rust_toolchain(
name = "toolchain_impl",
rust_doc = ":rustdoc",
rust_std = ":rust_std",
rustc = ":rustc",
linker = ":cc",
linker_type = "indirect",
rust_objcopy = ":rust_objcopy",
rustfmt = ":rustfmt",
cargo = ":cargo",
clippy_driver = ":clippy_driver",
cargo_clippy = ":cargo_clippy",
rustc_lib = ":rustc_lib",
allocator_library = None,
global_allocator_library = None,
binary_ext = "",
staticlib_ext = ".a",
dylib_ext = ".so",
stdlib_linkflags = ["-ldl", "-lpthread"],
default_edition = "2024",
exec_triple = "{host_triple}",
target_triple = "{host_triple}",
version = "{release}",
channel = "stable",
visibility = ["//visibility:public"],
)
toolchain(
name = "rust",
toolchain = ":toolchain_impl",
toolchain_type = "@rules_rust//rust:toolchain_type",
visibility = ["//visibility:public"],
)

51
bazel/rust.bzl Normal file
View File

@@ -0,0 +1,51 @@
load("//bazel:utils.bzl", "execute", "require_env")
def _rust_repository_impl(repository_ctx):
root = repository_ctx.path(require_env(repository_ctx, "RUST_TOOLCHAIN_ROOT"))
rustc = root.get_child("bin/rustc")
sysroot = execute(repository_ctx, [str(rustc), "--print", "sysroot"])
verbose_version = execute(repository_ctx, [str(rustc), "-vV"])
host_triple = None
release = None
for line in verbose_version.splitlines():
if line.startswith("host: "):
host_triple = line.removeprefix("host: ")
if line.startswith("release: "):
release = line.removeprefix("release: ")
if host_triple == None:
fail("rustc -vV did not report a host triple")
if release == None:
fail("rustc -vV did not report a release")
repository_ctx.symlink(root, "toolchain")
repository_ctx.symlink(sysroot, "rust")
repository_ctx.file("WORKSPACE.bazel", "")
repository_ctx.template(
"BUILD.bazel",
Label("//bazel:rust.BUILD.template"),
{
"{host_triple}": host_triple,
"{release}": release,
},
)
rust_repository = repository_rule(
implementation = _rust_repository_impl,
local = True,
environ = ["RUST_TOOLCHAIN_ROOT"],
)
def _rust_impl(module_ctx):
rust_repository(name = "rust")
rust = module_extension(
implementation = _rust_impl,
)

12
bazel/utils.bzl Normal file
View File

@@ -0,0 +1,12 @@
def require_env(repository_ctx, variable):
value = repository_ctx.os.environ.get(variable)
if not value:
fail("{} must be set.".format(variable))
return value
def execute(repository_ctx, arguments):
result = repository_ctx.execute(arguments)
if result.return_code != 0:
fail("command failed: {}\n{}".format(" ".join(arguments), result.stderr))
return result.stdout.strip()

42
configs/nixos/.sops.yaml Normal file
View File

@@ -0,0 +1,42 @@
# Recipients for the encrypted files under configs/nixos/secrets/.
keys:
# A recipient of every file.
# One readable only by machines becomes unrecoverable once they are wiped.
# Adding a recipient requires decrypting first.
# No private half here, only in the operator's password manager.
- &admin age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
# Generated on the machine it names.
- &neogaia age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
- &zeus age199atsykyh0hp0yuxymznxs0mqv4ram9ttaddk6jrsjjc43rmvu7snh9dq8
- &pikachu age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh
creation_rules:
# Material belonging to one machine.
# No machine other than the one named is a recipient, so a host that is
# compromised cannot decrypt another's material.
- path_regex: secrets/neogaia\.yaml$
key_groups:
- age:
- *admin
- *neogaia
- path_regex: secrets/zeus\.yaml$
key_groups:
- age:
- *admin
- *zeus
- path_regex: secrets/pikachu\.yaml$
key_groups:
- age:
- *admin
- *pikachu
# Material common to every machine, so it is stored once rather than per host.
- path_regex: secrets/shared\.yaml$
key_groups:
- age:
- *admin
- *neogaia
- *zeus
- *pikachu

70
configs/nixos/AGENTS.md Normal file
View File

@@ -0,0 +1,70 @@
# NixOS configuration instructions
## Desktop
- 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`.
`home.sessionVariables` do **not** reach the Hyprland session because UWSM does not source `hm-session-vars.sh`.
Source cursor values from `config.stylix.cursor`.
Set `XCURSOR_*` and `HYPRCURSOR_*` to the same theme for Bibata.
## Firefox
- Manage Firefox search engines through `programs.firefox.search` with `force = true`.
Hide built-in engines with `<engine>.metaData.hidden = true` because omission does not remove them.
Use current engine ids, such as `default = "ddg"` instead of `"DuckDuckGo"`.
Decode the built `search.json.mozlz4` with `mozlz4a -d <search.json.mozlz4>` to check the result.
- Avoid Home Manager Firefox extension settings unless the whole profile can use the legacy extension-storage backend.
Any non-empty `profiles.<name>.extensions.settings.<id>.settings` sets `extensions.webextensions.ExtensionStorageIDB.enabled = false` for the whole profile.
## Neovim
- Nixvim's flake input follows the root nixpkgs source, but its Home Manager module does not reuse the host's `pkgs` instance.
Keep `programs.nixvim.nixpkgs.useGlobalPackages = true`.
That setting makes Nixvim use the shared package set without warning that its source default changed.
## AI tooling
- Use produced literals for Herdr key names that include shifted punctuation.
For example, use `prefix+<` instead of `prefix+shift+comma`.
- Put monorepo Pi extension sources in `.pi/extensions/`.
Pi discovers them from subdirectories through the local ancestor-resource patch.
- Treat Home Manager-managed Pi settings as generated output.
Do not edit them except for throwaway development or local experiments.
Promote finished settings into the NixOS configuration before treating them as deployed.
## 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.
- `sops.secrets.<name>.owner` must name a user known on the decrypting host.
Do not set the owner to a guest-only service user unless the host also declares that user.
Use a host-visible owner plus a shared group mode when the guest service can read the decrypted secret file through its group.
- A read-only guest bind mount cannot contain nested writable bind mounts unless their mount-point
directories already exist inside the read-only source.
For aggregate Copyparty volumes such as `/srv` with nested `/srv/inbox`, keep the guest bind mount
writable and enforce read-only behavior through Copyparty access plus service `ReadWritePaths`.
- `deploy-to` builds from the source checkout, records the imported closure as the target system profile,
and activates it. Do not rely on a target-local monorepo checkout for remote deployment.
- 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.
- Public DNS entries are not managed through the NixOS configuration.
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`.

459
configs/nixos/README.md Normal file
View File

@@ -0,0 +1,459 @@
# NixOS configuration
This document covers the procedures that put a machine into the fleet and keep its secrets readable.
- [Installing a host from the live ISO](#installing-a-host-from-the-live-iso), the destructive one-shot that turns a host in this flake into a running, encrypted machine.
- [Provisioning an already-running host](#provisioning-an-already-running-host), done live on the machine with no reimage.
- [Editing secrets](#editing-secrets), the day-to-day workflow.
- [Recovering a wrongly-provisioned machine](#recovering-a-wrongly-provisioned-machine) from the live ISO.
The install is destructive: it formats the target disk in full.
Read it end to end before starting.
## What arrives by hand
Only the **LUKS passphrase** is entered by hand.
That passphrase unlocks the encrypted disk at boot.
The disk-formatting step asks for the same passphrase.
Everything else arrives through declared configuration.
The login password is a `sops`-encrypted secret consumed through `hashedPasswordFile`.
The SSH host keys are restored from secrets instead of generated.
No password is set interactively at any point.
`users.mutableUsers = false` means one set by hand would be ignored.
## Identity before first boot
A machine reads its secrets with an **age identity** at `/var/lib/sops-nix/key.txt` on its encrypted root.
Register its public half as a recipient of every secrets file the machine needs.
Keep the re-keyed files in the flake's git tree before building the system.
Nix bakes the ciphertext into the store.
**Generate and register a host's identity before its first boot.**
The login password arrives only from a decrypted secret.
There is no fallback credential: no interactive password, no unlocked root account, and no authorized SSH key.
A first boot without a registered identity cannot decrypt the password hash.
The account has no usable password, and recovery requires the [recovery procedure](#recovering-a-wrongly-provisioned-machine).
Identities come in two tiers.
The **admin identity** lives in Proton Pass.
It is a recipient of every file and authorizes registering a new host.
Each **host identity** is generated on its own machine and never transmitted.
It reads only that machine's file plus the shared one.
A host identity is not derived from the machine's SSH host key.
That separation lets the SSH host keys be secrets in their own right.
## Tooling
This configuration does not install `sops` or `age`.
Run them from nixpkgs as needed:
```console
$ nix run nixpkgs#sops -- <args>
$ nix shell nixpkgs#age -c age-keygen <args>
```
On the live ISO these commands need `--extra-experimental-features 'nix-command flakes'`.
The ISO's daemon does not enable those features.
## Installing a host from the live ISO
### 0. Push the monorepo to Gitea
From your working checkout, make sure `main` is committed and pushed to the Gitea remote:
```console
$ git push origin main
```
The clone in step 2 sees only what has been pushed.
Anything left behind in your working checkout will not reach the machine.
Changes made inside that clone afterwards are separate.
Step 4 deliberately makes one.
### 1. Boot the live ISO and join wifi
Boot from a NixOS live ISO (the minimal installer is enough).
The installer logs in as the `nixos` user, who has passwordless `sudo`.
On the minimal ISO, bring up wifi with `wpa_supplicant`:
```console
$ sudo systemctl start wpa_supplicant
$ wpa_cli
> add_network
0
> set_network 0 ssid "YOUR_SSID"
> set_network 0 psk "YOUR_WIFI_PASSWORD"
> enable_network 0
> quit
```
On the graphical ISO, which ships NetworkManager, use `nmcli` instead:
```console
$ nmcli device wifi connect "YOUR_SSID" password "YOUR_WIFI_PASSWORD"
```
Confirm you have connectivity (`ping -c1 github.com`) before continuing.
### 2. Clone the monorepo locally
Clone the monorepo onto the live ISO and work from that local checkout:
```console
$ git clone ssh://gitea@git.alexion.dev:2022/alexion/dev.git
$ cd dev
```
Cloning over SSH needs your Gitea SSH key present in the live session, since the ISO starts with none.
If getting the key onto the ISO is inconvenient, clone over HTTPS instead and tell git to skip the self-signed certificate:
```console
$ git -c http.sslVerify=false clone https://git.alexion.dev/alexion/dev.git
$ cd dev
```
Do **not** point `disko-install` straight at the Gitea flake URL.
Gitea serves HTTPS with a self-signed certificate and expects authentication.
Nix's flake fetcher has no easy way to skip certificate verification or supply those credentials mid-install.
A plain `git clone` sidesteps that entirely.
Over SSH there is no TLS.
Over HTTPS, git takes the `sslVerify=false` option above.
The flake fetcher does not.
`disko-install` then consumes the flake from a local path, so it does not fetch the monorepo during the build.
Every other flake input is public and still fetched from GitHub over ordinary, valid TLS.
Only the monorepo is the problem that the local clone solves.
### 3. Generate the host identity
Generate the identity in the live session and keep it there until step 6 writes it onto the installed root:
```console
$ nix shell nixpkgs#age -c age-keygen -o /tmp/key.txt
Public key: age1...
```
`age-keygen` prints the public recipient on generation.
Recover it later from the identity itself if the line scrolls away:
```console
$ nix shell nixpkgs#age -c age-keygen -y /tmp/key.txt
```
The private half never leaves this session except onto the target disk.
Do not copy it into the monorepo, and do not carry it to another machine.
### 4. Register the recipient and re-key
Add the public recipient to `configs/nixos/.sops.yaml` as a named anchor.
Then list it under every file the host must read:
```yaml
keys:
- &admin age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
- &neogaia age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
- &newhost age1...
creation_rules:
- path_regex: configs/nixos/secrets/shared\.yaml$
key_groups:
- age:
- *admin
- *neogaia
- *newhost
```
If the host gets a secrets file of its own, give it a rule too.
`sops` matches a file against these rules to decide who to encrypt it to.
It refuses a file no rule matches with `no matching creation rules found`:
```yaml
- path_regex: configs/nixos/secrets/newhost\.yaml$
key_groups:
- age:
- *admin
- *newhost
```
Then re-key each file you changed.
This rewrites its data key for the new recipient list without touching any value:
```console
$ SOPS_AGE_KEY_FILE=/path/to/admin-identity \
nix run nixpkgs#sops -- updatekeys configs/nixos/secrets/shared.yaml
```
Re-keying requires an identity that can already decrypt the file.
The live ISO holds no host identity of its own.
Paste the admin identity out of Proton Pass into a file in the live session for this step.
Then populate that file.
This needs no existing identity because encrypting only reads recipients:
```console
$ nix run nixpkgs#sops -- configs/nixos/secrets/newhost.yaml
```
A host with `modules.network.ssh.enable` expects one entry per key type.
Name each entry `ssh-host-<type>-key`.
Generate each private key with `ssh-keygen -t <type> -N "" -f /tmp/<type>`.
The build fails during evaluation if a declared secret is absent from the file.
A host that enables the daemon without these keys will not install.
Commit the matching public halves beside the host's configuration in plaintext.
Publishing them is their purpose.
Stage everything you changed.
A flake sees only git-tracked files, so an unstaged `configs/nixos/secrets/newhost.yaml` is invisible to evaluation even though it exists on disk:
```console
$ git add configs/nixos/.sops.yaml configs/nixos/secrets/
```
Staging is enough for the build.
The commit comes in step 8.
No push is needed here because the install builds from this local clone.
### 5. Run `disko-install`
Run the install as root from inside the clone:
```console
$ sudo nix --extra-experimental-features 'nix-command flakes' run \
github:nix-community/disko/latest#disko-install -- \
--flake .#neogaia \
--disk main /dev/nvme0n1 \
--write-efi-boot-entries \
--option extra-substituters https://nyx-cache.chaotic.cx/ \
--option extra-trusted-public-keys nyx-cache.chaotic.cx:dJxTrgMC3V3cFfyIiBQDQorG6k1LsqurH/srpMSq7qk=
```
What each part does:
- `--flake .#neogaia` installs the `neogaia` host from the local clone.
- `--disk main /dev/nvme0n1` maps disko's `main` disk to the NVMe device.
It matches the device declared in `configs/nixos/hosts/neogaia/disk.nix`.
The explicit device removes doubt about the target.
- `--write-efi-boot-entries` writes the systemd-boot entry into this machine's NVRAM.
The disk stays in the machine it was installed from.
- The two `--option` lines hand the **chaotic binary cache** to the install-time Nix daemon on the live ISO.
Pass the chaotic substituter explicitly here.
The `nix.settings` in the NixOS configuration configure the installed system's substituters.
They do not configure the live ISO daemon that runs this build.
The ISO daemon has no `substituters` beyond `cache.nixos.org`.
Without these two `--option` flags, the build cannot fetch the prebuilt CachyOS kernel.
It compiles `linuxPackages_cachyos` and its toolchain from source on the USB stick.
The cache avoids that detour.
The install runs as root, and root is a trusted Nix user.
The daemon honours these client-supplied substituter settings.
Partway through, disko formats the LUKS container and **prompts for a disk-encryption passphrase**.
This is the passphrase you will type at every boot to unlock the disk.
Choose it deliberately.
When it finishes, it prints `disko-install succeeded`.
`disko-install` unmounts the target filesystem on exit.
Nothing is mounted at this point.
Step 6 remounts it.
### 6. Write the identity onto the installed root
Remount the just-installed system with disko.
This reopens the LUKS container and prompts for the passphrase from step 5.
It mounts the subvolumes under `/mnt`:
```console
$ sudo nix --extra-experimental-features 'nix-command flakes' run \
github:nix-community/disko/latest#disko -- \
--mode mount --flake .#neogaia
```
Then place the identity generated in step 3.
Make root own it, and make nobody else able to read it:
```console
$ sudo install -d -m 0755 /mnt/var/lib/sops-nix
$ sudo install -m 0400 -o root -g root /tmp/key.txt /mnt/var/lib/sops-nix/key.txt
```
It goes on the root subvolume rather than anywhere mounted later.
The password secret is decrypted before user accounts are created.
That happens earlier than any other mount.
Confirm the identity matches the recipient you registered before rebooting.
This is the last cheap moment to catch a mismatch:
```console
$ sudo nix shell nixpkgs#age -c age-keygen -y /mnt/var/lib/sops-nix/key.txt
```
### 7. Reboot
Unmount and reboot into the installed system:
```console
$ sudo umount -R /mnt
$ sudo reboot
```
Remove the USB stick.
At boot you are prompted for the LUKS passphrase from step 5.
After unlocking, log in at the console as `alexion`.
Use the password from the shared secrets file.
The system now has fish, tmux, nvim, and Claude Code.
If the login is rejected, the identity and the registered recipient disagree.
See [recovery](#recovering-a-wrongly-provisioned-machine).
### 8. Commit the recipient change
The re-key from step 4 exists only in the live session's clone, which is gone.
From a machine that is already a recipient of the affected files, repeat the `configs/nixos/.sops.yaml` edit.
Re-key the files, then commit and push:
```console
$ sudo SOPS_AGE_KEY_FILE=/var/lib/sops-nix/key.txt \
nix run nixpkgs#sops -- updatekeys configs/nixos/secrets/shared.yaml
$ git add configs/nixos/.sops.yaml configs/nixos/secrets/
$ git commit -m "feat(secrets): register newhost as a recipient"
$ git push origin main
```
Until this lands, the monorepo's copy of each file has one recipient fewer than the copy the new machine was built from.
The next rebuild from the monorepo would lock it out.
## Provisioning an already-running host
A machine that is up and running gets its identity live.
There is no reimage and no live ISO.
The running generation is the fallback.
If activation fails, the rebuild fails and the machine keeps working as it is.
Generate the identity on the machine itself, straight into place:
```console
$ sudo install -d -m 0755 /var/lib/sops-nix
$ sudo nix shell nixpkgs#age -c age-keygen -o /var/lib/sops-nix/key.txt
$ sudo chmod 0400 /var/lib/sops-nix/key.txt
```
Register the printed public recipient in `configs/nixos/.sops.yaml`.
Re-key each file the host must read, exactly as in [step 4](#4-register-the-recipient-and-re-key).
Use the admin identity.
Then rebuild:
```console
$ sudo nixos-rebuild switch --flake .#neogaia
```
Activation decrypts the secrets with the new identity.
Confirm they materialized before trusting the change:
```console
$ sudo ls -l /run/secrets/ /run/secrets-for-users/
```
Both directories matter.
Ordinary secrets land in `/run/secrets/`.
A secret marked as needed for user creation decrypts in an earlier stage.
It lands in `/run/secrets-for-users/`, where the login password hash goes.
Check that directory before rebooting.
Commit and push the recipient change once the rebuild succeeds.
## Editing secrets
Opening a file decrypts it into an editor and re-encrypts on save:
```console
$ sudo SOPS_AGE_KEY_FILE=/var/lib/sops-nix/key.txt \
nix run nixpkgs#sops -- configs/nixos/secrets/shared.yaml
```
`sudo` is needed because the identity is mode `0400` and owned by root.
**What the workstation can do alone** is anything to a file it is already a recipient of.
For `neogaia`, that means `configs/nixos/secrets/shared.yaml` and `configs/nixos/secrets/neogaia.yaml`.
Changing a value, adding a key, and adding another recipient all work from the host identity.
Each operation only requires decrypting a file the machine can already decrypt.
**What needs the admin identity** is any file the workstation is not a recipient of.
For example, another machine's `configs/nixos/secrets/<host>.yaml` needs it.
Unlock the admin identity out of Proton Pass for that session and point `SOPS_AGE_KEY_FILE` at it:
```console
$ SOPS_AGE_KEY_FILE=/path/to/admin-identity \
nix run nixpkgs#sops -- configs/nixos/secrets/zeus.yaml
```
That friction is the point.
A workstation that could decrypt every machine's material would make the admin identity ceremonial.
A compromised laptop would carry the whole fleet with it.
The admin identity stays a break-glass credential rather than something sitting unlocked on a machine.
Two changes need more than a save.
Rotating the login password means generating a fresh hash with `mkpasswd`.
`users.mutableUsers = false` makes `passwd` inert.
Rebuild after changing the hash.
Re-keying the SSH host keys restarts `sshd`.
The restart is declared and automatic.
## Recovering a wrongly-provisioned machine
A machine whose identity and registered recipient disagree boots but cannot be logged into.
The password hash never decrypts, and there is no fallback credential.
Recovery is from the live ISO.
Boot the ISO, join wifi, and clone the monorepo as in steps 1 and 2.
Then reopen and mount the encrypted root:
```console
$ sudo nix --extra-experimental-features 'nix-command flakes' run \
github:nix-community/disko/latest#disko -- \
--mode mount --flake .#neogaia
```
Read the identity actually on the disk.
Compare it against the recipient the monorepo registered:
```console
$ sudo nix shell nixpkgs#age -c age-keygen -y /mnt/var/lib/sops-nix/key.txt
```
**If the monorepo's recipient is right and the disk's identity is wrong**, replace the identity with the one that matches.
Then reboot.
Nothing was built against the wrong key, so no rebuild is needed:
```console
$ sudo install -m 0400 -o root -g root /path/to/correct-key.txt /mnt/var/lib/sops-nix/key.txt
$ sudo umount -R /mnt && sudo reboot
```
**If the disk's identity is right and the monorepo's recipient is wrong**, re-key against the identity on the disk.
Use the admin identity to decrypt.
Then rebuild the target from the ISO:
```console
$ SOPS_AGE_KEY_FILE=/path/to/admin-identity \
nix run nixpkgs#sops -- updatekeys configs/nixos/secrets/shared.yaml
$ git add configs/nixos/.sops.yaml configs/nixos/secrets/
$ sudo NIX_CONFIG="experimental-features = nix-command flakes" \
nixos-install --root /mnt --flake .#neogaia --no-root-password \
--option extra-substituters https://nyx-cache.chaotic.cx/ \
--option extra-trusted-public-keys nyx-cache.chaotic.cx:dJxTrgMC3V3cFfyIiBQDQorG6k1LsqurH/srpMSq7qk=
$ sudo umount -R /mnt && sudo reboot
```
The rebuild is required here and not in the first case.
The secrets file is baked into the system closure at build time.
`nixos-install` reuses the already-formatted disk instead of touching the partition table.
The LUKS container and its passphrase are untouched.
It is idempotent if it fails partway.
The substituter flags matter for the same reason they do during the install.
Without them, the CachyOS kernel compiles from source on the USB stick.
If neither identity is recoverable, generate a new one as in [step 3](#3-generate-the-host-identity).
Register it, re-key, and rebuild.
The machine's own secrets are lost, but everything encrypted to the admin identity survives.

View File

@@ -0,0 +1,145 @@
{
config,
lib,
...
}:
let
cfg = config.guests.copyparty;
in
{
options.guests.copyparty = {
passwordSecret = lib.mkOption {
type = lib.types.str;
default = "services/copyparty/users/${config.user.name}/password";
description = "SOPS secret name that contains the Copyparty account password.";
};
statePath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/copyparty";
description = "Host path bind-mounted as Copyparty's state directory.";
};
reverseProxy.trustedSources = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "IP ranges that may supply Copyparty's forwarded client IP header.";
};
volumes = lib.mkOption {
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.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.statePath != null;
message = "guests.copyparty.statePath must point at the host directory that stores Copyparty state.";
}
{
assertion = cfg.volumes != { };
message = "guests.copyparty.volumes must declare the host's served Copyparty volumes.";
}
];
registry.homepage.${config.networking.hostName}.copyparty = {
tab = "Apps";
section = "Files";
name = "Copyparty";
description = "Files on ${config.networking.hostName}";
icon = "copyparty.svg";
href = lib.mkIf cfg.reverseProxy.enable "https://${cfg.reverseProxy.route.domain}/?h";
};
guests.copyparty = {
interior.modules.services.copyparty = {
enable = true;
passwordFile = "/run/secrets/${cfg.passwordSecret}";
reverseProxy = {
enable = cfg.reverseProxy.enable;
inherit (cfg.reverseProxy) trustedSources;
};
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 = {
"/var/lib/copyparty" = {
directory = cfg.statePath;
owner = config.registry."stable-uid".${config.networking.hostName}.copyparty;
group = "storage";
mode = "0770";
};
${"/run/secrets/${cfg.passwordSecret}"} = {
file = config.sops.secrets.${cfg.passwordSecret}.path;
readOnly = true;
};
}
// lib.mapAttrs' (
_urlPath: volume:
lib.nameValuePair volume.path {
directory = volume.hostPath;
owner = "root";
group = "storage";
mode = "2770";
}
) cfg.volumes;
limits = {
memory = lib.mkDefault "2G";
cpu = lib.mkDefault "200%";
tasksMax = lib.mkDefault 512;
};
};
sops.secrets.${cfg.passwordSecret} = {
owner = "root";
group = "storage";
mode = "0440";
};
};
}

View File

@@ -0,0 +1,46 @@
{
config,
lib,
...
}:
let
cfg = config.guests.homepage;
environmentFile = "/run/secrets/homepage.env";
in
{
config = lib.mkIf cfg.enable {
guests.homepage = {
interior.modules.services.homepage = {
enable = true;
hostName = config.networking.hostName;
inherit environmentFile;
};
mounts.${environmentFile} = {
file = config.sops.templates."homepage.env".path;
readOnly = true;
};
limits = {
memory = lib.mkDefault "1G";
cpu = lib.mkDefault "100%";
tasksMax = lib.mkDefault 256;
};
};
sops.secrets = {
"services/metadata/latitude" = { };
"services/metadata/longitude" = { };
};
sops.templates."homepage.env" = {
owner = "root";
group = "storage";
mode = "0440";
content = ''
HOMEPAGE_VAR_LATITUDE=${config.sops.placeholder."services/metadata/latitude"}
HOMEPAGE_VAR_LONGITUDE=${config.sops.placeholder."services/metadata/longitude"}
'';
};
};
}

View File

@@ -0,0 +1,103 @@
{
config,
lib,
...
}:
let
guestCfg = config.guests.navidrome;
serviceCfg = config.containers.navidrome.config.modules.services.navidrome;
in
{
options.guests.navidrome = {
dataPath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/navidrome";
description = "Host path mounted as Navidrome's data folder.";
};
libraryPath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/music";
description = "Host path mounted as Navidrome's library folder.";
};
lastfmApiKeySecret = lib.mkOption {
type = lib.types.str;
default = "services/navidrome/lastfm/api-key";
description = "SOPS secret name containing the Navidrome Last.fm API key.";
};
lastfmSecretSecret = lib.mkOption {
type = lib.types.str;
default = "services/navidrome/lastfm/secret";
description = "SOPS secret name containing the Navidrome Last.fm secret.";
};
};
config = lib.mkIf guestCfg.enable {
assertions = [
{
assertion = guestCfg.dataPath != null;
message = "guests.navidrome.dataPath must point at the host directory that stores Navidrome data.";
}
{
assertion = guestCfg.libraryPath != null;
message = "guests.navidrome.libraryPath must point at the host directory that stores Navidrome's library folder.";
}
];
registry.homepage.${config.networking.hostName}.navidrome = {
tab = "Apps";
section = "Media";
name = "Navidrome";
description = "Music";
icon = "navidrome.png";
};
guests.navidrome = {
interior.modules.services.navidrome.enable = true;
mounts = {
${serviceCfg.dataFolder} = {
directory = guestCfg.dataPath;
owner = config.registry."stable-uid".${config.networking.hostName}.navidrome;
group = "storage";
mode = "2770";
};
${serviceCfg.musicFolder} = {
directory = guestCfg.libraryPath;
owner = "root";
group = "storage";
mode = "2770";
};
${serviceCfg.environmentFile} = {
file = config.sops.templates."navidrome.env".path;
readOnly = true;
};
};
limits = {
memory = lib.mkDefault "2G";
cpu = lib.mkDefault "200%";
tasksMax = lib.mkDefault 512;
};
};
sops.secrets = {
${guestCfg.lastfmApiKeySecret}.sopsFile = ../secrets + "/${config.networking.hostName}.yaml";
${guestCfg.lastfmSecretSecret}.sopsFile = ../secrets + "/${config.networking.hostName}.yaml";
};
sops.templates."navidrome.env" = {
owner = "root";
group = "storage";
mode = "0440";
content = ''
ND_LASTFM_APIKEY=${config.sops.placeholder.${guestCfg.lastfmApiKeySecret}}
ND_LASTFM_SECRET=${config.sops.placeholder.${guestCfg.lastfmSecretSecret}}
'';
};
};
}

View File

@@ -0,0 +1,155 @@
{
config,
lib,
...
}:
let
cfg = config.guests.palworld;
steamDir = "/data/steamcmd";
installDir = "/data/server";
stateDir = "/data/state";
in
{
options.guests.palworld = {
steamPath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/steamcmd";
description = "Host path bind-mounted as SteamCMD's metadata directory.";
};
installPath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/palworld/server";
description = "Host path bind-mounted as Palworld's server install directory.";
};
statePath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/palworld/state";
description = "Host path bind-mounted as Palworld's durable state directory.";
};
serverName = lib.mkOption {
type = lib.types.str;
default = "palworld";
description = "Name shown for the Palworld server.";
};
port = lib.mkOption {
type = lib.types.port;
default = 8211;
description = "UDP port for the Palworld server.";
};
publicIP = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Public IP address advertised by the Palworld server.";
};
autoStart = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Start the Palworld server after the guest reaches network readiness.";
};
passwordSecret = lib.mkOption {
type = lib.types.str;
default = "services/game-servers/password";
description = "SOPS secret name containing the Palworld server password.";
};
adminPasswordSecret = lib.mkOption {
type = lib.types.str;
default = "services/game-servers/password";
description = "SOPS secret name containing the Palworld admin password.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.steamPath != null;
message = "guests.palworld.steamPath must point at the host directory that stores SteamCMD metadata.";
}
{
assertion = cfg.installPath != null;
message = "guests.palworld.installPath must point at the host directory that stores Palworld server files.";
}
{
assertion = cfg.statePath != null;
message = "guests.palworld.statePath must point at the host directory that stores Palworld durable state.";
}
];
guests.palworld = {
interior.modules.services.games.palworld = {
enable = true;
inherit (cfg)
serverName
port
publicIP
autoStart
;
inherit steamDir installDir stateDir;
passwordFile = "/run/secrets/${cfg.passwordSecret}";
adminPasswordFile = "/run/secrets/${cfg.adminPasswordSecret}";
};
mounts = {
${steamDir} = {
directory = cfg.steamPath;
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
group = "storage";
mode = "0770";
};
${installDir} = {
directory = cfg.installPath;
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
group = "storage";
mode = "0770";
};
${stateDir} = {
directory = cfg.statePath;
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
group = "storage";
mode = "0770";
};
${"/run/secrets/${cfg.passwordSecret}"} = {
file = config.sops.secrets.${cfg.passwordSecret}.path;
readOnly = true;
};
}
// lib.optionalAttrs (cfg.adminPasswordSecret != cfg.passwordSecret) {
${"/run/secrets/${cfg.adminPasswordSecret}"} = {
file = config.sops.secrets.${cfg.adminPasswordSecret}.path;
readOnly = true;
};
};
limits = {
memory = lib.mkDefault "8G";
cpu = lib.mkDefault "400%";
tasksMax = lib.mkDefault 2048;
};
};
sops.secrets = {
${cfg.passwordSecret} = {
owner = "root";
group = "storage";
mode = "0440";
};
}
// lib.optionalAttrs (cfg.adminPasswordSecret != cfg.passwordSecret) {
${cfg.adminPasswordSecret} = {
owner = "root";
group = "storage";
mode = "0440";
};
};
};
}

View File

@@ -0,0 +1,70 @@
{
config,
lib,
...
}:
let
giteaSshBackend = "10.23.20.45:2022";
in
{
config = lib.mkIf config.guests.reverse-proxy.enable {
guests.reverse-proxy = {
interior = {
modules.services.reverse-proxy.enable = true;
services.nginx.virtualHosts = {
"games.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"git.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"photos.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"plex.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"servers.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"todo.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"wings.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
};
networking.firewall.allowedTCPPorts = [ 2022 ];
services.nginx.streamConfig = ''
server {
listen 2022;
proxy_pass ${giteaSshBackend};
}
'';
};
limits = {
memory = lib.mkDefault "1G";
cpu = lib.mkDefault "100%";
tasksMax = lib.mkDefault 256;
};
};
};
}

View File

@@ -0,0 +1,116 @@
{
config,
lib,
...
}:
let
cfg = config.guests.valheim;
steamDir = "/data/steamcmd";
installDir = "/data/server";
stateDir = "/data/state";
in
{
options.guests.valheim = {
steamPath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/steamcmd";
description = "Host path bind-mounted as SteamCMD's metadata directory.";
};
installPath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/valheim/server";
description = "Host path bind-mounted as Valheim's server install directory.";
};
statePath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/valheim/state";
description = "Host path bind-mounted as Valheim's world state directory.";
};
serverName = lib.mkOption {
type = lib.types.str;
default = "valheim";
description = "Name shown for the Valheim server.";
};
port = lib.mkOption {
type = lib.types.port;
default = 2456;
description = "Base UDP port for the Valheim server.";
};
passwordSecret = lib.mkOption {
type = lib.types.str;
default = "services/game-servers/password";
description = "SOPS secret name containing the Valheim server password.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.steamPath != null;
message = "guests.valheim.steamPath must point at the host directory that stores SteamCMD metadata.";
}
{
assertion = cfg.installPath != null;
message = "guests.valheim.installPath must point at the host directory that stores Valheim server files.";
}
{
assertion = cfg.statePath != null;
message = "guests.valheim.statePath must point at the host directory that stores Valheim world state.";
}
];
guests.valheim = {
interior.modules.services.games.valheim = {
enable = true;
inherit (cfg) serverName port;
inherit steamDir installDir stateDir;
passwordFile = "/run/secrets/${cfg.passwordSecret}";
};
mounts = {
${steamDir} = {
directory = cfg.steamPath;
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
group = "storage";
mode = "0770";
};
${installDir} = {
directory = cfg.installPath;
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
group = "storage";
mode = "0770";
};
${stateDir} = {
directory = cfg.statePath;
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
group = "storage";
mode = "0770";
};
${"/run/secrets/${cfg.passwordSecret}"} = {
file = config.sops.secrets.${cfg.passwordSecret}.path;
readOnly = true;
};
};
limits = {
memory = lib.mkDefault "4G";
cpu = lib.mkDefault "400%";
tasksMax = lib.mkDefault 1024;
};
};
sops.secrets.${cfg.passwordSecret} = {
owner = "root";
group = "storage";
mode = "0440";
};
};
}

View File

@@ -0,0 +1,62 @@
{
inputs,
util,
...
}:
# neogaia — Dell XPS 13 9380 laptop.
# Disk layout is in ./disk.nix.
# `fileSystems` are derived from it, none declared here.
{
imports = [
inputs.nixos-hardware.nixosModules.dell-xps-13-9380
./hardware-configuration.nix
./disk.nix
];
modules.host.cachyos.enable = true;
modules.network.enable = true;
registry.address.neogaia.self = "10.23.50.146";
modules.network.vpn-client = {
enable = true;
sopsFile = ../../secrets/neogaia.yaml;
address = "10.23.100.3/32";
peer = {
publicKey = "sGEkSD+gEdIMXi4PBN3AhwHGc4tWMtcjOIey3s7yb3I=";
endpoint = "100.0.90.192:51820";
};
};
modules.monorepo.enable = true;
modules.shell.herdr.enable = true;
modules.shell.gitea-axi.enable = true;
modules.shell.pi.enable = true;
modules.desktop.enable = true;
modules.desktop.apps.firefox.uiScale = "1.25";
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,63 @@
_:
# neogaia's disk layout for disko: one NVMe disk, GPT, with an EFI system
# partition and a LUKS container holding btrfs subvolumes.
# No swap partition, since swap is zram.
# disko derives `fileSystems` and `boot.initrd.luks.devices` from this.
{
disko.devices.disk.main = {
type = "disk";
device = "/dev/nvme0n1";
content = {
type = "gpt";
partitions = {
ESP = {
# Each generation stores a kernel and initrd here and the CachyOS kernel is large.
# An exhausted partition fails bootloader installs.
size = "2G";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
luks = {
size = "100%";
content = {
type = "luks";
name = "cryptroot";
settings.allowDiscards = true;
content = {
type = "btrfs";
extraArgs = [ "-f" ];
subvolumes = {
"@root" = {
mountpoint = "/";
mountOptions = [
"compress=zstd"
"noatime"
];
};
"@home" = {
mountpoint = "/home";
mountOptions = [
"compress=zstd"
"noatime"
];
};
"@nix" = {
mountpoint = "/nix";
mountOptions = [
"compress=zstd"
"noatime"
];
};
};
};
};
};
};
};
};
}

View File

@@ -0,0 +1,17 @@
{ lib, modulesPath, ... }:
# Hardware detected by nixos-generate-config on this machine.
# disko derives `fileSystems` and the LUKS device, none declared here.
{
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
boot.initrd.availableKernelModules = [
"xhci_pci"
"nvme"
"rtsx_pci_sdmmc"
];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ];
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
}

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJS+wp7K123+4BT6G4f954R6WyrbWveY7VlpoBUf6I5p neogaia

View File

@@ -0,0 +1 @@
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

View File

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

View File

@@ -0,0 +1,157 @@
{
config,
pkgs,
registry,
...
}:
# pikachu — AZW ME Pro server.
# Disk layout is in ./disk.nix.
# `fileSystems` for the root disk are derived from it.
{
imports = [
./hardware-configuration.nix
./disk.nix
];
hardware.cpu.intel.updateMicrocode = true;
modules.network.vlans = {
interface = "enp2s0";
ids = [ 20 ];
};
boot.zfs.forceImportRoot = false;
modules.host.zfs = {
enable = true;
pools.pikachu = { };
};
registry.address.pikachu.self = "10.23.10.102";
guests.valheim = {
enable = true;
vlan = 20;
address = "10.23.20.197";
steamPath = "/pikachu/data/steamcmd";
installPath = "/pikachu/data/servers/valheim/main";
statePath = "/pikachu/media/games/servers/valheim/main";
serverName = "main";
reverseProxy = {
enable = true;
route = {
domain = "valheim.alexion.dev";
protocol = "udp";
ports = [
config.guests.valheim.port
(config.guests.valheim.port + 1)
];
};
};
};
guests.palworld = {
enable = true;
vlan = 20;
address = "10.23.20.250";
steamPath = "/pikachu/data/steamcmd";
installPath = "/pikachu/data/servers/palworld/tracion";
statePath = "/pikachu/media/games/servers/palworld/tracion";
serverName = "Tracion";
autoStart = false;
adminPasswordSecret = "services/game-servers/admin/password";
publicIP = config.guests.palworld.address;
reverseProxy = {
enable = true;
route = {
domain = "palworld.alexion.dev";
protocol = "udp";
ports = [ config.guests.palworld.port ];
};
};
};
guests.navidrome = {
enable = true;
vlan = 20;
mac = "BC:24:11:C9:17:83";
dataPath = "/pikachu/data/navidrome";
libraryPath = "/pikachu/media/music";
address = "10.23.20.43";
reverseProxy = {
enable = true;
route = {
domain = "music.alexion.dev";
protocol = "https";
port = 4533;
};
};
};
guests.reverse-proxy = {
enable = true;
vlan = 20;
mac = "02:98:64:5a:07:4f";
address = "10.23.20.109";
};
guests.homepage = {
enable = true;
vlan = 20;
address = "10.23.20.110";
reverseProxy = {
enable = true;
route = {
domain = "home";
protocol = "http";
port = 8082;
};
};
};
guests.copyparty = {
enable = true;
vlan = 20;
mac = "02:23:20:39:23:01";
statePath = "/pikachu/data/copyparty";
address = "10.23.20.126";
reverseProxy = {
enable = true;
route = {
domain = "files.alexion.dev";
protocol = "https";
port = 3923;
clientMaxBodySize = "1g";
};
trustedSources = [ "${registry.address.${config.networking.hostName}."reverse-proxy"}/32" ];
};
volumes = {
"/" = {
hostPath = "/pikachu/data/copyparty/root";
path = "/srv";
readOnly = true;
};
"/inbox" = {
hostPath = "/pikachu/__inbox";
path = "/pikachu/__inbox";
};
"/media" = {
hostPath = "/pikachu/media";
path = "/pikachu/media";
};
"/opds" = {
hostPath = "/pikachu/data/opds";
path = "/pikachu/data/opds";
readOnly = true;
flags.opds = true;
};
};
};
environment.systemPackages = with pkgs; [
pciutils
smartmontools
usbutils
];
}

View File

@@ -0,0 +1,32 @@
_:
# pikachu's install layout for disko: one NVMe boot disk with an EFI system partition and ext4 root.
# The existing 8 TB ZFS mirror is imported by name and is never declared here.
{
disko.devices.disk.main = {
type = "disk";
device = "/dev/nvme0n1";
content = {
type = "gpt";
partitions = {
ESP = {
size = "2G";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
}

View File

@@ -0,0 +1,18 @@
{ lib, modulesPath, ... }:
# Hardware detected from the Proxmox inventory for this machine.
# disko derives the root disk filesystems, none declared here.
{
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
boot.initrd.availableKernelModules = [
"ahci"
"nvme"
"sd_mod"
"xhci_pci"
];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ];
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
}

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu

View File

@@ -0,0 +1 @@
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

View File

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

View File

@@ -0,0 +1,67 @@
{
config,
lib,
...
}:
# zeus — Ryzen desktop with AMD graphics.
# Disk layout is in ./disk.nix.
# `fileSystems` are derived from it, none declared here.
{
imports = [
./disk.nix
]
++ lib.optional (builtins.pathExists ./hardware-configuration.nix) ./hardware-configuration.nix;
nixpkgs.hostPlatform = "x86_64-linux";
modules.host.cachyos.enable = true;
hardware.graphics = {
enable = true;
enable32Bit = true;
};
modules.network.enable = true;
services.logind.settings.Login = {
HandlePowerKey = "ignore";
HandlePowerKeyLongPress = "ignore";
};
registry.address.zeus.self = "10.23.50.221";
hardware.bluetooth = {
enable = true;
powerOnBoot = true;
};
modules.monorepo.enable = true;
modules.shell.herdr.enable = true;
modules.shell.gitea-axi.enable = true;
modules.shell.pi.enable = true;
modules.shell.omp.enable = true;
modules.desktop.enable = true;
modules.desktop.apps.obsidian.enable = true;
modules.desktop.apps.r2modman.enable = true;
modules.desktop.apps.signal.enable = true;
modules.desktop.apps.steam.enable = true;
modules.desktop.apps.vencord.enable = true;
home-manager.users.${config.user.name}.wayland.windowManager.hyprland.settings.monitor = [
{
output = "HDMI-A-1";
mode = "1920x1080@120";
position = "-1080x0";
scale = 1;
transform = 3;
}
{
output = "DP-3";
mode = "2560x1440@239.96";
position = "0x0";
scale = 1;
}
];
}

View File

@@ -0,0 +1,84 @@
_:
# zeus's disk layout for disko: two NVMe disks with one EFI system partition
# and two LUKS containers joined into one btrfs filesystem.
# No swap partition, since swap is zram.
# disko derives `fileSystems` and `boot.initrd.luks.devices` from this.
{
disko.devices.disk.primary = {
type = "disk";
device = "/dev/nvme0n1";
content = {
type = "gpt";
partitions = {
ESP = {
size = "2G";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
luks = {
size = "100%";
content = {
type = "luks";
name = "cryptroot0";
settings.allowDiscards = true;
};
};
};
};
};
disko.devices.disk.secondary = {
type = "disk";
device = "/dev/nvme1n1";
content = {
type = "gpt";
partitions = {
luks = {
size = "100%";
content = {
type = "luks";
name = "cryptroot1";
settings.allowDiscards = true;
content = {
type = "btrfs";
extraArgs = [
"-f"
"-d single"
"-m raid1"
"/dev/mapper/cryptroot0"
];
subvolumes = {
"@root" = {
mountpoint = "/";
mountOptions = [
"compress=zstd"
"noatime"
];
};
"@home" = {
mountpoint = "/home";
mountOptions = [
"compress=zstd"
"noatime"
];
};
"@nix" = {
mountpoint = "/nix";
mountOptions = [
"compress=zstd"
"noatime"
];
};
};
};
};
};
};
};
};
}

View File

@@ -0,0 +1,18 @@
# Do not modify this file! It was generated by ‘nixos-generate-config’
# and may be overwritten by future invocations. Please make changes
# to /etc/nixos/configuration.nix instead.
{ config, lib, pkgs, modulesPath, ... }:
{
imports =
[ (modulesPath + "/installer/scan/not-detected.nix")
];
boot.initrd.availableKernelModules = [ "nvme" "xhci_pci" "ahci" "usbhid" "usb_storage" "sd_mod" ];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-amd" ];
boot.extraModulePackages = [ ];
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.amd.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ4GBPt966LR4LpmXgiMMkepIqNIlXTUBh4zdNeek/lN root@zeus

View File

@@ -0,0 +1 @@
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

View File

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

View File

@@ -0,0 +1,44 @@
{
config,
lib,
inputs,
...
}:
# The shared foundation for every host and guest.
{
imports = [ inputs.home-manager.nixosModules.home-manager ];
options.user = {
name = lib.mkOption {
type = lib.types.str;
default = "alexion";
description = ''
Names the primary user account.
Host configuration uses this name for the Home Manager user.
'';
};
};
config = {
system.stateVersion = "26.05";
nixpkgs.config.allowUnfree = true;
nix.settings.experimental-features = [
"nix-command"
"flakes"
];
users.users.${config.user.name} = {
isNormalUser = true;
extraGroups = [
"wheel"
"storage"
];
};
users.groups.storage.gid = 10000;
time.timeZone = "America/New_York";
i18n.defaultLocale = "en_GB.UTF-8";
};
}

View File

@@ -0,0 +1,135 @@
{
config,
inputs,
lib,
pkgs,
util,
...
}:
# Configures Firefox as the desktop browser.
let
cfg = config.modules.desktop.apps.firefox;
in
{
options.modules.desktop.apps.firefox = {
enable = lib.mkEnableOption "Firefox as the desktop browser";
uiScale = lib.mkOption {
type = lib.types.str;
default = "1.0";
description = ''
Scales Firefox UI and page content.
Sets `layout.css.devPixelsPerPx`.
Use a larger value for high-DPI displays.
'';
};
};
config = lib.mkIf cfg.enable {
registry.window-icon.${config.networking.hostName}.firefox = {
class = ".*[Ff]irefox.*";
icon = util.glyph "f269";
};
home-manager.users.${config.user.name} = hm: {
programs.firefox = {
enable = true;
# Applies policies that users cannot change in Firefox.
policies = {
DisableTelemetry = true;
DisableFirefoxStudies = true;
DisablePocket = true;
OfferToSaveLogins = false;
DontCheckDefaultBrowser = true;
DisableFirefoxAccounts = true;
# Removes sponsored content from the new-tab page.
FirefoxHome = {
SponsoredTopSites = false;
SponsoredPocket = false;
Snippets = false;
};
};
profiles.default = {
extensions = {
packages = with inputs.firefox-addons.packages.${pkgs.stdenv.hostPlatform.system}; [
ublock-origin
proton-pass
sponsorblock
];
# Home Manager overwrites Nord theme changes made in Firefox.
force = true;
};
# 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;
in
lib.mkForce {
r = c."base03-rgb-r";
g = c."base03-rgb-g";
b = c."base03-rgb-b";
};
settings = {
"layout.css.devPixelsPerPx" = cfg.uiScale;
# Enables Firefox Color after Firefox sideloads the add-on.
"extensions.autoDisableScopes" = 0;
# 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 = {
# Home Manager replaces the engine list when this setting is enabled.
# Engines added in Firefox do not survive a rebuild.
force = true;
default = "ddg";
# Hides commercial search engines.
# Bangs still reach these engines.
# Entries with only `metaData` configure Firefox built-in engines.
engines = {
google.metaData.hidden = true;
bing.metaData.hidden = true;
ebay.metaData.hidden = true;
"amazondotcom-us".metaData.hidden = true;
};
order = [ "ddg" ];
};
};
};
# Applies the Nord theme through Stylix's Firefox Color add-on.
stylix.targets.firefox = {
enable = true;
profileNames = [ "default" ];
colorTheme.enable = true;
};
# Links opened from other applications land in Firefox.
xdg.mimeApps = {
enable = true;
defaultApplications = {
"text/html" = "firefox.desktop";
"application/xhtml+xml" = "firefox.desktop";
"x-scheme-handler/http" = "firefox.desktop";
"x-scheme-handler/https" = "firefox.desktop";
};
};
# 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

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
{
config,
lib,
pkgs,
util,
...
}:
# Signal desktop messenger.
let
user = config.user.name;
in
{
options.modules.desktop.apps.signal.enable = lib.mkEnableOption "Signal desktop messenger";
config = lib.mkIf config.modules.desktop.apps.signal.enable {
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

@@ -0,0 +1,33 @@
{
config,
lib,
pkgs,
util,
...
}:
# Steam game launcher and runtime integration.
{
options.modules.desktop.apps.steam.enable = lib.mkEnableOption "Steam game launcher";
config = lib.mkIf config.modules.desktop.apps.steam.enable {
programs.gamemode.enable = true;
programs.steam = {
enable = true;
package = pkgs.steam.override {
extraEnv.STEAM_FORCE_DESKTOPUI_SCALING = "1.5";
};
};
hardware.steam-hardware.enable = true;
environment.systemPackages = [
pkgs.gamescope
];
registry.window-icon.${config.networking.hostName}.steam = {
class = "[Ss]team";
icon = util.glyph "f1b6";
};
};
}

View File

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

View File

@@ -0,0 +1,28 @@
{ config, lib, ... }:
# The desktop aggregator: one flag brings up the whole graphical session.
{
options.modules.desktop.enable = lib.mkEnableOption "the keyboard-driven Hyprland desktop";
# Each piece is turned on at default priority, so a host can still override
# any one of them while the single flag above enables the whole desktop.
config = lib.mkIf config.modules.desktop.enable {
modules.desktop.apps.firefox.enable = lib.mkDefault true;
modules.desktop.apps.ghostty.enable = lib.mkDefault true;
modules.desktop.session.hyprland.enable = lib.mkDefault true;
modules.desktop.session.hyprland.hypridle.enable = lib.mkDefault true;
modules.desktop.session.hyprland.hyprlock.enable = lib.mkDefault true;
modules.desktop.session.login.enable = lib.mkDefault true;
modules.desktop.session.mako.enable = lib.mkDefault true;
modules.desktop.session.portals.enable = lib.mkDefault true;
modules.desktop.session.rofi.enable = lib.mkDefault true;
modules.desktop.session.waybar.enable = lib.mkDefault true;
modules.desktop.utilities.audio.enable = lib.mkDefault true;
modules.desktop.utilities.clipboard.enable = lib.mkDefault true;
modules.desktop.utilities.osd.enable = lib.mkDefault true;
modules.desktop.utilities.recording.enable = lib.mkDefault true;
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

@@ -0,0 +1,245 @@
{
config,
lib,
util,
...
}:
# The Hyprland compositor.
{
options.modules.desktop.session.hyprland.enable = lib.mkEnableOption "the Hyprland compositor";
config = lib.mkIf config.modules.desktop.session.hyprland.enable {
# This program integration owns the session, portals, and polkit, launched
# through the universal Wayland session manager.
programs.hyprland = {
enable = true;
withUWSM = true;
};
home-manager.users.${config.user.name}.wayland.windowManager.hyprland = {
enable = true;
# One package drives the whole session, so there is never a version split.
# The program integration above installs it and the portal, leaving home-
# manager to write only the config.
package = null;
portalPackage = null;
# uwsm owns the systemd graphical-session targets.
systemd.enable = false;
# Write the native Lua Hyprland config. Home Manager renders the Nix
# settings below as hl.* calls.
configType = "lua";
settings = {
config = {
input = {
kb_layout = config.services.xserver.xkb.layout;
repeat_delay = 250;
repeat_rate = 45;
accel_profile = "flat";
touchpad = {
natural_scroll = true;
tap_to_click = true;
disable_while_typing = true;
};
};
general = {
gaps_in = 4;
gaps_out = 8;
border_size = 2;
layout = "dwindle";
};
decoration = {
rounding = 6;
blur.enabled = false;
};
xwayland.force_zero_scaling = true;
dwindle.preserve_split = true;
};
env = lib.mapAttrsToList (name: value: {
_args = [
name
value
];
}) (lib.attrByPath [ config.networking.hostName ] { } config.registry.env);
bind = lib.mapAttrsToList (_name: keybind: {
_args = [
keybind.keys
(lib.generators.mkLuaInline (
{
exec = "hl.dsp.exec_cmd(${lib.generators.toLua { } keybind.action.command})";
focus = "hl.dsp.focus(${
lib.generators.toLua { } { inherit (keybind.action) direction workspace; }
})";
"move-window" = "hl.dsp.window.move(${
lib.generators.toLua { } { inherit (keybind.action) direction workspace; }
})";
"resize-window" = "hl.dsp.window.resize(${
lib.generators.toLua { } (
{
down = {
x = 0;
y = 10;
relative = true;
};
left = {
x = -10;
y = 0;
relative = true;
};
right = {
x = 10;
y = 0;
relative = true;
};
up = {
x = 0;
y = -10;
relative = true;
};
}
.${keybind.action.direction}
)
})";
"toggle-floating" = ''hl.dsp.window.float({ action = "toggle" })'';
"toggle-fullscreen" = "hl.dsp.window.fullscreen()";
"close-window" = "hl.dsp.window.close()";
}
.${keybind.action.kind}
))
]
++ lib.optional keybind.repeating {
repeating = true;
}
++ lib.optional keybind.locked {
locked = true;
};
}) (lib.attrByPath [ config.networking.hostName ] { } config.registry.keybind);
};
};
registry.keybind.${config.networking.hostName} = lib.mkMerge [
# Generate numeric workspace keybinds.
(lib.listToAttrs (
lib.concatMap (workspace: [
{
name = "hyprland-workspace-${toString workspace}";
value = {
keys = "SUPER + ${toString workspace}";
action = {
kind = "focus";
inherit workspace;
};
};
}
{
name = "hyprland-move-to-workspace-${toString workspace}";
value = {
keys = "SUPER + SHIFT + ${toString workspace}";
action = {
kind = "move-window";
inherit workspace;
};
};
}
]) (lib.range 1 9)
))
# Generate directional keybinds.
(lib.listToAttrs (
lib.concatMap
(
{ direction, key }:
[
{
name = "hyprland-focus-${direction}";
value = {
keys = "SUPER + ${key}";
action = {
kind = "focus";
inherit direction;
};
};
}
{
name = "hyprland-move-${direction}";
value = {
keys = "SUPER + SHIFT + ${key}";
action = {
kind = "move-window";
inherit direction;
};
};
}
{
name = "hyprland-resize-${direction}";
value = {
keys = "SUPER + ALT + ${key}";
repeating = true;
action = {
kind = "resize-window";
inherit direction;
};
};
}
]
)
[
{
key = "H";
direction = "left";
}
{
key = "J";
direction = "down";
}
{
key = "K";
direction = "up";
}
{
key = "L";
direction = "right";
}
]
))
{
hyprland-toggle-floating = {
keys = "SUPER + Space";
action.kind = "toggle-floating";
};
hyprland-toggle-fullscreen = {
keys = "SUPER + F";
action.kind = "toggle-fullscreen";
};
hyprland-close-window = {
keys = "SUPER + SHIFT + Q";
action.kind = "close-window";
};
}
];
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

@@ -0,0 +1,40 @@
{
config,
lib,
pkgs,
...
}:
# hypridle: idle-triggered locking and display power management.
{
options.modules.desktop.session.hyprland.hypridle.enable =
lib.mkEnableOption "hypridle idle management";
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.${config.user.name}.services.hypridle = {
enable = true;
settings = {
general = {
# The guard drops the call when a locker is already up, so no idle
# trigger stacks a second hyprlock over the first.
lock_cmd = "${lib.getExe' pkgs.procps "pidof"} hyprlock || ${lib.getExe pkgs.hyprlock}";
before_sleep_cmd = "loginctl lock-session";
# Waking pokes the compositor in case firmware leaves displays dark.
after_sleep_cmd = "${lib.getExe' config.programs.hyprland.package "hyprctl"} dispatch 'hl.dsp.dpms({ action = \"enable\" })'";
};
listener = [
# Lock on idle.
{
timeout = 300;
on-timeout = "loginctl lock-session";
}
];
};
};
};
}

View File

@@ -0,0 +1,126 @@
{
config,
lib,
pkgs,
util,
...
}:
# The lock screen: a hyprlock session-lock surface the compositor owns.
let
sleepIcon = util.glyph [
"db81"
"dcb2"
];
sleepOffIcon = util.glyph [
"db81"
"dcb3"
];
idleLockToggle = pkgs.writeShellScript "lockscreen-idle-inhibit-toggle" ''
unit=lockscreen-idle-inhibit.service
refresh_waybar() {
${lib.getExe' pkgs.procps "pkill"} -RTMIN+9 waybar >/dev/null 2>&1 || true
}
if ${lib.getExe' pkgs.systemd "systemctl"} --user is-active --quiet "$unit"; then
${lib.getExe' pkgs.systemd "systemctl"} --user stop "$unit"
refresh_waybar
${lib.getExe' pkgs.swayosd "swayosd-client"} --custom-message "${sleepIcon}"
exit 0
fi
if ${lib.getExe' pkgs.systemd "systemd-run"} \
--user \
--collect \
--unit=lockscreen-idle-inhibit \
--description="Disable idle-triggered screen locking" \
${lib.getExe' pkgs.systemd "systemd-inhibit"} \
--what=idle \
--who=lockscreen-hotkey \
--why="lock screen disabled by hotkey" \
${lib.getExe' pkgs.coreutils "sleep"} infinity >/dev/null; then
refresh_waybar
${lib.getExe' pkgs.swayosd "swayosd-client"} --custom-message "${sleepOffIcon}"
fi
'';
idleLockStatus = pkgs.writeShellScript "waybar-idle-lock-status" ''
if ${lib.getExe' pkgs.systemd "systemctl"} --user is-active --quiet lockscreen-idle-inhibit.service; then
printf '{"text":"${sleepOffIcon}","tooltip":"Idle locking inhibited","class":"inhibited"}\n'
else
printf '{"text":"","class":"idle"}\n'
fi
'';
in
{
options.modules.desktop.session.hyprland.hyprlock.enable =
lib.mkEnableOption "the hyprlock lock screen";
config = lib.mkIf config.modules.desktop.session.hyprland.hyprlock.enable {
home-manager.users.${config.user.name} = {
# Colors and the lock-screen background come from Stylix's hyprlock target,
# so only geometry and behaviour are set here.
programs.hyprlock = {
enable = true;
settings = {
general = {
hide_cursor = true;
# No progress bar flashes before the field is ready to take input.
disable_loading_bar = true;
};
# A centered password field.
# Its colors come from the Stylix hyprlock target.
input-field = {
size = "260, 52";
rounding = 8;
position = "0, -100";
halign = "center";
valign = "center";
};
# The current time, above the field.
label = {
text = "$TIME";
font_size = 48;
position = "0, 120";
halign = "center";
valign = "center";
};
};
};
};
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

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

View File

@@ -0,0 +1,65 @@
{
config,
lib,
pkgs,
util,
...
}:
# mako as the notification daemon: toasts, a do-not-disturb mode, and history recall.
let
dndStatus = pkgs.writeShellScript "waybar-dnd-status" ''
if ${lib.getExe' pkgs.mako "makoctl"} mode 2>/dev/null | ${lib.getExe pkgs.gnugrep} -qx dnd; then
printf '{"text":"${util.glyph "f1f6"}","tooltip":"Do not disturb: on","class":"dnd"}\n'
else
printf '{"text":"${util.glyph "f0f3"}","tooltip":"Notifications on","class":"active"}\n'
fi
'';
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 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 = {
enable = true;
settings = {
# Toasts auto-dismiss into history rather than lingering.
# mako's own default of 0 leaves every notification on screen until acted on.
default-timeout = 5000;
# The do-not-disturb mode.
# Invisible hides the toasts but still records them to history.
# Missed notifications can therefore be recalled.
"mode=dnd".invisible = true;
};
};
};
registry.keybind.${config.networking.hostName}.mako-history = {
keys = "SUPER + N";
action = {
kind = "exec";
command = "${lib.getExe' pkgs.mako "makoctl"} restore";
};
};
registry.waybar.${config.networking.hostName}."custom/dnd" = {
order = 80;
settings = {
return-type = "json";
exec = "${dndStatus}";
on-click = "${dndToggle}";
interval = "once";
signal = 8;
};
};
};
}

View File

@@ -0,0 +1,20 @@
{ config, lib, ... }:
# XDG desktop portal routing for in-app screen sharing and file dialogs.
{
options.modules.desktop.session.portals.enable =
lib.mkEnableOption "XDG desktop portals for in-app screen sharing and file dialogs";
config = lib.mkIf config.modules.desktop.session.portals.enable {
# The backend packages arrive with the compositor, so only the routing is set
# here.
# Screen sharing, screenshots, and global shortcuts need the compositor's
# own backend.
# Everything else uses GTK.
xdg.portal.config.common = {
default = [ "gtk" ];
"org.freedesktop.impl.portal.ScreenCast" = [ "hyprland" ];
"org.freedesktop.impl.portal.Screenshot" = [ "hyprland" ];
"org.freedesktop.impl.portal.GlobalShortcuts" = [ "hyprland" ];
};
};
}

View File

@@ -0,0 +1,66 @@
{
config,
lib,
pkgs,
...
}:
# rofi as the desktop launcher and dmenu-style menu frontend.
let
# A session menu built on the same themed rofi, so the power actions look
# like every other menu the launcher drives.
powerMenu = pkgs.writeShellScript "rofi-power-menu" ''
chosen=$(printf '%s\n' Lock Suspend 'Log out' Reboot 'Shut down' \
| ${lib.getExe pkgs.rofi} -dmenu -i -p Power)
case "$chosen" in
Lock) ${lib.getExe' pkgs.systemd "loginctl"} lock-session ;;
Suspend) ${lib.getExe' pkgs.systemd "systemctl"} suspend ;;
'Log out') ${lib.getExe' pkgs.systemd "loginctl"} terminate-session "$XDG_SESSION_ID" ;;
Reboot) ${lib.getExe' pkgs.systemd "systemctl"} reboot ;;
'Shut down') ${lib.getExe' pkgs.systemd "systemctl"} poweroff ;;
esac
'';
in
{
options.modules.desktop.session.rofi.enable = lib.mkEnableOption "the rofi launcher";
config = lib.mkIf config.modules.desktop.session.rofi.enable {
home-manager.users.${config.user.name} = hm: {
# Stylix supplies the Nord theme.
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.
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";
};
};
rofi-power-menu = {
keys = "SUPER + SHIFT + X";
action = {
kind = "exec";
command = "${powerMenu}";
};
};
};
};
}

View File

@@ -0,0 +1,71 @@
{
config,
lib,
pkgs,
...
}:
# Waybar: a top status bar reading system state at a glance.
let
moduleNames =
position:
map (entry: entry.name) (
lib.sort (left: right: left.module.order < right.module.order) (
lib.filter (entry: entry.module.position == position) moduleEntries
)
);
moduleEntries = lib.mapAttrsToList (name: module: {
inherit name module;
}) modules;
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 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.${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;
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)
];
};
registry.waybar.${config.networking.hostName} = {
clock = {
position = "center";
order = 10;
settings = {
format = "{:%a %d %b %H:%M}";
tooltip-format = "<tt>{calendar}</tt>";
calendar = {
mode = "month";
# Underline the current day so it stands out in the grid.
format.today = "<b><u>{}</u></b>";
};
};
};
};
};
}

View File

@@ -0,0 +1,86 @@
{
config,
lib,
pkgs,
util,
...
}:
# PipeWire as the desktop audio server.
let
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 config.modules.desktop.utilities.audio.enable {
# Realtime scheduling for the audio threads, so playback survives load.
security.rtkit.enable = true;
services.pipewire = {
enable = true;
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

@@ -0,0 +1,45 @@
{
config,
lib,
pkgs,
...
}:
# Clipboard history: cliphist records every copy, picked back through rofi.
let
# The picker reuses the themed rofi, so history looks like every other menu
# the launcher drives.
# decode is needed because list emits id-prefixed lines rather than the copied
# bytes, so the chosen id has to be resolved back before it can be re-copied.
picker = pkgs.writeShellScript "clipboard-picker" ''
${lib.getExe pkgs.cliphist} list \
| ${lib.getExe pkgs.rofi} -dmenu -i -p Clipboard \
| ${lib.getExe pkgs.cliphist} decode \
| ${lib.getExe' pkgs.wl-clipboard "wl-copy"}
'';
in
{
options.modules.desktop.utilities.clipboard.enable =
lib.mkEnableOption "cliphist clipboard history";
config = lib.mkIf config.modules.desktop.utilities.clipboard.enable {
home-manager.users.${config.user.name} = {
# 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
# is for interactive use alone.
home.packages = [ pkgs.wl-clipboard ];
# Two watchers record text and images to history, bound to the graphical
# session so uwsm starts and stops them with it.
services.cliphist.enable = true;
};
registry.keybind.${config.networking.hostName}.clipboard-history = {
keys = "SUPER + SHIFT + V";
action = {
kind = "exec";
command = "${picker}";
};
};
};
}

View File

@@ -0,0 +1,56 @@
{
config,
lib,
pkgs,
...
}:
# SwayOSD: a transient on-screen popup for volume and brightness changes.
{
options.modules.desktop.utilities.osd.enable =
lib.mkEnableOption "the SwayOSD volume and brightness popup";
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.${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.${config.user.name}.systemd.user.services.swayosd = {
Unit = {
Description = "SwayOSD on-screen display server";
PartOf = [ "graphical-session.target" ];
After = [ "graphical-session.target" ];
};
Service = {
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

@@ -0,0 +1,68 @@
{
config,
lib,
pkgs,
util,
...
}:
# Keybound screen recording: pick a region, then toggle a video-only capture.
let
# 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
}
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=$(${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"
${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 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

@@ -0,0 +1,57 @@
{
config,
lib,
pkgs,
...
}:
# Keyboard-driven screenshots copy to the clipboard before Satty opens for
# annotation. Satty copies annotated screenshots when it saves them.
let
screenshotKey = keys: target: {
inherit keys;
action = {
kind = "exec";
command = "${capture target}";
};
};
# 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="$(${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 ${lib.getExe' pkgs.wl-clipboard "wl-copy"} \
--save-after-copy \
--early-exit \
--actions-on-enter save-to-clipboard
'';
in
{
options.modules.desktop.utilities.screenshot.enable =
lib.mkEnableOption "screenshots via grimblast and satty";
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.
wayland.windowManager.hyprland.settings.window_rule = [
{
match.class = "^(com\\.gabm\\.satty)$";
float = true;
}
];
};
registry.keybind.${config.networking.hostName} = {
screenshot-area = screenshotKey "Print" "area";
screenshot-active = screenshotKey "SHIFT + Print" "active";
screenshot-screen = screenshotKey "CTRL + Print" "screen";
};
};
}

View File

@@ -0,0 +1,75 @@
{
config,
lib,
pkgs,
...
}:
# Nord theming for the graphical layer, driven from one base16 scheme by Stylix.
let
# 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" { } ''
${lib.getExe pkgs.imagemagick} \
-size 2560x1440 gradient:'#2E3440'-'#3B4252' \
"$out"
'';
in
{
options.modules.desktop.utilities.theming.enable =
lib.mkEnableOption "Nord theming of the graphical layer via Stylix";
config = lib.mkIf config.modules.desktop.utilities.theming.enable {
stylix = {
enable = true;
# Nord is a dark scheme.
# The polarity steers Stylix's contrast choices.
polarity = "dark";
base16Scheme = "${pkgs.base16-schemes}/share/themes/nord.yaml";
image = wallpaper;
# Regreet is not enabled, so its styling target stays off.
targets.regreet.enable = false;
cursor = {
package = pkgs.bibata-cursors;
# Solid white with a dark outline, so it stays easy to spot against the
# dark Nord wallpaper.
name = "Bibata-Modern-Ice";
size = 24;
};
fonts = {
monospace = {
package = pkgs.jetbrains-mono;
name = "JetBrains Mono";
};
# A step below Stylix's defaults (applications 12, desktop/popups 10,
# terminal 12), which read oversized on this display.
sizes = {
applications = 10;
desktop = 9;
popups = 9;
terminal = 10;
};
};
};
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;
# nvim keeps its dedicated Nord colorscheme, which is richer than the
# 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

@@ -0,0 +1,27 @@
{ config, lib, ... }:
# Declares XDG user directories, so tools that query them use predictable
# folders a host can relocate from one place.
let
homeDirectory = "/home/${config.user.name}";
in
{
options.modules.desktop.utilities.userdirs.enable = lib.mkEnableOption "XDG user directories";
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

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

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

@@ -0,0 +1,15 @@
{
config,
lib,
...
}:
# Tools for developing this monorepo.
{
options.modules.monorepo.enable = lib.mkEnableOption "tools for developing this monorepo";
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

@@ -0,0 +1,206 @@
{
config,
lib,
registry,
...
}:
# Configures inbound and outbound SSH access.
let
cfg = config.modules.network.ssh;
user = config.user.name;
workstationNames = [
"neogaia"
"zeus"
];
serverNames = [ "pikachu" ];
isWorkstation = lib.elem config.networking.hostName workstationNames;
isServer = lib.elem config.networking.hostName serverNames;
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")
];
managedTargets = lib.genAttrs (workstationNames ++ serverNames) (name: {
hostName = registry.address.${name}.self;
clientKey = clientKeyFor name;
hostKeys = hostKeysFor name;
});
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 ++ lib.optional isServer (clientKeysFor serverNames)
);
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;
knownHosts = lib.listToAttrs (
lib.concatLists (
lib.mapAttrsToList (
targetName: target:
lib.imap0 (
i: key:
lib.nameValuePair "${targetName}-${toString i}" {
hostNames = knownHostNamesFor target;
publicKey = keyWithoutComment key;
}
) target.hostKeys
) targets
)
);
knownHostNamesFor =
target:
let
withPort = name: if target.port == 22 then name else "[${name}]:${toString target.port}";
names = lib.unique (target.aliases ++ [ target.hostName ]);
in
map withPort names;
keyWithoutComment = key: lib.concatStringsSep " " (lib.take 2 (lib.splitString " " key));
sshSettingsFor =
name:
let
target = targets.${name};
in
{
header = "Host ${lib.concatStringsSep " " target.aliases}";
HostName = target.hostName;
User = target.user;
}
// lib.optionalAttrs (target.port != 22) { Port = target.port; };
hostKeySecret = type: "ssh-host-${type}-key";
hostKeyTypes = [
"ed25519"
"rsa"
];
userKeySecret = "ssh-user-ed25519-key";
in
{
options.modules.network.ssh = {
enable = lib.mkEnableOption "the OpenSSH daemon";
hostKeys.restore = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Restores host keys from secrets.
Preserves the fingerprint across reimages.
Guests disable this option and generate their own host keys.
'';
};
hostKeys.sopsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Sets encrypted file that contains host private keys.
Names each secret `ssh-host-<type>-key`.
Requires a value when `restore` is enabled.
'';
};
userKey.sopsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Sets encrypted file that contains the SSH client private key.
Names the secret `ssh-user-ed25519-key`.
Guests leave this unset because they do not authenticate to remote servers.
'';
};
};
config = lib.mkIf cfg.enable (
lib.mkMerge [
{
services.openssh.enable = true;
users.users.${user}.openssh.authorizedKeys.keys = defaultAuthorizedKeys;
programs.ssh.knownHosts = knownHosts;
home-manager.users.${user}.programs.ssh = {
enable = true;
enableDefaultConfig = false;
settings = lib.mapAttrs (name: _: sshSettingsFor name) (
lib.genAttrs outboundTargetNames (name: name)
);
};
}
(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 = lib.genAttrs (map hostKeySecret hostKeyTypes) (_: {
inherit (cfg.hostKeys) sopsFile;
mode = "0400";
restartUnits = [ "sshd.service" ];
});
services.openssh.hostKeys = [ ];
services.openssh.extraConfig = lib.concatStringsSep "" (
map (type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n") hostKeyTypes
);
})
(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;
IdentitiesOnly = true;
};
})
]
);
}

View File

@@ -0,0 +1,93 @@
{
config,
lib,
util,
...
}:
# Configures bridges for tagged VLANs on a physical interface that uses DHCP.
let
cfg = config.modules.network.vlans;
in
{
options.modules.network.vlans = {
interface = lib.mkOption {
type = lib.types.str;
example = "enp1s0";
description = ''
Names the physical interface that carries untagged DHCP traffic and tagged VLANs.
'';
};
ids = lib.mkOption {
type = lib.types.listOf (lib.types.ints.between 1 4094);
default = [ ];
example = [
10
20
30
];
description = ''
Creates bridges for the specified tagged VLAN IDs.
VLAN ID N creates bridge `br-vlanN`.
'';
};
};
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-${util.bridgeName id}" = {
netdevConfig = {
Name = util.bridgeName id;
Kind = "bridge";
};
};
"40-vlan${toString id}" = {
netdevConfig = {
Name = "vlan${toString id}";
Kind = "vlan";
};
vlanConfig.Id = id;
};
}) cfg.ids
);
networks = lib.mkMerge (
[
{
"10-${cfg.interface}" = {
matchConfig.Name = cfg.interface;
networkConfig.DHCP = "yes";
vlan = map (id: "vlan${toString id}") cfg.ids;
linkConfig.RequiredForOnline = "routable";
};
}
]
++ map (id: {
"40-vlan${toString id}" = {
matchConfig.Name = "vlan${toString id}";
networkConfig.Bridge = util.bridgeName id;
linkConfig.RequiredForOnline = "no";
};
"40-${util.bridgeName id}" = {
matchConfig.Name = util.bridgeName id;
linkConfig.RequiredForOnline = "no";
};
}) cfg.ids
);
};
};
}

View File

@@ -0,0 +1,109 @@
{
config,
lib,
pkgs,
util,
...
}:
let
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 = {
enable = lib.mkEnableOption "a WireGuard VPN client";
interface = lib.mkOption {
type = lib.types.str;
default = "wg-home";
description = "Names the WireGuard interface.";
};
address = lib.mkOption {
type = lib.types.str;
example = "10.23.100.3/32";
description = "Sets the client tunnel address in CIDR notation.";
};
sopsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Names the encrypted file that contains the WireGuard private key.";
};
peer = {
publicKey = lib.mkOption {
type = lib.types.str;
description = "Sets the WireGuard public key for the remote peer.";
};
endpoint = lib.mkOption {
type = lib.types.str;
example = "203.0.113.10:51820";
description = "Sets the WireGuard endpoint for the remote peer.";
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.sopsFile != null;
message = "Set modules.network.vpn-client.sopsFile when the VPN client is enabled.";
}
];
sops.secrets.wireguard-home-private-key.sopsFile = cfg.sopsFile;
registry.shell-shortcut.${config.networking.hostName} = {
vpn-on = "sudo systemctl start wireguard-${cfg.interface}.target";
vpn-off = "sudo systemctl stop 'wireguard-${cfg.interface}*.service' wireguard-${cfg.interface}.target";
};
registry.waybar.${config.networking.hostName}."custom/vpn" = {
order = 50;
settings = {
return-type = "json";
exec = "${vpnStatus}";
interval = 5;
};
};
systemd.targets."wireguard-${cfg.interface}".wantedBy = lib.mkForce [ ];
networking.wireguard.interfaces.${cfg.interface} = {
ips = [ cfg.address ];
mtu = 1280;
privateKeyFile = config.sops.secrets.wireguard-home-private-key.path;
peers = [
{
inherit (cfg.peer) publicKey endpoint;
allowedIPs = [ "10.23.0.0/16" ];
persistentKeepalive = 25;
}
];
};
};
}

View File

@@ -0,0 +1,209 @@
{
config,
lib,
pkgs,
util,
...
}:
let
cfg = config.modules.services.copyparty;
accountName = config.user.name;
serviceGroup = "storage";
serviceStateDir = "/var/lib/copyparty";
serviceUser = "copyparty";
writableVolumes = lib.filter (volume: !volume.readOnly) (lib.attrValues cfg.volumes);
port = 3923;
defaultAccess = {
A = [ accountName ];
};
defaultWritableFlags = {
d2d = true;
d2t = true;
dthumb = true;
};
credentialHash = builtins.substring 0 16 (builtins.hashString "sha256" accountName);
credentialName = "password-${credentialHash}";
passwordPlaceholder = "@COPYPARTY_PASSWORD_${credentialHash}@";
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";
};
globalExtraConfig = lib.optionalString cfg.reverseProxy.enable (
lib.concatMapStringsSep "\n" (source: "xff-src: ${source}") cfg.reverseProxy.trustedSources
);
volumeFlags =
flags:
lib.optionalAttrs (flags != { }) (
{
chmod_d = "2770";
chmod_f = "660";
}
// 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;
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
'';
in
{
options.modules.services.copyparty = {
enable = lib.mkEnableOption "Copyparty";
passwordFile = lib.mkOption {
type = lib.types.str;
description = "Sets the runtime file that contains the Copyparty account password.";
};
volumes = lib.mkOption {
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 = "Defines Copyparty volumes by URL path.";
};
reverseProxy = {
enable = lib.mkEnableOption "reverse-proxy trust settings for Copyparty";
trustedSources = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Lists proxy sources that can set Copyparty's forwarded client IP header.";
};
};
};
config = lib.mkMerge [
{
registry."stable-uid".${config.networking.hostName}.copyparty = 20002;
}
(lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.volumes != { };
message = "modules.services.copyparty.volumes must declare at least one served volume.";
}
{
assertion = !cfg.reverseProxy.enable || cfg.reverseProxy.trustedSources != [ ];
message = "modules.services.copyparty.reverseProxy.trustedSources must name at least one trusted proxy source when reverse-proxy support is enabled.";
}
];
users.users.${serviceUser} = {
isSystemUser = true;
uid = config.registry."stable-uid".${config.networking.hostName}.copyparty;
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 = serviceUser;
Group = serviceGroup;
WorkingDirectory = serviceStateDir;
Environment = [
"HOME=${serviceStateDir}"
"XDG_CONFIG_HOME=${serviceStateDir}"
];
StateDirectory = "copyparty";
RuntimeDirectory = "copyparty";
RuntimeDirectoryMode = "0700";
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 = [
serviceStateDir
]
++ map (volume: volume.path) writableVolumes;
};
};
networking.firewall.allowedTCPPorts = [ port ];
})
];
}

View File

@@ -0,0 +1,157 @@
{
config,
lib,
pkgs,
...
}:
{
options.modules.services.games.palworld = {
enable = lib.mkEnableOption "Palworld dedicated server";
steamDir = lib.mkOption {
type = lib.types.str;
example = "/data/steamcmd";
description = "Directory where SteamCMD stores metadata and cache files.";
};
installDir = lib.mkOption {
type = lib.types.str;
example = "/data/servers/palworld/tracion";
description = "Directory where SteamCMD installs the Palworld server files.";
};
stateDir = lib.mkOption {
type = lib.types.str;
example = "/data/state/palworld/tracion";
description = "Directory where Palworld stores durable server state.";
};
serverName = lib.mkOption {
type = lib.types.str;
default = "palworld";
description = "Name shown for the Palworld server.";
};
port = lib.mkOption {
type = lib.types.port;
default = 8211;
description = "UDP port for the Palworld server.";
};
publicIP = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Public IP address advertised by the Palworld server.";
};
maxPlayers = lib.mkOption {
type = lib.types.ints.between 1 32;
default = 32;
description = "Maximum player count for the Palworld server.";
};
publicLobby = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Advertise the server in Palworld's public lobby.";
};
autoStart = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Start the Palworld server after the guest reaches network readiness.";
};
rcon = {
enable = lib.mkEnableOption "Palworld RCON" // {
default = true;
};
port = lib.mkOption {
type = lib.types.port;
default = 25575;
description = "TCP port for Palworld RCON.";
};
};
passwordFile = lib.mkOption {
type = lib.types.path;
description = "Runtime file containing the Palworld server password.";
};
adminPasswordFile = lib.mkOption {
type = lib.types.path;
description = "Runtime file containing the Palworld admin password.";
};
};
config = lib.mkIf config.modules.services.games.palworld.enable (
let
service = config.modules.services.games.palworld;
savedDir = "${service.installDir}/Pal/Saved";
startScript = pkgs.writeShellScript "palworld-start" ''
set -eu
${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="$(${lib.getExe' pkgs.coreutils "readlink"} ${lib.escapeShellArg savedDir})"
if [ "$current_target" != ${lib.escapeShellArg service.stateDir} ]; then
${lib.getExe' pkgs.coreutils "rm"} ${lib.escapeShellArg savedDir}
fi
fi
if [ -d ${lib.escapeShellArg savedDir} ] && [ ! -L ${lib.escapeShellArg savedDir} ]; then
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
${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
${lib.getExe' pkgs.coreutils "ln"} -s ${lib.escapeShellArg service.stateDir} ${lib.escapeShellArg savedDir}
fi
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 \
"$@" \
"-serverpassword=$password" \
"-adminpassword=$admin_password"
'';
in
{
modules.services.steamcmd = {
enable = true;
inherit (service) steamDir installDir stateDir;
serviceName = "palworld-${lib.toLower service.serverName}";
appId = 2394010;
inherit (service) autoStart;
executable = "${startScript}";
arguments = [
"-useperfthreads"
"-NoAsyncLoadingThread"
"-UseMultithreadForDS"
"-port=${toString service.port}"
"-publicport=${toString service.port}"
"-servername=${service.serverName}"
"-players=${toString service.maxPlayers}"
]
++ lib.optional service.publicLobby "-publiclobby"
++ lib.optionals (service.publicIP != null) [ "-publicip=${service.publicIP}" ]
++ lib.optionals service.rcon.enable [
"-rcon"
"-RCONPort=${toString service.rcon.port}"
];
udpPorts = [ service.port ];
tcpPorts = lib.optional service.rcon.enable service.rcon.port;
};
}
);
}

View File

@@ -0,0 +1,89 @@
{
config,
lib,
pkgs,
...
}:
{
options.modules.services.games.valheim = {
enable = lib.mkEnableOption "Valheim dedicated server";
steamDir = lib.mkOption {
type = lib.types.str;
example = "/data/steamcmd";
description = "Directory where SteamCMD stores metadata and cache files.";
};
installDir = lib.mkOption {
type = lib.types.str;
example = "/data/servers/valheim/main";
description = "Directory where SteamCMD installs the Valheim server files.";
};
stateDir = lib.mkOption {
type = lib.types.str;
example = "/data/state/valheim/main";
description = "Directory where Valheim stores world data.";
};
serverName = lib.mkOption {
type = lib.types.str;
default = "valheim";
description = "Name shown for the Valheim server.";
};
port = lib.mkOption {
type = lib.types.port;
default = 2456;
description = "Base UDP port for the Valheim server.";
};
passwordFile = lib.mkOption {
type = lib.types.path;
description = "Runtime file containing the Valheim server password.";
};
};
config = lib.mkIf config.modules.services.games.valheim.enable (
let
service = config.modules.services.games.valheim;
startScript = pkgs.writeShellScript "valheim-start" ''
set -eu
password="$(${lib.getExe' pkgs.coreutils "tr"} -d '\n' < ${lib.escapeShellArg service.passwordFile})"
exec ./valheim_server.x86_64 "$@" -password "$password"
'';
in
{
modules.services.steamcmd = {
enable = true;
inherit (service) steamDir installDir stateDir;
serviceName = "valheim";
appId = 896660;
executable = "${startScript}";
arguments = [
"-nographics"
"-batchmode"
"-name"
service.serverName
"-port"
(toString service.port)
"-world"
service.serverName
"-savedir"
service.stateDir
"-public"
"0"
"-modifier"
"raids"
"none"
];
udpPorts = [
service.port
(service.port + 1)
];
};
}
);
}

View File

@@ -0,0 +1,228 @@
{
config,
lib,
registry,
...
}:
let
cfg = config.modules.services.homepage;
derivedAllowedHosts = lib.unique (
lib.optional (selfRoute != null) selfRoute.domain
++ lib.optionals (selfAddress != null) [
selfAddress
"${selfAddress}:${toString cfg.listenPort}"
]
);
selfRoute = selfEntry.route or null;
selfEntry = registry.join cfg.hostName cfg.entryName;
selfAddress = selfEntry.address or null;
homepageServices = map mkTab (lib.filter (tab: lib.hasAttr tab entriesByTab) orderedTabs);
mkTab =
tab:
let
sections = entriesByTab.${tab};
in
{
${tab} = map (section: { ${section} = sections.${section}; }) (orderedSections tab sections);
};
entriesByTab = lib.foldl' insertEntry { } sortedEntries;
insertEntry =
acc: item:
acc
// {
${item.homepage.tab} = (acc.${item.homepage.tab} or { }) // {
${item.homepage.section} = (acc.${item.homepage.tab}.${item.homepage.section} or [ ]) ++ [
{ ${item.homepage.name} = mkServiceAttrs item; }
];
};
};
sortedEntries = lib.sort (left: right: left.homepage.name < right.homepage.name) homepageEntries;
homepageEntries = lib.concatLists (
lib.mapAttrsToList (
host: entries:
lib.mapAttrsToList (entry: homepage: {
inherit host entry homepage;
composite = registry.join host entry;
}) entries
) registry.homepage
);
orderedTabs = tabOrder ++ lib.filter (tab: !lib.elem tab tabOrder) (lib.attrNames entriesByTab);
tabOrder = [
"Apps"
"Download"
"System"
];
mkRegistryPath =
registryName: host: entry:
"registry.${registryName}.${host}.${entry}";
mkHref =
item:
if item.homepage.href != null then
item.homepage.href
else if (item.composite.route or null) != null then
if item.composite.route.protocol == "https" then
"https://${item.composite.route.domain}"
else if item.composite.route.protocol == "http" then
"http://${item.composite.route.domain}"
else
throw "${mkRegistryPath "homepage" item.host item.entry} needs href or a matching HTTP route entry"
else
throw "${mkRegistryPath "homepage" item.host item.entry} needs href or a matching route entry";
mkServiceAttrs =
item:
lib.filterAttrs (_: value: value != null) {
href = mkHref item;
inherit (item.homepage) description icon;
};
sectionOrderByTab = {
Apps = [
"Media"
"Files"
"Other"
];
Download = [
"Library Managers"
"Other Utilities"
"Download Clients"
];
System = [
"Monitoring"
"Network"
"Machines"
"Docker"
];
};
orderedSections =
tab: sections:
let
sectionOrder = sectionOrderByTab.${tab} or [ ];
in
lib.filter (section: lib.hasAttr section sections) sectionOrder
++ lib.filter (section: !lib.elem section sectionOrder) (lib.attrNames sections);
in
{
options.modules.services.homepage = {
enable = lib.mkEnableOption "Homepage Dashboard";
listenPort = lib.mkOption {
type = lib.types.port;
default = 8082;
description = "Port for Homepage to bind to.";
};
hostName = lib.mkOption {
type = lib.types.str;
description = "Host registry key that owns the Homepage guest entry.";
};
entryName = lib.mkOption {
type = lib.types.str;
default = "homepage";
description = "Registry entry name for this Homepage service.";
};
allowedHosts = lib.mkOption {
type = lib.types.nullOr (lib.types.listOf lib.types.str);
default = null;
description = "Host headers accepted by Homepage. Defaults to matching address and route registry entries.";
};
environmentFile = lib.mkOption {
type = lib.types.path;
default = "/run/secrets/homepage.env";
description = "Environment file passed to Homepage.";
};
};
config = lib.mkIf cfg.enable {
services.homepage-dashboard = {
enable = true;
openFirewall = true;
inherit (cfg) listenPort;
environmentFiles = [ cfg.environmentFile ];
allowedHosts = lib.concatStringsSep "," (
if cfg.allowedHosts == null then derivedAllowedHosts else cfg.allowedHosts
);
services = homepageServices;
widgets = [
{
greeting = {
text_size = "xl";
text = "tracion";
};
}
{
openmeteo = {
units = "metric";
latitude = "{{HOMEPAGE_VAR_LATITUDE}}";
longitude = "{{HOMEPAGE_VAR_LONGITUDE}}";
cache = 5;
};
}
{
datetime = {
text_size = "xl";
locale = "en";
format = {
dateStyle = "full";
timeStyle = "medium";
hour12 = false;
};
};
}
];
settings = {
title = "Tracion";
theme = "dark";
color = "slate";
showStats = false;
hideVersion = true;
bookmarksStyle = "icons";
useEqualHeights = true;
layout = {
Apps = {
style = "row";
columns = 1;
tab = "Apps";
Media = {
style = "row";
columns = 2;
};
Files = {
style = "row";
columns = 2;
};
Other = {
style = "row";
columns = 3;
iconsOnly = true;
};
};
Download = {
style = "row";
columns = 1;
tab = "Download";
};
System = {
tab = "System";
};
};
};
};
};
}

View File

@@ -0,0 +1,64 @@
{
config,
lib,
...
}:
let
cfg = config.modules.services.navidrome;
in
{
options.modules.services.navidrome = {
enable = lib.mkEnableOption "Navidrome";
dataFolder = lib.mkOption {
type = lib.types.str;
default = "/var/lib/navidrome";
readOnly = true;
description = "Path for Navidrome's `DataFolder` setting.";
};
musicFolder = lib.mkOption {
type = lib.types.str;
default = "/music";
readOnly = true;
description = "Path for Navidrome's `MusicFolder` setting.";
};
environmentFile = lib.mkOption {
type = lib.types.path;
default = "/run/secrets/navidrome.env";
readOnly = true;
description = "Path to Navidrome's environment file.";
};
};
config = lib.mkMerge [
{
registry."stable-uid".${config.networking.hostName}.navidrome = 20004;
}
(lib.mkIf cfg.enable {
users.users.navidrome = {
isSystemUser = true;
uid = config.registry."stable-uid".${config.networking.hostName}.navidrome;
group = "storage";
home = cfg.dataFolder;
};
services.navidrome = {
enable = true;
user = "navidrome";
group = "storage";
openFirewall = true;
inherit (cfg) environmentFile;
settings = {
Address = "0.0.0.0";
Port = 4533;
DataFolder = cfg.dataFolder;
MusicFolder = cfg.musicFolder;
EnableSharing = true;
};
};
})
];
}

View File

@@ -0,0 +1,119 @@
{
config,
lib,
registry,
...
}:
let
generatedUdpStreams = lib.concatLists (
lib.mapAttrsToList (
host: routes:
lib.concatLists (
lib.mapAttrsToList (
entry: route: lib.optionals (route.protocol == "udp") (map (mkUdpStream host entry) route.ports)
) routes
)
) registry.route
);
mkUdpStream =
host: entry: port:
let
address =
compositeEntry.address
or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}");
compositeEntry = registry.join host entry;
in
{
inherit
host
entry
port
address
;
};
generatedStreamConfig = lib.concatMapStringsSep "\n" (stream: ''
server {
listen ${toString stream.port} udp;
proxy_pass ${stream.address}:${toString stream.port};
}
'') generatedUdpStreams;
generatedVirtualHosts = lib.listToAttrs generatedVirtualHostList;
# Route registry validation already requires unique domains.
generatedVirtualHostList = lib.concatLists (
lib.mapAttrsToList (
host: routes:
lib.mapAttrsToList (entry: route: mkVirtualHost host entry route.protocol) (
lib.filterAttrs (_entry: route: route.protocol != "udp") routes
)
) registry.route
);
mkVirtualHost =
host: entry: mode:
let
isHttp = mode == "http";
address =
compositeEntry.address
or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}");
compositeEntry = registry.join host entry;
inherit (compositeEntry) route;
inherit (route) port;
in
{
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}";
mkRegistryPath =
registryName: host: entry:
"registry.${registryName}.${host}.${entry}";
in
{
options.modules.services.reverse-proxy = {
enable = lib.mkEnableOption "the public edge reverse proxy";
};
config = lib.mkIf config.modules.services.reverse-proxy.enable {
networking.firewall.allowedTCPPorts = [
80
443
];
networking.firewall.allowedUDPPorts = map (stream: stream.port) generatedUdpStreams;
security.acme = {
acceptTerms = true;
defaults.email = "contact@alexion.dev";
};
services.nginx = {
enable = true;
commonHttpConfig = ''
server {
listen 0.0.0.0:443 ssl default_server;
listen [::0]:443 ssl default_server;
ssl_reject_handshake on;
}
'';
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
streamConfig = lib.mkIf (generatedUdpStreams != [ ]) generatedStreamConfig;
virtualHosts = generatedVirtualHosts;
};
};
}

View File

@@ -0,0 +1,190 @@
{
config,
lib,
pkgs,
...
}:
{
options.modules.services.steamcmd = {
enable = lib.mkEnableOption "SteamCMD-managed game server";
steamDir = lib.mkOption {
type = lib.types.str;
example = "/data/steamcmd";
description = "Directory where SteamCMD stores metadata and cache files.";
};
serviceName = lib.mkOption {
type = lib.types.strMatching "[a-z0-9][a-z0-9-]*";
example = "valheim";
description = "Lowercase systemd service basename for the game server.";
};
appId = lib.mkOption {
type = lib.types.ints.positive;
example = 896660;
description = "Steam app ID for the dedicated server package.";
};
installDir = lib.mkOption {
type = lib.types.str;
example = "/data/servers/valheim/main";
description = "Directory where SteamCMD installs the server files and where the game service starts.";
};
stateDir = lib.mkOption {
type = lib.types.str;
example = "/data/state/valheim/main";
description = "Writable directory for durable game server state.";
};
executable = lib.mkOption {
type = lib.types.str;
example = "./valheim_server.x86_64";
description = "Server executable, relative to installDir or absolute.";
};
arguments = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [
"-name"
"main"
];
description = "Arguments passed to the server executable.";
};
udpPorts = lib.mkOption {
type = lib.types.listOf lib.types.port;
default = [ ];
description = "UDP ports opened for the game service.";
};
tcpPorts = lib.mkOption {
type = lib.types.listOf lib.types.port;
default = [ ];
description = "TCP ports opened for the game service.";
};
autoStart = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Start the game service after the guest reaches network readiness.";
};
};
config = lib.mkMerge [
{
registry."stable-uid".${config.networking.hostName}.steamcmd = 20003;
}
(lib.mkIf config.modules.services.steamcmd.enable (
let
group = "storage";
service = config.modules.services.steamcmd;
user = "steamcmd";
prepareScript = pkgs.writeShellScript "steamcmd-${service.serviceName}-prepare" ''
set -eu
${lib.getExe' pkgs.coreutils "install"} -d \
-o ${user} \
-g ${group} \
-m 0770 \
${lib.escapeShellArg service.steamDir} \
${lib.escapeShellArg service.installDir} \
${lib.escapeShellArg service.stateDir}
'';
updateScript = pkgs.writeShellScript "steamcmd-${service.serviceName}-update" ''
set -eu
export HOME=${lib.escapeShellArg service.steamDir}
exec ${lib.getExe pkgs.steamcmd} \
+force_install_dir ${lib.escapeShellArg service.installDir} \
+login anonymous \
+app_update ${toString service.appId} validate \
+quit
'';
startScript = pkgs.writeShellScript "steamcmd-${service.serviceName}-start" ''
set -eu
cd ${lib.escapeShellArg service.installDir}
export LD_LIBRARY_PATH=linux64:''${LD_LIBRARY_PATH-}
exec ${lib.getExe pkgs.steam-run} ${lib.escapeShellArg service.executable} ${lib.escapeShellArgs service.arguments}
'';
in
{
users.users.${user} = {
isSystemUser = true;
uid = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
inherit group;
home = service.steamDir;
};
systemd.tmpfiles.rules = [
"d ${service.steamDir} 0770 ${user} ${group} - -"
"Z ${service.steamDir} 0770 ${user} ${group} - -"
"d ${service.installDir} 0770 ${user} ${group} - -"
"Z ${service.installDir} 0770 ${user} ${group} - -"
"d ${service.stateDir} 0770 ${user} ${group} - -"
"Z ${service.stateDir} 0770 ${user} ${group} - -"
];
systemd.services = {
"steamcmd-${service.serviceName}-update" = {
description = "Update ${service.serviceName} with SteamCMD";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
User = user;
Group = group;
WorkingDirectory = "-${service.steamDir}";
ExecStartPre = "+${prepareScript}";
ExecStart = updateScript;
TimeoutStartSec = "15min";
};
};
"${service.serviceName}-autostart" = {
description = "Queue ${service.serviceName} game server startup";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = lib.mkIf service.autoStart [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${lib.getExe' pkgs.systemd "systemctl"} --no-block start ${service.serviceName}.service";
};
};
${service.serviceName} = {
description = "${service.serviceName} game server";
after = [
"network-online.target"
"steamcmd-${service.serviceName}-update.service"
];
wants = [
"network-online.target"
"steamcmd-${service.serviceName}-update.service"
];
serviceConfig = {
Type = "simple";
User = user;
Group = group;
WorkingDirectory = "-${service.installDir}";
ExecStartPre = "+${prepareScript}";
ExecStart = startScript;
Restart = "on-failure";
TimeoutStartSec = "15min";
};
};
};
networking.firewall.allowedUDPPorts = service.udpPorts;
networking.firewall.allowedTCPPorts = service.tcpPorts;
}
))
];
}

View File

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

View File

@@ -0,0 +1,146 @@
set --query BAZEL_COMMAND_LIST; or set --global BAZEL_COMMAND_LIST analyze-profile aquery build canonicalize-flags clean coverage cquery dump fetch help info license mobile-install print_action query run shutdown test vendor version
function __bazel_seen_subcommand --description "Checks whether the command line contains a Bazel subcommand."
__fish_seen_subcommand_from $BAZEL_COMMAND_LIST
end
function __bazel_workspace_root --description "Prints the nearest Bazel workspace root."
set --local dir (pwd)
while test "$dir" != /
if test -e "$dir/MODULE.bazel"; or test -e "$dir/WORKSPACE"; or test -e "$dir/WORKSPACE.bazel"
printf '%s\n' "$dir"
return 0
end
set dir (dirname "$dir")
end
return 1
end
function __bazel_build_file --description "Prints the BUILD file for a package."
set --local root $argv[1]
set --local package $argv[2]
if test -f "$root/$package/BUILD.bazel"
printf '%s\n' "$root/$package/BUILD.bazel"
else if test -f "$root/$package/BUILD"
printf '%s\n' "$root/$package/BUILD"
end
end
function __bazel_complete_package --description "Completes Bazel package paths with BUILD-file scanning."
set --local root $argv[1]
set --local token $argv[2]
set --local absolute_prefix ''
set --local rel "$token"
if string match --quiet '//*' -- "$token"
set absolute_prefix '//'
set rel (string sub --start 3 -- "$token")
end
set --local parent (string replace --regex '[^/]*$' '' -- "$rel")
set --local prefix (string replace --regex '^.*/' '' -- "$rel")
set --local search_dir "$root/$parent"
test -d "$search_dir"; or return 0
if test -z "$rel"
set --local root_build_file (__bazel_build_file "$root" "")
if test -n "$root_build_file"
printf '%s:\tpackage\n' "$absolute_prefix"
end
end
for dir in (find "$search_dir" -mindepth 1 -maxdepth 1 -type d -name "$prefix*" 2>/dev/null)
set --local name (basename "$dir")
set --local package "$parent$name"
switch "$package"
case .direnv .git bazel-bin bazel-dev bazel-out bazel-testlogs target
continue
end
printf '%s%s/\tdirectory\n' "$absolute_prefix" "$package"
set --local package_build_file (__bazel_build_file "$root" "$package")
if test -n "$package_build_file"
printf '%s%s:\tpackage\n' "$absolute_prefix" "$package"
end
end
end
function __bazel_current_subcommand --description "Prints the active Bazel subcommand."
for token in (commandline --tokens-expanded)
if contains -- "$token" $BAZEL_COMMAND_LIST
printf '%s\n' "$token"
return 0
end
end
return 1
end
function __bazel_target_kind_pattern --description "Prints the target rule-kind pattern for the active subcommand."
switch (__bazel_current_subcommand)
case test coverage
printf '(^|_)test$|^test_suite$\n'
case run
printf '(_binary$|_test$|^test_suite$)\n'
case '*'
printf '.*\n'
end
end
function __bazel_complete_target --description "Completes targets from the selected package BUILD file."
set --local root $argv[1]
set --local token $argv[2]
set --local absolute_prefix ''
set --local rel "$token"
if string match --quiet '//*' -- "$token"
set absolute_prefix '//'
set rel (string sub --start 3 -- "$token")
end
set --local package (string replace --regex ':.*$' '' -- "$rel")
set --local target_prefix (string replace --regex '^.*:' '' -- "$rel")
set --local build_file (__bazel_build_file "$root" "$package")
set --local kind_pattern (__bazel_target_kind_pattern)
test -n "$build_file"; or return 0
awk -v prefix="$target_prefix" -v kind_pattern="$kind_pattern" '
match($0, /^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\(/, rule) {
kind = rule[1]
}
match($0, /(^|[[:space:]])name[[:space:]]*=[[:space:]]*"([^"]+)"/, target) {
if (kind ~ kind_pattern && index(target[2], prefix) == 1) {
print target[2]
}
}
' "$build_file" | while read --local target
printf '%s%s:%s\ttarget\n' "$absolute_prefix" "$package" "$target"
end
end
function __bazel_complete_label --description "Completes local Bazel labels without running Bazel query."
set --local token (commandline --current-token)
string match --quiet -- '-*' "$token"; and return 0
string match --quiet -- '@*' "$token"; and return 0
set --local root (__bazel_workspace_root); or return 0
if string match --quiet '*:*' -- "$token"
__bazel_complete_target "$root" "$token"
else
__bazel_complete_package "$root" "$token"
end
end
complete -c bazel -e
complete -c bazel -f -n "not __bazel_seen_subcommand" -a "$BAZEL_COMMAND_LIST"
complete -c bazel -f -n "__fish_seen_subcommand_from build coverage fetch mobile-install print_action query run test" -a "(__bazel_complete_label)"

View File

@@ -0,0 +1,23 @@
# vi-style modal editing on the command line.
set -g fish_key_bindings fish_vi_key_bindings
set -gx EDITOR nvim
set -gx VISUAL nvim
# Render man pages through bat.
set -x MANROFFOPT "-c"
set -x MANPAGER "sh -c 'col -bx | bat -l man -p'"
# Tune the done plugin: only notify for commands past 10s, at low urgency.
set -g __done_min_cmd_duration 10000
set -g __done_notification_urgency_level low
# Prepend ~/.local/bin to PATH when it exists.
if test -d ~/.local/bin
fish_add_path ~/.local/bin
end
# Apply fish-compatible profile overrides if present.
if test -f ~/.fish_profile
source ~/.fish_profile
end

View File

@@ -0,0 +1,109 @@
{
config,
lib,
pkgs,
...
}:
# fish for the primary user, configured through home-manager.
let
cfg = config.modules.shell.fish;
commandsOf = shortcuts: lib.mapAttrs (_name: shortcut: shortcut.command) shortcuts;
shortcuts = lib.attrByPath [ config.networking.hostName ] { } config.registry.shell-shortcut;
user = config.user.name;
in
{
options.modules.shell.fish = {
enable = lib.mkEnableOption "fish as the user's shell, configured via home-manager";
defaultShell = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Set fish as the user's default login shell.";
};
};
config = lib.mkIf cfg.enable {
# System-level fish registers it in /etc/shells and installs vendor completions.
programs.fish.enable = true;
users.users.${user}.shell = lib.mkIf cfg.defaultShell pkgs.fish;
home-manager.users.${user} = {
home.packages = with pkgs; [
bat # backs the manpager
fastfetch # the shell greeting
];
xdg.configFile."fish/completions/bazel.fish".source = ./completions/bazel.fish;
programs.fish = {
enable = true;
preferAbbrs = true;
plugins = [
# Notify when a long command finishes.
{
name = "done";
src = pkgs.fishPlugins.done.src;
}
# Restore the !! and !$ history bindings.
{
name = "bang-bang";
src = pkgs.fishPlugins.bang-bang.src;
}
];
shellAliases = commandsOf (lib.filterAttrs (_name: shortcut: !shortcut.abbreviation) shortcuts);
shellAbbrs = commandsOf (lib.filterAttrs (_name: shortcut: shortcut.abbreviation) shortcuts);
functions = {
# Pick the logo that fits the terminal so the greeting never wraps.
# The full logo needs about 118 columns beside the info block, the
# compact one about 89, the info block alone about 71.
fish_greeting = ''
if test "$COLUMNS" -ge 118
fastfetch
else if test "$COLUMNS" -ge 89
fastfetch --logo NixOS_small
else
fastfetch --logo none
end
'';
# vi insert mode omits the emacs ctrl-f and ctrl-r bindings, so restore both.
# ctrl-f accepts an autosuggestion and ctrl-r opens the history pager.
# vi normal mode keeps its ctrl-r redo, since only insert mode is bound.
fish_user_key_bindings = ''
bind -M insert ctrl-f forward-char
bind -M insert ctrl-r history-pager
'';
history = {
description = "Show command history with timestamps";
body = "builtin history --show-time='%F %T '";
};
backup = {
description = "Copy <file> to <file>.bak";
argumentNames = "filename";
body = "cp $filename $filename.bak";
};
copy = {
description = "Copy a file, or recursively copy a source directory into a destination";
body = builtins.readFile ./functions/copy.fish;
};
deploy-to = {
description = "Build the current checkout locally, import it on a host, and activate it";
body = builtins.readFile ./functions/deploy-to.fish;
};
};
# Rendered by home-manager into ~/.config/fish/config.fish.
interactiveShellInit = builtins.readFile ./config.fish;
};
};
};
}

View File

@@ -0,0 +1,8 @@
set count (count $argv | tr -d \n)
if test "$count" = 2; and test -d "$argv[1]"
set from (echo $argv[1] | trim-right /)
set to (echo $argv[2])
command cp -r $from $to
else
command cp $argv
end

View File

@@ -0,0 +1,95 @@
if test (count $argv) -ne 1
echo "usage: deploy-to <host>" >&2
return 2
end
set host $argv[1]
git rev-parse --show-toplevel >/dev/null 2>&1
or begin
echo "deploy-to: run this from a git checkout" >&2
return 2
end
set repository_root (git rev-parse --show-toplevel)
or return $status
set untracked_nix_files (git -C $repository_root ls-files --others --exclude-standard -- '*.nix' flake.nix flake.lock)
if test (count $untracked_nix_files) -gt 0
echo "deploy-to: warning: untracked Nix files are not part of Git flake evaluation" >&2
printf ' %s\n' $untracked_nix_files >&2
end
set installable "$repository_root#nixosConfigurations.$host.config.system.build.toplevel"
echo "deploy-to: building $installable" >&2
set system_path (nix build --no-link --print-out-paths $installable)
or begin
echo "deploy-to: build failed for $host" >&2
return $status
end
if test (count $system_path) -ne 1
echo "deploy-to: expected one build output, got "(count $system_path) >&2
return 1
end
set closure_paths (nix-store -qR $system_path)
or begin
echo "deploy-to: closure query failed for $host" >&2
return $status
end
echo "deploy-to: checking which closure paths $host is missing" >&2
set missing_paths (printf '%s\n' $closure_paths | ssh $host 'xargs -r nix-store --check-validity --print-invalid')
or begin
echo "deploy-to: remote closure check failed for $host" >&2
return $status
end
if test (count $missing_paths) -gt 0
set nar_file (mktemp --suffix=.nar)
or return $status
set remote_nar "/tmp/deploy-to-$host-"(date +%s)"-$fish_pid.nar"
echo "deploy-to: exporting "(count $missing_paths)" missing closure paths for $system_path" >&2
nix-store --export $missing_paths >$nar_file
or begin
set export_status $status
rm -f $nar_file
echo "deploy-to: closure export failed for $host" >&2
return $export_status
end
echo "deploy-to: copying closure archive to $host:$remote_nar" >&2
scp $nar_file $host:$remote_nar
set scp_status $status
rm -f $nar_file
if test $scp_status -ne 0
echo "deploy-to: closure archive copy failed for $host" >&2
return $scp_status
end
echo "deploy-to: importing closure on $host" >&2
ssh -t $host "sudo sh -c 'nix-store --import < $remote_nar' && rm -f $remote_nar"
or begin
echo "deploy-to: closure import failed for $host" >&2
return $status
end
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
echo "deploy-to: activation failed for $host" >&2
return $status
end

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