Project 2: Script tool derivation
Project 2 — Real script tool with deps and wrap
Goal: Package a Bash (or tiny Python) script that depends on other programs (jq, curl, …) so the runtime closure is correct—using writeShellApplication or stdenv + wrapProgram.
| Field | Value |
|---|---|
| Baseline | Nix 2.34, nixpkgs nixos-26.05 |
| Time | ~1–3 hours |
| Depends on | Project 1 (or equivalent stdenv comfort) |
| Artifact | Runnable tool whose references include runtime inputs |
Why this project exists
Raw install of scripts often shebang-points at /usr/bin/env bash and assumes jq is on PATH. That works on your fat laptop and fails in the sandbox, on servers, and for users who nix shell only your package.
Nix’s rule: runtime tools are dependencies, not ambient hope. This project makes that rule visceral: remove jq from runtimeInputs, watch the tool fail closed, restore it, and see the store reference reappear.
Prerequisites
- Project 1 green (you know
$out/binandnix run).
- Rough idea of build-time vs run-time inputs.
- Optional reading: the stdenv phases chapter phases; wrappers are a packaging pattern, not a new language.
Theory
Three ways to ship shell tools
| Helper | Best for | Runtime deps |
|---|---|---|
writeShellApplication |
Short tools, clear API | runtimeInputs → PATH wrap |
writeShellScriptBin |
One-liners / tiny glue | Manual or none |
stdenv.mkDerivation + makeWrapper |
Multi-file layouts, custom share/ | wrapProgram |
Prefer writeShellApplication for this lab unless you need a multi-file tree.
What the wrapper actually does
Inspect the built binary:
nix build
head -40 ./result/bin/weather-labYou will typically see a shell wrapper that prefixes PATH with store paths of jq, curl, etc., then execs the real script. The script’s shebang is rewritten to a store bash when using the write* helpers—another purity win.
Closure vs references
nix-store -q --references ./result # direct refs
nix path-info -r ./result # full closure walk
nix path-info -rS ./result | sort -nk2 # size-awareIf jq is a true runtime input, its store path appears in the graph. If you only mention jq in a comment, it will not.
Build-time vs run-time (again)
| Kind | Example | In this project |
|---|---|---|
nativeBuildInputs |
makeWrapper, compilers |
stdenv+wrap path |
buildInputs |
libs to link | rare for pure scripts |
runtimeInputs |
jq, curl |
writeShellApplication |
| propagated* | infect dependents | avoid in this lab |
Lab tree
~/lab/nixos-projects/script-tool/
flake.nix
flake.lock
weather.nix # writeShellApplication path
weather-wrap.nix # optional stdenv + wrapProgram
weather.sh # only if using wrap path
NOTES.md
mkdir -p ~/lab/nixos-projects/script-tool
cd ~/lab/nixos-projects/script-toolStep 1 — Preferred path: writeShellApplication
weather.nix:
{ writeShellApplication, jq, curl }:
writeShellApplication {
name = "weather-lab";
runtimeInputs = [ jq curl ];
text = ''
set -euo pipefail
usage() {
echo "usage: weather-lab [--live URL]" >&2
echo " default: offline demo JSON (CI-friendly)" >&2
}
if [[ "''${1:-}" == "-h" || "''${1:-}" == "--help" ]]; then
usage
exit 0
fi
# Default: no network required for reproducible demos / CI.
if [[ "''${1:-}" == "--live" ]]; then
url="''${2:-https://httpbin.org/json}"
curl -fsSL "$url" | jq -c .
else
echo '{"temp":21,"city":"lab"}' | jq -r '"\(.city): \(.temp)C"'
fi
'';
}In real Nix strings, ''${...} escapes ${...} so Bash—not Nix—expands the variable. When you type the file, keep that escaping for any Bash ${...} inside '' ... '' multiline strings.
Flake
flake.nix:
{
description = "weather-lab — Project 2";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
forAll = nixpkgs.lib.genAttrs systems;
in {
packages = forAll (system:
let pkgs = nixpkgs.legacyPackages.${system};
in {
default = pkgs.callPackage ./weather.nix { };
weather-lab = pkgs.callPackage ./weather.nix { };
});
apps = forAll (system: {
default = {
type = "app";
program = "${self.packages.${system}.default}/bin/weather-lab";
};
});
};
}Build and inspect
nix flake lock
nix build -L
./result/bin/weather-lab
# expect: lab: 21C
head -30 ./result/bin/weather-lab
nix-store -q --references ./result
nix path-info -rS ./result | rg 'jq|curl|bash' || true
nix runStep 2 — Fail closed (required demo)
- Edit
weather.nix: removejqfromruntimeInputs(leavecurlor empty).
- Rebuild and run:
nix build --rebuild -L
./result/bin/weather-lab
# should fail: jq: command not found (or similar)- Restore
jq, rebuild, confirm success.
- In
NOTES.md, paste before/afternix-store -q --referenceslines showing jq appear/disappear.
This single demo is the educational core of Project 2.
Step 3 — Alternative: stdenv + wrapProgram
Use when you already have a multi-file tree or want to see makeWrapper explicitly.
weather.sh:
#!/usr/bin/env bash
set -euo pipefail
echo '{"temp":21,"city":"lab"}' | jq -r '"\(.city): \(.temp)C"'weather-wrap.nix:
{ stdenv, lib, makeWrapper, jq, curl }:
stdenv.mkDerivation {
pname = "weather-lab";
version = "0.1.0";
src = lib.cleanSource ./.;
nativeBuildInputs = [ makeWrapper ];
dontConfigure = true;
dontBuild = true;
installPhase = ''
runHook preInstall
mkdir -p $out/bin
cp weather.sh $out/bin/weather-lab
chmod +x $out/bin/weather-lab
wrapProgram $out/bin/weather-lab \
--prefix PATH : ${lib.makeBinPath [ jq curl ]}
runHook postInstall
'';
meta = with lib; {
description = "weather-lab via makeWrapper";
license = licenses.mit;
mainProgram = "weather-lab";
platforms = platforms.all;
};
}# point flake default at weather-wrap.nix once, build, compare wrapper text
head -40 ./result/bin/weather-labNote: wrapProgram renames the original to .weather-lab-wrapped (or similar) and installs a wrapper—inspect both.
Step 4 — Optional live mode (network at runtime)
--live uses network when you run the tool, not when Nix builds it. That is allowed and normal: purity applies to the build sandbox, not to every program you ship.
Still:
- Default offline path keeps CI and classrooms network-free.
- Do not
curlduringinstallPhasewithout a FOD fetcher—that fights the sandbox.
- Document that live mode needs network + DNS.
./result/bin/weather-lab --live https://httpbin.org/json || echo "network optional"Lab tasks
- Build with
writeShellApplicationand offline JSON path.
- Remove
jqfromruntimeInputs; show runtime failure.
- Restore; show references include jq.
- Document store path of the wrapper (
readlink -f result+head).
- (Optional) Port once to
wrapProgram; note differences inNOTES.md.
Pitfalls
| Symptom | Likely cause | Fix |
|---|---|---|
Works in dev shell, fails via nix run |
Ambient PATH only | Declare runtimeInputs / wrap |
Bash ${var} eaten by Nix |
Unescaped ${ in '' string |
Use ''${var} |
curl missing only on live path |
Not in runtimeInputs | Declare it |
| Dirty rebuild loops | Outputs under src |
cleanSource; keep result out |
Acceptance criteria
nix runprintslab: 21C(or equivalent).
- Missing-dep experiment fails closed; notes capture the lesson.
- Closure/references include runtime tools (
jqat minimum).
- Wrapper inspected with
head.
flake.lockcommitted;NOTES.mdcompares wrapper vs plain install.
Stretch goals
--versionfrom installed metadata.
- Second binary in the same derivation.
checks.weatherasserting offline output.
symlinkJoin“kit” metapackage; compare closure.
Theory recap
| Question | Target answer |
|---|---|
Why not /usr/bin/jq? |
Not in the closure; not reproducible |
| Who rewrites shebangs? | write* helpers / patchShebangs |
| Runtime network impure for Nix? | Build purity ≠ runtime policy |
| When wrapProgram? | Custom layout, multi-bin |
Next: Project 3 — Language package.