Language ecosystems (part B)

Updated

July 30, 2026

Language ecosystems (part B)

Goal: Package a second language ecosystem tool so the patterns transfer: FODs for language deps, helpers vs raw stdenv, and how overrides differ across builders.

Why it matters

At work you will not stay monoglot. The point is not fluency in five builders—it is recognizing the same skeleton:

src FOD → language-dep FOD → build/install hooks → $out/bin → meta

Part A was depth. This chapter is transfer with a deliberately smaller scope: one binary, clear version, known license.


Theory 1 — Pattern map across ecosystems

Concern Go Rust Python Node
Source pin src.hash src.hash src.hash / fetchPypi src.hash
Dep pin vendorHash cargoHash / lock package set / frozen npmDepsHash
Builder buildGoModule buildRustPackage buildPython* buildNpmPackage
Skip tests doCheck doCheck / cargo flags pytest hooks often build-only
Binary name module main crate bin meta.mainProgram meta.mainProgram
Cross GOOS/GOARCH care target triples often pure often pure JS
Override flavor overrideAttrs overrideAttrs overridePythonAttrs overrideAttrs

When stuck, ask: which hash is wrong—source or language deps?


Theory 2 — Choose B deliberately

If part A was… Good part B pick Why
Go Python CLI or Rust Different dep story
Rust Go small tool Faster vendor cycle
Python Node or Go Leaves interpreter wrapping
Node Python or Go Sees compiled vs interpreted

Avoid starting a second multi-crate workspace. One binary, clear version, known license.

Transfer questions (answer in notes)

  1. What is the second FOD field named?
  2. Where does the binary land under $out?
  3. What is the fastest hash-mismatch workflow for this builder?
  4. Would I pick this ecosystem for internal CLIs at work? Why?

Theory 3 — Wrapping and runtime deps

Interpreted ecosystems often need wrappers:

{ stdenv, lib, python3, makeWrapper }:
stdenv.mkDerivation {
  pname = "tool";
  version = "0.1.0";
  dontUnpack = true;
  nativeBuildInputs = [ makeWrapper ];
  installPhase = ''
    runHook preInstall
    mkdir -p $out/bin
    cat > $out/bin/tool <<'EOF'
    #!/bin/sh
    echo tool
    EOF
    chmod +x $out/bin/tool
    wrapProgram $out/bin/tool \
      --prefix PATH : ${lib.makeBinPath [ python3 ]}
    runHook postInstall
  '';
  meta = {
    mainProgram = "tool";
    license = lib.licenses.mit;
  };
}
Situation Prefer
Official language builder exists Use it
Need one env var or PATH sibling wrapProgram / makeWrapper
Need whole tree of modules Builder’s dependency graph
Need different interpreter Override package set / python version

Python applications usually wrap PYTHONPATH via the builder—do not reimplement unless you must.


Theory 4 — Overrides preview

Deep dive lives in the patching chapter; know these exist:

# Change flags without forking the whole expression
helloGo.overrideAttrs (old: {
  ldflags = (old.ldflags or []) ++ [ "-X main.extra=eco-b" ];
})
# Python package override inside python3.pkgs
python3.pkgs.requests.overridePythonAttrs (old: {
  doCheck = false;
})
# Node / generic
helloNode.overrideAttrs (old: {
  postPatch = (old.postPatch or "") + ''
    substituteInPlace package.json --replace-fail '"private": true' '"private": false'
  '';
})

Composition rules matter later—for now, confirm your ecosystem B package can be lightly tweaked without a full fork.


Theory 5 — Cross-ecosystem FOD discipline

Whatever language B you chose, fetchers obey the same rules as the fetchers chapter:

Rule Why
Pin URL + hash Reproducible
Vendor offline where possible CI without network flakiness
Prefer language-native lock → Nix conversion carefully Don’t invent hashes by hand when tooling exists
Never commit fakeHash CI will fail or worse, someone “fixes” blindly
# Always capture both hashes when documenting
echo "src.hash=…"
echo "langDep.hash=…"   # vendorHash / cargoHash / npmDepsHash

Theory 6 — Runtime vs build-time closure

nativeBuildInputs  → tools for build (compilers, make)
buildInputs        → linked/used at build; may propagate
propagated*        → infect dependents (use sparingly)

A package that “works in nix develop” but fails as nix build often mixed shell conveniences with missing buildInputs.

Ecosystem Common closure weight
Pure Go (CGO_ENABLED=0) Small
Rust release bin Small–medium
Python app Interpreter + site-packages
Node CLI Node runtime + modules (or bundled)
nix path-info -Sh ./result
nix path-info -rS ./result | sort -k2 -n | tail -n 20

Theory 7 — Dual interface pattern

Make both A and B print something comparable so checks share structure:

eco-a: hello from ecosystem A
eco-b: hello from ecosystem B

Or both support --version with Nix-injected versions. Unified checks then look like:

both-run = pkgs.runCommand "both-run" {
  nativeBuildInputs = [ a b ];
} ''
  ${a}/bin/${a.meta.mainProgram} --version
  ${b}/bin/${b.meta.mainProgram} --version
  touch $out
'';

Worked example — dual packages in one flake

