Skip to content
Closed
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
12 changes: 10 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,18 @@ jobs:
fi

- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
uses: cachix/install-nix-action@v31

- name: Setup Nix cache
uses: DeterminateSystems/magic-nix-cache-action@main
uses: nix-community/cache-nix-action@v7
with:
primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-
# don't keep caches from previous CI runs around. newest main should always be enough
purge: true
purge-prefixes: nix-${{ runner.os }}-
purge-created: 0
purge-primary-key: never

- name: Run checks
run: nix flake check --log-format bar-with-logs
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Changelog

## Unreleased

### Breaking changes

- `wrapPackage`: when passing explicit `args`, `"$@"` is no longer
appended automatically by the wrapper template. If you pass custom
`args` and want passthrough, include `"$@"` in your args list.
The default `args` (generated from `flags`) still includes `"$@"`.

- `flagSeparator` default changed from `" "` to `null`. The old `" "`
default was misleading: it produced separate argv entries, not a
space-joined arg. `null` now means separate argv entries. If you
were explicitly passing `flagSeparator = " "` to get separate args,
remove it (or change to `null`).

### 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).
- `wrapper.nix` injects `"$@"` into args at order 1001, controllable
via the ordering system.
- `outputs.wrapper` as the canonical output path (config.wrapper is
a backward-compatible alias).
111 changes: 109 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ This library provides two main components:
vo=gpu
hwdec=auto
'';
"mpv.input".content = ''
"input.conf".content = ''
WHEEL_UP seek 10
WHEEL_DOWN seek -10
'';
Expand Down Expand Up @@ -140,12 +140,14 @@ Arguments:
- Example: `[ "--silent" "--connect-timeout" "30" ]`
- If provided, overrides automatic generation from `flags`
- `preHook`: Shell script executed before the command (default: `""`)
- `postHook`: Shell script executed after the command. This will leave a bash process running, use with caution (default: `""`)
- `passthru`: Additional attributes for the derivation's passthru (default: `{}`)
- `aliases`: List of additional symlink names for the executable (default: `[]`)
- `filesToPatch`: List of file paths (glob patterns) relative to package root to patch for self-references (default: `["share/applications/*.desktop"]`)
- Example: `["bin/*", "lib/*.sh"]` to replace original package paths with wrapped package paths
- Desktop files are patched by default to update Exec= and Icon= paths
- `filesToExclude`: List of file paths (glob patterns) to exclude from the wrapped package (default: `[]`)
- `patchHook`: Shell script that runs after patchPhase to modify the wrapper package files (default: `""`)
- `wrapper`: Custom wrapper function (optional, overrides default exec wrapper)

The function:
Expand All @@ -170,12 +172,18 @@ Built-in options (always available):
- `flagSeparator`: Separator between flag name and value (default: `" "`)
- `args`: Command-line arguments list (auto-generated from `flags` if not provided)
- `env`: Environment variables
- `preHook`: Shell script executed before the command (default: `""`)
- `postHook`: Shell script executed after the command. This will leave a bash process running, use with caution (default: `""`)
- `passthru`: Additional passthru attributes
- `filesToPatch`: List of file paths (glob patterns) to patch for self-references (default: `["share/applications/*.desktop"]`)
- `filesToExclude`: List of file paths (glob patterns) to exclude from the wrapped package (default: `[]`)
- `patchHook`: Shell script that runs after patchPhase to modify the wrapper package files (default `""`)
- `wrapper`: The resulting wrapped package (read-only, auto-generated from other options)
- `apply`: Function to extend the configuration with additional modules (read-only)

Optional modules (import via `wlib.modules.<name>`):
- `systemd`: Generates systemd service files (user and/or system), options are passed through from NixOS

