Skip to content

package-bundle-options

Modules

Turn named package bundles into per-host enable toggles, where each bundle's human description is its mkEnableOption text — plus a linuxOnly helper that silently drops Darwin-incompatible packages so one bundle set works across a mixed Linux/macOS fleet.

The problem

On a fleet of machines you want each host to opt into coherent sets of packages — "the dev tools", "the sysadmin tools", "the desktop apps" — not to maintain a hand-curated environment.systemPackages list per host. The naive approach duplicates package lists across hosts, or scatters lib.optionals throughout host configs.

The pattern

Define bundles as an attrset. Each bundle has a description and a packages list. A tiny bit of glue then:

  1. Generates one mkEnableOption per bundle — reusing the bundle's description as the option's help text, so there's a single source of truth and no drift between what a bundle is called and what it contains.
  2. Collects the packages of every bundle whose flag is set into environment.systemPackages.

A host config becomes just a list of booleans:

{
  programs.nix-helpers.enable = true;
  programs.develop.enable     = true;
  programs.sysadmin-tools.enable = true;
}

The traps this encodes

linuxOnly — share one bundle across Linux and Darwin

linuxOnly ps is the identity on Linux and [] on Darwin. Wrap the entries in a shared bundle that don't build (or don't make sense) on macOS, and the same bundle imports cleanly on every host — the incompatible packages just vanish on Darwin instead of failing evaluation. Two representative reasons you reach for it:

  • Archived / broken-on-Darwin tools. e.g. cargo-watch is archived upstream and won't build on aarch64-darwin under recent nixpkgs (Cocoa module-cache issues in the sandbox). Prefer a cross-platform equivalent like bacon in the shared part, and linuxOnly-gate the holdout.
  • FUSE-linked tools. sshfs's Nix build links libfuse3 and can't drive macOS FUSE; on Darwin you install a fuse-t-based sshfs out of band, so the Nix package is Linux-only.

The null filter — inline-gated packages

A bundle entry may need to be conditional on a host option (e.g. a GPU-only package). Writing that inline as if cond then pkg else null is clean, but a raw null in systemPackages crashes evaluation. The final flatten therefore runs filter (p: p != null), which makes the inline conditional safe. The example bundle gates btop-cuda behind programs.enableGpuTools.

Usage

Import the module and set the booleans. To adapt it, edit the configurations attrset in default.nix — the bundle names and contents there are only illustrative; the reusable part is the option-generation and platform-filtering glue around them.

Every bundle automatically gains a programs.<name>.enable option. Options are derived at eval time from the attrset, so adding a bundle is a one-line change with a matching enable flag appearing for free.

Caveats

  • Bundle names must be valid attribute / option names (they become programs.<name>). Avoid clashing with real upstream programs.* options already defined by NixOS (e.g. programs.git); namespace your bundles if in doubt.
  • The example gates btop-cuda on programs.enableGpuTools, a plain mkEnableOption declared alongside the generated set. Wire it to whatever host predicate you actually use (a hardware option, config.hardware.*, etc.).
  • description is mandatory on every bundle — it is the only source of the option's help text.

Source

modules/package-bundle-options/default.nix
# NixOS module: turn named package bundles into per-host enable toggles.
#
# Each bundle carries a human-readable `description` that does double duty:
# it is the text of the bundle's `mkEnableOption`. So a host writes
#
#   programs.develop.enable = true;
#   programs.sysadmin-tools.enable = true;
#
# and gets exactly those package sets in environment.systemPackages.
#
# Two patterns worth stealing from this file:
#
#   * `linuxOnly`  — wrap packages that only build / make sense on Linux so a
#                    shared bundle can be imported unchanged on Darwin (macOS)
#                    hosts, silently dropping the incompatible entries instead
#                    of failing the whole evaluation.
#
#   * null filter  — a bundle entry may evaluate to `null` (e.g. a GPU-only
#                    package gated on a host option). The final list is
#                    filtered so those nulls never reach systemPackages, which
#                    lets you write `if cond then pkg else null` inline.
#
# This is a drop-in module. Import it and flip the `programs.<bundle>.enable`
# booleans per host. Edit the `configurations` set below to taste — the bundle
# contents here are only illustrative.

{
  config,
  lib,
  pkgs,
  ...
}:

