diff --git a/README.md b/README.md index 0a25670c..c1f54896 100644 --- a/README.md +++ b/README.md @@ -133,12 +133,12 @@ source by. | `caisson-facter` | none | the hardware of a machine, a nixos-facter report checked against the report versions caisson knows and extended with a project's own options, with the NixOS configurations that run on it declared inside it; a machine gets the report through nixpkgs' `hardware.facter` | | `facter` | none | a `facter.json` as nixos-facter writes it, with the NixOS configurations that run on it declared inside it; a machine gets it as `hardware.facter.reportPath` | | `treefmt` | `treefmt-nix` | the formatters of a tree, as treefmt-nix modules; the configuration named `default` is published as the `formatter.` that `nix fmt` runs | -| `nvf` | `nvf` | a Neovim written as nvf modules, published as `nvfConfigurations..`, in the shape `neovimConfiguration` returns | -| `nixvim` | `nixvim` | a Neovim written as nixvim modules, published as `nixvimConfigurations..` | +| `nvf` | `nvf` | a Neovim written as nvf modules, published as `nvfConfigurations..`, in the shape `neovimConfiguration` returns, kept in a Nix profile of its own that the machine, home or hjem configuration it is declared inside activates | +| `nixvim` | `nixvim` | a Neovim written as nixvim modules, published as `nixvimConfigurations..`, kept in a profile as an nvf configuration is | | `topology` | `nix-topology` | the diagram of the NixOS configurations declared inside it, as nix-topology renders it; the configuration named `default` is published as `topology.` | | `kubenix` | `kubenix` | Kubernetes resources written as kubenix modules, published as `kubenixConfigurations..`, the evaluation, whose `config.kubernetes.result` is the JSON `kubectl apply -f` reads | | `nixidy` | `nixidy` | an Argo CD environment written as nixidy modules, evaluated with nixidy's `mkEnv` and published as the `nixidyEnvs..` the nixidy CLI reads | -| `system-manager` | `system-manager` | the systems of machines running another Linux distribution, published as the `systemConfigs` that `system-manager switch` reads; they activate the homes and hjem configurations declared inside them | +| `system-manager` | `system-manager` | the systems of machines running another Linux distribution, published as the `systemConfigs` that `system-manager switch` reads; they activate the homes and hjem configurations declared inside them, and the profiles of the nvf and nixvim configurations declared inside them | | `structural` | none | a configuration that only exports and holds configurations beneath it: the top of a repository whose point is what it exports, such as caisson itself, or a layer at any depth | Each integration finds its ecosystem in this order: the `ecosystemSrc` diff --git a/lib-overlays/home-manager/default.nix b/lib-overlays/home-manager/default.nix index b1387f3d..5d1dfffb 100644 --- a/lib-overlays/home-manager/default.nix +++ b/lib-overlays/home-manager/default.nix @@ -517,15 +517,15 @@ defaultPkgs = args.defaultPkgs or null; inherit exportsTo; # A home is activated for its user. Its identity is its - # activation package; its marker is a file in the state - # directory of the user, which the home-manager module + # activation package; its marker is a file under + # `$HOME/.local/state`, named by `$HOME` and not by + # `$XDG_STATE_HOME`, which a unit of the machine and a shell + # may see differently. The home-manager module # `activation-marker` writes. The configurations with an # activation declared inside the home check it. activation = { identity = manifest: manifest.outputs.activationPackage.outPath; - marker = - manifest: - "\${XDG_STATE_HOME:-$HOME/.local/state}/caisson/markers/${selection.markerFileName manifest}"; + marker = manifest: "$HOME/.local/state/caisson/markers/${selection.markerFileName manifest}"; }; evaluate = evaluation variant args; }; diff --git a/lib-overlays/integrations/default.nix b/lib-overlays/integrations/default.nix index 7c2b50c8..6a5c11fe 100644 --- a/lib-overlays/integrations/default.nix +++ b/lib-overlays/integrations/default.nix @@ -753,6 +753,184 @@ fi ''; + # A name a configuration is declared under, with the characters + # the shell would read inside double quotes, and the path + # separator, replaced, for use in a path. + safeName = + name: + builtins.replaceStrings [ "/" "\"" "$" "`" "\\" ] [ "_" "_" "_" "_" "_" ] ( + if name == null then "default" else name + ); + + # A configuration that is a program a person runs, such as a + # Neovim, kept in a Nix profile of its own, the way a home keeps + # its generations in the home-manager profile. + # + # `activationPackage` holds `activate`, which checks for drift + # against the nearest configuration above with an activation + # (`driftCheckOf`, when `warn`), and points the profile at + # `package` with `nix-env --profile`, which also makes the + # profile a garbage-collector root. When the profile already + # points at `package`, it changes nothing, so activating the same + # generation again adds no generation to the profile. + # + # Where the profile is depends on the configuration above. Inside + # a machine it is a profile of the system, + # `/nix/var/nix/profiles/caisson//`, which the machine + # activates as root. Inside a home or a hjem configuration, or as + # a top, it is a profile of the user, + # `$HOME/.local/state/caisson/profiles//`. The path + # names `$HOME` and not `$XDG_STATE_HOME`: the units of a machine + # run without `$XDG_STATE_HOME`, and a shell may set it, and the + # profile must be the same file for both. The + # configuration above activates it each time it is activated + # itself, so activating the configuration above puts the profile + # back to what that configuration declares. + # + # `profile` is what the configuration above reads from the + # manifest: the shell text of the path of the profile, whether it + # is a profile of the system or of the user, the `package` the + # profile points at, the `programs` the + # package has in `bin`, and `variable`, the name of the variable a + # shell sets to choose which profile of this sort it runs + # (`profileShimsOf`). + profileOf = + { + pkgs, + manifest, + kind, + package, + programs, + variable, + warn ? true, + }: + let + ancestor = activatingAncestorOf manifest; + ofSystem = ancestor != null && (ancestor.activation.standsAlone or false); + name = safeName (manifest.name or null); + path = + if ofSystem then + "/nix/var/nix/profiles/caisson/${kind}/${name}" + else + "$HOME/.local/state/caisson/profiles/${kind}/${name}"; + check = + if warn then + driftCheckOf { + jq = "${pkgs.jq}/bin/jq"; + optionText = "`caisson.drift.warn`"; + } manifest + else + ""; + in + { + activationPackage = pkgs.writeTextFile { + name = "${kind}-${name}-activation"; + destination = "/activate"; + executable = true; + text = '' + #!${pkgs.runtimeShell} + set -eu + ${check} + profile="${path}" + ${pkgs.coreutils}/bin/mkdir -p "$(${pkgs.coreutils}/bin/dirname "$profile")" + if [ "$(${pkgs.coreutils}/bin/readlink -f "$profile" 2>/dev/null || true)" != '${package}' ]; then + ${pkgs.nix}/bin/nix-env --profile "$profile" --set '${package}' + fi + ''; + }; + profile = { + inherit + path + programs + variable + kind + name + package + ; + scope = if ofSystem then "system" else "user"; + }; + }; + + # The entries of `caisson.exports.configurations` of the + # configuration of `manifest` that it activates as profiles + # (`profileOf`): those with a `profile`, evaluated for the system + # of the configuration, whose nearest configuration above with an + # activation is this one. A profile inside a home inside this + # machine is the home's to activate, and is left out. + profilesActivatedBy = + manifest: configurations: + builtins.filter ( + entry: + (entry.manifest.outputs or { }) ? profile + && (entry.manifest.system or null) == (manifest.system or null) + && checksMarkerOf manifest entry + ) configurations; + + # For each variable the profiles in `entries` are chosen with, the + # profile a configuration runs when a shell sets no other, and the + # programs of all of them. The profile is the one `selected` names + # for the variable, as `/`, or the only one. `what` + # names the configuration, for the message when several profiles + # share a variable and `selected` names none of them. + profileChoicesOf = + { selected, what }: + entries: + let + variables = unique (builtins.map (entry: entry.manifest.outputs.profile.variable) entries); + choiceOf = + variable: + let + sharing = builtins.filter (entry: entry.manifest.outputs.profile.variable == variable) entries; + nameOf = entry: "${entry.manifest.outputs.profile.kind}/${entry.manifest.outputs.profile.name}"; + named = builtins.filter (entry: nameOf entry == selected.${variable}) sharing; + chosen = + if selected ? ${variable} then + if named != [ ] then + builtins.head named + else + throw "caisson: ${what} selects `${selected.${variable}}` with `caisson.profiles.selected.${variable}`, and its profiles for that variable are ${builtins.concatStringsSep ", " (builtins.map nameOf sharing)}." + else if builtins.length sharing == 1 then + builtins.head sharing + else + throw "caisson: ${what} activates several profiles chosen with `${variable}` (${builtins.concatStringsSep ", " (builtins.map nameOf sharing)}). Set `caisson.profiles.selected.${variable}` to the one a shell runs when it sets no other."; + in + { + inherit chosen; + programs = unique (builtins.concatMap (entry: entry.manifest.outputs.profile.programs) sharing); + }; + in + genAttrs variables choiceOf; + + # The programs with which a configuration that activates profiles + # (`profileOf`) lets a shell choose among them: for each program, + # an executable of that name that runs the program of the profile + # the variable names, read each time it runs, and of `fallback` + # when the variable is unset. A configuration sets the variable to + # its default, a configuration declared inside it sets it to its + # own, and a shell can `export` another. `fallback` is shell text, + # which may name variables such as `$HOME`. + profileShimsOf = + { + pkgs, + variable, + programs, + fallback, + }: + pkgs.runCommand "caisson-profile-shims-${variable}" { } ( + '' + mkdir -p "$out/bin" + '' + + builtins.concatStringsSep "" ( + builtins.map (program: '' + cat > "$out/bin/${program}" <<'EOF' + #!${pkgs.runtimeShell} + exec "''${${variable}:-${fallback}}/bin/${program}" "$@" + EOF + chmod +x "$out/bin/${program}" + '') programs + ) + ); + isManifest = value: builtins.isAttrs value && (value._type or null) == "caisson-manifest"; # How a configuration is published: the exports its manifest @@ -1057,6 +1235,11 @@ markerOf checksMarkerOf driftCheckOf + safeName + profileOf + profileShimsOf + profilesActivatedBy + profileChoicesOf mkEvaluation publish topValue diff --git a/lib-overlays/nixvim/default.nix b/lib-overlays/nixvim/default.nix index 0963bf41..5a737b82 100644 --- a/lib-overlays/nixvim/default.nix +++ b/lib-overlays/nixvim/default.nix @@ -18,6 +18,12 @@ # configuration is published as `nixvimConfigurations..`, # the evaluated configuration, under the name nixvim's flake publishes # its own configurations under. +# +# A nixvim configuration is kept in a Nix profile of its own, as an nvf +# configuration is (`lib.caisson.integrations.profileOf`): its output +# `activationPackage` holds `activate`, and the configuration it is +# declared inside activates it with the module `profile-activation` of +# its class. { mkLibOverlay, ... @@ -132,10 +138,27 @@ } // (if (args.ecosystemArgs or null) != null then args.ecosystemArgs else { }) ); + package = value.config.build.package; + kept = selection.profileOf { + inherit pkgs manifest package; + kind = "nixvim"; + # The programs the Neovim has in `bin`: `nvim`, and `vi` + # and `vim` when nixvim adds them. + programs = [ + "nvim" + ] + ++ (if value.config.viAlias or false then [ "vi" ] else [ ]) + ++ (if value.config.vimAlias or false then [ "vim" ] else [ ]); + variable = "CAISSON_NVIM_PROFILE"; + warn = value.config.caisson.drift.warn or true; + }; in { inherit value; - outputs.package = value.config.build.package; + outputs = { + inherit package; + inherit (kept) activationPackage profile; + }; }; # A nixvim configuration is published as @@ -156,6 +179,12 @@ perSystem = true; defaultPkgs = args.defaultPkgs or null; inherit exportsTo; + # A nixvim configuration is activated into its profile. Its + # identity is its Neovim. + activation = { + identity = manifest: manifest.outputs.package.outPath; + marker = manifest: "${manifest.outputs.profile.path}.marker.json"; + }; evaluate = evaluate args; }; diff --git a/lib-overlays/nvf/default.nix b/lib-overlays/nvf/default.nix index d53a2593..365bdc79 100644 --- a/lib-overlays/nvf/default.nix +++ b/lib-overlays/nvf/default.nix @@ -17,6 +17,14 @@ # `neovimConfiguration` returns. A configuration is published as # `nvfConfigurations..`, in the shape `neovimConfiguration` # returns: `neovim`, `config` and `options`. +# +# An nvf configuration is kept in a Nix profile of its own, the way a +# home keeps its generations (`lib.caisson.integrations.profileOf`): +# its output `activationPackage` holds `activate`, which points the +# profile at the Neovim, and `profile` says where the profile is. A +# machine, a home or a hjem configuration activates the nvf +# configurations declared inside it each time it is activated itself, +# with the module `profile-activation` of its class. { mkLibOverlay, ... @@ -141,10 +149,28 @@ } // (if (args.ecosystemArgs or null) != null then args.ecosystemArgs else { }) ); + neovim = value.config.vim.build.finalPackage; + kept = selection.profileOf { + inherit pkgs manifest; + kind = "nvf"; + package = neovim; + # The programs the Neovim has in `bin`: `nvim`, and `vi` + # and `vim` when nvf adds them. + programs = [ + "nvim" + ] + ++ (if value.config.vim.viAlias then [ "vi" ] else [ ]) + ++ (if value.config.vim.vimAlias then [ "vim" ] else [ ]); + variable = "CAISSON_NVIM_PROFILE"; + warn = value.config.caisson.drift.warn or true; + }; in { inherit value; - outputs.neovim = value.config.vim.build.finalPackage; + outputs = { + inherit neovim; + inherit (kept) activationPackage profile; + }; }; # An nvf configuration is published as @@ -168,6 +194,12 @@ perSystem = true; defaultPkgs = args.defaultPkgs or null; inherit exportsTo; + # An nvf configuration is activated into its profile. Its + # identity is its Neovim. + activation = { + identity = manifest: manifest.outputs.neovim.outPath; + marker = manifest: "${manifest.outputs.profile.path}.marker.json"; + }; evaluate = evaluate args; }; diff --git a/modules/darwin/default/default.nix b/modules/darwin/default/default.nix index e7baecdc..ec3e6fc2 100644 --- a/modules/darwin/default/default.nix +++ b/modules/darwin/default/default.nix @@ -28,6 +28,7 @@ (mkModule ./caisson) closure-lib.caisson.modules.darwin.home-manager-activation closure-lib.caisson.modules.darwin.hjem-activation + closure-lib.caisson.modules.darwin.profile-activation closure-lib.caisson.modules.darwin.activation-marker ] else diff --git a/modules/darwin/profile-activation/default.nix b/modules/darwin/profile-activation/default.nix new file mode 100644 index 00000000..1c22ce08 --- /dev/null +++ b/modules/darwin/profile-activation/default.nix @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: MIT +# +# A darwin module with which a macOS machine activates the profiles +# declared inside it, as the NixOS module `profile-activation` does for a +# NixOS machine: see there. +# +# A step of the machine's `postActivation` script runs `activate` of +# each profile of the system as root, and of each profile declared +# inside a hjem configuration inside the machine as its user, through +# `launchctl asuser` and `sudo`, after the hjem agents are restarted. A +# profile inside a home is activated by the home. The machine installs +# the programs that choose among the profiles of the system in +# `environment.systemPackages`. +# +# caisson's `default` darwin module imports this module. It defines +# nothing in the childless evaluation. +{ closure-lib, ... }: +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib.caisson) evalManifest; + selection = lib.caisson.integrations; + + exported = config.caisson.exports.configurations; + + ofSystem = selection.profilesActivatedBy evalManifest exported; + + hjemUsers = + builtins.map + (entry: { + inherit (entry.manifest.value.config) user; + profiles = selection.profilesActivatedBy entry.manifest exported; + }) + ( + builtins.filter ( + entry: entry.manifest.type == "hjem" && selection.checksMarkerOf evalManifest entry + ) exported + ); + + asRoot = entry: '' + ${entry.manifest.outputs.activationPackage}/activate + ''; + + asUser = user: entry: '' + launchctl asuser "$(id -u ${lib.escapeShellArg user})" sudo -u ${lib.escapeShellArg user} --set-home ${entry.manifest.outputs.activationPackage}/activate + ''; + + choices = selection.profileChoicesOf { + selected = config.caisson.profiles.selected; + what = "this machine"; + } ofSystem; + + shims = lib.mapAttrsToList ( + variable: choice: + selection.profileShimsOf { + inherit pkgs variable; + inherit (choice) programs; + fallback = choice.chosen.manifest.outputs.profile.path; + } + ) choices; + + steps = + builtins.map asRoot ofSystem + ++ builtins.concatMap (hjem: builtins.map (asUser hjem.user) hjem.profiles) hjemUsers; +in +{ + imports = [ closure-lib.caisson.modules.darwin.core ]; + + options.caisson.profiles.selected = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + example = { + CAISSON_NVIM_PROFILE = "nvf/editor"; + }; + description = '' + By the variable that chooses among them, the profile a shell runs + when it sets no other, as `/`: `nvf/editor` is the nvf + configuration declared as `editor`. Needed only where several + profiles declared inside this configuration share a variable. + ''; + }; + + config = lib.mkIf (!evalManifest.childless && steps != [ ]) { + system.activationScripts.postActivation.text = lib.mkAfter (lib.concatStrings steps); + environment.systemPackages = shims; + }; +} diff --git a/modules/hjem/default/default.nix b/modules/hjem/default/default.nix index f97a6379..bd923e99 100644 --- a/modules/hjem/default/default.nix +++ b/modules/hjem/default/default.nix @@ -3,13 +3,18 @@ # The `default` hjem module caisson registers. A hjem configuration # imports every registered module named `default` unless it passes # `moduleImports`, so this is what it gets from caisson when it does not -# select its modules itself: `activation-marker`, which records what the -# hjem configuration applied, for the configurations declared inside it -# to check. +# select its modules itself: +# +# - `profile-activation`, which lets a shell choose among the profiles +# of the nvf or nixvim configurations declared inside the hjem +# configuration, which the machine activates; +# - `activation-marker`, which records what the hjem configuration +# applied, for the configurations declared inside it to check. { closure-lib, ... }: { ... }: { imports = [ + closure-lib.caisson.modules.hjem.profile-activation closure-lib.caisson.modules.hjem.activation-marker ]; } diff --git a/modules/hjem/profile-activation/default.nix b/modules/hjem/profile-activation/default.nix new file mode 100644 index 00000000..b8d71837 --- /dev/null +++ b/modules/hjem/profile-activation/default.nix @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: MIT +# +# A hjem module for the profiles declared inside a hjem configuration: +# the configurations that are programs a person runs, such as an nvf or +# nixvim Neovim, each kept in a profile of the user, +# `$HOME/.local/state/caisson/profiles//` +# (`lib.caisson.integrations.profileOf`). +# +# hjem's activation has no step a configuration can add, so the machine +# the hjem configuration is declared inside activates the profiles, as +# the user, after the hjem configuration is applied (the module +# `profile-activation` of the class of the machine). This module adds +# to the packages of the user the programs with which a shell chooses +# among profiles (`lib.caisson.integrations.profileShimsOf`), and sets +# the variable that chooses, `CAISSON_NVIM_PROFILE` for a Neovim, to the +# profile of the user, so that the programs the machine installs choose +# the same. +# +# caisson's `default` hjem module imports this module. It defines +# nothing in the childless evaluation. +{ ... }: +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib.caisson) evalManifest; + selection = lib.caisson.integrations; + + profiles = selection.profilesActivatedBy evalManifest config.caisson.exports.configurations; + + choices = selection.profileChoicesOf { + selected = config.caisson.profiles.selected; + what = "this hjem configuration"; + } profiles; + + # The path of a profile, with `$HOME` written out as the directory of + # the user, for a variable hjem writes as given. + pathOf = + choice: + builtins.replaceStrings [ "$HOME" ] [ config.directory ] + choice.chosen.manifest.outputs.profile.path; +in +{ + options.caisson.profiles.selected = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + example = { + CAISSON_NVIM_PROFILE = "nvf/editor"; + }; + description = '' + By the variable that chooses among them, the profile a shell runs + when it sets no other, as `/`: `nvf/editor` is the nvf + configuration declared as `editor`. Needed only where several + profiles declared inside this hjem configuration share a variable. + ''; + }; + + config = lib.mkIf (!evalManifest.childless && profiles != [ ]) { + packages = lib.mapAttrsToList ( + variable: choice: + selection.profileShimsOf { + inherit pkgs variable; + inherit (choice) programs; + fallback = pathOf choice; + } + ) choices; + + environment.sessionVariables = builtins.mapAttrs (_variable: pathOf) choices; + }; +} diff --git a/modules/homeManager/activation-marker/default.nix b/modules/homeManager/activation-marker/default.nix index 8654916e..80df72ee 100644 --- a/modules/homeManager/activation-marker/default.nix +++ b/modules/homeManager/activation-marker/default.nix @@ -7,8 +7,8 @@ # # A step of the activation of the home writes the identity of the # childless evaluation of the home, its activation package without the -# configurations declared inside it, into a file in the state directory -# of the user, `$XDG_STATE_HOME/caisson/markers/home-manager-.json`. +# configurations declared inside it, into a file in the home directory +# of the user, `$HOME/.local/state/caisson/markers/home-manager-.json`. # A configuration declared inside the home, such as an nvf # configuration, compares that file with the identity it was built for # when it is activated by hand. The step runs after the files of the diff --git a/modules/homeManager/default/default.nix b/modules/homeManager/default/default.nix index 02a02e7e..35cd0c99 100644 --- a/modules/homeManager/default/default.nix +++ b/modules/homeManager/default/default.nix @@ -5,6 +5,9 @@ # `moduleImports`, so this is what a home gets from caisson when it does # not select its modules itself: # +# - `profile-activation`, which activates the profile of each nvf or +# nixvim configuration declared inside the home, and lets a shell +# choose among them; # - `activation-drift`, with which the activation of the home warns when # the home was built for a different version of the configuration it # is declared inside, such as its machine; @@ -15,6 +18,7 @@ { ... }: { imports = [ + closure-lib.caisson.modules.homeManager.profile-activation closure-lib.caisson.modules.homeManager.activation-drift closure-lib.caisson.modules.homeManager.activation-marker ]; diff --git a/modules/homeManager/profile-activation/default.nix b/modules/homeManager/profile-activation/default.nix new file mode 100644 index 00000000..78b4c440 --- /dev/null +++ b/modules/homeManager/profile-activation/default.nix @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: MIT +# +# A home-manager module with which a home activates the profiles +# declared inside it: the configurations that are programs a person +# runs, such as an nvf or nixvim Neovim, each kept in a Nix profile of +# its own, here a profile of the user, +# `$HOME/.local/state/caisson/profiles//` +# (`lib.caisson.integrations.profileOf`). +# +# A step of the activation of the home runs `activate` of each, after +# the files of the home are linked and the home has recorded what it +# applied. So `home-manager switch` puts each profile back to what the +# home declares, and so does the machine when it activates the home, at +# a switch of the machine and at boot. +# +# The home also installs the programs with which a shell chooses among +# profiles (`lib.caisson.integrations.profileShimsOf`), and sets the +# variable that chooses, `CAISSON_NVIM_PROFILE` for a Neovim, to the +# profile of the home, so that the programs the machine installs choose +# the same. A shell can `export` another value. +# +# caisson's `default` home-manager module imports this module. It +# defines nothing in the childless evaluation. +{ ... }: +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib.caisson) evalManifest; + selection = lib.caisson.integrations; + + profiles = selection.profilesActivatedBy evalManifest config.caisson.exports.configurations; + + choices = selection.profileChoicesOf { + selected = config.caisson.profiles.selected; + what = "this home"; + } profiles; +in +{ + options.caisson.profiles.selected = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + example = { + CAISSON_NVIM_PROFILE = "nvf/editor"; + }; + description = '' + By the variable that chooses among them, the profile a shell runs + when it sets no other, as `/`: `nvf/editor` is the nvf + configuration declared as `editor`. Needed only where several + profiles declared inside this home share a variable. + ''; + }; + + config = lib.mkIf (!evalManifest.childless && profiles != [ ]) { + home.activation.caissonProfiles = + lib.hm.dag.entryAfter [ "linkGeneration" "caissonActivationMarker" ] + ( + lib.concatMapStrings (entry: '' + run ${entry.manifest.outputs.activationPackage}/activate + '') profiles + ); + + home.packages = lib.mapAttrsToList ( + variable: choice: + selection.profileShimsOf { + inherit pkgs variable; + inherit (choice) programs; + fallback = choice.chosen.manifest.outputs.profile.path; + } + ) choices; + + home.sessionVariables = builtins.mapAttrs ( + _variable: choice: choice.chosen.manifest.outputs.profile.path + ) choices; + }; +} diff --git a/modules/nixos/default/default.nix b/modules/nixos/default/default.nix index da2aab6f..b1e9167a 100644 --- a/modules/nixos/default/default.nix +++ b/modules/nixos/default/default.nix @@ -23,6 +23,10 @@ # - The registered module `home-manager-activation` writes the # systemd units that activate those homes on the machine. # +# The registered module `profile-activation` activates the profile of +# each nvf or nixvim configuration declared inside the machine, and +# installs the programs with which a shell chooses among them. +# # The registered module `activation-marker` writes a file on the # machine that names what the machine applied. Every configuration with # an activation declared inside the machine, a home among them, compares @@ -42,6 +46,7 @@ (mkModule ./caisson) closure-lib.caisson.modules.nixos.home-manager-activation closure-lib.caisson.modules.nixos.hjem-activation + closure-lib.caisson.modules.nixos.profile-activation closure-lib.caisson.modules.nixos.activation-marker ] else diff --git a/modules/nixos/profile-activation/default.nix b/modules/nixos/profile-activation/default.nix new file mode 100644 index 00000000..414e7f17 --- /dev/null +++ b/modules/nixos/profile-activation/default.nix @@ -0,0 +1,170 @@ +# SPDX-License-Identifier: MIT +# +# A module with which a machine activates the profiles declared inside +# it: the configurations that are programs a person runs, such as an +# nvf or nixvim Neovim, each kept in a Nix profile of its own +# (`lib.caisson.integrations.profileOf`). +# +# A profile declared directly inside the machine is a profile of the +# system, `/nix/var/nix/profiles/caisson//`. The module +# writes a system unit for each, `caisson-profile--`, which +# runs its `activate` as root. The unit is wanted by +# `multi-user.target`, so it runs at boot and again when a switch +# changes it, and the profile is put back to what the machine declares +# each time. +# +# A profile declared inside a hjem configuration inside the machine is +# a profile of the hjem user. hjem's activation has no step a +# configuration can add, so the machine activates it, with a unit +# `caisson-profile---` that runs as the user after the +# hjem configuration is applied. A profile inside a home is activated by +# the home. +# +# The machine also installs, in `environment.systemPackages`, the +# programs with which a shell chooses among the profiles of the system +# (`lib.caisson.integrations.profileShimsOf`): `nvim` runs the Neovim of +# the profile `$CAISSON_NVIM_PROFILE` names, and of the default profile +# of the machine when it is unset. A home or a hjem configuration sets +# the variable to its own profile. +# +# The module is registered for the `nixos` and `systemManager` classes, +# under the same name, and the `default` module of each imports it. +# system-manager starts the units wanted by `multi-user.target` with +# `system-manager.target`. It defines nothing in the childless +# evaluation, or in an evaluation without systemd options. +{ closure-lib, ... }: +{ + config, + lib, + options, + pkgs, + ... +}: +let + inherit (lib.caisson) evalManifest; + selection = lib.caisson.integrations; + + hasUnits = options ? systemd && options.systemd ? services; + hasPackages = options ? environment && options.environment ? systemPackages; + + coreModule = + if (evalManifest.type or null) == "system-manager" then + closure-lib.caisson.modules.systemManager.core + else + closure-lib.caisson.modules.nixos.core; + + exported = config.caisson.exports.configurations; + + ofSystem = selection.profilesActivatedBy evalManifest exported; + + # The hjem configurations this machine activates, each with the + # profiles declared inside it, which the machine passes up as well. + hjemUsers = + builtins.map + (entry: { + inherit (entry.manifest.value.config) user; + profiles = selection.profilesActivatedBy entry.manifest exported; + }) + ( + builtins.filter ( + entry: entry.manifest.type == "hjem" && selection.checksMarkerOf evalManifest entry + ) exported + ); + + # The parts of a unit name: the name of a profile has no `/` + # (`lib.caisson.integrations.safeName`), and a user name is used as + # the module `hjem-activation` uses it for `hjem-activate@`. + escape = part: part; + + unitOf = + { + entry, + user ? null, + }: + let + inherit (entry.manifest.outputs) profile activationPackage; + in + { + name = + if user == null then + "caisson-profile-${escape profile.kind}-${escape profile.name}" + else + "caisson-profile-${escape user}-${escape profile.kind}-${escape profile.name}"; + value = { + description = "Activate the ${profile.kind} profile ${profile.name}${ + if user == null then "" else " of ${user}" + }"; + wantedBy = [ "multi-user.target" ]; + wants = [ "nix-daemon.socket" ]; + after = [ + "nix-daemon.socket" + ] + ++ (if user == null then [ ] else [ "hjem-activate@${escape user}.service" ]); + # The unit is not stopped when its definition changes; the new + # generation starts it again, which activates the new profile. + stopIfChanged = false; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "${activationPackage}/activate"; + } + // (if user == null then { } else { User = user; }); + }; + }; + + choices = selection.profileChoicesOf { + selected = config.caisson.profiles.selected; + what = "this machine"; + } ofSystem; + + shims = lib.mapAttrsToList ( + variable: choice: + selection.profileShimsOf { + inherit pkgs variable; + inherit (choice) programs; + fallback = choice.chosen.manifest.outputs.profile.path; + } + ) choices; +in +{ + imports = [ coreModule ]; + + options.caisson.profiles.selected = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + example = { + CAISSON_NVIM_PROFILE = "nvf/editor"; + }; + description = '' + By the variable that chooses among them, the profile a shell runs + when it sets no other, as `/`: `nvf/editor` is the nvf + configuration declared as `editor`. Needed only where several + profiles declared inside this configuration share a variable. + ''; + }; + + # `optionalAttrs` and not `mkIf` decides whether each option is + # defined at all: NixOS refuses a definition, even under `mkIf false`, + # of an option that no module declares, and the nixos-minimal + # integration can produce an evaluation without these options. + config = lib.mkIf (!evalManifest.childless) ( + lib.optionalAttrs hasUnits { + systemd.services = builtins.listToAttrs ( + builtins.map (entry: unitOf { inherit entry; }) ofSystem + ++ builtins.concatMap ( + hjem: + builtins.map ( + entry: + unitOf { + inherit entry; + inherit (hjem) user; + } + ) hjem.profiles + ) hjemUsers + ); + } + // lib.optionalAttrs hasPackages { + environment.systemPackages = shims; + } + ); +} diff --git a/modules/nixvim/default b/modules/nixvim/default new file mode 120000 index 00000000..a5ee35aa --- /dev/null +++ b/modules/nixvim/default @@ -0,0 +1 @@ +../nvf/default \ No newline at end of file diff --git a/modules/nvf/default/default.nix b/modules/nvf/default/default.nix new file mode 100644 index 00000000..338a1964 --- /dev/null +++ b/modules/nvf/default/default.nix @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: MIT +# +# The `default` module caisson registers for the `nvf` class, and, for +# the same file, for the `nixvim` class. A configuration of either +# class imports every registered module named `default` unless it +# passes `moduleImports`. +# +# It declares `caisson.drift.warn`. The configuration is kept in a Nix +# profile of its own, and its `activate` checks for drift against the +# configuration it is declared inside, as a home does +# (`lib.caisson.integrations.profileOf`). The option turns the warning +# off. +{ ... }: +{ lib, ... }: +{ + options.caisson.drift.warn = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether `activate` of this configuration prints a warning when the + configuration was built for a version of the configuration it is + declared inside, such as a home or a machine, that differs from + the version that is applied. + ''; + }; +} diff --git a/modules/systemManager/default/default.nix b/modules/systemManager/default/default.nix index 4b893502..d2def52e 100644 --- a/modules/systemManager/default/default.nix +++ b/modules/systemManager/default/default.nix @@ -17,6 +17,13 @@ # `hjem-activation`, the same modules a NixOS machine uses, write the # systemd units that activate the homes and the hjem configurations # when the machine is activated. +# - The registered module `profile-activation`, the same module a NixOS +# machine uses, activates the profile of each nvf or nixvim +# configuration declared inside the machine, and lets a shell choose +# among them. +# - The registered module `activation-marker` records what the machine +# applied, for the configurations declared inside it to check for +# drift. # # The parts read options of caisson's core module. The core module # leaves its options out when the library of the evaluation is not a @@ -30,6 +37,7 @@ (mkModule ./caisson) closure-lib.caisson.modules.systemManager.home-manager-activation closure-lib.caisson.modules.systemManager.hjem-activation + closure-lib.caisson.modules.systemManager.profile-activation closure-lib.caisson.modules.systemManager.activation-marker ] else diff --git a/modules/systemManager/profile-activation b/modules/systemManager/profile-activation new file mode 120000 index 00000000..dd679a0a --- /dev/null +++ b/modules/systemManager/profile-activation @@ -0,0 +1 @@ +../nixos/profile-activation \ No newline at end of file diff --git a/tests/pinned-world/default.nix b/tests/pinned-world/default.nix index d1b3a6b3..813c96a8 100644 --- a/tests/pinned-world/default.nix +++ b/tests/pinned-world/default.nix @@ -1782,6 +1782,11 @@ let inherit pkgs; modules = [ module ]; }).neovim; + machine = top.nixosConfigurations.workstation.config; + home = top.homeConfigurations.alice.config; + unit = machine.systemd.services.caisson-profile-nvf-machine-editor; + names = packages: builtins.map (package: package.name or "") packages; + topProfile = top.caisson.manifest.children.system.x86_64-linux.children.nvf.editor.outputs.profile; published = top.nvfConfigurations.x86_64-linux.editor; in # The configuration is published in the shape `neovimConfiguration` @@ -1794,7 +1799,25 @@ let "editor" "home-editor" "machine-editor" - ]; + ] + # The machine activates the profile of the system as root, at boot + # and at a switch, and lets a shell choose it with `nvim`. + && unit.wantedBy == [ "multi-user.target" ] + && !(unit.serviceConfig ? User) + && builtins.elem "caisson-profile-shims-CAISSON_NVIM_PROFILE" ( + names machine.environment.systemPackages + ) + # The home activates its own profile, after its files are linked, + # and chooses it for its shells. + && pkgs.lib.hasInfix "/activate" home.home.activation.caissonProfiles.data + && + home.home.sessionVariables.CAISSON_NVIM_PROFILE + == "$HOME/.local/state/caisson/profiles/nvf/home-editor" + # The top's own profile is a profile of the user, and it points at + # the Neovim nvf builds. + && topProfile.scope == "user" + && topProfile.path == "$HOME/.local/state/caisson/profiles/nvf/editor" + && topProfile.package.drvPath == upstream.drvPath; # A nixvim configuration is published as # `nixvimConfigurations..`, and its package is the one @@ -1955,12 +1978,12 @@ let in top.nixidyEnvs.x86_64-linux.prod.environmentPackage.drvPath == upstream.environmentPackage.drvPath; - # A system-manager machine activates the home and the hjem - # configuration declared inside it, as a NixOS machine does: both - # take their directory from the account the machine declares, the - # machine writes the units that activate them and installs the - # packages of the hjem user, and the home is published as - # `probe@box`. + # A system-manager machine activates the home, the hjem configuration + # and the nvf configurations declared inside it, as a NixOS machine + # does: the home and the hjem configuration take their directory from + # the account the machine declares, the machine writes the units that + # activate them and the profiles, installs the packages of the hjem + # user, and the home is published as `probe@box`. systemManagerActivatesWhatIsDeclaredInside = let top = hiveLib.caisson.structural.mkTopConfiguration { @@ -1988,12 +2011,20 @@ let caisson.hjem.configurations.probe = lib.caisson.hjem.mkConfiguration { ecosystemSrc = inputs.hjem; configModule = - { pkgs, ... }: + { lib, pkgs, ... }: { files.".config/probe/greeting".text = "hello"; packages = [ pkgs.hello ]; + caisson.nvf.configurations.hjem-editor = lib.caisson.nvf.mkConfiguration { + ecosystemSrc = inputs.nvf; + configModule.vim.vimAlias = true; + }; }; }; + caisson.nvf.configurations.editor = lib.caisson.nvf.mkConfiguration { + ecosystemSrc = inputs.nvf; + configModule.vim.viAlias = true; + }; }; }; }; @@ -2004,6 +2035,8 @@ let home = children.home-manager.probe.value.config; hjem = children.hjem.probe.value.config; names = packages: builtins.map (package: package.name or "") packages; + systemUnit = box.systemd.services.caisson-profile-nvf-editor; + hjemUnit = box.systemd.services.caisson-profile-probe-nvf-hjem-editor; in home.home.homeDirectory == "/home/probe" # The machine records what it applied, and the home compares @@ -2013,6 +2046,17 @@ let && hjem.directory == "/home/probe" && box.systemd.services ? home-manager-probe && box.systemd.services ? "hjem-activate@" + # The machine activates its profile of the system as root, and the + # profile inside the hjem configuration as its user, after hjem + # applies the configuration; hjem chooses the profile of the user. + && systemUnit.wantedBy == [ "multi-user.target" ] + && !(systemUnit.serviceConfig ? User) + && hjemUnit.serviceConfig.User == "probe" + && builtins.elem "hjem-activate@probe.service" hjemUnit.after + && builtins.elem "caisson-profile-shims-CAISSON_NVIM_PROFILE" (names box.environment.systemPackages) + && + hjem.environment.sessionVariables.CAISSON_NVIM_PROFILE + == "/home/probe/.local/state/caisson/profiles/nvf/hjem-editor" && builtins.elem pkgs.hello.name (names box.users.users.probe.packages) && builtins.attrNames top.homeConfigurations == [ "probe@box" ];