2026-07-04 Home Manager athena Server Profile

What I set out to do

Scope and then prototype bringing declarative Home Manager to athena (the Intel Mac home server, x86_64-darwin), which the flake had intentionally excluded because arrow-cpp (pulled by the LiteLLM stack) is broken on that platform. Goal for the session: the de-risking “step 1” — admit the arch for a curated server profile and get it to evaluate, so a build on athena can surface the real Intel breakage.

What I actually did

Confirmed the current config is keyed on system, not machine role — a single auto-discovering home.nix builds every module everywhere, with only a thin local.* identity layer. Designed a capability-flag model (dotfiles.<feature>.enable) with a machine→profile matrix (atlas=desktop, athena=server, oracle=headless, enterprise=work-via-private-flake) and generated an overview image (~/Pictures/generated/hm-capability-matrix.png).

Then implemented step 1:

  • Extracted nix/home-manager/base.nix (shared host-agnostic identity/XDG/Nix-daemon settings), imported by both home.nix (desktop) and a new curated home-server.nix (server).
  • Admitted x86_64-darwin to the flake systems, for the server profile only.
  • Refactored homeConfigurations into a mkHome helper + homeConfigurations."x86_64-darwin" -> home-server.nix, keeping checks desktop-only.
  • Gated ghostty off on x86_64-darwin in shell.nix (no prebuilt; server is headless) and made shell.nix’s uvLib arg optional (defaulted to null in base.nix, overridden by python.nix) so it imports without the Python stack.

What was striking

  • Provable no-op refactor: diffing atlas’s activationPackage.drvPath before/after the base.nix extraction — byte-identical (gjlka2k6…). Cross-eval on atlas also proved athena evaluates (9zc85ann…), catching broken-marked packages without needing the Intel box at all.
  • The uvLib ? null footgun: a formal-level default does NOT work in the HM module system — it eagerly passes every declared function argument, shadowing the default. The fallback has to live in _module.args (base.nix sets _module.args.uvLib = lib.mkDefault null).
  • Cross-eval only catches broken-marked packages; genuine build-broken-but-not-marked Intel deps (jankyborders, packages.nix entries) will only appear when athena actually builds — the whack-a-mole loop starts there.

Top 3 tomorrow

  1. Commit + push, then hm switch on athena to surface build-time Intel breakage.
  2. Gate / overrideRemove whatever fails to build, iterate until green.
  3. Once the buildable set is known, convert survivors to the durable dotfiles.<feature>.enable flags + a real profiles/server.nix.

2026-07-05 — Bring-up complete

athena is now running Home Manager (generation 1). The whole path in one sitting: installed upstream Nix (nixos.org/nix/install --daemon — Determinate ships no Intel binary anymore), adopted ~/.config as the repo, added extra-trusted-users = achhina, split packages.nix into lean core + desktop extras (114→87 packages on athena, no Zotero), and activated via nix run home-manager -- switch -b backup.

The big result: the entire profile builds on Intel with zero package errors — the whack-a-mole I braced for all session never happened. Intel’s only tax is source-build time, not breakage.

What was striking (day 2):

  • Determinate’s installer 404s on x86_64-darwin — Intel is being dropped ecosystem-wide (installer, nixpkgs 26.05 last release).
  • Two fresh-bootstrap-only bugs that never show on a desktop: non-interactive SSH has no nix/flakes (fix: full path + NIX_CONFIG), and mkZshCompletionGen used $XDG_DATA_HOME under set -u which is unbound on a bare shell (fix: bake config.xdg.dataHome). Both invisible on atlas because a live HM session always provides that env.
  • The SSH session dropped mid-build once (athena slept — its keep-awake is part of the not-yet-active config), so the rebuild ran detached under nohup caffeinate -s. Store cache made the resume near-instant.

2026-07-05 — Merged to main + second capability flag

