Bundling Scripts with Nix
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:
|
|
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 NixpkgswriteTextFile
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 towriteTextFile
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 itsbin
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 thebin/
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 ofbin
directories using the NixpkgsmakeBinPath
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 writemktemp -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.