Skip to content

Commit f648cfa

Browse files
committed
lib.modules.env: structured env with prefix/suffix/values/fallback
Resolves the composability gap from #55 ("Prepend ENV variables") and adds handling for the related cases that came up during that discussion. The old env option was attrsOf str, which forced users into bash parameter-expansion gymnastics whenever they wanted to prefix PATH, provide a fallback default for EDITOR, or unset an inherited variable. The new env option is a submodule per entry with: - value / values: literal or list-of-parts - prefix / suffix: splice around the existing value, makeWrapper style - separator: join separator (default ":") - fallback: only set when the variable isn't already set - unset: emit `unset VAR` List-valued fields merge by concatenation, so modules composing via apply stack contributions cleanly instead of fighting over a single string. Empty/unset env references drop out at runtime via a shared shell helper (_wrapper_env_join), so no dangling separators. Plain strings and null keep working via coercedTo, so existing env.FOO = "bar" / env.FOO = null usage is unchanged. wlib.envRef produces runtime references for the values list; wlib.renderEnvString is exposed for tests and downstream tooling. The systemd integration reads from the new outputs.staticEnv output, which only exposes entries that resolve to a plain literal. https://claude.ai/code/session_01UiEmmBrtkNEstoRemZpnp7
1 parent 9d8397d commit f648cfa

10 files changed

Lines changed: 904 additions & 17 deletions

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,49 @@
1515
were explicitly passing `flagSeparator = " "` to get separate args,
1616
remove it (or change to `null`).
1717

18+
- `env` option type changed from `attrsOf str` to a richer submodule
19+
(see "Added" below). Plain-string and path values keep coercing to
20+
the old behaviour, so `env.FOO = "bar"` is unchanged. Passthru
21+
`wrapPackage` callers now get the structured form on
22+
`passthru.env`; read `passthru.env.<name>.value` instead of
23+
`passthru.env.<name>` if you need the literal. The systemd
24+
integration reads from `config.outputs.staticEnv` instead of
25+
`config.env` and silently drops entries that can't be expressed as
26+
a static literal (prefix/suffix, values, fallback, unset).
27+
1828
### Added
1929