Custom types:
- `wlib.types.file`: File type with `content` and `path` options
- `content`: File contents as string
Expand Down Expand Up @@ -231,7 +239,7 @@ Wraps mpv with configuration file support and script management:
vo=gpu
profile=gpu-hq
'';
"mpv.input".content = ''
"input.conf".content = ''
RIGHT seek 5
LEFT seek -5
'';
Expand Down Expand Up @@ -261,6 +269,105 @@ Wraps notmuch with INI-based configuration:
}).wrapper
```

### Generating systemd Services

Import `wlib.modules.systemd` to generate systemd service files for your wrapper.
The options under `systemd` are the same as `systemd.services.<name>` in NixOS,
passed through directly.

`ExecStart` (including args), `Environment`, `PATH`, `preStart` and `postStop`
are picked up from the wrapper automatically, so you only need to set what's
specific to the service.

The same config produces both a user and system service file, available at
`config.outputs.systemd-user` and `config.outputs.systemd-system`. Use
whichever fits your deployment.

```nix
wlib.wrapModule ({ config, wlib, ... }: {
imports = [ wlib.modules.systemd ];

config = {
package = config.pkgs.hello;
flags."--greeting" = "world";
env.HELLO_LANG = "en";
systemd = {
description = "Hello service";
serviceConfig.Type = "simple";
serviceConfig.Restart = "on-failure";
};
};
})
```

Settings merge when using `apply`:

```nix
extended = myWrapper.apply {
systemd.serviceConfig.Restart = "always";
systemd.environment.EXTRA = "value";
};
```

#### Using in NixOS

You need both `systemd.packages` for the unit file and the corresponding
`wantedBy` to actually activate it. NixOS does not read the `[Install]` section
from unit files, it creates the `.wants` symlinks from the module option instead.

As a user service (for all users):

```nix
# configuration.nix
{ pkgs, wrappers, ... }:
let
myHello = wrappers.wrapperModules.hello.apply {
inherit pkgs;
systemd.serviceConfig.Restart = "always";
};
in {
systemd.packages = [ myHello.outputs.systemd-user ];
# NixOS needs this to create the .wants symlink, the [Install]
# section in the unit file alone is not enough
systemd.user.services.hello.wantedBy = [ "default.target" ];
}
```

As a system service:

```nix
# configuration.nix
{ pkgs, wrappers, ... }:
let
myHello = wrappers.wrapperModules.hello.apply {
inherit pkgs;
systemd.serviceConfig.Restart = "always";
};
in {
systemd.packages = [ myHello.outputs.systemd-system ];
systemd.services.hello.wantedBy = [ "multi-user.target" ];
}
```

#### Using in home-manager

For per-user services, link via `xdg.dataFile`:

```nix
# home.nix
{ pkgs, wrappers, ... }:
let
myHello = wrappers.wrapperModules.hello.apply {
inherit pkgs;
systemd.wantedBy = [ "default.target" ];
systemd.serviceConfig.Restart = "always";
};
in {
xdg.dataFile."systemd/user/hello.service".source =
"${myHello.outputs.systemd-user}/systemd/user/hello.service";
}
```

## alternatives

- [wrapper-manager](https://github.com/viperML/wrapper-manager) by viperML. This project focuses more on a single module system, configuring wrappers and exporting them. This was an inspiration when building this library, but I wanted to have a more granular approach with a single module per package and a collection of community made modules.
Expand Down
16 changes: 16 additions & 0 deletions checks/exe-path-bin-name.nix
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ let
};
};

# Test 5: Custom binName updates meta.mainProgram
wrappedWithMeta = self.lib.wrapPackage {
inherit pkgs;
package = pkgs.hello;
binName = "hello-wrapped";
};

in
pkgs.runCommand "exe-path-bin-name-test" { } ''
set -e
Expand Down Expand Up @@ -124,6 +131,15 @@ pkgs.runCommand "exe-path-bin-name-test" { } ''
exit 1
fi

# Test 5: Custom binName updates meta.mainProgram
echo -e "\n=== Test 5: custom binName meta.mainProgram ==="
if [ "${wrappedWithMeta.meta.mainProgram}" = "hello-wrapped" ]; then
echo "PASS: meta.mainProgram follows custom binName"
else
echo "FAIL: meta.mainProgram should be 'hello-wrapped', got '${wrappedWithMeta.meta.mainProgram}'"
exit 1
fi

echo -e "\n=== SUCCESS: All exePath and binName tests passed ==="
touch $out
''
1 change: 0 additions & 1 deletion checks/flags-empty-list.nix
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ let
"--empty" = [ ];
"--output" = "file.txt";
};
flagSeparator = " ";
};

in
Expand Down
1 change: 0 additions & 1 deletion checks/flags-false.nix
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ let
"--empty" = [ ];
"--output" = "file.txt";
};
flagSeparator = " ";
};

in
Expand Down
1 change: 0 additions & 1 deletion checks/flags-list.nix
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ let
];
"--verbose" = true;
};
flagSeparator = " ";
};

wrappedWithEqualsSep = self.lib.wrapPackage {
Expand Down
70 changes: 70 additions & 0 deletions checks/flags-order.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
pkgs,
self,
}:

let
helloModule = self.lib.wrapModule (
{ config, ... }:
{
config.package = config.pkgs.hello;
config.flags = {
# default order 1000: before "$@" (which is 1001)
"--greeting" = "hello";
# explicit early order: should come first
"--early" = {
value = true;
order = 500;
};
# explicit late order: should come after "$@"
"--late" = {
value = true;
order = 1500;
};
};
}
);

wrappedPackage = (helloModule.apply { inherit pkgs; }).wrapper;

in
pkgs.runCommand "flags-order-test" { } ''
echo "Testing flag ordering with priorities..."

wrapperScript="${wrappedPackage}/bin/hello"
if [ ! -f "$wrapperScript" ]; then
echo "FAIL: Wrapper script not found"
exit 1
fi

cat "$wrapperScript"

# Flatten the script to a single line for position comparison
flat=$(cat "$wrapperScript" | tr -d '\n' | tr -s ' ')

# --early (500) should come before --greeting (1000)
# --greeting (1000) should come before "$@" (1001)
# "$@" (1001) should come before --late (1500)
earlyPos=$(echo "$flat" | grep -bo -- '--early' | head -1 | cut -d: -f1)
greetingPos=$(echo "$flat" | grep -bo -- '--greeting' | head -1 | cut -d: -f1)
passthruPos=$(echo "$flat" | grep -bo '"\$@"' | head -1 | cut -d: -f1)
latePos=$(echo "$flat" | grep -bo -- '--late' | head -1 | cut -d: -f1)

echo "Positions: early=$earlyPos greeting=$greetingPos passthru=$passthruPos late=$latePos"

if [ "$earlyPos" -ge "$greetingPos" ]; then
echo "FAIL: --early should come before --greeting"
exit 1
fi
if [ "$greetingPos" -ge "$passthruPos" ]; then
echo "FAIL: --greeting should come before \"\$@\""
exit 1
fi
if [ "$passthruPos" -ge "$latePos" ]; then
echo "FAIL: \"\$@\" should come before --late"
exit 1
fi

echo "SUCCESS: Flag ordering test passed"
touch $out
''
1 change: 0 additions & 1 deletion checks/flags-space-separator.nix
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ let
"--greeting" = "hi";
"--verbose" = true;
};
flagSeparator = " ";
};

in
Expand Down
7 changes: 6 additions & 1 deletion checks/formatting.nix
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
}:

pkgs.runCommand "formatting-check" { } ''
${pkgs.lib.getExe self.formatter.${pkgs.system}} --no-cache --fail-on-change ${../.}
cp -r ${../.}/ src
# will be copied readonly from the /nix/store
# nixfmt sadly ignores --fail-on-change and still tries to write to the file
# ergo, we create our own writable copy
chmod -R +w src
${pkgs.lib.getExe self.formatter.${pkgs.stdenv.hostPlatform.system}} --ci --tree-root ./src ./src
touch $out
''
42 changes: 42 additions & 0 deletions checks/patchHook.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
pkgs,
self,
}:

let
# Create a dummy package with a desktop file that references itself
dummyPackage =
(pkgs.runCommand "dummy-app" { } ''
# empty dir as a package
mkdir -p $out
'')
// {
meta.mainProgram = "dummy-app";
};

# Wrap the package
wrappedPackage = self.lib.wrapPackage {
inherit pkgs;
package = dummyPackage;
patchHook = ''
touch $out/test
'';
};

in
pkgs.runCommand "patchHook-test"
{
wrappedPath = "${wrappedPackage}";
}
''
echo "Testing patchHook functionality..."
echo "Wrapped package path: $wrappedPath"

if [ ! -f "$wrappedPath/test" ]; then
echo "FAIL: file not created in patched package"
exit 1
fi

echo "SUCCESS: patchHook executed correctly"
touch $out
''
Loading