Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,42 @@
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
(args, env, hooks, exePath) used by both wrapper and systemd outputs.
- `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.<VAR>.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.<VAR>.separator`: join separator for `value` (default `:`).
- `env.<VAR>.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.<VAR>.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
Expand Down
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,53 @@ wrappers.lib.wrapPackage {
}
```

### Environment Variables

Each `env.<NAME>` 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
Expand Down
47 changes: 47 additions & 0 deletions checks/env-if-unset.nix
Original file line number Diff line number Diff line change
@@ -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-<unset>}"
'';

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
''
71 changes: 71 additions & 0 deletions checks/env-list-value.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
pkgs,
self,
}:

# End-to-end test for list-valued `env.<VAR>.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-<unset>}"
'';

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
''
87 changes: 87 additions & 0 deletions checks/env-read.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
{
pkgs,
self,
}:

# Verify that `env.<VAR>.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
''
89 changes: 89 additions & 0 deletions checks/env-render.nix
Original file line number Diff line number Diff line change
@@ -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
''
Loading
Loading