{
  description = "language ecosystems A+B lab";
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";

  outputs = { self, nixpkgs }:
    let
      systems = [ "x86_64-linux" "aarch64-linux" ];
      forAllSystems = nixpkgs.lib.genAttrs systems;
    in {
      packages = forAllSystems (system:
        let pkgs = import nixpkgs { inherit system; }; in {
          lang-a = pkgs.callPackage ./pkgs/lang-a.nix { };
          lang-b = pkgs.callPackage ./pkgs/lang-b.nix { };
          default = self.packages.${system}.lang-b;
        });

      checks = forAllSystems (system:
        let
          pkgs = import nixpkgs { inherit system; };
          a = self.packages.${system}.lang-a;
          b = self.packages.${system}.lang-b;
        in {
          both-run = pkgs.runCommand "both-run" {
            nativeBuildInputs = [ a b ];
          } ''
            "${a}/bin/${a.meta.mainProgram}" --help >/dev/null 2>&1 \
              || "${a}/bin/${a.meta.mainProgram}" >/dev/null
            "${b}/bin/${b.meta.mainProgram}" --help >/dev/null 2>&1 \
              || "${b}/bin/${b.meta.mainProgram}" >/dev/null
            touch $out
          '';
        });
    };
}

Tiny Python app (if A was compiled)

{ lib, python3Packages }:
python3Packages.buildPythonApplication {
  pname = "eco-b-greet";
  version = "0.1.0";
  pyproject = true;
  src = ../src/eco-b-greet;
  build-system = with python3Packages; [ setuptools ];
  meta = {
    description = "Ecosystem B greet";
    license = lib.licenses.mit;
    mainProgram = "eco-b-greet";
  };
}
# src/eco-b-greet/pyproject.toml
[project]
name = "eco-b-greet"
version = "0.1.0"
requires-python = ">=3.11"
[project.scripts]
eco-b-greet = "eco_b_greet:main"
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
# src/eco-b-greet/eco_b_greet.py
def main():
    print("hello from ecosystem B")

if __name__ == "__main__":
    main()

Tiny Node CLI (alternative)

{ lib, buildNpmPackage }:
buildNpmPackage {
  pname = "eco-b-node";
  version = "0.1.0";
  src = ../src/eco-b-node;
  npmDepsHash = "sha256-…";
  meta = {
    mainProgram = "eco-b-node";
    license = lib.licenses.mit;
  };
}

Tiny Go (if A was Python)

{ lib, buildGoModule }:
buildGoModule {
  pname = "eco-b-go";
  version = "0.1.0";
  src = ../src/eco-b-go;
  vendorHash = null;
  meta = {
    mainProgram = "eco-b-go";
    license = lib.licenses.mit;
  };
}

Lab — multi-step

Suggested workspace: ~/lab/nixos/packaging/eco-b
(or extend the ecosystem A flake)

Step 1 — Import or copy the part A package

Keep lang-a buildable so you compare logs side by side.

Step 2 — Scaffold smaller B project

Budget: under ~50 lines of application code. No microservices.

Step 3 — Package with the B builder

nix build .#lang-b -L
nix run .#lang-b

Record every hash you had to set (src + language deps).

Step 4 — Comparison table (required)

In NOTES.md:

Question Lang A Lang B
Builder name
Source hash field
Dep hash field
Time to first success
Hardest error
Where binary lands
Closure (nar size)
Would I use this at work for CLIs?

Step 5 — Unified check

Add a flake checks.* that smoke-tests both tools. nix flake check.

Step 6 — Optional same interface

Make both print a comparable line so a shell script can assert either.

Step 7 — Closure glance

nix path-info -rS ./result | sort -k2 -n | tail
nix path-info -Sh ./result

Note which ecosystem pulled a heavier closure and why (interpreter? compiler runtime? libc?).

Step 8 — Hash bump drill

  1. Intentionally change a src rev or use lib.fakeHash
  2. Run build; capture the got: hash
  3. Paste correct hash; rebuild green
  4. Write the exact error class name in notes

Step 9 — Light override

Apply overrideAttrs or overridePythonAttrs to flip doCheck or add an ldflags entry. Rebuild once.


Exercises

  1. Draw the FOD graph for A and B (boxes and arrows).
  2. Time cold build vs warm cache for each (rough wall clock).
  3. Document one override API difference between the two builders.
  4. Try nix run without mainProgram set—observe failure mode—then fix.
  5. Write a recommendation: “default internal CLI language for our team” with two bullets of rationale from your measurements.

Common gotchas

Symptom / mistake What to do
Trying to “finish” a second huge app Shrink scope; transfer patterns, not product
Mixing package sets (python311 vs python312) Align interpreters
Assuming vendorHash = null always works Only pure module-free trees
npmDepsHash churn from lock format Commit lock; pin npm
Comparing wall times unfairly Same machine, warm vs cold cache noted
Forgetting meta.license Set it; habit for nixpkgs-shaped work
Unified check hardcodes wrong bin names Use meta.mainProgram
Porting Go ldflags habits to Python Use each ecosystem’s idioms
Skipping comparison table Required—this chapter is about transfer

Checkpoint

  • Second ecosystem package builds and runs
  • Comparison table filled honestly
  • Both A and B reachable from one flake
  • At least one combined check
  • Closure notes captured
  • Hash bump drill completed
  • Light override demonstrated

Commit

git add .
git commit -m "packaging: language ecosystem B + comparison"

Write three personal gotchas before patching and overrides.