let
  inherit (pkgs.stdenv) isLinux;

  # Drop a package list entirely on non-Linux hosts. On Linux it is the
  # identity; on Darwin it evaluates to `[]`, so a bundle shared across a
  # cross-platform fleet stays importable everywhere.
  #
  # Use it for packages that either don't build on Darwin or are meaningless
  # there. Two real-world examples of *why* you reach for this:
  #
  #   * `cargo-watch` is archived upstream and won't build on aarch64-darwin
  #     under recent nixpkgs (Cocoa module-cache issues in the sandbox); prefer
  #     a cross-platform equivalent like `bacon`.
  #   * `sshfs`'s Nix build links libfuse3 and can't drive macOS FUSE; on Darwin
  #     you install a fuse-t-based sshfs out of band instead.
  linuxOnly = ps: if isLinux then ps else [ ];

  # ---------------------------------------------------------------------------
  # The bundles. `description` is REQUIRED and becomes the mkEnableOption text.
  # `packages` is the list pulled into systemPackages when the bundle is on.
  # Contents below are generic examples — replace with your own.
  # ---------------------------------------------------------------------------
  configurations = with pkgs; {
    nix-helpers = {
      description = "tools that make nix easier to use";
      packages = [
        any-nix-shell
        nixfmt-rfc-style
        nix-index
        nix-prefetch
        nix-tree
        nvd
      ]
      # Some packages are unavailable / broken on a given system; guard with
      # the same platform-predicate style you'd use anywhere.
      ++ lib.optionals (pkgs.stdenv.hostPlatform.system != "aarch64-linux") [
        cachix
      ]
      ++ linuxOnly [
        nixos-shell
      ];
    };

    develop = {
      description = "CLI development tools and utilities";
      packages = [
        gh
        go
        cargo
        rustc
        rust-analyzer
        clippy
        rustfmt
        openssl
        pkg-config
        jq
      ]
      ++ linuxOnly [
        # Archived upstream + Darwin build breakage — see `linuxOnly` note.
        cargo-watch
        # libfuse3-linked; Darwin needs a fuse-t build instead.
        sshfs
        fuse3
        gdb
      ];
    };

    desktop = {
      description = "windowed / GUI applications";
      packages = [
        vlc
        inkscape
        ffmpeg-full
        imagemagick
      ];
    };

    sysadmin-tools = {
      description = "sysadmin tools such as lsof, htop, ripgrep";
      packages = [
        bat
        btop
        eza
        fd
        file
        fzf
        htop
        lsof
        nmap
        ripgrep
        rsync
        tcpdump
        tree
        unzip
      ]
      ++ linuxOnly [
        ethtool
        iotop
        lm_sensors
        strace
        usbutils
        # Bundle entry that may resolve to `null`, gated on a host option.
        # The `null` is stripped by the filter below, so this inline
        # conditional is safe. Flip `programs.enableGpuTools` per host.
        (if config.programs.enableGpuTools then btop-cuda else null)
      ];
    };
  };

  inherit (builtins)
    mapAttrs
    attrValues
    concatMap
    filter
    ;
  inherit (lib) filterAttrs;

  # description -> `{ enable = mkEnableOption ...; }`, one nested option per
  # bundle. The nesting is what makes the documented `programs.<bundle>.enable`
  # interface work: a bare `mkEnableOption` would declare `programs.<bundle>`
  # itself as the boolean, and setting `programs.<bundle>.enable = true` on that
  # is a type error ("not of type boolean").
  makeEnableOptions = mapAttrs (_: value: {
    enable = lib.mkEnableOption value.description;
  });

  # The bundles whose enable flag is set for this host.
  enabledConfigurations = attrValues (
    filterAttrs (name: _: config.programs.${name}.enable or false) configurations
  );

  # Flatten to a package list, dropping any `null` entries (see the null-filter
  # note above). Without this filter, a gated-off package would crash eval.
  enabledPackages = filter (p: p != null) (concatMap (x: x.packages) enabledConfigurations);
in
{
  options.programs = makeEnableOptions configurations // {
    # Extra host toggle used by the `null`-gated example above. Declared
    # outside the generated set so it isn't itself a package bundle.
    enableGpuTools = lib.mkEnableOption "GPU-accelerated variants of some tools (e.g. btop-cuda)";
  };

  config.environment.systemPackages = enabledPackages;
}