2030
- `lib/modules/command.nix`: base module with shared command spec
2131
(args, env, hooks, exePath) used by both wrapper and systemd outputs.
2232
- `lib/modules/flags.nix`: flags module with per-flag ordering via
2333
`{ value, order }` submodules. Default order is 1000. Reading
2434
`config.flags` returns clean values (order is transparent).
35+
- `lib/modules/env.nix`: env module with richer per-variable options
36+
for safe composition, modelled on `makeWrapper`'s `--prefix` /
37+
`--suffix` but usable through the NixOS module system.
38+
- `env.<VAR>.value`: literal string (same as `env.<VAR> = "..."`).
39+
- `env.<VAR>.prefix` / `.suffix`: parts to splice around the
40+
existing value of the variable. Empty or unset existing values
41+
drop out cleanly with no stray separators.
42+
- `env.<VAR>.values`: explicit list of parts to join, with
43+
`wlib.envRef "OTHER"` placeholders for runtime env references.
44+
- `env.<VAR>.separator`: join separator (default `:`).
45+
- `env.<VAR>.fallback = true`: only set the variable when it is
46+
not already set in the caller's environment (uses `${VAR+set}`
47+
semantics).
48+
- `env.<VAR>.unset = true`: emit `unset VAR` instead of an
49+
assignment. `env.VAR = null` is sugar for this.
50+
- List-valued entries (`values`, `prefix`, `suffix`) merge by
51+
concatenation when composed via `apply`, so multiple modules can
52+
stack contributions onto the same variable without fighting.
53+
- `wlib.envRef :: name -> envRef`: marker used inside `values` /
54+
`prefix` / `suffix` lists to reference another env variable at
55+
runtime. Dropped cleanly if the referenced variable is unset.
56+
- `wlib.renderEnvString :: env -> str`: pure helper that renders an
57+
`env` attrset into the shell snippet the wrapper uses. Exposed
58+
for testing and downstream composition.
59+
- `outputs.staticEnv`: subset of `env` that resolves to a plain
60+
literal string, used by the systemd integration.
2561
- `wrapper.nix` injects `"$@"` into args at order 1001, controllable
2662
via the ordering system.
2763
- `outputs.wrapper` as the canonical output path (config.wrapper is

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,64 @@ wrappers.lib.wrapPackage {
9191
}
9292
```
9393

94+
### Environment Variables
95+
96+
Environment variables can be set in three forms, with the richer form
97+
available both via `wrapPackage` and through the `env.<NAME>` option
98+
of wrapper modules.
99+
100+
```nix
101+
{
102+
env = {
103+
# 1. Plain literal string (same as before):
104+
FOO = "bar";
105+
106+
# 2. Null to explicitly unset a variable inherited from the
107+
# caller's environment:
108+
NOISY_OLD_VAR = null;
109+
110+
# 3. Structured form with the following fields:
111+
#
112+
# - value: literal string
113+
# - prefix / suffix: parts to splice around the existing value
114+
# of the variable (like `makeWrapper --prefix/--suffix`).
115+
# Empty or unset existing values drop out cleanly.
116+
# - values: explicit list of parts, joined with `separator`.
117+
# Use `wlib.envRef "OTHER"` to reference another variable
118+
# at runtime.
119+
# - separator: join separator (default ":")
120+
# - fallback: only set if the variable isn't already set
121+
# - unset: emit `unset VAR` (takes precedence)
122+
123+
# Prepend to PATH, keeping the caller's existing entries:
124+
PATH.prefix = [ "/opt/bin" ];
125+
126+
# Append to XDG_DATA_DIRS:
127+
XDG_DATA_DIRS.suffix = [ "/opt/share" ];
128+
129+
# Build LD_LIBRARY_PATH with the existing value somewhere in the
130+
# middle, not just at the edges:
131+
LD_LIBRARY_PATH.values = [
132+
"/opt/lib"
133+
(wrappers.lib.envRef "LD_LIBRARY_PATH")
134+
"/other/lib"
135+
];
136+
137+
# Only set EDITOR if the user hasn't picked one already:
138+
EDITOR = {
139+
value = "vim";
140+
fallback = true;
141+
};
142+
};
143+
}
144+
```
145+
146+
`prefix`, `suffix` and `values` are lists, so they compose via module
147+
merging: multiple modules (or multiple `apply` calls) contributing to
148+
the same variable stack their contributions instead of fighting over
149+
a single string. Empty parts are filtered at runtime, so unset env
150+
references never leave behind dangling separators.
151+
94152
### Creating Custom Wrapper Modules
95153

96154
```nix

checks/env-fallback-unset.nix

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
{
2+
pkgs,
3+
self,
4+
}:
5+
6+
# Fallback (only-set-if-unset) and explicit unset handling.
7+
let
8+
wlib = self.lib;
9+
10+
showEnv = pkgs.writeShellScriptBin "show-env" ''
11+
printf 'EDITOR=%s\n' "''${EDITOR-<unset>}"
12+
printf 'BLOAT=%s\n' "''${BLOAT-<unset>}"
13+
'';
14+
15+
wrapped =
16+
(wlib.wrapModule (
17+
{ config, ... }:
18+
{
19+
config.package = showEnv;
20+
config.env.EDITOR = {
21+
value = "vim";
22+
fallback = true;
23+
};
24+
# `null` is sugar for `unset = true`, exercise both forms.
25+
config.env.BLOAT = null;
26+
}
27+
).apply { pkgs = pkgs; }).wrapper;
28+
in
29+
pkgs.runCommand "env-fallback-unset-test" { } ''
30+
set -eu
31+
script="${wrapped}/bin/show-env"
32+
33+
# Fallback: EDITOR unset → set to vim.
34+
editor_unset=$(unset EDITOR && "$script" | grep '^EDITOR=' | cut -d= -f2-)
35+
if [ "$editor_unset" = "vim" ]; then
36+
echo "PASS: fallback applied when unset"
37+
else
38+
echo "FAIL: fallback unset case: '$editor_unset'"
39+
cat "$script"
40+
exit 1
41+
fi
42+
43+
# Fallback: EDITOR already set → preserved.
44+
editor_set=$(EDITOR=nano "$script" | grep '^EDITOR=' | cut -d= -f2-)
45+
if [ "$editor_set" = "nano" ]; then
46+
echo "PASS: fallback preserved existing value"
47+
else
48+
echo "FAIL: fallback preserved case: '$editor_set'"
49+
exit 1
50+
fi
51+
52+
# Unset: BLOAT should be unset even if caller exports it.
53+
bloat_result=$(BLOAT=garbage "$script" | grep '^BLOAT=' | cut -d= -f2-)
54+
if [ "$bloat_result" = "<unset>" ]; then
55+
echo "PASS: unset overrides caller env"
56+
else
57+
echo "FAIL: unset case: '$bloat_result'"
58+
cat "$script"
59+
exit 1
60+
fi
61+
62+
touch $out
63+
''

checks/env-prefix-suffix.nix

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
{
2+
pkgs,
3+
self,
4+
}:
5+
6+
# Prefix/suffix composition for env variables.
7+
#
8+
# Goals:
9+
# 1. A `prefix`/`suffix` entry splices around the existing value of
10+
# the variable and leaves no dangling separator when that variable
11+
# is unset or empty.
12+
# 2. Multiple modules contributing to the same variable via `apply`
13+
# compose via list concatenation, not via string overwriting.
14+
#
15+
# We use a custom variable name (WRAPPER_TEST_VAR) instead of PATH so
16+
# that unsetting it inside a subshell doesn't break the shell itself.
17+
let
18+
wlib = self.lib;
19+
20+
showVar = pkgs.writeShellScriptBin "show-var" ''
21+
printf 'WRAPPER_TEST_VAR=%s\n' "''${WRAPPER_TEST_VAR-<unset>}"
22+
'';
23+
24+
base = wlib.wrapModule (
25+
{ config, ... }:
26+
{
27+
config.package = showVar;
28+
config.env.WRAPPER_TEST_VAR.prefix = [ "/base-pre" ];
29+
config.env.WRAPPER_TEST_VAR.suffix = [ "/base-post" ];
30+
}
31+
);
32+
33+
extended = (base.apply { pkgs = pkgs; }).apply {
34+
env.WRAPPER_TEST_VAR.prefix = [ "/extra-pre" ];
35+
env.WRAPPER_TEST_VAR.suffix = [ "/extra-post" ];
36+
};
37+
38+
wrapped = extended.wrapper;
39+
in
40+
pkgs.runCommand "env-prefix-suffix-test" { } ''
41+
set -eu
42+
script="${wrapped}/bin/show-var"
43+
if [ ! -f "$script" ]; then
44+
echo "FAIL: wrapper script not found"
45+
exit 1
46+
fi
47+
48+
# Case 1: WRAPPER_TEST_VAR unset — prefix and suffix join with no
49+
# dangling separators, no stray reference to the empty existing
50+
# value.
51+
unset WRAPPER_TEST_VAR
52+
result_unset=$("$script" | grep '^WRAPPER_TEST_VAR=' | cut -d= -f2-)
53+
case "$result_unset" in
54+
*:*:*)
55+
# good, at least three parts (two prefixes + two suffixes all
56+
# joined together — order is list-concatenation order)
57+
;;
58+
*)
59+
echo "FAIL: unset case collapsed too aggressively: '$result_unset'"
60+
cat "$script"
61+
exit 1
62+
;;
63+
esac
64+
case "$result_unset" in
65+
*::*)
66+
echo "FAIL: unset case has double colon (stray separator): '$result_unset'"
67+
cat "$script"
68+
exit 1
69+
;;
70+
:*|*:)
71+
echo "FAIL: unset case has leading/trailing colon: '$result_unset'"
72+
exit 1
73+
;;
74+
*)
75+
echo "PASS: unset case has no dangling separators: $result_unset"
76+
;;
77+
esac
78+
case "$result_unset" in
79+
*/base-pre*) ;;
80+
*)
81+
echo "FAIL: base prefix missing: '$result_unset'"
82+
exit 1
83+
;;
84+
esac
85+
case "$result_unset" in
86+
*/extra-pre*) ;;
87+
*)
88+
echo "FAIL: extra prefix missing (apply list merge?): '$result_unset'"
89+
exit 1
90+
;;
91+
esac
92+
case "$result_unset" in
93+
*/base-post*) ;;
94+
*)
95+
echo "FAIL: base suffix missing: '$result_unset'"
96+
exit 1
97+
;;
98+
esac
99+
case "$result_unset" in
100+
*/extra-post*) ;;
101+
*)
102+
echo "FAIL: extra suffix missing (apply list merge?): '$result_unset'"
103+
exit 1
104+
;;
105+
esac
106+
107+
# Case 2: WRAPPER_TEST_VAR set — prefixes appear before, suffixes
108+
# after, and the existing value survives in the middle.
109+
export WRAPPER_TEST_VAR=/user-value
110+
result_set=$("$script" | grep '^WRAPPER_TEST_VAR=' | cut -d= -f2-)
111+
case "$result_set" in
112+
*/user-value*)
113+
echo "PASS: existing value preserved: $result_set"
114+
;;
115+
*)
116+
echo "FAIL: existing value lost: '$result_set'"
117+
exit 1
118+
;;
119+
esac
120+
# Prefixes must appear before the existing middle value.
121+
prefix_part="''${result_set%%/user-value*}"
122+
case "$prefix_part" in
123+
*/base-pre*) ;;
124+
*)
125+
echo "FAIL: base prefix not before existing value: '$result_set'"
126+
exit 1
127+
;;
128+
esac
129+
# Suffixes must appear after.
130+
suffix_part="''${result_set##*/user-value}"
131+
case "$suffix_part" in
132+
*/base-post*) ;;
133+
*)
134+
echo "FAIL: base suffix not after existing value: '$result_set'"
135+
exit 1
136+
;;
137+
esac
138+
139+
# Case 3: WRAPPER_TEST_VAR set to the empty string. Empty is filtered
140+
# the same as unset so the middle drops out cleanly.
141+
export WRAPPER_TEST_VAR=""
142+
result_empty=$("$script" | grep '^WRAPPER_TEST_VAR=' | cut -d= -f2-)
143+
case "$result_empty" in
144+
*::*)
145+
echo "FAIL: empty existing value left a double colon: '$result_empty'"
146+
exit 1
147+
;;
148+
*)
149+
echo "PASS: empty existing value collapsed cleanly: $result_empty"
150+
;;
151+
esac
152+
153+
echo "SUCCESS: env prefix/suffix composition works"
154+
touch $out
155+
''

0 commit comments

Comments
 (0)