All posts

Some Dependencies Can't Be Forked

Published May 20, 2026 · Martín Fernández

A month ago, in Open Source Became More Reachable. The Standard Didn't., we wrote about forking Bun because the upstream PRs we needed were sitting in a queue and waiting wasn't an option. That was the easy version of the build-vs-wait decision. There was a fix; we held it locally until it landed.

This time the shape of the dependency was wrong, and no fork could change that.

What Actually Happened

Dap runs user code inside Dev Containers. The reference implementation of the spec is @devcontainers/cli — a Node binary. Embedding it in our Go runtime meant six different call sites shelling out to a child process, parsing JSON substrings from stdout to detect failures, and serializing every interaction through CLI flags.

That setup had specific costs we'd been absorbing for months:

  • devcontainer up exits 0 while emitting "outcome":"error" on stdout. Failure detection was string-matching.
  • Every interaction was a fresh Node process. No streaming build progress, no reusing a resolved config, no introspection mid-lifecycle.
  • A Node runtime in our container image we'd rather not ship.
  • Env vars, mounts, and extra args serialized through --remote-env flag soup with manual quoting.

None of these are bugs in @devcontainers/cli. They're the natural consequences of using a CLI as if it were a library. The tool is doing exactly what it was built to do. We were the ones holding it wrong.

So we wrote crunchloop/devcontainer: a Go library that implements the spec as an embeddable runtime. No Node, no shell-out, no CLI-flag-shaped API. Typed errors, structured events, context.Context everywhere. Apache 2.0. Alpha, but stable enough that Dap runs on it today.

The Tension We Worked Through

Build-your-own is the most overused move in software. The default critique is correct: most teams reach for "we'll write our own" because they don't want to read someone else's code, and they end up with a half-built version of what they replaced.

We held that critique against ourselves for a while. The questions we kept returning to:

  • Was the upstream tool wrong, or were we using it wrong? Both, but not symmetrically. The CLI is correct as a CLI. Our use case wasn't a CLI use case.
  • Could a fork fix this? No. The shape mismatch wasn't a bug; it was the product.
  • Could we live with shelling out forever? Probably. But every new feature in our runtime — streaming, structured events, programmatic lifecycle — would have to be smuggled through a CLI boundary that was never designed to carry it. The debt compounded.
  • Is the spec stable enough to implement once and maintain? Yes. Dev Containers is a narrow, well-documented spec. Most projects use a small subset. We could be faithful to that subset without chasing a moving target.

The decision became clearer once we stopped framing it as build-vs-buy and started framing it as: what's the shape of the dependency we actually need? A library. The thing we had was a CLI. No amount of forking turns one into the other.

What This Library Is, And Isn't

The README is explicit about scope. The library covers the embedding-relevant subset of the spec: image and Dockerfile sources, compose, features (OCI/HTTPS/local with DAG ordering), full lifecycle phases, substitution, mounts, env, users, secrets. It deliberately doesn't cover templates, dotfiles, IDE injection, or Kubernetes drivers. Those are different problems for different consumers.

Two backends share the same Runtime interface: Docker (the default) and Apple's container runtime (darwin/arm64, via a Swift bridge). The engine, feature pipeline, and lifecycle code don't know which one is wired in.

The Apple backend isn't there because we needed Apple Silicon support badly enough to justify a Swift bridge on its own. It's there for three reasons, in this order:

  1. To prove the runtime abstraction actually abstracts. A Runtime interface that only ever has one implementation is wishful typing. The second backend is what tells us the seams are in the right places — or forces us to move them when they aren't.
  2. To prove our compose orchestrator is runtime-agnostic. We wrote an in-process compose orchestrator that drives any Runtime implementing the compose primitives. The only way to know it's actually runtime-agnostic, and not accidentally Docker-shaped, is to point it at something that isn't Docker.
  3. There's a third reason tied to some experiments we're running. Not ready to talk about it yet.

The Apple backend ships with a clear list of upstream limitations — port forwarding parsed but not actuated, healthcheck-dependent compose gates refused at plan-validate with typed errors, service-name DNS patched via /etc/hosts. None of these are pretended away. If you read the README and decide the Docker backend fits you better, that's a feature.

What This Costs Us

Owning a library is not free. We're now on the hook for:

  • Tracking the Dev Containers spec as it evolves.
  • Maintaining two runtime backends, including a Swift bridge that talks to Apple's apiserver.
  • Keeping integration tests green against real Docker and a real container apiserver in CI.
  • Reviewing contributions from outside, eventually, with the same standards we outlined in Open Source Became More Reachable. The Standard Didn't..

We took that on because the alternative — staying coupled to a CLI that wasn't built for embedding — was a slower, quieter cost that would have shown up forever in opaque failures and serialization workarounds.

The Question Underneath

There's a version of this story that reads as "we wrote our own and it's better." That's not the story. The story is that the shape of the tool you're depending on determines what kinds of problems you can solve cleanly. When the shape is wrong, you have three options: bend your problem to fit the tool, smuggle a different shape through workarounds, or rebuild the tool in the shape you need.

The first two are usually right. The third is occasionally right. The mistake is letting habit decide which one you reach for.

The question we keep coming back to, and the one we'd ask anyone considering this move: are you replacing the tool because its shape is wrong for your problem, or because reading other people's code is harder than writing your own?

If you can't tell the difference, don't build it yet.