diff --git a/CHANGELOG.md b/CHANGELOG.md index f3b67ef..8e1cbbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ were explicitly passing `flagSeparator = " "` to get separate args, remove it (or change to `null`). +- `env` option type changed from `attrsOf str` to a small submodule + with `value` / `separator` / `ifUnset`. Plain strings keep working + via coercion (`env.FOO = "bar"` is unchanged). To unset a + variable, put `unset VAR` in `preHook` — the module system + doesn't model it as a declarative option. The systemd integration + reads from `config.outputs.staticEnv` instead of `config.env` and + drops any entries it can't express as a literal assignment. + ### Added - `lib/modules/command.nix`: base module with shared command spec @@ -22,6 +30,27 @@ - `lib/modules/flags.nix`: flags module with per-flag ordering via `{ value, order }` submodules. Default order is 1000. Reading `config.flags` returns clean values (order is transparent). +- `lib/modules/env.nix`: env module with per-variable options for + safe composition through the NixOS module system. + - `env..value`: always a list of parts joined with + `separator`. A plain string coerces to a singleton list, so + `env.FOO = "bar"` works, but reading back always gives a list. + Parts can be plain strings or `wlib.env.ref "NAME"` runtime + references. Empty/unset refs drop out cleanly, so no dangling + separators. + - `env..separator`: join separator for `value` (default `:`). + - `env..ifUnset = true`: only apply when the caller's + environment doesn't already have the variable set. + - List `value`s merge by concatenation when composed via `apply`, + so modules stack contributions without fighting over a string. + - To read another wrapper's literal entry as a string, use + `lib.concatStringsSep entry.separator entry.value`. +- `wlib.env.ref NAME`: marker for a runtime env-variable reference + inside `env..value` lists. +- `wlib.env.render`: render an `env` attrset into a shell snippet. + Exposed for tests and downstream composition. +- `outputs.staticEnv`: subset of `env` that resolves to a plain + literal string, used by the systemd integration. - `wrapper.nix` injects `"$@"` into args at order 1001, controllable via the ordering system. - `outputs.wrapper` as the canonical output path (config.wrapper is diff --git a/README.md b/README.md index bde5a53..6f65bf4 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,53 @@ wrappers.lib.wrapPackage { } ``` +### Environment Variables + +Each `env.` entry is either a plain string or a small +submodule: + +```nix +{ + env = { + # Literal: + FOO = "bar"; + + # Prepend to PATH using a list and `wlib.env.ref`. The ref + # expands to the caller's existing PATH at runtime; if it's + # unset or empty, the ref drops out and no stray separators + # are left behind. + PATH.value = [ "/opt/bin" (wrappers.lib.env.ref "PATH") ]; + + # Only set EDITOR if the caller hasn't already picked one. + EDITOR = { value = "vim"; ifUnset = true; }; + }; + + # To unset a variable inherited from the caller, drop a line in + # preHook — the env option is for declarative assignments only: + preHook = '' + unset LD_PRELOAD + ''; +} +``` + +Submodule options: +- `value`: a list of parts joined with `separator`. Plain strings + coerce to singleton lists, so `env.FOO = "bar"` works, but + reading back always gives a list. Parts can be plain strings or + `wlib.env.ref "NAME"` runtime references. +- `separator`: join separator (default `:`). +- `ifUnset`: only apply when the caller's environment doesn't + already have the variable set (empty counts as unset). + +`value` is always a list when read back, so consumers inspecting +another wrapper's config don't need to safeguard against two +types. To read a literal entry as a joined string, use +`lib.concatStringsSep entry.separator entry.value`. + +List `value`s merge by concatenation when composed via `apply`, so +multiple modules stack contributions onto the same variable without +fighting over a single string. + ### Creating Custom Wrapper Modules ```nix diff --git a/checks/env-if-unset.nix b/checks/env-if-unset.nix new file mode 100644 index 0000000..e5e1faf --- /dev/null +++ b/checks/env-if-unset.nix @@ -0,0 +1,47 @@ +{ + pkgs, + self, +}: + +# `ifUnset`: only apply when the caller's env doesn't have the +# variable set (or has it empty). +let + wlib = self.lib; + + showEnv = pkgs.writeShellScriptBin "show-env" '' + printf 'EDITOR=%s\n' "''${EDITOR-}" + ''; + + wrapped = + (wlib.wrapModule ( + { config, ... }: + { + config.package = showEnv; + config.env.EDITOR = { + value = "vim"; + ifUnset = true; + }; + } + ).apply { pkgs = pkgs; }).wrapper; +in +pkgs.runCommand "env-if-unset-test" { } '' + set -eu + script="${wrapped}/bin/show-env" + + # ifUnset applies when EDITOR is unset. + r=$(unset EDITOR && "$script" | grep '^EDITOR=' | cut -d= -f2-) + [ "$r" = "vim" ] || { echo "FAIL: ifUnset unset: '$r'"; cat "$script"; exit 1; } + echo "PASS: ifUnset applies when unset" + + # ifUnset yields to an existing value. + r=$(EDITOR=nano "$script" | grep '^EDITOR=' | cut -d= -f2-) + [ "$r" = "nano" ] || { echo "FAIL: ifUnset overrode existing: '$r'"; exit 1; } + echo "PASS: ifUnset preserves existing" + + # Empty counts as unset. + r=$(EDITOR="" "$script" | grep '^EDITOR=' | cut -d= -f2-) + [ "$r" = "vim" ] || { echo "FAIL: ifUnset empty: '$r'"; exit 1; } + echo "PASS: ifUnset treats empty as unset" + + touch $out +'' diff --git a/checks/env-list-value.nix b/checks/env-list-value.nix new file mode 100644 index 0000000..470bfe5 --- /dev/null +++ b/checks/env-list-value.nix @@ -0,0 +1,71 @@ +{ + pkgs, + self, +}: + +# End-to-end test for list-valued `env..value` with +# `wlib.env.ref` to prepend to an existing variable. Also exercises +# composition via `apply`: lists merge by concatenation. +let + wlib = self.lib; + + showVar = pkgs.writeShellScriptBin "show-var" '' + printf 'TEST_VAR=%s\n' "''${TEST_VAR-}" + ''; + + base = wlib.wrapModule ( + { config, ... }: + { + config.package = showVar; + config.env.TEST_VAR.value = [ + "/base-front" + (wlib.env.ref "TEST_VAR") + "/base-back" + ]; + } + ); + + extended = (base.apply { pkgs = pkgs; }).apply { + # List merging via the module system: apply concatenates. + env.TEST_VAR.value = [ "/extra" ]; + }; + + wrapped = extended.wrapper; +in +pkgs.runCommand "env-list-value-test" { } '' + set -eu + script="${wrapped}/bin/show-var" + + # Case 1: TEST_VAR unset — envRef drops out, no stray colons. + r1=$(unset TEST_VAR && "$script" | grep '^TEST_VAR=' | cut -d= -f2-) + case "$r1" in + *::*) + echo "FAIL: unset case has stray separator: '$r1'" + cat "$script"; exit 1 ;; + :*|*:) + echo "FAIL: unset case has leading/trailing colon: '$r1'" ; exit 1 ;; + esac + case "$r1" in + */base-front*) ;; + *) echo "FAIL: base-front missing: '$r1'"; exit 1 ;; + esac + case "$r1" in + */base-back*) ;; + *) echo "FAIL: base-back missing: '$r1'"; exit 1 ;; + esac + case "$r1" in + */extra*) ;; + *) echo "FAIL: extra missing (list merge via apply): '$r1'"; exit 1 ;; + esac + echo "PASS: unset case: $r1" + + # Case 2: TEST_VAR=/mid — envRef expands in place. + r2=$(TEST_VAR=/mid "$script" | grep '^TEST_VAR=' | cut -d= -f2-) + case "$r2" in + */mid*) ;; + *) echo "FAIL: existing value lost: '$r2'"; exit 1 ;; + esac + echo "PASS: set case: $r2" + + touch $out +'' diff --git a/checks/env-read.nix b/checks/env-read.nix new file mode 100644 index 0000000..df0d8e1 --- /dev/null +++ b/checks/env-read.nix @@ -0,0 +1,87 @@ +{ + pkgs, + self, +}: + +# Verify that `env..value` is always a list when read from +# another wrapper's config, regardless of whether the user wrote +# the input as a string or a list. This is the invariant that lets +# consumers inspect env entries without safeguarding against two +# types. +let + lib = pkgs.lib; + wlib = self.lib; + + producer = wlib.wrapModule ( + { config, ... }: + { + config.package = pkgs.hello; + # Plain string input. + config.env.EDITOR = "vim"; + # Already-a-list input. + config.env.PATH.value = [ + "/opt/bin" + (wlib.env.ref "PATH") + ]; + } + ); + + applied = producer.apply { inherit pkgs; }; + + editorValue = applied.env.EDITOR.value; + pathValue = applied.env.PATH.value; + + # Literal read helper: joins the parts with separator. Works as + # long as all parts are plain strings (no envRefs). + readLiteral = e: lib.concatStringsSep e.separator e.value; + + editorString = readLiteral applied.env.EDITOR; + + checks = [ + ( + if builtins.isList editorValue then + "PASS: string input is a list when read back" + else + throw "FAIL: editor value is ${builtins.typeOf editorValue}, expected list" + ) + ( + if editorValue == [ "vim" ] then + "PASS: string coerced to singleton [ \"vim\" ]" + else + throw "FAIL: editor value = ${builtins.toJSON editorValue}" + ) + ( + if editorString == "vim" then + "PASS: readLiteral round-trips the string" + else + throw "FAIL: readLiteral gave ${editorString}" + ) + ( + if builtins.isList pathValue && lib.length pathValue == 2 then + "PASS: list input preserved as list" + else + throw "FAIL: path value = ${builtins.toJSON pathValue}" + ) + ( + if builtins.isString (lib.head pathValue) then + "PASS: string parts stay strings" + else + throw "FAIL: path first part = ${builtins.toJSON (lib.head pathValue)}" + ) + ( + let + second = lib.elemAt pathValue 1; + in + if builtins.isAttrs second && second._type or null == "envRef" && second.name == "PATH" then + "PASS: envRef round-trips" + else + throw "FAIL: path second part = ${builtins.toJSON second}" + ) + ]; +in +pkgs.runCommand "env-read-test" { } '' + cat <<'EOF' + ${lib.concatStringsSep "\n" checks} + EOF + touch $out +'' diff --git a/checks/env-render.nix b/checks/env-render.nix new file mode 100644 index 0000000..d8cbeea --- /dev/null +++ b/checks/env-render.nix @@ -0,0 +1,89 @@ +{ + pkgs, + self, +}: + +# Pure tests for `wlib.env.render`. No wrapper build, just string +# comparison so iteration on escaping stays fast. +let + lib = pkgs.lib; + wlib = self.lib; + + cases = [ + { + name = "empty"; + input = { }; + expected = ""; + } + { + name = "literal"; + input.FOO = "bar"; + expected = '' + export FOO="bar" + ''; + } + { + name = "if-unset"; + input.EDITOR = { + value = "vim"; + ifUnset = true; + }; + expected = '' + if [ -z "''${EDITOR:-}" ]; then + export EDITOR="vim" + fi + ''; + } + { + name = "escaped"; + input.MSG = ''He said "hi" and went \home''; + expected = '' + export MSG="He said \"hi\" and went \\home" + ''; + } + ]; + + run = + { name, input, expected }: + let + actual = wlib.env.render input; + in + if actual == expected then + "PASS: ${name}" + else + throw '' + FAIL: ${name} + expected: ${builtins.toJSON expected} + actual: ${builtins.toJSON actual} + ''; + + results = map run cases; + + # Structural checks for list values: the join helper and all + # literal parts must show up somewhere in the rendered snippet. + listOut = wlib.env.render { + PATH.value = [ + "/opt/bin" + (wlib.env.ref "PATH") + "/extra/bin" + ]; + }; + listAssert = + if + lib.hasInfix "_wrapper_env_join" listOut + && lib.hasInfix ''"/opt/bin"'' listOut + && lib.hasInfix ''"''${PATH-}"'' listOut + && lib.hasInfix ''"/extra/bin"'' listOut + then + "PASS: list-value renders helper + literals + envRef" + else + throw "FAIL: list-value rendering:\n${listOut}"; + + all = results ++ [ listAssert ]; +in +pkgs.runCommand "env-render-test" { } '' + cat <<'EOF' + ${lib.concatStringsSep "\n" all} + EOF + touch $out +'' diff --git a/lib/default.nix b/lib/default.nix index 2957575..0b6ad71 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -123,6 +123,116 @@ let in ''"${escaped}"''; + /** + Environment variable helpers. + + - `env.ref NAME`: marker placed inside an `env..value` list + to reference another variable at runtime. Empty/unset refs + drop out cleanly via a runtime join helper, so there are no + dangling separators. + + - `env.render`: render an `env` attrset into a shell snippet. + Used by `wrapPackage` and exposed for tests. + + `env.render` accepts the same shapes as the `env` module option: + + - `env.FOO = "bar"` literal + - `env.FOO.value = "bar"` literal (explicit) + - `env.FOO.value = [ "a" "b" ]` list, joined with `separator` + - `env.FOO.value = [ "/opt/bin" (env.ref "PATH") ]` prepend + - `env.FOO.ifUnset = true` only set when caller hasn't + + To unset a variable, put `unset VAR` in `preHook` instead. + */ + env = + let + esc = + s: ''"${lib.replaceStrings [ ''\'' ''"'' ] [ ''\\'' ''\"'' ] (toString s)}"''; + + norm = + e: + if builtins.isString e || builtins.isList e then { value = e; } else e; + + renderPart = + p: + if builtins.isString p then + esc p + else if (p._type or null) == "envRef" then + ''"''${${p.name}-}"'' + else + throw "wlib.env.render: invalid part ${lib.generators.toPretty { } p}"; + + line = + name: raw: + let + e = norm raw; + val = e.value or null; + parts = + if val == null then + [ ] + else if builtins.isList val then + val + else + [ val ]; + simple = lib.length parts == 1 && builtins.isString (lib.head parts); + sep = e.separator or ":"; + body = + if parts == [ ] then + null + else if simple then + "export ${name}=${esc (lib.head parts)}" + else + "export ${name}=\"$(_wrapper_env_join ${esc sep} ${ + lib.concatStringsSep " " (map renderPart parts) + })\""; + wrapped = + if body == null then + null + else if e.ifUnset or false then + '' + if [ -z "''${${name}:-}" ]; then + ${body} + fi'' + else + body; + in + { + join = !simple && wrapped != null; + text = wrapped; + }; + + helper = '' + _wrapper_env_join() { + local sep="$1" out="" p + shift + for p in "$@"; do + [ -n "$p" ] || continue + out="''${out:+$out$sep}$p" + done + printf '%s' "$out" + }''; + in + { + ref = name: { + _type = "envRef"; + inherit name; + }; + + render = + raw: + let + lines = lib.filter (l: l.text != null) (lib.mapAttrsToList line raw); + needsJoin = lib.any (l: l.join) lines; + in + if lines == [ ] then + "" + else + lib.concatStringsSep "\n" ( + lib.optional needsJoin helper ++ map (l: l.text) lines + ) + + "\n"; + }; + /** A collection of types for wrapper modules. For now this only contains a file type. @@ -253,7 +363,7 @@ let inherit modules class specialArgs; }; - modules = lib.genAttrs [ "package" "flags" "command" "wrapper" "meta" "systemd" ] ( + modules = lib.genAttrs [ "package" "flags" "env" "command" "wrapper" "meta" "systemd" ] ( name: import ./modules/${name}.nix ); @@ -482,14 +592,7 @@ let inherit (pkgs) lndir; # Generate environment variable exports - envString = - if env == { } then - "" - else - lib.concatStringsSep "\n" ( - lib.mapAttrsToList (name: value: ''export ${name}="${toString value}"'') env - ) - + "\n"; + envString = wrapperLib.env.render env; # Generate flag arguments with proper line breaks and indentation flagsString = @@ -698,6 +801,7 @@ let escapeShellArgWithEnv generateArgsFromFlags flagToArgs + env ; }; in diff --git a/lib/modules/command.nix b/lib/modules/command.nix index 9949432..9c8134a 100644 --- a/lib/modules/command.nix +++ b/lib/modules/command.nix @@ -9,6 +9,7 @@ imports = [ wlib.modules.package wlib.modules.flags + wlib.modules.env ]; options.args = lib.mkOption { type = lib.types.listOf lib.types.str; @@ -28,13 +29,6 @@ These packages will be added to the wrapper's runtime dependencies, ensuring they are available when the wrapped program is executed. ''; }; - options.env = lib.mkOption { - type = lib.types.attrsOf lib.types.str; - default = { }; - description = '' - Environment variables to set in the wrapper. - ''; - }; options.preHook = lib.mkOption { type = lib.types.str; default = ""; diff --git a/lib/modules/env.nix b/lib/modules/env.nix new file mode 100644 index 0000000..2ad13d2 --- /dev/null +++ b/lib/modules/env.nix @@ -0,0 +1,109 @@ +{ + lib, + wlib, + config, + ... +}: +let + # A part of an `env..value` list: a literal string or a + # runtime reference produced by `wlib.env.ref`. + envPart = lib.mkOptionType { + name = "envPart"; + description = "string or wlib.env.ref"; + check = + v: builtins.isString v || (builtins.isAttrs v && (v._type or null) == "envRef"); + merge = lib.options.mergeEqualOption; + }; + + valueType = lib.types.coercedTo lib.types.str (v: [ v ]) (lib.types.listOf envPart); + + entry = lib.types.submodule { + options = { + value = lib.mkOption { + type = lib.types.nullOr valueType; + default = null; + description = '' + Parts to join with `separator`. Accepts a plain string + (coerced to a singleton list) or a list of parts. List + parts can be plain strings or `wlib.env.ref "NAME"` + runtime references; empty parts are filtered at runtime + so unset refs don't leave dangling separators. + + This option is always a list when read back. To read a + known-literal entry as a string from another wrapper's + config, use `lib.head entry.value` (for a singleton) or + `lib.concatStringsSep entry.separator entry.value` (for + multiple literal parts). + ''; + }; + separator = lib.mkOption { + type = lib.types.str; + default = ":"; + description = "Separator used when joining a list-valued `value`."; + }; + ifUnset = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Only apply this entry when the variable is unset (or + empty) in the caller's environment. Useful for defaults + like `EDITOR = "vim"`. + ''; + }; + }; + }; + + envValue = lib.types.coercedTo ( + lib.types.either lib.types.str lib.types.path + ) (v: { value = toString v; }) entry; +in +{ + _file = "lib/modules/env.nix"; + + options.env = lib.mkOption { + type = lib.types.attrsOf envValue; + default = { }; + example = lib.literalExpression '' + { + FOO = "bar"; # literal + PATH.value = [ "/opt/bin" (wlib.env.ref "PATH") ]; # prepend + EDITOR = { value = "vim"; ifUnset = true; }; # default + } + ''; + description = '' + Environment variables to set in the wrapper. + + Each entry accepts a plain string (literal) or a submodule + with `value` / `separator` / `ifUnset`. Unset a variable by + putting `unset VAR` in `preHook` — the wrapper already runs + arbitrary shell there and that's the cleanest way to scrub + inherited env. + + To prepend/append to an existing variable, pass `value` as a + list and include `wlib.env.ref "NAME"` at the point where the + existing value should appear: + + env.PATH.value = [ "/opt/bin" (wlib.env.ref "PATH") ]; + ''; + }; + + # Subset of `env` that resolves to a plain literal string. Used by + # the systemd integration, which cannot express runtime env + # composition. Complex entries are silently dropped — set them via + # `systemd.environment` directly if you need them. + options.outputs.staticEnv = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + internal = true; + readOnly = true; + description = "Plain literal `env` entries, for integrations like systemd."; + default = lib.mapAttrs (_: e: lib.concatStringsSep e.separator e.value) ( + lib.filterAttrs ( + _: e: + !e.ifUnset + && e.value != null + && e.value != [ ] + && lib.all builtins.isString e.value + ) config.env + ); + }; +} diff --git a/lib/modules/systemd.nix b/lib/modules/systemd.nix index 1993ca9..b553d9b 100644 --- a/lib/modules/systemd.nix +++ b/lib/modules/systemd.nix @@ -115,7 +115,7 @@ in in lib.concatStringsSep " " ([ config.exePath ] ++ map escapeForSystemd config.args) ); - environment = lib.mkDefault config.env; + environment = lib.mkDefault config.outputs.staticEnv; path = lib.mkDefault config.extraPackages; preStart = lib.mkIf (config.preHook != "") (lib.mkDefault config.preHook); postStop = lib.mkIf (config.postHook != "") (lib.mkDefault config.postHook);