jack@macbook-pro: ~/kavanagh.nl/writing/packaging-aiostreams-for-nixpkgs.md
← Writing

2026-08-15

What it took to get a new package into nixpkgs with Claude Code

I wanted a second, low-stakes way to build up nixpkgs credibility for a client-facing consulting identity — something more substantial than an orphaned two-letter CLI tool, but not a multi-week undertaking either. I run AIOStreams (a Stremio addon, north of 2,500 GitHub stars) in production as a plain OCI container, and it turned out nobody had packaged it for nixpkgs at all. That's a decent shape of task: real software, real users, a codebase I already understood from running it, and a clear "not packaged yet" gap rather than a crowded space with an existing maintainer. I did the packaging with Claude Code end to end — this is what that actually looked like, including the parts where it didn't just work.

The mechanical part, which mostly just worked

AIOStreams is a pnpm monorepo — four workspace packages (core, server, frontend, a small extensions bundle), TypeScript throughout, Vite for the frontend. Nixpkgs has good primitives for this now (fetchPnpmDeps, pnpmConfigHook), and Claude found and used them correctly on the first pass, pointed at immich as reference since it's a similarly-shaped package already in the tree. The fixed-output-derivation hash dance — set lib.fakeHash, build, read the real hash out of the error, repeat for both the source fetch and the pnpm store fetch — is exactly the kind of tight, mechanical, verifiable loop this tool is good at. No complaints there.

Where it stopped being mechanical

The friction showed up at the native-addon boundary. Two of AIOStreams' dependencies — yencode (a usenet decoder) and better-sqlite3 — compile C++ via node-gyp, and getting that to work inside Nix's sandboxed, network-isolated build took several genuinely wrong turns before landing:

  • pnpm rebuild silently did nothing. The natural command for "go build the native addons you skipped during --ignore-scripts install" just no-op'd, no error, no output, nothing to grep for. It took reading node-gyp's actual source inside pnpm's bundled copy to learn why: pnpm ships its own node-gyp, and that copy doesn't translate npm_config_nodedir into its internal options the way the standalone nixpkgs node-gyp on PATH does — so it tried to download Node headers from nodejs.org, failed silently offline, and gave up. The fix was to stop delegating to pnpm for this step and just cd into the dependency's directory and call node-gyp rebuild directly.
  • pnpm deploy wanted a setting the frozen lockfile didn't have. The idiomatic way to produce a clean, prod-only install for one workspace package rejected itself with a lockfile/config mismatch. Rather than fight it, we dropped it and mirrored the project's own Dockerfile runtime stage instead — copy exactly the dist and node_modules directories it copies. Less elegant, more proven: it's literally what upstream ships.
  • A version-metadata script shelled out to git, which doesn't exist for a fetchFromGitHub source with no .git directory — replaced with a hand-written file of the same shape.

None of these were hard once diagnosed. What they had in common is that the fix required reading the error, forming a real hypothesis about a specific tool's internals, and testing it — not pattern-matching against a thousand similar Stack Overflow questions, because there weren't a thousand similar questions.

The bug that only showed up on the branch that mattered

The package built cleanly, ran cleanly, and served real HTTP traffic — against the nixpkgs release I run in production. Then I went to actually open the PR against nixpkgs master, which is what CI would build it against, and the server crashed on startup with a native assertion failure inside better-sqlite3's compiled addon.

The cause: nixpkgs master had already moved to a newer Node.js 24.x point release than the one my production channel pins, and AIOStreams' locked better-sqlite3 version turned out to be incompatible with it — a real, currently-open gap between two pieces of software that don't know about each other, not a packaging mistake. Nothing about my local build would ever have surfaced this; it only exists on the exact branch a nixpkgs reviewer would actually test against.

The instinctive fix — pin the derivation to the older, known-good Node.js point release — turned out to be the wrong one on inspection. Nixpkgs' Node builds carry a patch set that's specific to each point release; the version I wanted and the version master currently builds don't carry the same patches, so overriding just the version number risked a derivation that fails to build at all, for an unrelated reason, on the next `nix-build`. We caught that by reading the two versions' build recipes side by side before writing any code — worth the five minutes it cost, since the alternative was burning a multi-minute build cycle to discover it the hard way.

The fix that actually shipped: override the one dependency, not the whole toolchain. Applied at fetch time, via a postFetch hook that patches in a pnpm dependency override and does a real network resolve inside the (network- permitted) fixed-output derivation — so both the dependency fetch and the main build see an already-correct, self-consistent lockfile, rather than two derivations trying to independently reconcile a change mid-build. Verified by deliberately reproducing the crash with the old dependency first, then confirming it was gone after.

What CI still caught after all that

After all of that, the PR's own CI failed anyway — on a linter (nixpkgs-vet) requiring two flags, strictDeps and __structuredAttrs, that nixpkgs now expects on new packages and that neither of us had reason to know about going in. Small fix, but it's a fair reminder that "builds and runs for me" and "passes the actual gate a maintainer will apply" are different bars, and the second one is the one that counts.

What this says about the tool, not just the package

The parts of this that went fastest were the parts with a tight, legible feedback loop — build, read the exact error, fix, rebuild. The parts that took real judgment were the ones where two plausible fixes existed and only one of them was actually safe once you looked past the symptom: patch the one dependency that's actually broken, not the runtime underneath it just because that's where the stack trace pointed. That's not a knock on the tool — it did the diagnosis, ran the experiments, and surfaced the tradeoff clearly enough to decide on. But deciding which of two working fixes to actually ship is still a step a person does, and pretending otherwise is how you end up with a merged PR that quietly pins a toolchain version nobody can explain in six months.

The PR is NixOS/nixpkgs#552900, open as I write this. If it gets review comments, that'll probably be worth its own follow-up.

If you're weighing where an agent actually pays for itself versus where it just moves the same work somewhere less visible, I'm happy to talk it through.