diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 34bd7c36..fe99b3ba 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -118,8 +118,10 @@ Path follows directory structure: `modules/systems//` → `mod lib.my.mkFeature { description = "Feature description"; platforms = [ "linux" "darwin" ]; # Supported platforms - requires = [ "home.shell.zsh" ]; # Dependencies auto-enabled per user - extraOptions = { ... }; # Per-user typed extra options + requires = [ "home.shell.zsh" ]; # Hard dependencies auto-enabled per user + recommends = [ ]; # Soft dependencies (overridable by enable = false) + homeOptions = { ... }; # Per-user typed Home Manager options + systemOptions = { ... }; # System-wide typed options dotfiles = { # Optional dotfiles symlinking path = "app"; @@ -140,7 +142,7 @@ Home-manager configuration is scoped strictly per-user to users who enable the f ## Key Helpers (lib/default.nix) -- `lib.my.mkFeature { description?, platforms?, requires?, extraOptions?, dotfiles?, homeModule?, homeModuleLinux?, homeModuleDarwin?, nixosModule?, darwinModule? }` — User feature descriptors +- `lib.my.mkFeature { description?, platforms?, requires?, recommends?, homeOptions?, systemOptions?, dotfiles?, homeModule?, homeModuleLinux?, homeModuleDarwin?, nixosModule?, darwinModule? }` — User feature descriptors - `lib.my.mkSystemModuleV2 args { namespace, module, moduleLinux?, moduleDarwin?, ... }` — System modules - `lib.my.shouldEnableModule { config, modulePath }` — Check hierarchical enable - `lib.my.isFeatureEnabledForUser { config, userName, modulePath }` — Check per-user feature enable diff --git a/.agents/skills/dotfiles-management/SKILL.md b/.agents/skills/dotfiles-management/SKILL.md index cafd9204..fcd595d9 100644 --- a/.agents/skills/dotfiles-management/SKILL.md +++ b/.agents/skills/dotfiles-management/SKILL.md @@ -35,16 +35,16 @@ lib.my.mkDotfilesSymlink { - Custom target: set `target` explicitly (e.g., `"~/.tmux"`) - Requires `DOTFILES_DIR` env variable in host config -## Using in mkModuleV2 +## Using in mkFeature -Preferred way — use the `dotfiles` parameter: +Preferred way — use the `dotfiles` parameter in `lib.my.mkFeature`: ```nix -lib.my.mkModuleV2 args { +lib.my.mkFeature { description = "Ghostty terminal"; - module = { - allSystems.home.packages = [ pkgs.ghostty ]; + homeModule = { pkgs, ... }: { + programs.ghostty.enable = true; }; dotfiles = { @@ -87,5 +87,5 @@ Without it, `mkDotfilesSymlink` throws an error. 1. Create `dotfiles/` directory next to the module `.nix` file 2. Put config files inside -3. Add `dotfiles` parameter to `mkModuleV2` call +3. Add `dotfiles` parameter to `mkFeature` call 4. No rebuild needed for future config edits — just edit files in `dotfiles/` diff --git a/.agents/skills/host-configuration/SKILL.md b/.agents/skills/host-configuration/SKILL.md index bd45ec6a..0bca5007 100644 --- a/.agents/skills/host-configuration/SKILL.md +++ b/.agents/skills/host-configuration/SKILL.md @@ -31,11 +31,12 @@ in isDefault = true; }]; ssh = [ - { host = "*"; identityFile = "~/.ssh/_id_ed25519"; extraOptions.AddKeysToAgent = "yes"; } + { host = "*"; identityFile = "~/.ssh/_id_ed25519"; extraConfig.AddKeysToAgent = "yes"; } { host = "github.com"; user = "git"; identityFile = "~/.ssh/_id_ed25519"; } ] ++ userDefaults.ssh.knownHosts; modules = lib.recursiveUpdate profiles.userModules { home.desktop.hyprland.enable = true; # WM choice is host-specific + home.editors.antigravity = userDefaults.antigravity // { enable = true; }; }; sudo.nopasswd = true; # remove if not needed }; @@ -52,7 +53,6 @@ in graphics.nvidia = { enable = true; open = true; }; }; - modules.home.editors.antigravity = userDefaults.antigravity; time.timeZone = "Europe/Moscow"; theme.name = "catppuccin-macchiato"; } @@ -78,11 +78,12 @@ in enable = true; keys = [{ name = ""; type = "ed25519"; purpose = [ "git" "ssh" ]; isDefault = true; }]; ssh = [ - { host = "*"; identityFile = "~/.ssh/_id_ed25519"; extraOptions.AddKeysToAgent = "yes"; } + { host = "*"; identityFile = "~/.ssh/_id_ed25519"; extraConfig.AddKeysToAgent = "yes"; } { host = "github.com"; user = "git"; identityFile = "~/.ssh/_id_ed25519"; } ] ++ userDefaults.ssh.knownHosts; modules = lib.recursiveUpdate profiles.userModules { home.ai-tools.enable = true; + home.editors.antigravity = userDefaults.antigravity // { enable = true; }; }; }; @@ -95,7 +96,6 @@ in openssh = { enable = true; userMap = { "dgarifullin" = "gdr"; }; }; }; - modules.home.editors.antigravity = userDefaults.antigravity; time.timeZone = "Europe/Moscow"; theme.name = "rose-pine-moon"; } @@ -145,9 +145,9 @@ Generate: `nixos-generate-config --show-hardware-config` 2. Create `hosts/machines//hardware-configuration.nix` 3. Add to `flake.nix`: ```nix - nixosConfigurations. = flakeHelpers.mkNixosConfiguration ./hosts/machines/; + nixosConfigurations. = flakeHelpers.mkNixosConfiguration "" ./hosts/machines/; # or for Darwin: - darwinConfigurations. = flakeHelpers.mkDarwinConfiguration ./hosts/machines/; + darwinConfigurations. = flakeHelpers.mkDarwinConfiguration "" ./hosts/machines/; ``` 4. Pick profiles — `developer` for any machine, `desktop` for GUI, `server` for headless 5. Add `justfile` target (optional) diff --git a/.agents/skills/nix-module-creation/SKILL.md b/.agents/skills/nix-module-creation/SKILL.md index 777c1fdf..ca59e61a 100644 --- a/.agents/skills/nix-module-creation/SKILL.md +++ b/.agents/skills/nix-module-creation/SKILL.md @@ -78,15 +78,15 @@ lib.my.mkFeature { } ``` -### With custom options +### With custom options (`homeOptions` / `systemOptions`) ```nix -{ lib, pkgs, ... }: +{ lib, ... }: lib.my.mkFeature { - description = "Feature with custom options"; + description = "Feature with custom per-user and system options"; - extraOptions = { + homeOptions = { showHostname = lib.mkOption { default = true; type = lib.types.bool; @@ -94,17 +94,29 @@ lib.my.mkFeature { }; }; + systemOptions = { + openFirewall = lib.mkOption { + default = false; + type = lib.types.bool; + description = "Open firewall ports at system level"; + }; + }; + homeModule = { pkgs, featureCfg, ... }: { home.packages = [ pkgs.my-tool ]; - # Access option: featureCfg.showHostname + # Access per-user option: featureCfg.showHostname + }; + + nixosModule = { featureCfg, ... }: { + # Access system option: featureCfg.openFirewall }; } ``` -### With dependencies (requires) +### With dependencies (requires / recommends) ```nix -{ lib, pkgs, ... }: +{ lib, ... }: lib.my.mkFeature { description = "Feature with dependencies"; @@ -113,6 +125,9 @@ lib.my.mkFeature { "home.shell.zsh" "home.desktop.services.audio" ]; + recommends = [ + "home.cli.htop" + ]; homeModule = { pkgs, ... }: { home.packages = [ pkgs.my-tool ]; @@ -126,8 +141,10 @@ lib.my.mkFeature { |-----------|------|----------|-------------| | `description` | string | No | Feature description | | `platforms` | list | No | `["linux" "darwin"]` (default: both) | -| `requires` | list | No | Feature path dependencies (auto-enabled per user) | -| `extraOptions` | attrset | No | Additional typed NixOS/HM options (merged per user) | +| `requires` | list | No | Hard feature path dependencies (cannot be disabled while parent is enabled) | +| `recommends` | list | No | Soft feature path dependencies (overridable by `enable = false`) | +| `homeOptions` | attrset | No | Per-user typed Home Manager options under `hostUsers..modules` | +| `systemOptions` | attrset | No | System-wide typed options under `modules.home` | | `dotfiles` | `{ path, source, target?, live? }` | No | Live-editable symlinks | | `homeModule` | function or attrset | No | Home Manager config for users enabling the feature | | `homeModuleLinux` | function or attrset | No | Linux-only Home Manager config | @@ -152,13 +169,6 @@ lib.my.mkSystemModuleV2 args { # For namespace = "all", optional platform-specific additions: # moduleLinux = cfg: { ... }; # moduleDarwin = cfg: { ... }; - - extraOptions = { - open = lib.mkOption { - default = false; - type = lib.types.bool; - }; - }; } ``` diff --git a/README.md b/README.md index f63292da..d8f04b02 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ graph TD end subgraph Modules["Module System"] - HM["User Modules (mkModuleV2)
modules/home/*"] + HM["User Features (mkFeature)
modules/home/*"] SM["System Modules (mkSystemModuleV2)
modules/systems/{all,linux,darwin}/*"] P --> HM P --> SM @@ -247,9 +247,9 @@ nixos-generate-config --show-hardware-config > hosts/machines/my-new-host/hardwa 4. **Add to `flake.nix`:** ```nix -nixosConfigurations.my-new-host = mkNixosConfiguration ./hosts/machines/my-new-host; +nixosConfigurations.my-new-host = flakeHelpers.mkNixosConfiguration "my-new-host" ./hosts/machines/my-new-host; # or for Darwin: -darwinConfigurations.my-new-host = mkDarwinConfiguration ./hosts/machines/my-new-host; +darwinConfigurations.my-new-host = flakeHelpers.mkDarwinConfiguration "my-new-host" ./hosts/machines/my-new-host; ``` --- @@ -266,7 +266,7 @@ darwinConfigurations.my-new-host = mkDarwinConfiguration ./hosts/machines/my-new fullName = lib.mkDefault "New User"; email = lib.mkDefault "newuser@example.com"; github = lib.mkDefault "newuser"; - extraGroups = lib.mkDefault [ "wheel" "audio" "video" ]; + extraGroups = lib.mkDefault [ "audio" "video" ]; } ``` @@ -284,7 +284,7 @@ hostUsers.newuser = importUser "newuser" // { }]; # Hierarchical module enables modules = { - home.core.enable = true; + home.cli.enable = true; home.shell.enable = true; home.media.vlc.enable = true; # specific module }; @@ -324,24 +324,24 @@ Module paths follow the directory structure: `home..` ### Create a New Module -#### User Module +#### User Feature (`mkFeature`) ```nix # modules/home/tools/my-tool.nix -{ lib, pkgs, ... }@args: +{ lib, ... }: -lib.my.mkModuleV2 args { +lib.my.mkFeature { description = "My awesome tool"; platforms = [ "linux" "darwin" ]; # optional, defaults to both - module = { - # Cross-platform config (goes to home-manager.users.*) - allSystems.home.packages = [ pkgs.my-tool ]; - - # Or platform-specific - nixosSystems.programs.my-tool.enable = true; - darwinSystems.homebrew.casks = [ "my-tool" ]; + homeModule = { pkgs, ... }: { + # Per-user Home Manager config (scoped strictly to users enabling the feature) + home.packages = [ pkgs.my-tool ]; }; + + # Optional platform-specific Home Manager or system activation: + # homeModuleLinux = { pkgs, ... }: { programs.my-tool.enable = true; }; + # darwinModule = { homebrew.casks = [ "my-tool" ]; }; } ``` @@ -444,7 +444,7 @@ Enable modules hierarchically per-user: ```nix hostUsers.myuser.modules = { home.browsers.enable = true; # enables all browsers - home.core.enable = true; # enables htop, shell-utils + home.cli.enable = true; # enables htop, shell-utils home.shell.enable = true; # enables zsh, tmux home.editors.neovim.enable = true; # specific module }; @@ -452,7 +452,7 @@ hostUsers.myuser.modules = { | Path | Modules | |------|---------| -| `home.core` | htop, shell-utils (bat, fzf, wget, direnv) | +| `home.cli` | htop, shell-utils (bat, fzf, wget, direnv) | | `home.shell` | zsh with oh-my-zsh, zplug, tmux | | `home.terminal` | ghostty | | `home.browsers` | chromium, vivaldi | diff --git a/checks.nix b/checks.nix index acb0cf46..c6b2244a 100644 --- a/checks.nix +++ b/checks.nix @@ -57,18 +57,53 @@ let # bob receives pamixer/pavucontrol (via transitive requires audio) assert hasPackage "pamixer" bobPkgs; assert hasPackage "pavucontrol" bobPkgs; - # bob does NOT receive telegram-desktop, google-antigravity-ide, .gemini/antigravity/mcp_config.json, or docker group + # bob does NOT receive telegram-desktop, google-antigravity-ide, .gemini/antigravity/mcp_config.json, docker, wheel, or trusted-users assert !(hasPackage "telegram-desktop" bobPkgs); assert !(hasPackage "google-antigravity" bobPkgs); assert !(bobFiles ? ".gemini/antigravity/mcp_config.json"); assert !(lib.elem "docker" bobGroups); - # dgarifullin retains all of their packages, configs, and groups + assert !(lib.elem "wheel" bobGroups); + assert !(lib.elem "bob" testHost.config.nix.settings.trusted-users); + # dgarifullin retains all of their packages, configs, groups, and trusted-users assert hasPackage "htop" dgarifullinPkgs; assert hasPackage "telegram-desktop" dgarifullinPkgs; assert (dgarifullinFiles ? ".gemini/antigravity/mcp_config.json"); assert (lib.elem "docker" dgarifullinGroups); + assert (lib.elem "wheel" dgarifullinGroups); + assert (lib.elem user testHost.config.nix.settings.trusted-users); pkgs.runCommand "multi-user-isolation-check" { } "touch $out"; + scoped-options-and-requires = + let + testHost = self.nixosConfigurations.nix-goldstar.extendModules { + modules = [ + { + modules.home.games.steam.remotePlayOpenFirewall = lib.mkForce false; + hostUsers.${user}.modules.home.editors.antigravity.rules = lib.mkForce "Custom per-user rules"; + } + ]; + }; + rulesText = testHost.config.home-manager.users.${user}.home.file.".gemini/config/AGENTS.md".text; + conflictHost = self.nixosConfigurations.nix-goldstar.extendModules { + modules = [ + { + hostUsers.bob = { + enable = true; + modules = { + home.games.soundpad.enable = true; + home.desktop.services.audio.enable = false; + }; + }; + } + ]; + }; + conflictEval = builtins.tryEval (builtins.deepSeq conflictHost.config.home-manager.users.bob.home.packages true); + in + assert testHost.config.programs.steam.remotePlay.openFirewall == false; + assert rulesText == "Custom per-user rules\n" || rulesText == "Custom per-user rules"; + assert !conflictEval.success; + pkgs.runCommand "scoped-options-and-requires-check" { } "touch $out"; + cascade-child-opt-out = let testDarwin = self.darwinConfigurations.mac-brightstar.extendModules { diff --git a/flake.nix b/flake.nix index 07a8ae7b..f4d6d370 100644 --- a/flake.nix +++ b/flake.nix @@ -103,9 +103,9 @@ ); # Host configurations - darwinConfigurations.mac-brightstar = flakeHelpers.mkDarwinConfiguration ./hosts/machines/mac-brightstar; - nixosConfigurations.nix-oldstar = flakeHelpers.mkNixosConfiguration ./hosts/machines/nix-oldstar; - nixosConfigurations.nix-goldstar = flakeHelpers.mkNixosConfiguration ./hosts/machines/nix-goldstar; + darwinConfigurations.mac-brightstar = flakeHelpers.mkDarwinConfiguration "mac-brightstar" ./hosts/machines/mac-brightstar; + nixosConfigurations.nix-oldstar = flakeHelpers.mkNixosConfiguration "nix-oldstar" ./hosts/machines/nix-oldstar; + nixosConfigurations.nix-goldstar = flakeHelpers.mkNixosConfiguration "nix-goldstar" ./hosts/machines/nix-goldstar; templates = { python = { diff --git a/hosts/machines/nix-goldstar/default.nix b/hosts/machines/nix-goldstar/default.nix index f64db779..5bb65efd 100644 --- a/hosts/machines/nix-goldstar/default.nix +++ b/hosts/machines/nix-goldstar/default.nix @@ -55,7 +55,7 @@ in }; home.editors.neovim.enable = true; home.editors.ghidra.enable = true; - home.editors.antigravity = { + home.editors.antigravity = userDefaults.antigravity // { enable = true; # Global MCP servers — written to ~/.gemini/antigravity/mcp_config.json # Applies to ALL workspaces opened in Antigravity IDE @@ -67,7 +67,6 @@ in }; context7 = { url = "https://mcp.context7.com/mcp"; - headers.CONTEXT7_API_KEY = builtins.getEnv "CONTEXT7_API_KEY"; }; ghidra = { command = "bridge-mcp-ghidra"; diff --git a/hosts/users/dgarifullin.nix b/hosts/users/dgarifullin.nix index 21058cee..75d62cce 100644 --- a/hosts/users/dgarifullin.nix +++ b/hosts/users/dgarifullin.nix @@ -11,6 +11,7 @@ email = "gosugdr@gmail.com"; github = "gdr"; extraGroups = [ "wheel" "audio" "libvirtd" "input" ]; + trustedNixUser = true; uid = 1000; }; diff --git a/justfile b/justfile index 4ddb8dde..f8d82ef6 100644 --- a/justfile +++ b/justfile @@ -13,6 +13,10 @@ # just darwin Switch all Darwin hosts # ────────────────────────────────────────────────────────────────────────────── +# Optional private extension path override (set DOT_PRIVATE_EXTENSION=/path/to/dot-private) +private_ext := env("DOT_PRIVATE_EXTENSION", "") +private_override := if private_ext != "" { "--override-input private-extension path:" + private_ext } else { "" } + # ── Help ────────────────────────────────────────────────────────────────────── [doc('Show available recipes')] help: @@ -49,7 +53,7 @@ nixos: nix-oldstar nix-goldstar [doc('darwin-rebuild switch for mac-brightstar (local)')] mac-brightstar: @printf "\033[1m\033[32m▶ Switching mac-brightstar (local)…\033[0m\n" - sudo SSH_AUTH_SOCK=$SSH_AUTH_SOCK darwin-rebuild switch --flake .#mac-brightstar + sudo SSH_AUTH_SOCK=$SSH_AUTH_SOCK darwin-rebuild switch --flake .#mac-brightstar {{private_override}} # ── NixOS hosts (SSH + nixos-rebuild) ───────────────────────────────────────── # SSH into the remote host, pull latest config, and nixos-rebuild switch. @@ -57,12 +61,12 @@ mac-brightstar: [doc('nixos-rebuild switch for nix-oldstar (remote)')] nix-oldstar: @printf "\033[1m\033[32m▶ Rebuilding nix-oldstar…\033[0m\n" - ssh -t nix-oldstar 'cd ${DOTFILES_DIR:-$HOME/Workspaces/gdr/dot} && git pull && sudo nixos-rebuild switch --flake .#nix-oldstar' + ssh -t nix-oldstar 'cd ${DOTFILES_DIR:-$HOME/Workspaces/gdr/dot} && git pull && sudo nixos-rebuild switch --flake .#nix-oldstar ${DOT_PRIVATE_EXTENSION:+--override-input private-extension path:$DOT_PRIVATE_EXTENSION}' [doc('nixos-rebuild switch for nix-goldstar (remote)')] nix-goldstar: @printf "\033[1m\033[32m▶ Rebuilding nix-goldstar…\033[0m\n" - ssh -t nix-goldstar 'cd ${DOTFILES_DIR:-$HOME/Workspaces/gdr/dot} && git pull && sudo nixos-rebuild switch --flake .#nix-goldstar' + ssh -t nix-goldstar 'cd ${DOTFILES_DIR:-$HOME/Workspaces/gdr/dot} && git pull && sudo nixos-rebuild switch --flake .#nix-goldstar ${DOT_PRIVATE_EXTENSION:+--override-input private-extension path:$DOT_PRIVATE_EXTENSION}' # ── Maintenance ─────────────────────────────────────────────────────────────── [doc('nix flake update (refresh lock file)')] @@ -89,11 +93,11 @@ lint: [doc('Dry-run build/eval host configurations')] check-hosts: @printf "\033[1m\033[32m▶ Evaluating system host configurations…\033[0m\n" - nix build .#nixosConfigurations.nix-goldstar.config.system.build.toplevel --dry-run - nix build .#nixosConfigurations.nix-oldstar.config.system.build.toplevel --dry-run - nix eval .#darwinConfigurations.mac-brightstar.config.system.build.toplevel.drvPath + nix build .#nixosConfigurations.nix-goldstar.config.system.build.toplevel --dry-run {{private_override}} + nix build .#nixosConfigurations.nix-oldstar.config.system.build.toplevel --dry-run {{private_override}} + nix eval .#darwinConfigurations.mac-brightstar.config.system.build.toplevel.drvPath {{private_override}} [doc('Composite target (fmt-check + lint + check-hosts + flake check)')] check: fmt-check lint check-hosts @printf "\033[1m\033[32m▶ Running nix flake check…\033[0m\n" - nix flake check --all-systems + nix flake check --all-systems {{private_override}} diff --git a/lib/features.nix b/lib/features.nix index 276caabd..bd4f0fb4 100644 --- a/lib/features.nix +++ b/lib/features.nix @@ -3,8 +3,6 @@ { lib, inputs ? { }, ... }: let - moduleRegistryHelper = import ./moduleRegistry.nix { inherit lib; }; - # Compute module path from file path # e.g., "/path/to/modules/home/cli/htop.nix" -> "home.cli.htop" # e.g., "/path/to/modules/home/cli/htop/htop.nix" -> "home.cli.htop" (dedup) @@ -30,7 +28,9 @@ let { description ? null , platforms ? [ "linux" "darwin" ] , requires ? [ ] - , extraOptions ? { } + , recommends ? [ ] + , homeOptions ? { } + , systemOptions ? { } , dotfiles ? null , homeModule ? null , homeModuleLinux ? null @@ -40,7 +40,9 @@ let }: assert lib.assertMsg (lib.isList platforms) "mkFeature: platforms must be a list of strings"; assert lib.assertMsg (lib.isList requires) "mkFeature: requires must be a list of strings"; - assert lib.assertMsg (lib.isAttrs extraOptions) "mkFeature: extraOptions must be an attribute set"; + assert lib.assertMsg (lib.isList recommends) "mkFeature: recommends must be a list of strings"; + assert lib.assertMsg (lib.isAttrs homeOptions) "mkFeature: homeOptions must be an attribute set"; + assert lib.assertMsg (lib.isAttrs systemOptions) "mkFeature: systemOptions must be an attribute set"; assert lib.assertMsg (dotfiles == null || lib.isAttrs dotfiles) "mkFeature: dotfiles must be null or an attribute set"; assert lib.assertMsg (homeModule == null || lib.isFunction homeModule || lib.isAttrs homeModule) "mkFeature: homeModule must be null, a function, or an attribute set"; assert lib.assertMsg (homeModuleLinux == null || lib.isFunction homeModuleLinux || lib.isAttrs homeModuleLinux) "mkFeature: homeModuleLinux must be null, a function, or an attribute set"; @@ -49,16 +51,16 @@ let assert lib.assertMsg (darwinModule == null || lib.isFunction darwinModule || lib.isAttrs darwinModule) "mkFeature: darwinModule must be null, a function, or an attribute set"; { _type = "feature"; - inherit description platforms requires extraOptions dotfiles + inherit description platforms requires recommends homeOptions systemOptions dotfiles homeModule homeModuleLinux homeModuleDarwin nixosModule darwinModule; meta = { - inherit description platforms requires; + inherit description platforms requires recommends; }; }; # Generate strongly-typed option trees for categories and leaf features - # Both options.modules.home.* and options.hostUsers..modules.home.* use this - buildFeatureOptionTree = { featureCatalog ? [ ], knownModulePaths ? [ ] }: + # scope = "user" exposes homeOptions; scope = "system" exposes systemOptions + buildFeatureOptionTree = { featureCatalog ? [ ], knownModulePaths ? [ ], scope ? "both" }: let featurePaths = map (f: lib.splitString "." f.path) featureCatalog; allPaths = (map (p: lib.splitString "." p) knownModulePaths) ++ featurePaths; @@ -103,7 +105,11 @@ let description = "Override dotfile symlink mode for ${matchingFeature.path} (true = live edit out-of-store, false = immutable nix store)."; }; }) - // (matchingFeature.extraOptions or { }) + // ( + if scope == "user" then (matchingFeature.homeOptions or { }) + else if scope == "system" then (matchingFeature.systemOptions or { }) + else (matchingFeature.homeOptions or { }) // (matchingFeature.systemOptions or { }) + ) else { }; children = lib.listToAttrs (map @@ -122,9 +128,11 @@ let # Check if a feature is enabled for a specific user: # 1. Walks from leaf to root in userModules: most specific non-null setting wins. # 2. If user setting is null, falls back to global config.modules hierarchy. - # 3. If directly enabled, returns true. - # 4. If user explicitly set enable = false, returns false (transitive requires cannot override user opt-out). - # 5. Otherwise, checks per-user transitive requires via moduleRegistry.resolveDeps. + # 3. Validates all requires/recommends paths exist in moduleMap. + # 4. If an enabled feature transitively requires this path, and user explicitly disabled it (enable = false), throws a conflict error. + # 5. If an enabled feature transitively requires this path, auto-enables it. + # 6. If user explicitly set enable = false, returns false (overriding category cascades and recommends). + # 7. If an enabled feature recommends this path (and user did not explicitly disable it), auto-enables it. isFeatureEnabledForUser = { config ? null , osConfig ? null @@ -172,57 +180,147 @@ let if userSetting != null then userSetting else if globalSetting != null then globalSetting else false; - in - if directlyEnabled then true - else if userSetting == false then false # User explicit opt-out wins over transitive requires - else - # Check transitive requirements - let - registryModules = - if modulesRegistry != null then (modulesRegistry.modules or [ ]) - else if cfg ? _modulesRegistry && cfg._modulesRegistry != null then (cfg._modulesRegistry.modules or [ ]) - else [ ]; - - moduleMap = - (lib.listToAttrs (map (m: { name = m.path; value = m.meta or m; }) registryModules)) - // (lib.listToAttrs (map (f: { name = f.path; value = f; }) catalog)); - - allKnownPaths = lib.attrNames moduleMap; - - isPathDirectlyEnabled = p: - let - pParts = lib.splitString "." p; - pPrefixes = lib.genList (i: lib.take (i + 1) pParts) (lib.length pParts); - pRevPrefixes = lib.reverseList pPrefixes; - pFindSetting = modules: - lib.foldl' - (acc: prefix: - if acc != null then acc - else - let sub = lib.foldl' (a: part: a.${part} or { }) modules prefix; in - sub.enable or null + + registryModules = + if modulesRegistry != null then (modulesRegistry.modules or [ ]) + else if cfg ? _modulesRegistry && cfg._modulesRegistry != null then (cfg._modulesRegistry.modules or [ ]) + else [ ]; + + moduleMap = + (lib.listToAttrs (map (m: { name = m.path; value = m.meta or m; }) registryModules)) + // (lib.listToAttrs (map (f: { name = f.path; value = f; }) catalog)); + + allKnownPaths = lib.attrNames moduleMap; + + # Validate dependencies exist in moduleMap + validateDeps = + if allKnownPaths == [ ] then [ ] + else + lib.concatMap + (modPath: + let + meta = moduleMap.${modPath}; + reqs = if meta != null then (meta.requires or [ ]) else [ ]; + recs = if meta != null then (meta.recommends or [ ]) else [ ]; + in + (map + (req: + if !(lib.elem req allKnownPaths) + then throw "Evaluation error: Feature '${modPath}' requires unknown module '${req}'" + else null ) - null - pRevPrefixes; - uSet = pFindSetting userModules; - gSet = pFindSetting globalModules; - in - if uSet != null then uSet - else if gSet != null then gSet - else false; - - directlyEnabledPaths = lib.filter (p: p != path && isPathDirectlyEnabled p) allKnownPaths; - - isRequired = lib.any - (parentPath: - let - deps = moduleRegistryHelper.resolveDeps moduleMap parentPath [ ]; - in - lib.elem path deps - ) - directlyEnabledPaths; - in - isRequired; + reqs) + ++ + (map + (recPath: + if !(lib.elem recPath allKnownPaths) + then throw "Evaluation error: Feature '${modPath}' recommends unknown module '${recPath}'" + else null + ) + recs) + ) + allKnownPaths; + + isPathExplicitlyDisabled = p: + let + pParts = lib.splitString "." p; + leaf = (lib.foldl' (a: part: a.${part} or { }) userModules pParts).enable or null; + in + leaf == false; + + userExplicitDisabled = isPathExplicitlyDisabled path; + + isPathDirectlyEnabled = p: + let + pParts = lib.splitString "." p; + pPrefixes = lib.genList (i: lib.take (i + 1) pParts) (lib.length pParts); + pRevPrefixes = lib.reverseList pPrefixes; + pFindSetting = modules: + lib.foldl' + (acc: prefix: + if acc != null then acc + else + let sub = lib.foldl' (a: part: a.${part} or { }) modules prefix; in + sub.enable or null + ) + null + pRevPrefixes; + uSet = pFindSetting userModules; + gSet = pFindSetting globalModules; + in + if uSet != null then uSet + else if gSet != null then gSet + else false; + + directlyEnabledPaths = lib.filter isPathDirectlyEnabled allKnownPaths; + + resolveTransitiveRequires = startPath: + let + step = visited: current: + if lib.elem current visited then visited + else + let + meta = moduleMap.${current} or null; + deps = if meta != null then (meta.requires or [ ]) else [ ]; + nextVisited = visited ++ [ current ]; + in + lib.foldl' step nextVisited deps; + in + step [ ] startPath; + + requiringPaths = lib.filter + (parentPath: + let deps = resolveTransitiveRequires parentPath; + in lib.elem path deps && parentPath != path + ) + directlyEnabledPaths; + + # Conflict check 1: another enabled feature requires this path, but user explicitly set enable = false + _checkConflictRequired = + if userExplicitDisabled && requiringPaths != [ ] then + throw "Evaluation conflict: Feature '${lib.concatStringsSep ", " requiringPaths}' enabled for user '${userName}' requires '${path}', but '${path}' is explicitly disabled (enable = false)." + else true; + + # Conflict check 2: this path is directly enabled, but one of its transitive required dependencies was explicitly disabled + pathTransitiveReqs = lib.filter (d: d != path) (resolveTransitiveRequires path); + conflictDep = lib.findFirst isPathExplicitlyDisabled null pathTransitiveReqs; + + _checkConflictDirect = + if directlyEnabled && conflictDep != null then + throw "Evaluation conflict: Feature '${path}' enabled for user '${userName}' requires '${conflictDep}', but '${conflictDep}' is explicitly disabled (enable = false)." + else true; + + # Find if path is recommended by any enabled feature (pruning explicitly disabled paths) + isPathRecommended = + let + allRecommended = lib.unique (lib.concatMap + (parentPath: + let + step = visited: current: + if lib.elem current visited then visited + else if (current != parentPath && isPathExplicitlyDisabled current) then visited + else + let + meta = moduleMap.${current} or null; + recs = if meta != null then (meta.recommends or [ ]) else [ ]; + reqs = if meta != null then (meta.requires or [ ]) else [ ]; + nextVisited = visited ++ [ current ]; + in + lib.foldl' step nextVisited (reqs ++ recs); + in + lib.filter (p: p != parentPath) (step [ ] parentPath) + ) + directlyEnabledPaths); + in + lib.elem path allRecommended; + in + assert lib.deepSeq validateDeps true; + assert _checkConflictRequired; + assert _checkConflictDirect; + if requiringPaths != [ ] then true + else if userExplicitDisabled then false + else if directlyEnabled then true + else isPathRecommended; # Resolve all active features for a user resolveUserFeatures = @@ -274,8 +372,12 @@ let source = dotfiles.source; target = dotfiles.target or null; + pathParts = lib.splitString "." feature.path; + globalCfg = lib.foldl' (acc: p: acc.${p} or { }) (osConfig.modules or { }) pathParts; + isLive = if (featureCfg.dotfilesLive or null) != null then featureCfg.dotfilesLive + else if (globalCfg.dotfilesLive or null) != null then globalCfg.dotfilesLive else if (dotfiles.live or null) != null then dotfiles.live else (osConfig.dotfiles.live or false); @@ -345,9 +447,7 @@ let compileSingleFeature = feature: let pathParts = lib.splitString "." feature.path; - globalCfg = lib.foldl' (acc: p: acc.${p} or { }) (osConfig.modules or { }) pathParts; - userRawCfg = lib.foldl' (acc: p: acc.${p} or { }) (userCfg.modules or { }) pathParts; - featureCfg = lib.recursiveUpdate globalCfg userRawCfg; + featureCfg = lib.foldl' (acc: p: acc.${p} or { }) (userCfg.modules or { }) pathParts; dotfilesConfig = compileUserDotfiles { inherit osConfig feature featureCfg userName self; diff --git a/lib/flakeHelpers.nix b/lib/flakeHelpers.nix index 02633d50..51def395 100644 --- a/lib/flakeHelpers.nix +++ b/lib/flakeHelpers.nix @@ -90,69 +90,85 @@ in inherit wrapModuleFile computeModulePath mkConfigurationModules mkSystemModules; # Build a Darwin (macOS) system configuration - mkDarwinConfiguration = host-config: + # Accepts either (hostName: host-config:) or (host-config:) + mkDarwinConfiguration = arg1: let - system = "aarch64-darwin"; + build = hostName: host-config: + let + system = "aarch64-darwin"; + in + nix-darwin.lib.darwinSystem { + inherit system; + specialArgs = { + inherit self inputs lib overlays system vscode-server; + modulesRegistry = modulesRegistry; + }; + modules = [ host-config ] + ++ (privateExtension.hosts.${hostName}.modules or [ ]) + ++ [ + home-manager.darwinModules.home-manager + charon-key.darwinModules.default + # antigravity-nix exposes only packages/overlays (no darwinModules); + # google-antigravity* are injected via overlays/additions.nix + ] + ++ (privateExtension.systemModules.all or [ ]) + ++ (privateExtension.systemModules.darwin or [ ]) + ++ mkConfigurationModules [ + ../modules/home + ] + ++ mkSystemModules ../modules/systems/all + ++ mkSystemModules ../modules/systems/darwin + ++ [ + # Import foundational modules separately (not package modules) + ../modules/_core/user.nix + ../modules/_core/theme.nix + ../modules/_core/system.nix + ]; + }; in - nix-darwin.lib.darwinSystem { - inherit system; - specialArgs = { - inherit self inputs lib overlays system vscode-server; - modulesRegistry = modulesRegistry; - }; - modules = [ host-config ] - ++ [ - home-manager.darwinModules.home-manager - charon-key.darwinModules.default - # antigravity-nix exposes only packages/overlays (no darwinModules); - # google-antigravity* are injected via overlays/additions.nix - ] - ++ (privateExtension.systemModules.all or [ ]) - ++ (privateExtension.systemModules.darwin or [ ]) - ++ mkConfigurationModules [ - ../modules/home - ] - ++ mkSystemModules ../modules/systems/all - ++ mkSystemModules ../modules/systems/darwin - ++ [ - # Import foundational modules separately (not package modules) - ../modules/_core/user.nix - ../modules/_core/theme.nix - ../modules/_core/system.nix - ]; - }; + if builtins.isString arg1 + then (host-config: build arg1 host-config) + else build (builtins.baseNameOf (toString arg1)) arg1; # Build a NixOS (Linux) system configuration - mkNixosConfiguration = host-config: + # Accepts either (hostName: host-config:) or (host-config:) + mkNixosConfiguration = arg1: let - system = "x86_64-linux"; + build = hostName: host-config: + let + system = "x86_64-linux"; + in + nixpkgs.lib.nixosSystem { + inherit system; + specialArgs = { + inherit self inputs lib overlays system hardware; + modulesRegistry = modulesRegistry; + }; + modules = [ host-config ] + ++ (privateExtension.hosts.${hostName}.modules or [ ]) + ++ [ + # Core NixOS modules from inputs + home-manager.nixosModules.home-manager + vscode-server.nixosModules.default + charon-key.nixosModules.default + sops-nix.nixosModules.sops + ] + ++ (privateExtension.systemModules.all or [ ]) + ++ (privateExtension.systemModules.linux or [ ]) + ++ mkConfigurationModules [ + ../modules/home + ] + ++ mkSystemModules ../modules/systems/all + ++ mkSystemModules ../modules/systems/linux + ++ [ + # Import foundational modules separately (not package modules) + ../modules/_core/user.nix + ../modules/_core/theme.nix + ../modules/_core/system.nix + ]; + }; in - nixpkgs.lib.nixosSystem { - inherit system; - specialArgs = { - inherit self inputs lib overlays system hardware; - modulesRegistry = modulesRegistry; - }; - modules = [ host-config ] - ++ [ - # Core NixOS modules from inputs - home-manager.nixosModules.home-manager - vscode-server.nixosModules.default - charon-key.nixosModules.default - sops-nix.nixosModules.sops - ] - ++ (privateExtension.systemModules.all or [ ]) - ++ (privateExtension.systemModules.linux or [ ]) - ++ mkConfigurationModules [ - ../modules/home - ] - ++ mkSystemModules ../modules/systems/all - ++ mkSystemModules ../modules/systems/linux - ++ [ - # Import foundational modules separately (not package modules) - ../modules/_core/user.nix - ../modules/_core/theme.nix - ../modules/_core/system.nix - ]; - }; + if builtins.isString arg1 + then (host-config: build arg1 host-config) + else build (builtins.baseNameOf (toString arg1)) arg1; } diff --git a/modules/_core/user.nix b/modules/_core/user.nix index 4936a446..e5c88569 100644 --- a/modules/_core/user.nix +++ b/modules/_core/user.nix @@ -20,9 +20,16 @@ let if modulesRegistry != null then map (m: m.path) (modulesRegistry.allModules or modulesRegistry.modules or [ ]) else [ ]; - generatedFeatureOptions = lib.my.buildFeatureOptionTree { + userFeatureOptions = lib.my.buildFeatureOptionTree { inherit featureCatalog; knownModulePaths = allKnownModulePaths; + scope = "user"; + }; + + systemFeatureOptions = lib.my.buildFeatureOptionTree { + inherit featureCatalog; + knownModulePaths = allKnownModulePaths; + scope = "system"; }; # SSH key submodule @@ -89,7 +96,7 @@ let # SSH configuration ssh = mkOption { - type = types.listOf (types.submodule { + type = types.listOf (types.submodule ({ config, ... }: { options = { host = mkOption { type = types.str; @@ -120,13 +127,21 @@ let default = null; description = "Actual hostname/IP address"; }; - extraOptions = mkOption { + extraConfig = mkOption { type = types.attrsOf types.str; default = { }; description = "Additional SSH options (e.g., AddKeysToAgent, Compression)"; }; + extraOptions = mkOption { + type = types.attrsOf types.str; + default = { }; + description = "Deprecated alias for extraConfig"; + }; }; - }); + config = { + extraConfig = mkIf (config.extraOptions != { }) config.extraOptions; + }; + })); default = [ ]; description = "SSH host configurations"; }; @@ -180,20 +195,21 @@ let extraGroups = mkOption { type = types.listOf types.str; - default = [ "wheel" ]; + default = [ ]; description = "Extra groups for the user (Linux only)"; }; + trustedNixUser = mkOption { + type = types.bool; + default = false; + description = "Whether to add this user to nix.settings.trusted-users"; + }; + # Per-user module configuration (hierarchical enables) # Enable at any level: home.browsers.enable (all browsers) or home.browsers.vivaldi.enable (specific) modules = mkOption { - type = types.submoduleWith { - modules = [ - { - freeformType = types.attrsOf types.anything; - options = generatedFeatureOptions; - } - ]; + type = types.submodule { + options = userFeatureOptions; }; default = { }; description = '' @@ -226,7 +242,7 @@ let in { - options.modules = generatedFeatureOptions; + options.modules = systemFeatureOptions; options._modulesRegistry = mkOption { internal = true; @@ -314,7 +330,7 @@ in # Add users to nix trusted/allowed users nix.settings = { - trusted-users = [ "root" ] ++ (attrNames enabledUsers); + trusted-users = [ "root" ] ++ (attrNames (filterAttrs (_: u: u.trustedNixUser) enabledUsers)); allowed-users = [ "root" ] ++ (attrNames enabledUsers); }; diff --git a/modules/home/editors/antigravity/antigravity.nix b/modules/home/editors/antigravity/antigravity.nix index 0e85e304..a69f8343 100644 --- a/modules/home/editors/antigravity/antigravity.nix +++ b/modules/home/editors/antigravity/antigravity.nix @@ -4,7 +4,7 @@ lib.my.mkFeature { description = "Antigravity IDE & standalone Antigravity 2.0"; - extraOptions = { + homeOptions = { rules = lib.mkOption { type = lib.types.lines; default = '' diff --git a/modules/home/games/steam.nix b/modules/home/games/steam.nix index fd692c0b..a83f9edd 100644 --- a/modules/home/games/steam.nix +++ b/modules/home/games/steam.nix @@ -5,7 +5,7 @@ lib.my.mkFeature { platforms = [ "linux" ]; description = "Steam gaming platform with Gamescope support"; - extraOptions = { + systemOptions = { enableGamescope = lib.mkOption { default = true; type = lib.types.bool; diff --git a/modules/home/security/clavis.nix b/modules/home/security/clavis.nix index 51a4501a..bac162f3 100644 --- a/modules/home/security/clavis.nix +++ b/modules/home/security/clavis.nix @@ -4,7 +4,7 @@ lib.my.mkFeature { description = "Clavis — macOS Keychain SSH Agent & age plugin with Touch ID"; platforms = [ "darwin" ]; - extraOptions = { + homeOptions = { autostart = lib.mkOption { type = lib.types.bool; default = true; diff --git a/modules/home/shell/git.nix b/modules/home/shell/git.nix index 44bfe7a2..34d41cc4 100644 --- a/modules/home/shell/git.nix +++ b/modules/home/shell/git.nix @@ -5,7 +5,7 @@ lib.my.mkFeature { description = "Git configuration (reads from hostUsers)"; platforms = [ "linux" "darwin" ]; - extraOptions = { + homeOptions = { editor = lib.mkOption { type = lib.types.str; default = "nvim"; diff --git a/modules/home/shell/ssh/ssh.nix b/modules/home/shell/ssh/ssh.nix index 6029eacd..aa1a08e4 100644 --- a/modules/home/shell/ssh/ssh.nix +++ b/modules/home/shell/ssh/ssh.nix @@ -18,7 +18,7 @@ let Port = entry.port; } // lib.optionalAttrs (entry.hostname != null) { Hostname = entry.hostname; - } // entry.extraOptions; + } // (entry.extraConfig or { }); in lib.listToAttrs (map (entry: { diff --git a/modules/home/shell/zsh/zsh.nix b/modules/home/shell/zsh/zsh.nix index 4f06b070..1c495e58 100644 --- a/modules/home/shell/zsh/zsh.nix +++ b/modules/home/shell/zsh/zsh.nix @@ -5,7 +5,7 @@ lib.my.mkFeature { platforms = [ "linux" "darwin" ]; description = "Zsh shell configuration with oh-my-zsh and zplug"; - extraOptions = { + homeOptions = { showHostname = lib.mkOption { default = true; type = lib.types.bool;