Zombie Zen

Bundling Scripts with Nix

By Ross Light

I write a lot of shell scripts. Many are one-offs or specific to a project, but every so often, I’ll have a script that transcends to become a part of my toolbelt. For example, nix-op-key is a script I wrote to generate new Nix signing keys and place them in 1Password. It’s not a task that requires a dedicated program, it just needs to glue two existing programs together: nix key generate-secret and op (the 1Password CLI). These sorts of scripts are great, but if you want to share them with someone else (or even just use it on a different computer), how do you do it? Scripts like these depend on specific programs (or maybe even specific versions) being installed and Bash does not have a package manager like pip or the go tool.

As it turns out, Nix is such a package manager. And with flakes, there’s built-in support for installing and running scripts with well-specified dependencies in a single command. For example, you can run my nix-op-key script I mentioned earlier (pinned to a specific version) with:

COMMIT=25e9bd52e977cca415df84ea91028efd92d3da92
nix run "github:zombiezen/dotfiles?dir=nix&rev=$COMMIT#nix-op-key" -- --help

Furthermore, you can install the script using the nix profile install command:

nix profile install "github:zombiezen/dotfiles?dir=nix&rev=$COMMIT#nix-op-key"

(If you try this out yourself, you can uninstall the script with nix profile remove '.*.nix-op-key').

In this blog post, I’ll show you how you can package your own shell scripts with Nix to make them more reliable and easier to share. This article assumes familiarity with Unix command line and Bash shell scripting. I’m using Nix 2.17.1. All of the source code in this post is released under the Unlicense and is available on GitHub.

To follow along, you will need to install Nix. We’re also going to be using flakes, which is an experimental feature at time of writing, so you will need to add the following line to your ~/.config/nix/nix.conf:

extra-experimental-features = nix-command flakes

Create a new directory somewhere and let’s make a script called my-hello-script.sh:

#!/usr/bin/env bash
# my-hello-script.sh
set -euo pipefail
hello --greeting='Hello, World!' "$@"

Next, create a flake.nix file in the same directory:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
  inputs = {
    nixpkgs.url = "nixpkgs";
    flake-utils.url = "flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils, ... }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = import nixpkgs { inherit system; };
      in
      {
        packages.hello = pkgs.writeTextFile {
          name = "my-hello-script";
          executable = true;
          destination = "/bin/my-hello-script";
          text =
            let
              binPath = pkgs.lib.makeBinPath [
                pkgs.hello
                pkgs.coreutils
              ];
            in ''
              #!${pkgs.runtimeShell}
              export PATH="${binPath}:$PATH"
            '' + builtins.readFile ./my-hello-script.sh;
        };
      }
    );
}

Some of this is flakes boilerplate, but let me guide you through the notable parts:

  • Line 13 gives our script its “attribute name” of hello, which is what gets used on the command line to build and run it. We’re using the standard Nixpkgs writeTextFile function to write a string as a file in a folder structure.

  • Line 14 specifies the name of the “derivation” that will be built. In practice, this just means the name of the directory. If you’re interested in learning more, see my previous post about how derivations work.

  • The destination parameter to writeTextFile on line 16 tells the function where to place the script inside its output directory. This allows us to specify the script’s name in its bin directory.

  • Line 24 is an interpolated expression that evaluates to Nixpkgs’s copy of Bash.

  • Line 25 is where things get really interesting: we can grab any package from Nixpkgs and prepend it to the PATH. This is where the Nix magic kicks in: just by writing the path to the bin/ directories into the script, Nix will automatically register those packages as runtime dependencies. From the derivation reference:

    Nix scans each output path for references to input paths by looking for the hash parts of the input paths. Since these are potential runtime dependencies, Nix registers them as dependencies of the output paths.

    In this example, we add GNU Hello and GNU Coreutils to our PATH. Lines 19-22 generate a colon-separated string of bin directories using the Nixpkgs makeBinPath function. Including Coreutils is pretty handy for most scripts because it guarantees that we’re using the same standard Unix utilities regardless of platform. For example, you no longer have to resort to strange hacks in order to portably create a temporary directory in your script: you can just write mktemp -d.

  • And finally, on Line 26 we concatenate our Nix-fueled prelude with the script that we’ve written.

Let’s see what our build generates: (If you’re following along in a Git repository, you will need to git add the files first.)

nix build '.#hello' &&
cat result/bin/my-hello-script

On my (64-bit Intel Linux) machine, I get:

#!/nix/store/q1c2flcykgr4wwg5a6h450hxbk4ch589-bash-5.2-p15/bin/bash
PATH="/nix/store/sbldylj3clbkc0aqvjjzfa6slp4zdvlj-hello-2.12.1/bin:/nix/store/bblyj5b3ii8n6v4ra0nb37cmi3lf8rz9-coreutils-9.3/bin:$PATH"
#!/usr/bin/env bash
# my-hello-script.sh
set -euo pipefail
hello --greeting='Hello, World!' "$@"

If you use the same flake.lock file as I did on a 64-bit Intel-based Linux environment, your script will be identical. In fact, you can build my Gist directly to compare:

nix build 'git+https://gist.github.com/2288c85813d9a8a161484e334c858a5f.git#hello' &&
cat result/bin/my-hello-script

You can run your script using nix run:

$ nix run '.#hello'
Hello, World!

Or you can install it into your Nix profile with nix profile install:

nix profile install '.#hello' &&
hash -r &&
my-hello-script

I hope this helps you see how helpful Nix can be, even for simple scripts. Nix can package even more complex software, so even if your script requires data files or you want to write it in another scripting language, you can still use Nix to package it up. If you’re curious to learn more about Nix works, see my post Connecting Bash to Nix.


Edit 2023-12-15: Travis A. Everett pointed out that writeShellApplication can simplify the Nix expression. writeShellApplication also validates with shellcheck and sets a few extra shell options in the resulting script. For pedagogical purposes, I’m keeping writeTextFile in the examples to cut down on how much I have to explain, but this is very useful in production shell scripts.

In researching writeShellApplication, I realized I had the opportunity to make the PATH expression easier to add to by using lib.makeBinPath, so I edited the example to incorporate it. This does not change the output of the resulting script and makes the example easier to read and build upon.

Posted at
Permalink