Project 2: Script tool derivation

Updated

July 30, 2026

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/bin and nix 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-lab

You 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-aware

If 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-tool

Step 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
  '';
}
Important

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 run

Step 2 — Fail closed (required demo)

  1. Edit weather.nix: remove jq from runtimeInputs (leave curl or empty).
  2. Rebuild and run:
nix build --rebuild -L
./result/bin/weather-lab
# should fail: jq: command not found (or similar)
  1. Restore jq, rebuild, confirm success.
  2. In NOTES.md, paste before/after nix-store -q --references lines 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-lab

Note: 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 curl during installPhase without 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

  1. Build with writeShellApplication and offline JSON path.
  2. Remove jq from runtimeInputs; show runtime failure.
  3. Restore; show references include jq.
  4. Document store path of the wrapper (readlink -f result + head).
  5. (Optional) Port once to wrapProgram; note differences in NOTES.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 run prints lab: 21C (or equivalent).
  • Missing-dep experiment fails closed; notes capture the lesson.
  • Closure/references include runtime tools (jq at minimum).
  • Wrapper inspected with head.
  • flake.lock committed; NOTES.md compares wrapper vs plain install.

Stretch goals

  1. --version from installed metadata.
  2. Second binary in the same derivation.
  3. checks.weather asserting offline output.
  4. 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.