diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..e37e787f
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,10 @@
+version: 2
+updates:
+ # Keep the GitHub Actions used by the workflows pinned and current.
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ labels:
+ - "dependencies"
+ - "automated"
diff --git a/.github/workflows/fmt.yml b/.github/workflows/fmt.yml
index 064060a6..8ccd0778 100644
--- a/.github/workflows/fmt.yml
+++ b/.github/workflows/fmt.yml
@@ -1,4 +1,4 @@
-name: Nix Format Check
+name: Nix Format & Lint Check
on:
push:
@@ -13,7 +13,7 @@ on:
jobs:
fmt:
- name: Check Nix formatting
+ name: Check Nix formatting & lint
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -26,7 +26,11 @@ jobs:
extra_nix_config: |
experimental-features = nix-command flakes
- - name: Check formatting
+ - name: Cache Nix store
+ uses: DeterminateSystems/magic-nix-cache-action@v8
+
+ # Builds the treefmt check (alejandra + statix + deadnix). Fails if any
+ # file is unformatted or trips a lint. `nix fmt` locally fixes them.
+ - name: Check formatting & lint
working-directory: nixos
- run: |
- nix fmt -- --check .
+ run: nix build .#checks.x86_64-linux.treefmt -L
diff --git a/.github/workflows/update-flake-lock.yml b/.github/workflows/update-flake-lock.yml
new file mode 100644
index 00000000..9d8e2f90
--- /dev/null
+++ b/.github/workflows/update-flake-lock.yml
@@ -0,0 +1,30 @@
+name: Update flake.lock
+
+on:
+ schedule:
+ # Mondays at 06:00 UTC
+ - cron: "0 6 * * 1"
+ workflow_dispatch: {} # allow manual runs
+
+jobs:
+ update-lock:
+ name: Update nixos/flake.lock
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Install Nix
+ uses: DeterminateSystems/nix-installer-action@main
+
+ - name: Update flake.lock and open PR
+ uses: DeterminateSystems/update-flake-lock@main
+ with:
+ path-to-flake-dir: nixos
+ pr-title: "chore(flake): update flake.lock"
+ pr-labels: |
+ dependencies
+ automated
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index 5ac66e51..1d38954e 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -84,3 +84,9 @@ jobs:
run: |
echo "Building moria..."
nix build .#darwinConfigurations.moria.config.system.build.toplevel --dry-run
+
+ - name: Validate citadel
+ working-directory: nixos
+ run: |
+ echo "Building citadel..."
+ nix build .#darwinConfigurations.citadel.config.system.build.toplevel --dry-run
diff --git a/nixos/CLAUDE.md b/nixos/CLAUDE.md
index c1aa7438..f2012507 100644
--- a/nixos/CLAUDE.md
+++ b/nixos/CLAUDE.md
@@ -124,24 +124,55 @@ See [.devcontainer/README.md](.devcontainer/README.md) for details.
If needed, see [README.md](README.md) for detailed documentation on repository structure, VM setup, and development workflows.
-## Future: Automatic Nix Garbage Collection for Darwin Hosts
+## Automatic Nix Garbage Collection for Darwin Hosts
-Both darwin hosts use Determinate Nix (`nix.enable = false` in `modules/darwin/common.nix`),
-which means nix-darwin's built-in `nix.gc` module **cannot** be used (it asserts `nix.gc.automatic
-requires nix.enable`). If store bloat becomes a problem, create a custom launchd daemon in
-`modules/darwin/nix-gc.nix`:
+The darwin hosts run Determinate Nix (`nix.enable = false` in `modules/darwin/common.nix`),
+so nix-darwin's built-in `nix.gc` module can't be used (it asserts `nix.gc.automatic` requires
+`nix.enable`). **A hand-rolled `launchd.daemons.nix-gc` is no longer the right answer** —
+Determinate Nix ships **Determinate Nixd**, a daemon that performs garbage collection natively.
+
+To enable scheduled GC, adopt the Determinate nix-darwin module instead of writing custom
+launchd jobs:
+
+1. Add the flake input: `determinate.url = "github:DeterminateSystems/determinate"`.
+2. Import `inputs.determinate.darwinModules.default` in `modules/darwin/common.nix`.
+3. Configure the collector, e.g.:
```nix
-{ ... }: {
- launchd.daemons.nix-gc = {
- command = "/nix/var/nix/profiles/default/bin/nix-collect-garbage --delete-older-than 30d";
- serviceConfig = {
- RunAtLoad = false;
- StartCalendarInterval = [{ Weekday = 7; Hour = 3; Minute = 0; }];
- };
- };
+{
+ determinate-nix.customSettings = { };
+ # GC strategy lives under the Determinate Nixd config:
+ # /etc/determinate/config.json (written by the module)
+ # e.g. garbageCollector.strategy = "automatic";
}
```
-Import it from each host's `default.nix`. Runs as root (launchd daemons default), so it
-cleans both user and system generations — without `sudo`, only user generations are collected.
+See for the current option names
+(`determinateNixd.garbageCollector.strategy`). This replaces — and is simpler than — the
+old custom-daemon approach, and is correctly disk-pressure aware.
+
+## Secret Management — Decision Record (1Password vs. agenix / sops-nix)
+
+**Current approach (keep):** Secrets live in 1Password (vault **Infra**). Committed `.tpl`
+files hold `{{ op://Infra/Item/field }}` references; `just secrets` runs `op inject` to
+write the real (gitignored) files. See the toolbox root `CLAUDE.md` → "Secret Management"
+for the exact commands and prerequisites.
+
+**Why it's worth a note:** `op inject` writes **plaintext** generated files to disk and
+needs an interactive 1Password GUI unlock. On headless **dungeon** that means connecting
+via VNC + Touch ID before every `just secrets` — a manual, non-reproducible step that
+doesn't fit the otherwise declarative activation flow.
+
+**Alternatives considered (not adopted this round):**
+
+- **agenix** — secrets are `age`-encrypted *into the repo* (safe to commit) and decrypted
+ at activation to a tmpfs (RAM, never written to disk) using each host's existing SSH host
+ key. Simplest fit for our small set of standalone tokens; no GUI, no manual step, works
+ headless. Tradeoff: re-keying when host keys change, and editing requires the `agenix` CLI.
+- **sops-nix** — same activation-time, key-based decryption but with `sops`/YAML/`age` and
+ better ergonomics for *bundled* multi-key secret files. More machinery than we need today.
+
+**Decision: defer.** 1Password stays the source of truth. The manual headless `just secrets`
+step is tolerable while dungeon is the only headless Darwin host. **Revisit (lean agenix)**
+if a second headless host appears, or if the VNC-unlock dance becomes a recurring pain —
+both decrypt at activation to tmpfs and eliminate the plaintext-on-disk + GUI-unlock steps.
diff --git a/nixos/README.md b/nixos/README.md
index ceee436e..6e38decf 100644
--- a/nixos/README.md
+++ b/nixos/README.md
@@ -43,17 +43,14 @@ See [.devcontainer/README.md](.devcontainer/README.md) for more details.
### Git Hooks
-Git hooks are available to automate validation:
+Git hooks are managed declaratively via [git-hooks.nix](https://github.com/cachix/git-hooks.nix)
+(see [flake-modules/dev.nix](flake-modules/dev.nix)) and installed automatically when you
+enter the dev shell (`nix develop` from `nixos/`):
-- **pre-commit**: Runs `nix fmt` on staged `.nix` files
-- **pre-push**: Validates all NixOS configs using the devcontainer
+- **pre-commit**: Runs `treefmt` (alejandra + statix + deadnix) on staged `.nix` files
+- **pre-push**: Runs `nix flake check`
-**Installation:**
-```bash
-./nixos/scripts/install-hooks.sh
-```
-
-After installation, the hooks run automatically. To skip (not recommended):
+To skip (not recommended):
```bash
git commit --no-verify
git push --no-verify
@@ -61,7 +58,10 @@ git push --no-verify
## Pre Configured Shell
-We have a pre configured shell available, with all the required tooling. Simply run `$ nix-shell` and you have everything set up. This is powered by our `shell.nix` file, and is useful on a freshly installed NixOS machine.
+A reproducible dev shell with all required tooling (treefmt, just, nh, ...) is defined as the
+flake's default dev shell in [flake-modules/dev.nix](flake-modules/dev.nix). From `nixos/`, run
+`$ nix develop` (or let direnv do it) to get everything set up — useful on a freshly installed
+machine. Entering the shell also installs the git hooks above.
## Claude Code Best Practices
diff --git a/nixos/flake-modules/dev.nix b/nixos/flake-modules/dev.nix
new file mode 100644
index 00000000..29908c64
--- /dev/null
+++ b/nixos/flake-modules/dev.nix
@@ -0,0 +1,41 @@
+# Dev shell + git hooks.
+#
+# Replaces the standalone shell.nix and the hand-rolled scripts/hooks/*:
+# * `nix develop` gives a reproducible env (treefmt + the tools the justfile uses)
+# and installs the git hooks on entry.
+# * pre-commit runs treefmt (alejandra + statix + deadnix) on staged files.
+# * pre-push runs `nix flake check` so broken configs never reach the remote.
+{
+ perSystem = {
+ config,
+ pkgs,
+ ...
+ }: {
+ pre-commit.settings.hooks = {
+ treefmt = {
+ enable = true;
+ package = config.treefmt.build.wrapper;
+ };
+
+ flake-check = {
+ enable = true;
+ name = "nix flake check";
+ entry = "nix flake check";
+ pass_filenames = false;
+ stages = ["pre-push"];
+ };
+ };
+
+ devShells.default = pkgs.mkShell {
+ inputsFrom = [config.treefmt.build.devShell];
+ shellHook = config.pre-commit.installationScript;
+ packages = with pkgs; [
+ git
+ just
+ nh
+ nix-output-monitor
+ nvd
+ ];
+ };
+ };
+}
diff --git a/nixos/flake-modules/hosts.nix b/nixos/flake-modules/hosts.nix
new file mode 100644
index 00000000..003fbfda
--- /dev/null
+++ b/nixos/flake-modules/hosts.nix
@@ -0,0 +1,110 @@
+# NixOS + nix-darwin host definitions.
+#
+# Each host used to repeat an identical specialArgs + module list; the mkNixos/
+# mkDarwin helpers below collapse that boilerplate so a host is one entry
+# (system + module path + any host-specific extra modules / vars overrides).
+{
+ inputs,
+ self,
+ ...
+}: let
+ inherit (inputs) nixpkgs nixos-wsl nixos-hardware home-manager stylix darwin;
+
+ vars = import ../config/vars.nix {inherit (nixpkgs) lib;};
+
+ # Minimal home-manager wiring shared by every host.
+ mkHomeManagerModule = _: {
+ home-manager = {
+ useUserPackages = true;
+ backupFileExtension = "backup";
+ users.${vars.user.name} = {};
+ };
+ };
+
+ mkNixos = {
+ system,
+ modulePath,
+ extraModules ? [],
+ hostVars ? vars,
+ }:
+ nixpkgs.lib.nixosSystem {
+ inherit system;
+ specialArgs = {
+ inherit inputs;
+ vars = hostVars;
+ outputs = self;
+ };
+ modules =
+ [
+ modulePath
+ stylix.nixosModules.stylix
+ home-manager.nixosModules.home-manager
+ mkHomeManagerModule
+ ]
+ ++ extraModules;
+ };
+
+ mkDarwin = {
+ modulePath,
+ system ? "aarch64-darwin",
+ extraModules ? [],
+ hostVars ? vars,
+ }:
+ darwin.lib.darwinSystem {
+ inherit system;
+ specialArgs = {
+ inherit inputs;
+ vars = hostVars;
+ outputs = self;
+ };
+ modules =
+ [
+ modulePath
+ home-manager.darwinModules.home-manager
+ ]
+ ++ extraModules;
+ };
+in {
+ flake.nixosConfigurations = {
+ foundation = mkNixos {
+ system = "x86_64-linux";
+ modulePath = ../hosts/vms/foundation;
+ extraModules = [nixos-wsl.nixosModules.default];
+ };
+
+ isengard = mkNixos {
+ system = "x86_64-linux";
+ modulePath = ../hosts/pcs/isengard;
+ extraModules = [nixos-hardware.nixosModules.lenovo-thinkpad-t420];
+ };
+
+ home-lab = mkNixos {
+ system = "x86_64-linux";
+ modulePath = ../hosts/vms/home-lab;
+ };
+
+ # Writerdeck — console-only, distraction-free writing machine.
+ # See: https://veronicaexplains.net/my-first-writerdeck/
+ rohan = mkNixos {
+ system = "x86_64-linux";
+ modulePath = ../hosts/pcs/rohan;
+ };
+
+ mines = mkNixos {
+ system = "aarch64-linux";
+ modulePath = ../hosts/vms/mines;
+ };
+ };
+
+ flake.darwinConfigurations = {
+ dungeon = mkDarwin {modulePath = ../hosts/macs/dungeon;};
+
+ moria = mkDarwin {modulePath = ../hosts/macs/moria;};
+
+ # Mozilla work machine: override the user name (drives git identity, home dir).
+ citadel = mkDarwin {
+ modulePath = ../hosts/macs/citadel;
+ hostVars = vars // {user = vars.user // {name = "greghilston";};};
+ };
+ };
+}
diff --git a/nixos/flake-modules/treefmt.nix b/nixos/flake-modules/treefmt.nix
new file mode 100644
index 00000000..a024a7ea
--- /dev/null
+++ b/nixos/flake-modules/treefmt.nix
@@ -0,0 +1,22 @@
+# Formatting + linting, exposed as `nix fmt` and a `nix flake check` check.
+#
+# treefmt-nix wires these together: alejandra formats, statix flags anti-patterns,
+# deadnix removes dead code. `nix fmt` applies fixes; `nix flake check` fails on diff.
+{
+ perSystem = _: {
+ treefmt = {
+ projectRootFile = "flake.nix";
+
+ programs = {
+ alejandra.enable = true;
+ statix.enable = true;
+ deadnix = {
+ enable = true;
+ # Leave unused function args (e.g. `{ pkgs, ... }`) alone — they are
+ # part of NixOS/home-manager module signatures, not dead code.
+ no-lambda-pattern-names = true;
+ };
+ };
+ };
+ };
+}
diff --git a/nixos/flake.lock b/nixos/flake.lock
index 557ceab8..cf7fe2c5 100644
--- a/nixos/flake.lock
+++ b/nixos/flake.lock
@@ -126,6 +126,22 @@
}
},
"flake-compat": {
+ "flake": false,
+ "locked": {
+ "lastModified": 1767039857,
+ "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
+ "owner": "NixOS",
+ "repo": "flake-compat",
+ "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "repo": "flake-compat",
+ "type": "github"
+ }
+ },
+ "flake-compat_2": {
"flake": false,
"locked": {
"lastModified": 1767039857,
@@ -142,6 +158,26 @@
}
},
"flake-parts": {
+ "inputs": {
+ "nixpkgs-lib": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1778716662,
+ "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
+ "owner": "hercules-ci",
+ "repo": "flake-parts",
+ "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hercules-ci",
+ "repo": "flake-parts",
+ "type": "github"
+ }
+ },
+ "flake-parts_2": {
"inputs": {
"nixpkgs-lib": [
"nur",
@@ -162,7 +198,7 @@
"type": "github"
}
},
- "flake-parts_2": {
+ "flake-parts_3": {
"inputs": {
"nixpkgs-lib": [
"stylix",
@@ -219,6 +255,49 @@
"type": "github"
}
},
+ "git-hooks": {
+ "inputs": {
+ "flake-compat": "flake-compat",
+ "gitignore": "gitignore",
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1781733627,
+ "narHash": "sha256-U3yTuGBnmXvXoQI3qkpfEDsn9RovQPAjN7ndRco+3u0=",
+ "owner": "cachix",
+ "repo": "git-hooks.nix",
+ "rev": "3bbec39bc90eadfa031e6f3b77272f3f60803e39",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "repo": "git-hooks.nix",
+ "type": "github"
+ }
+ },
+ "gitignore": {
+ "inputs": {
+ "nixpkgs": [
+ "git-hooks",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1709087332,
+ "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
+ "owner": "hercules-ci",
+ "repo": "gitignore.nix",
+ "rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hercules-ci",
+ "repo": "gitignore.nix",
+ "type": "github"
+ }
+ },
"gnome-shell": {
"flake": false,
"locked": {
@@ -292,7 +371,7 @@
},
"nixos-wsl": {
"inputs": {
- "flake-compat": "flake-compat",
+ "flake-compat": "flake-compat_2",
"nixpkgs": "nixpkgs_3"
},
"locked": {
@@ -392,7 +471,7 @@
},
"nur": {
"inputs": {
- "flake-parts": "flake-parts",
+ "flake-parts": "flake-parts_2",
"nixpkgs": "nixpkgs_5"
},
"locked": {
@@ -438,7 +517,9 @@
"inputs": {
"claude-desktop": "claude-desktop",
"darwin": "darwin",
+ "flake-parts": "flake-parts",
"flake-utils": "flake-utils",
+ "git-hooks": "git-hooks",
"home-manager": "home-manager",
"nix-vscode-extensions": "nix-vscode-extensions",
"nixos-hardware": "nixos-hardware",
@@ -446,7 +527,8 @@
"nixpkgs": "nixpkgs_4",
"nur": "nur",
"stylix": "stylix",
- "systems": "systems_2"
+ "systems": "systems_2",
+ "treefmt-nix": "treefmt-nix"
}
},
"stylix": {
@@ -456,7 +538,7 @@
"base16-helix": "base16-helix",
"base16-vim": "base16-vim",
"firefox-gnome-theme": "firefox-gnome-theme",
- "flake-parts": "flake-parts_2",
+ "flake-parts": "flake-parts_3",
"gnome-shell": "gnome-shell",
"nixpkgs": [
"nixpkgs"
@@ -575,6 +657,26 @@
"repo": "base16-zed",
"type": "github"
}
+ },
+ "treefmt-nix": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1780220602,
+ "narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=",
+ "owner": "numtide",
+ "repo": "treefmt-nix",
+ "rev": "db947814a175b7ca6ded66e21383d938df01c227",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "treefmt-nix",
+ "type": "github"
+ }
}
},
"root": "root",
diff --git a/nixos/flake.nix b/nixos/flake.nix
index e90de661..fe79e648 100644
--- a/nixos/flake.nix
+++ b/nixos/flake.nix
@@ -8,6 +8,15 @@
nixos-hardware.url = "github:NixOS/nixos-hardware/master";
# Use the full default set (includes Darwin)
systems.url = "github:nix-systems/default";
+
+ # Flake structure + dev tooling
+ flake-parts.url = "github:hercules-ci/flake-parts";
+ flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs";
+ treefmt-nix.url = "github:numtide/treefmt-nix";
+ treefmt-nix.inputs.nixpkgs.follows = "nixpkgs";
+ git-hooks.url = "github:cachix/git-hooks.nix";
+ git-hooks.inputs.nixpkgs.follows = "nixpkgs";
+
# Home manager
home-manager = {
url = "github:nix-community/home-manager";
@@ -25,160 +34,22 @@
# removed from nixpkgs-unstable on 2026-03-03. Let it use its own pinned nixpkgs.
claude-desktop.inputs.flake-utils.follows = "flake-utils";
- # nix-darwin for macOS support (Keep input if you might use it later, even if no configs defined now)
+ # nix-darwin for macOS support
darwin.url = "github:LnL7/nix-darwin";
darwin.inputs.nixpkgs.follows = "nixpkgs";
};
- outputs = inputs @ {
- self,
- nixpkgs,
- systems,
- nixos-wsl,
- nixos-hardware,
- home-manager,
- stylix,
- darwin, # Keep this input if you anticipate defining macOS configs later
- ...
- }: let
- lib = nixpkgs.lib // home-manager.lib;
- # Support all major systems (Linux and Darwin)
- allSystems = import systems;
- forEachSystem = f: lib.genAttrs allSystems (system: f pkgsFor.${system});
- pkgsFor = lib.genAttrs allSystems (
- system:
- import nixpkgs {
- inherit system;
- config = {allowUnfree = true;};
- }
- );
- vars = import ./config/vars.nix {inherit (nixpkgs) lib;};
-
- # Helper for home-manager module
- mkHomeManagerModule = {config, ...}: {
- home-manager = {
- useUserPackages = true;
- backupFileExtension = "backup";
- users.${vars.user.name} = {};
- };
- };
- in {
- formatter = forEachSystem (pkgs: pkgs.alejandra);
-
- nixosConfigurations = {
- foundation = nixpkgs.lib.nixosSystem {
- system = "x86_64-linux";
- specialArgs = {
- inherit inputs vars;
- outputs = self;
- };
- modules = [
- nixos-wsl.nixosModules.default
- ./hosts/vms/foundation
- stylix.nixosModules.stylix
- home-manager.nixosModules.home-manager
- mkHomeManagerModule
- ];
- };
-
- isengard = nixpkgs.lib.nixosSystem {
- system = "x86_64-linux";
- specialArgs = {
- inherit inputs vars;
- outputs = self;
- };
- modules = [
- nixos-hardware.nixosModules.lenovo-thinkpad-t420
- ./hosts/pcs/isengard
- stylix.nixosModules.stylix
- home-manager.nixosModules.home-manager
- mkHomeManagerModule
- ];
- };
-
- home-lab = nixpkgs.lib.nixosSystem {
- system = "x86_64-linux";
- specialArgs = {
- inherit inputs vars;
- outputs = self;
- };
- modules = [
- ./hosts/vms/home-lab
- stylix.nixosModules.stylix
- home-manager.nixosModules.home-manager
- mkHomeManagerModule
- ];
- };
-
- # Writerdeck — console-only, distraction-free writing machine
- # See: https://veronicaexplains.net/my-first-writerdeck/
- rohan = nixpkgs.lib.nixosSystem {
- system = "x86_64-linux";
- specialArgs = {
- inherit inputs vars;
- outputs = self;
- };
- modules = [
- ./hosts/pcs/rohan
- stylix.nixosModules.stylix
- home-manager.nixosModules.home-manager
- mkHomeManagerModule
- ];
- };
-
- # This is the correct configuration for 'mines' as an ARM NixOS VM
- mines = nixpkgs.lib.nixosSystem {
- system = "aarch64-linux"; # Correct system type
- specialArgs = {
- inherit inputs vars;
- outputs = self;
- };
- modules = [
- ./hosts/vms/mines # This points to its specific host config
- stylix.nixosModules.stylix
- home-manager.nixosModules.home-manager
- mkHomeManagerModule
- ];
- };
+ outputs = inputs:
+ inputs.flake-parts.lib.mkFlake {inherit inputs;} {
+ # Systems the per-system outputs (formatter, checks, devShells) are built for.
+ systems = import inputs.systems;
+
+ imports = [
+ inputs.treefmt-nix.flakeModule
+ inputs.git-hooks.flakeModule
+ ./flake-modules/hosts.nix
+ ./flake-modules/treefmt.nix
+ ./flake-modules/dev.nix
+ ];
};
-
- darwinConfigurations = {
- dungeon = darwin.lib.darwinSystem {
- system = "aarch64-darwin";
- specialArgs = {
- inherit inputs vars;
- outputs = self;
- };
- modules = [
- ./hosts/macs/dungeon
- home-manager.darwinModules.home-manager
- ];
- };
-
- moria = darwin.lib.darwinSystem {
- system = "aarch64-darwin";
- specialArgs = {
- inherit inputs vars;
- outputs = self;
- };
- modules = [
- ./hosts/macs/moria
- home-manager.darwinModules.home-manager
- ];
- };
-
- citadel = darwin.lib.darwinSystem {
- system = "aarch64-darwin";
- specialArgs = {
- inherit inputs;
- vars = vars // {user = vars.user // {name = "greghilston";};};
- outputs = self;
- };
- modules = [
- ./hosts/macs/citadel
- home-manager.darwinModules.home-manager
- ];
- };
- };
- };
}
diff --git a/nixos/hosts/macs/citadel/default.nix b/nixos/hosts/macs/citadel/default.nix
index 3daf2491..fb9a2292 100644
--- a/nixos/hosts/macs/citadel/default.nix
+++ b/nixos/hosts/macs/citadel/default.nix
@@ -1,12 +1,12 @@
{
vars,
- pkgs,
lib,
...
}: {
imports = [
../../../modules/darwin/common.nix
../../../modules/darwin/home.nix
+ ../../../modules/darwin/homebrew-base.nix
../../../modules/darwin/omlx.nix
];
@@ -28,32 +28,15 @@
"/Applications/Docker.app"
];
- # --- Homebrew ---
+ # --- Homebrew (work-machine extras) ---
+ # Shared baseline (enable, onActivation, common brews/casks, oMLX agent) comes
+ # from modules/darwin/homebrew-base.nix. Only citadel-specific additions here.
homebrew = {
- enable = true;
-
- onActivation = {
- autoUpdate = true;
- cleanup = "none"; # don't touch apps not declared here
- upgrade = true;
- };
-
taps = [
- "nikitabobko/tap" # AeroSpace
"hashicorp/tap" # Terraform
- {
- name = "jundot/omlx";
- clone_target = "https://github.com/jundot/omlx";
- }
];
brews = [
- "stow"
- "tmux"
- "just"
- "pandoc"
- "gh"
-
# Node via volta (manages node/npm/npx shims in ~/.volta/bin)
"volta"
@@ -64,87 +47,33 @@
# Python version management
"pyenv"
"xz"
-
- # AI / LLM
- "jundot/omlx/omlx"
- "pi-coding-agent"
];
casks = [
- # Core
- "ghostty"
- "firefox"
"firefox@nightly"
"firefox@developer-edition"
- "visual-studio-code"
- "1password"
"magic-wormhole"
# Communication
- "slack"
"zoom"
"google-drive"
"thunderbird"
- # Media
- "spotify"
- "vlc"
-
# Dev
- "bruno"
- "db-browser-for-sqlite"
"google-cloud-sdk"
- "ngrok"
- "docker-desktop"
- "dbeaver-community"
-
- # Drivers
- "displaylink"
- # Productivity
- "obsidian"
- "raycast"
- "stats"
- "jordanbaird-ice"
- "aerospace"
-
- # Productivity
+ # Networking
"mozilla-vpn"
];
};
- # oMLX launchd agent — starts inference server at login
- launchd.user.agents.omlx = {
- command = "/opt/homebrew/bin/omlx serve";
- serviceConfig = {
- RunAtLoad = true;
- KeepAlive = true;
- StandardOutPath = "/Users/${vars.user.name}/Library/Logs/omlx.log";
- StandardErrorPath = "/Users/${vars.user.name}/Library/Logs/omlx.log";
- };
+ # Deploy oMLX with citadel-specific settings (12GB hot cache for M5 Pro 48GB).
+ # The stow + jq-merge + restart logic lives in modules/darwin/omlx.nix.
+ services.omlxDeploy = {
+ enable = true;
+ cacheSize = "12GB";
};
- # Deploy oMLX with citadel-specific settings (12GB hot cache for M5 Pro 48GB)
- system.activationScripts.postActivation.text = lib.mkBefore ''
- export PATH="${pkgs.stow}/bin:${pkgs.jq}/bin:$PATH"
- TOOLBOX="/Users/${vars.user.name}/Git/toolbox/dot"
-
- cd "$TOOLBOX"
- stow -R --no-folding omlx
-
- # Merge base settings.json + citadel overlay → ~/.omlx/settings.json
- OMLX_SETTINGS="/Users/${vars.user.name}/.omlx/settings.json"
- jq -s '.[0] * .[1]' \
- "$TOOLBOX/omlx/.omlx/settings.json" \
- "$TOOLBOX/omlx-citadel/.omlx/settings.json" \
- > "$OMLX_SETTINGS.tmp"
- mv -f "$OMLX_SETTINGS.tmp" "$OMLX_SETTINGS"
-
- launchctl kickstart -k "gui/$(id -u ${vars.user.name})/org.nixos.omlx" 2>/dev/null || true
-
- echo "✓ oMLX configured for citadel (hot_cache_max_size=12GB)"
- '';
-
home-manager.users.${vars.user.name} = {
# 6-bit is the best quality/memory balance for 48GB
custom.programs.pi.defaultModel = lib.mkForce "Qwen3.6-35B-A3B-6bit";
diff --git a/nixos/hosts/macs/dungeon/default.nix b/nixos/hosts/macs/dungeon/default.nix
index 9a9eebf6..e4df7488 100644
--- a/nixos/hosts/macs/dungeon/default.nix
+++ b/nixos/hosts/macs/dungeon/default.nix
@@ -3,7 +3,60 @@
lib,
pkgs,
...
-}: {
+}: let
+ # NFS mounts on macOS: nix-darwin has no `fileSystems` support, so each share is a
+ # launchd daemon that waits for the server to answer ping, then mounts NFSv3.
+ # KeepAlive(SuccessfulExit=false) + the "already mounted? exit 0" guard means it
+ # remounts after a network drop without thrashing. This helper holds the shared
+ # retry/ping/mount logic; only the per-share fields below differ.
+ mkNfsMountDaemon = {
+ mountPoint,
+ server,
+ path,
+ retries,
+ logFile,
+ }: {
+ script = ''
+ MOUNT_POINT="${mountPoint}"
+ NFS_SERVER="${server}"
+ NFS_PATH="${path}"
+
+ # Create mount point if it doesn't exist
+ /bin/mkdir -p "$MOUNT_POINT"
+
+ # If already mounted, nothing to do
+ if /sbin/mount | /usr/bin/grep -q "$MOUNT_POINT"; then
+ exit 0
+ fi
+
+ # Wait for the NFS server to be reachable (${toString retries} tries × 5s)
+ for i in $(seq 1 ${toString retries}); do
+ if /sbin/ping -c 1 -W 1 "$NFS_SERVER" >/dev/null 2>&1; then
+ break
+ fi
+ /bin/sleep 5
+ done
+
+ # Mount the NFS share. Flags (macOS NFSv3):
+ # resvport privileged source port (required on macOS)
+ # vers=3 macOS defaults to v4, which hangs against these servers
+ # nolock servers don't run rpc.statd; consistency handled elsewhere
+ # soft,intr return/interrupt on timeout instead of hanging indefinitely
+ # rw read-write (Docker bind mounts / backup writes)
+ /sbin/mount -t nfs -o resvport,vers=3,nolock,soft,intr,rw "$NFS_SERVER:$NFS_PATH" "$MOUNT_POINT"
+ '';
+ serviceConfig = {
+ RunAtLoad = true;
+ # Retry every 30s if the mount fails (e.g., server not yet up after reboot)
+ KeepAlive = {
+ SuccessfulExit = false;
+ };
+ ThrottleInterval = 30;
+ StandardOutPath = logFile;
+ StandardErrorPath = logFile;
+ };
+ };
+in {
imports = [
../../../modules/darwin/common.nix
../../../modules/darwin/homebrew.nix
@@ -75,99 +128,25 @@
};
};
- # NFS mount for Unraid data share
- # Mirrors the NixOS VM mount at nixos/hosts/vms/home-lab/default.nix (192.168.1.2:/mnt/user/data)
- # Mount point matches SERVER_DATA_SHARE_MOUNT_POINT in the home-lab .env
- # macOS doesn't support fileSystems in nix-darwin, so we use a launchd daemon instead.
- # The daemon waits for the NFS server to be reachable, then mounts.
- # KeepAlive + exit-on-already-mounted ensures it remounts after network recovery.
- launchd.daemons.mount-unraid-data = {
- script = ''
- MOUNT_POINT="/Volumes/unraid-data"
- NFS_SERVER="${vars.networking.hosts.unraid.lan}"
- NFS_PATH="/mnt/user/data"
-
- # Create mount point if it doesn't exist
- /bin/mkdir -p "$MOUNT_POINT"
-
- # If already mounted, nothing to do
- if /sbin/mount | /usr/bin/grep -q "$MOUNT_POINT"; then
- exit 0
- fi
-
- # Wait for NFS server to be reachable (up to 60s)
- for i in $(seq 1 12); do
- if /sbin/ping -c 1 -W 1 "$NFS_SERVER" >/dev/null 2>&1; then
- break
- fi
- /bin/sleep 5
- done
-
- # Mount the NFS share
- # -o resvport: required on macOS for NFS (uses privileged source port)
- # -o vers=3: Unraid exports NFSv3 — macOS defaults to v4 which hangs
- # -o nolock: skip NFS locking — Unraid uses local_lock=none, and rpc.statd isn't available
- # -o soft: return errors on timeout rather than hanging indefinitely
- # -o intr: allow signals to interrupt hung operations
- # -o rw: read-write access for Docker container bind mounts
- /sbin/mount -t nfs -o resvport,vers=3,nolock,soft,intr,rw "$NFS_SERVER:$NFS_PATH" "$MOUNT_POINT"
- '';
- serviceConfig = {
- RunAtLoad = true;
- # Retry every 30s if the mount fails (e.g., server not yet up after reboot)
- KeepAlive = {
- SuccessfulExit = false;
- };
- # Don't restart too aggressively
- ThrottleInterval = 30;
- StandardOutPath = "/var/log/mount-unraid-data.log";
- StandardErrorPath = "/var/log/mount-unraid-data.log";
- };
+ # NFS mount for Unraid data share (NFSv3 over LAN). Used by Docker container bind
+ # mounts; mirrors the NixOS VM mount at hosts/vms/home-lab/default.nix and matches
+ # SERVER_DATA_SHARE_MOUNT_POINT in the home-lab .env.
+ launchd.daemons.mount-unraid-data = mkNfsMountDaemon {
+ mountPoint = "/Volumes/unraid-data";
+ server = vars.networking.hosts.unraid.lan;
+ path = "/mnt/user/data";
+ retries = 12; # ~60s — LAN server, usually up quickly
+ logFile = "/var/log/mount-unraid-data.log";
};
- # NFS mount for Fob offsite backup (Raspberry Pi via Tailscale)
- # Used by Kopia for offsite backups — not used by Docker containers.
- # Tailscale may not be connected at boot, so we retry more aggressively.
- launchd.daemons.mount-fob-backup = {
- script = ''
- MOUNT_POINT="/Volumes/fob-backup"
- NFS_SERVER="${vars.networking.hosts.fob.tailscale}"
- NFS_PATH="/mnt/mothership"
-
- # Create mount point if it doesn't exist
- /bin/mkdir -p "$MOUNT_POINT"
-
- # If already mounted, nothing to do
- if /sbin/mount | /usr/bin/grep -q "$MOUNT_POINT"; then
- exit 0
- fi
-
- # Wait for NFS server to be reachable (up to 120s — Tailscale may take time)
- for i in $(seq 1 24); do
- if /sbin/ping -c 1 -W 1 "$NFS_SERVER" >/dev/null 2>&1; then
- break
- fi
- /bin/sleep 5
- done
-
- # Mount the NFS share
- # -o resvport: required on macOS for NFS (uses privileged source port)
- # -o vers=3: use NFSv3 explicitly — macOS defaults to v4 which can hang
- # -o nolock: skip NFS locking — Fob doesn't run rpc.statd, and Kopia handles its own consistency
- # -o soft: return errors on timeout rather than hanging indefinitely
- # -o intr: allow signals to interrupt hung operations
- # -o rw: read-write access for backup writes
- /sbin/mount -t nfs -o resvport,vers=3,nolock,soft,intr,rw "$NFS_SERVER:$NFS_PATH" "$MOUNT_POINT"
- '';
- serviceConfig = {
- RunAtLoad = true;
- KeepAlive = {
- SuccessfulExit = false;
- };
- ThrottleInterval = 30;
- StandardOutPath = "/var/log/mount-fob-backup.log";
- StandardErrorPath = "/var/log/mount-fob-backup.log";
- };
+ # NFS mount for Fob offsite backup (Raspberry Pi over Tailscale). Used by Kopia for
+ # offsite backups, not by Docker. Tailscale may be slow to connect at boot, so wait longer.
+ launchd.daemons.mount-fob-backup = mkNfsMountDaemon {
+ mountPoint = "/Volumes/fob-backup";
+ server = vars.networking.hosts.fob.tailscale;
+ path = "/mnt/mothership";
+ retries = 24; # ~120s — Tailscale may take time to come up
+ logFile = "/var/log/mount-fob-backup.log";
};
# Healthchecks.io ping — signals that dungeon is alive and has network.
@@ -203,41 +182,25 @@
};
};
- # Deploy oMLX with dungeon-specific settings (8GB hot cache for M3 Pro 36GB)
- # See ~/Git/toolbox/dot/omlx/README.md for stow strategy explanation
- # Combined with home-lab-config deployment and power management
+ # Deploy oMLX with dungeon-specific settings (8GB hot cache for M3 Pro 36GB).
+ # The stow + jq-merge + restart logic lives in modules/darwin/omlx.nix.
+ services.omlxDeploy = {
+ enable = true;
+ cacheSize = "8GB";
+ };
+
+ # Dungeon-specific activation: ser2net dotfiles, clamshell-sleep prevention,
+ # NFS mount points, and the home-lab repo clone/pull.
# NOTE: Uses postActivation (not custom names) because nix-darwin only runs well-known activation script names.
system.activationScripts.postActivation.text = ''
set -euo pipefail # Exit on error, undefined vars, and pipeline failures
- # Deploy oMLX dotfiles: base config + dungeon-specific overrides
- # settings.json is excluded from stow (via .stow-local-ignore) because it
- # contains host-specific cache sizes AND auth keys. Merged with jq instead.
- export PATH="${pkgs.stow}/bin:${pkgs.jq}/bin:$PATH"
+ # Stow ser2net dotfiles (USB serial exposure for OrbStack containers).
+ export PATH="${pkgs.stow}/bin:$PATH"
TOOLBOX="/Users/${vars.user.name}/Git/toolbox/dot"
- # --no-folding prevents stow from symlinking the .omlx/ directory itself
- # into the repo. Without it, oMLX writes land directly in the git tree.
cd "$TOOLBOX"
- stow -R --no-folding omlx
stow -R --no-folding ser2net
- # Merge base settings.json + dungeon cache overlay → ~/.omlx/settings.json
- # Write to a temp file first, then mv into place. This avoids truncating the
- # source if ~/.omlx/settings.json is a stale symlink pointing back to it,
- # and is atomic (the old file survives if jq fails).
- OMLX_SETTINGS="/Users/${vars.user.name}/.omlx/settings.json"
- jq -s '.[0] * .[1]' \
- "$TOOLBOX/omlx/.omlx/settings.json" \
- "$TOOLBOX/omlx-dungeon/.omlx/settings.json" \
- > "$OMLX_SETTINGS.tmp"
- mv -f "$OMLX_SETTINGS.tmp" "$OMLX_SETTINGS"
-
- # Restart oMLX so it picks up the merged settings.json.
- # KeepAlive only restarts on crashes, not config changes.
- launchctl kickstart -k "gui/$(id -u ${vars.user.name})/org.nixos.omlx" 2>/dev/null || true
-
- echo "✓ oMLX configured for dungeon (hot_cache_max_size=8GB)"
-
# Prevent clamshell sleep on Apple Silicon (lid-close with no external display).
# See the detailed explanation in the power.sleep section above.
#
diff --git a/nixos/hosts/macs/moria/default.nix b/nixos/hosts/macs/moria/default.nix
index f5fca2b5..106c1d90 100644
--- a/nixos/hosts/macs/moria/default.nix
+++ b/nixos/hosts/macs/moria/default.nix
@@ -24,38 +24,10 @@
];
};
- # Deploy oMLX with moria-specific settings (32GB hot cache for M4 Max 128GB)
- # See ~/Git/toolbox/dot/omlx/README.md for stow strategy explanation
- #
- # settings.json is excluded from stow (via .stow-local-ignore) because it
- # contains host-specific cache sizes AND auth keys that shouldn't be duplicated
- # into per-host stow packages. Instead we merge base + overlay with jq.
- system.activationScripts.postActivation.text = lib.mkBefore ''
- export PATH="${pkgs.stow}/bin:${pkgs.jq}/bin:$PATH"
- TOOLBOX="/Users/${vars.user.name}/Git/toolbox/dot"
-
- # Stow shared oMLX files (model_settings.json, stats.json, etc.)
- # --no-folding prevents stow from symlinking the .omlx/ directory itself
- # into the repo. Without it, oMLX writes (settings saves, cache, logs)
- # land directly in the git working tree.
- cd "$TOOLBOX"
- stow -R --no-folding omlx
-
- # Merge base settings.json + moria cache overlay → ~/.omlx/settings.json
- # Write to a temp file first, then mv into place. This avoids truncating the
- # source if ~/.omlx/settings.json is a stale symlink pointing back to it,
- # and is atomic (the old file survives if jq fails).
- OMLX_SETTINGS="/Users/${vars.user.name}/.omlx/settings.json"
- jq -s '.[0] * .[1]' \
- "$TOOLBOX/omlx/.omlx/settings.json" \
- "$TOOLBOX/omlx-moria/.omlx/settings.json" \
- > "$OMLX_SETTINGS.tmp"
- mv -f "$OMLX_SETTINGS.tmp" "$OMLX_SETTINGS"
-
- # Restart oMLX so it picks up the merged settings.json.
- # KeepAlive only restarts on crashes, not config changes.
- launchctl kickstart -k "gui/$(id -u ${vars.user.name})/org.nixos.omlx" 2>/dev/null || true
-
- echo "✓ oMLX configured for moria (hot_cache_max_size=32GB)"
- '';
+ # Deploy oMLX with moria-specific settings (32GB hot cache for M4 Max 128GB).
+ # The stow + jq-merge + restart logic lives in modules/darwin/omlx.nix.
+ services.omlxDeploy = {
+ enable = true;
+ cacheSize = "32GB";
+ };
}
diff --git a/nixos/modules/common/default.nix b/nixos/modules/common/default.nix
index 4219900b..42780b1c 100644
--- a/nixos/modules/common/default.nix
+++ b/nixos/modules/common/default.nix
@@ -6,9 +6,7 @@
pkgs,
vars,
...
-} @ args:
-# Optional: give a name to the whole argument set
-{
+}: {
imports = [
inputs.home-manager.nixosModules.home-manager
../../modules/stylix
diff --git a/nixos/modules/darwin/homebrew-base.nix b/nixos/modules/darwin/homebrew-base.nix
new file mode 100644
index 00000000..86e73c5a
--- /dev/null
+++ b/nixos/modules/darwin/homebrew-base.nix
@@ -0,0 +1,91 @@
+# Shared Homebrew baseline for all Darwin hosts.
+#
+# homebrew.brews/casks/taps are list options, so each host imports this module
+# and *adds* its own host-specific extras on top (the module system concatenates
+# the lists). Only this module sets the non-list options (enable, onActivation)
+# and the oMLX launchd agent, so hosts never need to repeat them.
+{vars, ...}: {
+ # Launch omlx LLM inference server at login so pi and other tools can connect.
+ # Runs as a user agent (not a system daemon) so it has access to ~/models and ~/.omlx/.
+ launchd.user.agents.omlx = {
+ command = "/opt/homebrew/bin/omlx serve";
+ serviceConfig = {
+ RunAtLoad = true;
+ KeepAlive = true;
+ StandardOutPath = "/Users/${vars.user.name}/Library/Logs/omlx.log";
+ StandardErrorPath = "/Users/${vars.user.name}/Library/Logs/omlx.log";
+ };
+ };
+
+ homebrew = {
+ enable = true;
+
+ onActivation = {
+ autoUpdate = true;
+ # "none" — don't touch packages not declared in the config. Change to
+ # "check" once manually-installed brew drift has been cleaned up.
+ cleanup = "none";
+ upgrade = true;
+ };
+
+ taps = [
+ "nikitabobko/tap" # AeroSpace
+ {
+ name = "jundot/omlx";
+ clone_target = "https://github.com/jundot/omlx";
+ } # omlx LLM inference server
+ # GOTCHA: newer Homebrew enforces a tap-trust check that silently aborts the
+ # omlx source build (it builds from source via rust) with NO output — leaving the
+ # host stuck on an old version (this is how dungeon got pinned at 0.3.8 while moria
+ # was on 0.4.4rc1). If `brew upgrade jundot/omlx/omlx` fails with a bare
+ # "build.rb exited with 1", run `brew trust jundot/omlx` once, or upgrade with
+ # HOMEBREW_NO_REQUIRE_TAP_TRUST=1. Not a Command Line Tools / OS-version problem.
+ ];
+
+ # Common CLI tools — managed via brew rather than nix for easier macOS updates.
+ brews = [
+ "stow"
+ "tmux"
+ "just"
+ "pandoc"
+ "gh"
+
+ # AI / LLM
+ "jundot/omlx/omlx"
+ "pi-coding-agent"
+ ];
+
+ # Apps every Darwin host gets. Host-specific apps live in each host's config.
+ casks = [
+ # Core
+ "ghostty"
+ "firefox"
+ "visual-studio-code"
+ "1password"
+
+ # Communication
+ "slack"
+
+ # Media
+ "spotify"
+ "vlc"
+
+ # Dev
+ "bruno"
+ "docker-desktop"
+ "dbeaver-community"
+ "db-browser-for-sqlite"
+ "ngrok"
+
+ # Drivers
+ "displaylink"
+
+ # Productivity
+ "obsidian"
+ "raycast"
+ "stats"
+ "jordanbaird-ice"
+ "aerospace"
+ ];
+ };
+}
diff --git a/nixos/modules/darwin/homebrew.nix b/nixos/modules/darwin/homebrew.nix
index 305232df..a9f29807 100644
--- a/nixos/modules/darwin/homebrew.nix
+++ b/nixos/modules/darwin/homebrew.nix
@@ -1,60 +1,18 @@
-{vars, ...}: {
- # Launch omlx LLM inference server at login so pi and other tools can connect.
- # Runs as a user agent (not a system daemon) so it has access to ~/models and ~/.omlx/.
- launchd.user.agents.omlx = {
- command = "/opt/homebrew/bin/omlx serve";
- serviceConfig = {
- RunAtLoad = true;
- KeepAlive = true;
- StandardOutPath = "/Users/${vars.user.name}/Library/Logs/omlx.log";
- StandardErrorPath = "/Users/${vars.user.name}/Library/Logs/omlx.log";
- };
- };
+# Homebrew for the "media/server" Darwin hosts (dungeon, moria).
+# Imports the shared baseline and adds host-class-specific extras. The oMLX
+# launchd agent and the common brews/casks/taps live in homebrew-base.nix.
+{...}: {
+ imports = [./homebrew-base.nix];
homebrew = {
- enable = true;
-
- onActivation = {
- autoUpdate = true;
- # "none" — don't touch packages not in this file. Change to "check" once
- # manually-installed brew drift has been cleaned up on this machine.
- cleanup = "none";
- upgrade = true;
- };
-
- taps = [
- "nikitabobko/tap" # AeroSpace
- {
- name = "jundot/omlx";
- clone_target = "https://github.com/jundot/omlx";
- } # omlx LLM inference server
- # GOTCHA: newer Homebrew enforces a tap-trust check that silently aborts the
- # omlx source build (it builds from source via rust) with NO output — leaving the
- # host stuck on an old version (this is how dungeon got pinned at 0.3.8 while moria
- # was on 0.4.4rc1). If `brew upgrade jundot/omlx/omlx` fails with a bare
- # "build.rb exited with 1", run `brew trust jundot/omlx` once, or upgrade with
- # HOMEBREW_NO_REQUIRE_TAP_TRUST=1. Not a Command Line Tools / OS-version problem.
- ];
-
brews = [
- "stow"
- "ser2net" # Exposes USB serial devices over TCP for OrbStack containers
-
- # CLI tools — managed here rather than nix for easier updates on macOS
- "tmux"
- "just"
- "pandoc"
- "gh"
"go"
"hugo"
+ "ser2net" # Exposes USB serial devices over TCP for OrbStack containers
# Runtime (needed by pi for npm: packages)
"node"
- # AI / LLM
- "jundot/omlx/omlx"
- "pi-coding-agent"
-
# Monitoring exporters — scraped by the home-lab Prometheus over
# host.docker.internal. Native (not containers) so they report the real Mac,
# not OrbStack's Linux VM. See launchd.user.agents in hosts/macs/dungeon.
@@ -64,51 +22,20 @@
];
casks = [
- # Core
- "ghostty"
- "firefox"
"google-chrome"
- "visual-studio-code"
-
- # Communication
"discord"
- "slack"
-
- # Media
- "spotify"
- "vlc"
"calibre"
-
- # Dev
- "bruno"
- "docker-desktop"
"orbstack"
- "dbeaver-community"
- "db-browser-for-sqlite"
- "ngrok"
-
- # Productivity
- "obsidian"
- "raycast"
"shortcat"
- "stats"
- "jordanbaird-ice"
- "aerospace"
+ "tailscale-app"
+ "1password-cli"
# AI
"claude"
"lm-studio"
"draw-things"
- # Networking
- "tailscale-app"
-
- # Drivers
- "displaylink"
-
# Other
- "1password"
- "1password-cli"
"flux-app"
"steam"
"godot"
diff --git a/nixos/modules/darwin/omlx.nix b/nixos/modules/darwin/omlx.nix
index a4037b68..1a93a3ed 100644
--- a/nixos/modules/darwin/omlx.nix
+++ b/nixos/modules/darwin/omlx.nix
@@ -1,26 +1,90 @@
{
+ config,
lib,
+ pkgs,
vars,
...
-}: {
- # Activation script for oMLX model setup on Darwin hosts
- # Creates model variant symlinks (e.g., Qwen3.6-35B-A3B-8bit-long-context → Qwen3.6-35B-A3B-8bit)
- # Allows model_settings.json to define separate configurations without duplicating model weights.
- # See: https://github.com/jundot/omlx/issues/341#issuecomment-4202459307
+}: let
+ cfg = config.services.omlxDeploy;
+ user = vars.user.name;
+ host = config.networking.hostName;
+in {
+ options.services.omlxDeploy = {
+ enable = lib.mkEnableOption ''
+ oMLX dotfile deployment on this Darwin host: stow the shared oMLX package,
+ merge the base settings.json with the per-host omlx- overlay via jq,
+ and restart the launchd agent so it picks up the new settings
+ '';
- system.activationScripts.postActivation.text = lib.mkAfter ''
- # Create oMLX model variant symlinks for multi-configuration support.
- # Models are stored in ~/Git/toolbox/dot/omlx/.omlx/models (per .stow-local-ignore)
- # and accessed directly by oMLX via settings.json model_dir configuration.
- # See: https://github.com/jundot/omlx/issues/341#issuecomment-4202459307
- MODELS_DIR="/Users/${vars.user.name}/Git/toolbox/dot/omlx/.omlx/models"
- if [ -d "$MODELS_DIR" ]; then
- # Qwen3.6-35B-A3B-8bit-long-context: extended context (1M tokens) for long-form analysis
- # Only create if source exists and target doesn't
- if [ -d "$MODELS_DIR/Qwen3.6-35B-A3B-8bit" ] && [ ! -e "$MODELS_DIR/Qwen3.6-35B-A3B-8bit-long-context" ]; then
- ln -s Qwen3.6-35B-A3B-8bit "$MODELS_DIR/Qwen3.6-35B-A3B-8bit-long-context"
- echo "✓ Created model variant symlink: Qwen3.6-35B-A3B-8bit-long-context"
- fi
- fi
- '';
+ cacheSize = lib.mkOption {
+ type = lib.types.str;
+ example = "8GB";
+ description = ''
+ Human-readable hot-cache size used only for the activation log line.
+ The real value lives in the host's omlx-/.omlx/settings.json overlay.
+ '';
+ };
+ };
+
+ config = {
+ # Activation scripts for oMLX on Darwin hosts.
+ #
+ # nix-darwin concatenates every postActivation.text fragment into ONE bash
+ # script, so ordering is controlled with mkBefore/mkAfter:
+ # * mkBefore — deploy settings.json early, before host-specific blocks
+ # (pmset, NFS, repo clones) that may depend on a running oMLX.
+ # * mkAfter — create model-variant symlinks last, after deploy.
+ system.activationScripts.postActivation.text = lib.mkMerge [
+ # ── Deploy: stow + jq settings merge + agent restart (shared across hosts)
+ (lib.mkIf cfg.enable (lib.mkBefore ''
+ # Deploy oMLX dotfiles: base config + ${host}-specific overrides.
+ # settings.json is excluded from stow (via .stow-local-ignore) because it
+ # contains host-specific cache sizes AND auth keys; we merge with jq instead.
+ # See ~/Git/toolbox/dot/omlx/README.md for the stow strategy explanation.
+ export PATH="${pkgs.stow}/bin:${pkgs.jq}/bin:$PATH"
+ TOOLBOX="/Users/${user}/Git/toolbox/dot"
+
+ # --no-folding prevents stow from symlinking the .omlx/ directory itself
+ # into the repo. Without it, oMLX writes (settings saves, cache, logs)
+ # land directly in the git working tree.
+ cd "$TOOLBOX"
+ stow -R --no-folding omlx
+
+ # Merge base settings.json + ${host} cache overlay → ~/.omlx/settings.json.
+ # Write to a temp file first, then mv into place: avoids truncating the
+ # source if ~/.omlx/settings.json is a stale symlink pointing back to it,
+ # and is atomic (the old file survives if jq fails).
+ OMLX_SETTINGS="/Users/${user}/.omlx/settings.json"
+ jq -s '.[0] * .[1]' \
+ "$TOOLBOX/omlx/.omlx/settings.json" \
+ "$TOOLBOX/omlx-${host}/.omlx/settings.json" \
+ > "$OMLX_SETTINGS.tmp"
+ mv -f "$OMLX_SETTINGS.tmp" "$OMLX_SETTINGS"
+
+ # Restart oMLX so it picks up the merged settings.json.
+ # KeepAlive only restarts on crashes, not config changes.
+ launchctl kickstart -k "gui/$(id -u ${user})/org.nixos.omlx" 2>/dev/null || true
+
+ echo "✓ oMLX configured for ${host} (hot_cache_max_size=${cfg.cacheSize})"
+ ''))
+
+ # ── Model-variant symlinks for multi-configuration support.
+ # Creates e.g. Qwen3.6-35B-A3B-8bit-long-context → Qwen3.6-35B-A3B-8bit so
+ # model_settings.json can define separate configs without duplicating weights.
+ # See: https://github.com/jundot/omlx/issues/341#issuecomment-4202459307
+ (lib.mkAfter ''
+ # Models live in ~/Git/toolbox/dot/omlx/.omlx/models (per .stow-local-ignore)
+ # and are accessed directly by oMLX via settings.json model_dir.
+ MODELS_DIR="/Users/${user}/Git/toolbox/dot/omlx/.omlx/models"
+ if [ -d "$MODELS_DIR" ]; then
+ # Qwen3.6-35B-A3B-8bit-long-context: extended context (1M tokens).
+ # Only create if source exists and target doesn't.
+ if [ -d "$MODELS_DIR/Qwen3.6-35B-A3B-8bit" ] && [ ! -e "$MODELS_DIR/Qwen3.6-35B-A3B-8bit-long-context" ]; then
+ ln -s Qwen3.6-35B-A3B-8bit "$MODELS_DIR/Qwen3.6-35B-A3B-8bit-long-context"
+ echo "✓ Created model variant symlink: Qwen3.6-35B-A3B-8bit-long-context"
+ fi
+ fi
+ '')
+ ];
+ };
}
diff --git a/nixos/modules/programs/gui/alacritty/default.nix b/nixos/modules/programs/gui/alacritty/default.nix
index 06d6917c..3889cf9b 100644
--- a/nixos/modules/programs/gui/alacritty/default.nix
+++ b/nixos/modules/programs/gui/alacritty/default.nix
@@ -1,4 +1,4 @@
-{...}: {
+_: {
programs.alacritty = {
enable = true;
settings.selection.save_to_clipboard = true;
diff --git a/nixos/modules/programs/tui/atuin/default.nix b/nixos/modules/programs/tui/atuin/default.nix
index b2e37ac9..52061f99 100644
--- a/nixos/modules/programs/tui/atuin/default.nix
+++ b/nixos/modules/programs/tui/atuin/default.nix
@@ -1,5 +1,5 @@
# nixos/modules/programs/tui/atuin/default.nix
-{...}: {
+_: {
programs.atuin = {
enable = true;
enableZshIntegration = true;
diff --git a/nixos/modules/programs/tui/eza/default.nix b/nixos/modules/programs/tui/eza/default.nix
index 089cf214..490e22ab 100644
--- a/nixos/modules/programs/tui/eza/default.nix
+++ b/nixos/modules/programs/tui/eza/default.nix
@@ -1,5 +1,5 @@
# nixos/modules/programs/tui/eza/default.nix
-{...}: {
+_: {
programs.eza = {
enable = true;
enableZshIntegration = true;
diff --git a/nixos/modules/programs/tui/fzf/default.nix b/nixos/modules/programs/tui/fzf/default.nix
index 07aa1f3f..e863c405 100644
--- a/nixos/modules/programs/tui/fzf/default.nix
+++ b/nixos/modules/programs/tui/fzf/default.nix
@@ -1,5 +1,5 @@
# nixos/modules/programs/tui/fzf/default.nix
-{...}: {
+_: {
programs.fzf = {
enable = true;
enableZshIntegration = true;
diff --git a/nixos/modules/programs/tui/git/default.nix b/nixos/modules/programs/tui/git/default.nix
index 2814c774..fef509b9 100644
--- a/nixos/modules/programs/tui/git/default.nix
+++ b/nixos/modules/programs/tui/git/default.nix
@@ -26,7 +26,7 @@
settings = {
user = {
name = vars.user.fullName;
- email = vars.user.email;
+ inherit (vars.user) email;
};
# Use difftastic for all diffs
diff --git a/nixos/modules/programs/tui/opencode.nix b/nixos/modules/programs/tui/opencode.nix
index d5abf7d3..84dde855 100644
--- a/nixos/modules/programs/tui/opencode.nix
+++ b/nixos/modules/programs/tui/opencode.nix
@@ -38,7 +38,7 @@ in {
name = "oMLX (local)";
options = {
baseURL = cfg.omlxBaseUrl;
- apiKey = cfg.apiKey;
+ inherit (cfg) apiKey;
};
models = {
"Qwen3.6-35B-A3B-8bit" = {
diff --git a/nixos/modules/programs/tui/pi.nix b/nixos/modules/programs/tui/pi.nix
index cd5412c1..711c4709 100644
--- a/nixos/modules/programs/tui/pi.nix
+++ b/nixos/modules/programs/tui/pi.nix
@@ -45,9 +45,9 @@ in {
home.file.".pi/agent/settings.json" = {
text = builtins.toJSON {
defaultProvider = "omlx";
- defaultModel = cfg.defaultModel;
+ inherit (cfg) defaultModel;
lastChangelogVersion = "0.67.6";
- packages = cfg.packages;
+ inherit (cfg) packages;
};
};
diff --git a/nixos/modules/programs/tui/zellij/default.nix b/nixos/modules/programs/tui/zellij/default.nix
index 728fd401..d4ee6fc2 100644
--- a/nixos/modules/programs/tui/zellij/default.nix
+++ b/nixos/modules/programs/tui/zellij/default.nix
@@ -1,5 +1,5 @@
# nixos/modules/programs/tui/zellij/default.nix
-{...}: {
+_: {
programs.zellij = {
enable = true;
enableZshIntegration = false; # Don't auto-attach; use manually alongside tmux
diff --git a/nixos/modules/programs/tui/zoxide/default.nix b/nixos/modules/programs/tui/zoxide/default.nix
index 4f3cc4ce..230a1978 100644
--- a/nixos/modules/programs/tui/zoxide/default.nix
+++ b/nixos/modules/programs/tui/zoxide/default.nix
@@ -1,5 +1,5 @@
# nixos/modules/programs/tui/zoxide/default.nix
-{...}: {
+_: {
programs.zoxide = {
enable = true;
enableZshIntegration = true;
diff --git a/nixos/scripts/hooks/pre-commit b/nixos/scripts/hooks/pre-commit
deleted file mode 100755
index 9d4214c6..00000000
--- a/nixos/scripts/hooks/pre-commit
+++ /dev/null
@@ -1,50 +0,0 @@
-#!/usr/bin/env bash
-# Pre-commit hook: Format Nix files
-# Install with: ./nixos/scripts/install-hooks.sh
-
-set -e
-
-# Only run if we have staged nix files in the nixos directory
-STAGED_NIX_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '^nixos/.*\.nix$' || true)
-
-if [ -z "$STAGED_NIX_FILES" ]; then
- exit 0
-fi
-
-echo "Running nix fmt on staged files..."
-
-# Change to nixos directory for formatting
-cd "$(git rev-parse --show-toplevel)/nixos"
-
-# Check if nix is available
-if ! command -v nix &> /dev/null; then
- echo "Warning: nix not found, skipping format check"
- exit 0
-fi
-
-# Run formatter and check for changes
-nix fmt . 2>/dev/null
-
-# Check if any staged files were modified by the formatter
-REFORMATTED=false
-for file in $STAGED_NIX_FILES; do
- # Get the file path relative to repo root
- FULL_PATH="$(git rev-parse --show-toplevel)/$file"
- if [ -f "$FULL_PATH" ]; then
- # Check if file has unstaged changes (meaning formatter modified it)
- if ! git diff --quiet "$FULL_PATH" 2>/dev/null; then
- echo "Reformatted: $file"
- REFORMATTED=true
- fi
- fi
-done
-
-if [ "$REFORMATTED" = true ]; then
- echo ""
- echo "Some files were reformatted. Please review and re-stage the changes:"
- echo " git add -p nixos/"
- echo " git commit"
- exit 1
-fi
-
-echo "All Nix files formatted correctly."
diff --git a/nixos/scripts/hooks/pre-push b/nixos/scripts/hooks/pre-push
deleted file mode 100755
index 6513d066..00000000
--- a/nixos/scripts/hooks/pre-push
+++ /dev/null
@@ -1,52 +0,0 @@
-#!/usr/bin/env bash
-# Pre-push hook: Validate all NixOS configurations using the dev container
-# Install with: ./nixos/scripts/install-hooks.sh
-
-set -e
-
-REPO_ROOT="$(git rev-parse --show-toplevel)"
-NIXOS_DIR="$REPO_ROOT/nixos"
-
-# Check if Docker is available
-if ! command -v docker &> /dev/null; then
- echo "Warning: Docker not found, skipping NixOS validation"
- echo "Install Docker to enable pre-push validation"
- exit 0
-fi
-
-# Check if Docker daemon is running
-if ! docker info &> /dev/null 2>&1; then
- echo "Warning: Docker daemon not running, skipping NixOS validation"
- exit 0
-fi
-
-echo "=========================================="
-echo "Pre-push: Validating NixOS configurations"
-echo "=========================================="
-
-# Check if the devcontainer image exists, build if not
-IMAGE_NAME="nixos-devcontainer"
-if ! docker image inspect "$IMAGE_NAME" &> /dev/null; then
- echo "Building devcontainer image (first time only)..."
- docker build -t "$IMAGE_NAME" "$NIXOS_DIR/.devcontainer" || {
- echo "Warning: Failed to build devcontainer image, skipping validation"
- exit 0
- }
-fi
-
-# Run validation in the container
-echo "Running validation..."
-if docker run --rm -v "$NIXOS_DIR:/workspaces/nixos" "$IMAGE_NAME" just validate; then
- echo ""
- echo "All NixOS configurations validated successfully!"
- exit 0
-else
- echo ""
- echo "=========================================="
- echo "ERROR: NixOS configuration validation failed!"
- echo "=========================================="
- echo ""
- echo "Fix the issues above before pushing."
- echo "To skip this check (not recommended): git push --no-verify"
- exit 1
-fi
diff --git a/nixos/scripts/install-hooks.sh b/nixos/scripts/install-hooks.sh
deleted file mode 100755
index 11af85ff..00000000
--- a/nixos/scripts/install-hooks.sh
+++ /dev/null
@@ -1,46 +0,0 @@
-#!/usr/bin/env bash
-# Install git hooks for NixOS configuration validation
-#
-# Usage: ./nixos/scripts/install-hooks.sh
-#
-# This installs:
-# - pre-commit: Runs nix fmt on staged .nix files
-# - pre-push: Validates all NixOS configs using the devcontainer
-
-set -e
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
-HOOKS_DIR="$REPO_ROOT/.git/hooks"
-SOURCE_DIR="$SCRIPT_DIR/hooks"
-
-echo "Installing git hooks..."
-
-# Install pre-commit hook
-if [ -f "$HOOKS_DIR/pre-commit" ]; then
- echo "Backing up existing pre-commit hook to pre-commit.backup"
- mv "$HOOKS_DIR/pre-commit" "$HOOKS_DIR/pre-commit.backup"
-fi
-cp "$SOURCE_DIR/pre-commit" "$HOOKS_DIR/pre-commit"
-chmod +x "$HOOKS_DIR/pre-commit"
-echo "Installed: pre-commit (nix fmt)"
-
-# Install pre-push hook
-if [ -f "$HOOKS_DIR/pre-push" ]; then
- echo "Backing up existing pre-push hook to pre-push.backup"
- mv "$HOOKS_DIR/pre-push" "$HOOKS_DIR/pre-push.backup"
-fi
-cp "$SOURCE_DIR/pre-push" "$HOOKS_DIR/pre-push"
-chmod +x "$HOOKS_DIR/pre-push"
-echo "Installed: pre-push (full validation)"
-
-echo ""
-echo "Git hooks installed successfully!"
-echo ""
-echo "Hooks will run automatically:"
-echo " - pre-commit: Formats .nix files with 'nix fmt'"
-echo " - pre-push: Validates all configs via devcontainer"
-echo ""
-echo "To skip hooks (not recommended):"
-echo " git commit --no-verify"
-echo " git push --no-verify"
diff --git a/nixos/shell.nix b/nixos/shell.nix
deleted file mode 100644
index 83a27d9c..00000000
--- a/nixos/shell.nix
+++ /dev/null
@@ -1,10 +0,0 @@
-{pkgs ? import {}}:
-pkgs.mkShell {
- buildInputs = [
- pkgs.git
- pkgs.vim
- pkgs.just
- pkgs.tmux
- pkgs.nixos-rebuild
- ];
-}