Cleared the three bring-up follow-ups. Merged hm-athena-server-profile (3 commits) into main as a clean fast-forward, then added the second capability flag on a fresh branch and merged that too.

  • local.devTools — a dev-toolchain master switch with independent languageServers / formatters / runtimes sub-toggles, copying the releaseCooldown master+sub-toggle idiom already in machine.nix. packages.nix gates its three dev groups on it; home-server.nix sets the master false → athena 87→60 packages (drops the 11 LSPs + 8 formatters + 8 runtimes/compilers). If editing config over SSH later wants LSPs, just local.devTools.languageServers.enable = true.
  • Verified no desktop regression the empirical way: stashed my changes, eval’d atlas’s activationPackage.drvPath, popped, eval’d again — byte-identical (229c3i27…). The optionals true xs == xs no-reorder property means desktop hosts don’t rebuild. (Amusing self-own: the git stash pop un-staged my files mid-flow, so the first commit aborted with “no changes added” until I re-git added.)

What was striking: the genuinely valuable design call this round was a restraint — deciding not to migrate the curated import list to auto-discovery + per-module flags. That path sounds cleaner but would need a correct enable guard on every Intel-broken heavy module, and one miss re-breaks the build. Codified the two-axis split (curated imports = arch boundary; capability flags = content trimming) directly in the home-server.nix header so the next person doesn’t “tidy” the import list into a landmine.

Code is on main but athena’s active generation still predates it — the trim only lands on the next hm switch over SSH.

2026-07-05 (later) — deployed the trim, then made uv a real server capability

Deployed the devTools trim to athena (generation 2): 87→60 packages live, expired generation 1, GC’d 5.8 GiB of reclaimed dev toolchain. Then a good question surfaced mid-cleanup: a uv-missing warning during activation prompted me to gate the check-upstream-issues completion on hasUv — but the real question was “athena should have uv, what feature flag is it under?” Answer: none. uv wasn’t flagged at all; it rode inside python.nix, gated purely by whether the module was imported (the server’s curated list omitted it).

So I promoted uv to a proper capability — the third after desktopApps and devTools:

  • local.python — master enable (uv CLI + managed CPython + uvLib + the local uv2nix tools) and tools.enable (the heavy uv tool install set: jupyter/ipython/comfy/torch). Same master+sub-toggle idiom as devTools.
  • athena imports python.nix with tools.enable = false, so it gets uv 0.11.25, CPython 3.10–3.15, and the nix-built claude-ops + gh-review-preview, but uv tool list is empty. Deployed as generation 3; expired generation 2, GC’d another 437.6 MiB.

What was striking: the whole session was really about the difference between the two profile axes I codified yesterday. uv sat on the wrong axis — controlled by import (arch boundary) when it should have been a content flag. Moving it to a capability flag is exactly the migration the note has been circling. And once more the feared Intel breakage didn’t happen: the uv2nix venvs (claude-ops, gh-review-preview) built clean on x86_64-darwin. Three capability flags are now real (desktopApps, devTools, python), all verified against atlas’s unchanged drvPath (229c3i27…).

Also a small self-own worth remembering: git stash pop returns previously-staged files as unstaged, which silently aborted a commit with “no changes added” until I re-git added. The stash-and-diff trick for proving drvPath equivalence has that footgun.

2026-07-05 (later still) — named local.profile bundle

Closed the loop on the capability model with the piece the note had been circling since April: named profile bundles. Added local.profile (enum desktop|server); home-server.nix now says just local.profile = "server" instead of three separate flag lines, and machine.nix maps the profile to mkDefault false on desktopApps/devTools/python.tools. The mkDefault priority (1000) is the key: it overrides the option’s own default (1500) but loses to an explicit host setting (100), so “server” is a floor, not a cage — a server host can still turn one capability back on.

Pure refactor, and I could prove it: both drvPaths unchanged (atlas 229c3i27…, athena gqa9ngn8…). The athena redeploy was a genuine no-op — home-manager switch re-ran activation but created no new generation (still id 3), which is exactly the signal that the profile bundle resolves to byte-identical config. Adding oracle later is now a one-liner.

What was striking: the arc of the last two days was really about putting each capability on the right axis. desktop apps, dev tooling, and uv all started life gated by module import (the arch-safety boundary) and moved, one at a time, to content-level capability flags — and now those flags roll up into a single declarative profile. The whole thing held the same invariant end to end: atlas never rebuilt once. Seven commits, three real capability flags, one profile bundle, one live headless host.