feat(modules): export NixOS modules by reading their directory #1287

Open
lytedev wants to merge 10 commits from propose/module-directory-scanner into main
Owner

Stacked on #1286 (its commits are included here); review the last four commits one by one, since each message carries the rationale and testing.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Asr2hJ2uNCVY7UH8yPE4Vh

Stacked on #1286 (its commits are included here); review the last four commits one by one, since each message carries the rationale and testing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Asr2hJ2uNCVY7UH8yPE4Vh
beefcake, battleship, router, rascal, htpc and htpc2 imported
nixos-hardware modules through a `hardware` module argument that only
lib/host.nix's specialArgs provided, and htpc2 did the same with
`diskoConfigurations`. Every other host already lists them by name in
`hardwareModules` / `diskConfig`, which lib/host.nix resolves outside
the module system, so these hosts now do the same. generic.nix took
`diskoConfigurations` without using it.

This is the first half of dropping specialArgs entirely; see
https://ysun.co/special/ for why an injected module argument makes a
module depend on whoever evaluates it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Asr2hJ2uNCVY7UH8yPE4Vh
No module reads `hardware` or `diskoConfigurations` as a module
argument any more, and darwinHost's specialArgs was already empty.
Without specialArgs a module in this flake can only depend on what it
is curried with or what it imports, so it evaluates the same in any
nixosSystem call.

Tested by evaluating every nixosConfigurations.<host> toplevel drvPath
before and after this and the previous commit, with lyte.flakeStorePath
and system.configurationRevision pinned (both embed the flake's own
source, which changes with any edit): all 30 identical. router needed
lyte.router.netboot.enable forced off as well, because it embeds the
netboot image's closure, where the pin does not reach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Asr2hJ2uNCVY7UH8yPE4Vh
`import path` hands the module system an anonymous function, so an
evaluation error in a host file names no file and the module gets no
key to deduplicate by. Passing the path lets the module system import
it and record where it came from. hostImportsFor still needs the
imported value to read hardwareModules and diskConfig out of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Asr2hJ2uNCVY7UH8yPE4Vh
battleship.nix and battleship/ci-runner-vm.nix read `nixvirt` as a
module argument that an extra module in packages/hosts/default.nix set
through `_module.args`. A module argument set that way cannot be used
in `imports` (it needs `config`, which needs `imports` first), and two
modules setting it conflict even when the values agree. Both files now
take nixvirt as a static argument applied with lib.modules.importApply,
which keeps the file name in evaluation errors. baseHost grows
`hostModuleStaticArgs` so a host file can be curried while
hostImportsFor still reads hardwareModules out of it.

importApply wraps the module one level deeper, which changes the order
that list-typed definitions merge in. battleship's toplevel drv
therefore changes, but only by order: system-path holds the same
package set, the set of derivations in the closure is identical, and
the otel metrics pipeline lists the same three receivers (node,
smartctl, zfs) in a different order. Applying the curried module
without the wrapper and setting _file/key on its result by hand keeps
the drv byte-identical, but reimplements importApply to avoid a change
no service can observe, so I did not keep it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Asr2hJ2uNCVY7UH8yPE4Vh
htpifour.nix and base.nix take an `outputs` module argument that
nothing in this flake provides, and generic-headless.nix defines
lyte.shell.enable twice, which nix rejects at parse time. None of them
is wired into packages/hosts/default.nix. History keeps them if one is
ever wanted as a starting point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Asr2hJ2uNCVY7UH8yPE4Vh
refactor(beefcake): import service modules by path
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 10m9s
d83630b4ef
`builtins.map import` handed the module system anonymous functions,
which gives an evaluation error no file name and gives the module no
key to deduplicate by. beefcake/daniel.nix was listed twice, and
because nothing deduplicated it, its definitions merged twice: daniel
appeared two or three times in wheel, users, caddy, jellyfin and the
other groups it joins, and postgresql-setup ran the ensure statements
for the daniel database and role twice. Importing by path deduplicates
it, so beefcake's closure changes by exactly those duplicates. Both
were idempotent, so nothing observable changes on the host. The second
listing is deleted too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Asr2hJ2uNCVY7UH8yPE4Vh
steam.nix sets nothing (`lib.mkIf … { }`) and nothing imports it.
nixosModules.remote-disk-key-entry-on-boot is imported by no host.
nixosModules.nix-config is flake.nix's nixConfig attrset, not a module,
and nothing reads it; default-module.nix reads nixConfig from flake.nix
directly. None of them fits a directory where every file is a module,
which the next commits build. History keeps the initrd SSH unlock
module if a host ever wants it again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Asr2hJ2uNCVY7UH8yPE4Vh
Every top-level .nix file in lib/modules/nixos/ is now an exported
module, and every export is such a file, so the next commit can build
the export list by reading the directory:

- assistant-pi.nix is imported only by claude.nix, so it moves to
  claude/assistant-pi.nix.
- multiboot-refuse-mount-held-by-another-os.nix is a package, not a
  module, so it moves to multiboot/.
- shell-config-linux.nix becomes shell-defaults-and-applications.nix
  and imports ../shared/shell-config.nix itself, replacing the inline
  composite export.
- user-env and wyoming-whisper-cpp live outside this directory, so each
  gets a one-line file here that imports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Asr2hJ2uNCVY7UH8yPE4Vh
default-module, dns-zones, niri and greeter were each curried with the
whole flake inputs set, but their headers disagreed: `inputs:`,
`flakeInputs:`, or a destructuring pattern of the inputs they used.
They now all take `{ inputs }:`. As a module, that header would ask
the module system for an `inputs` module argument, which this flake
no longer provides now that specialArgs is gone, so the next commit can
recognise a curried module from builtins.functionArgs without guessing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Asr2hJ2uNCVY7UH8yPE4Vh
feat(modules): export NixOS modules by reading their directory
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 11m21s
48743383e0
lib/modules/nixos/default.nix listed every module by hand, so adding a
module meant editing two files, and the list was free to drift from the
directory (it had: see the previous commits). It now exports every
top-level .nix file in the directory under its file name, via
lib/modules/import-modules-from-directory.nix:

- A file whose header is exactly `{ inputs }:` is applied to the flake
  inputs with lib.modules.importApply, which records the file for
  error messages, plus an explicit key.
- Every other file is exported as its path, so the module system names
  it in errors and deduplicates it by path. Previously each export was
  `import ./x.nix`, an anonymous function.
- default.nix and anything in a subdirectory are not exported, which is
  where helpers live.

`default` stays an alias of default-module, since lib/host.nix and the
outside world know it by that name.

The idea is from https://ysun.co/special/, whose importApplyWithArgs
also probes non-pattern lambdas (`args: …`) by calling them. That
is left out here: a probe cannot tell `args: { … }` (a module) from
`args: { … }: …` (curried) without calling it with something, and
the exact-header rule makes the probe unnecessary.

The post also says importApply needs an explicit key to deduplicate.
That holds for the pinned nixpkgs: importApply wraps the module in
`{ _file; imports = [ m ]; }`, collectModules keys the inner module as
`<parent key>:anon-1`, and the two parents of a double import have
different keys. The curried-module-imported-twice-is-merged-once check
fails with the key removed. Its fixture option carries a description
on purpose: two declarations of a bare `types.str` option merge
without error, which hid the duplicate on the first attempt.

importApply's extra nesting, and shell-defaults-and-applications now
importing shell-config itself, change the order in which list options
merge, so most hosts' toplevel drvs change (system-path, dbus and
polkit restart triggers, man-paths, ...). Tested that this is order
only: for all 30 nixosConfigurations, with lyte.flakeStorePath and
system.configurationRevision pinned, I compared the full derivation
closure before and after this series. Each derivation was reduced to
its name and the sorted tokens of its env and args, with store hashes
stripped, plus the closure's content-addressed inputSrcs. All 30 are
identical. The same comparison does tell main and the series below
apart for beefcake (it catches the removed duplicate group members), so
it is not blind to small changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Asr2hJ2uNCVY7UH8yPE4Vh
All checks were successful
/ check-format (push) Successful in 11s
Required
Details
/ build (push) Successful in 11m21s
Required
Details
This pull request has changes conflicting with the target branch.
  • lib/modules/nixos/default.nix
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin propose/module-directory-scanner:propose/module-directory-scanner
git switch propose/module-directory-scanner
Sign in to join this conversation.
No reviewers
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lytedev/nix!1287
No description provided.