ovm.sh / dev log

The npm that answered from the past

A routine freshness check said npm's latest Claude Code was nine days and ten releases old, and our registry was somehow ahead of the source it mirrors. The story almost shipped — a dev log post, a home-page feature. Then one curl returned a different answer than npm view, and the real story turned out to be about which of them was lying.

Published · Last updated · npm · evidence

1The claim that almost shipped

While reviewing registry freshness we asked npm what the latest Claude Code release was:

$ npm view @anthropic-ai/claude-code dist-tags
{ stable: '2.1.227', latest: '2.1.235', next: '2.1.235' }

Our own registry, claude.json, carried 2.1.247 — benchmarked, verified on both platforms. The explanation wrote itself: Anthropic now ships through its native-installer CDN first, npm publishing lags behind, and OVM — which installs Claude from that CDN — sees releases npm has not been given yet. Interesting, publishable, and worth a home-page panel so users could watch the channels drift apart.

It had one problem. None of it was true.

2One request over a different road

Before writing it down we asked the same registry the same question without going through npm:

$ curl -s https://registry.npmjs.org/-/package/\
@anthropic-ai/claude-code/dist-tags
{ "stable": "2.1.231", "latest": "2.1.247", "next": "2.1.247" }

npm was current. 2.1.247 had been published the previous day, the latest tag pointed at it, and every release between it and the version the CLI reported existed too. Same registry, same package, same machine, same minute — two answers, nine days apart.

3Ruling things out

The configured registry was the real one, not a mirror. The abbreviated metadata document npm requests — a smaller "corgi" variant of the package document — was as fresh as the full one over raw HTTP. And --prefer-online, the flag whose documented job is to force staleness checks, returned the same nine-day-old answer.

What settled it was pointing the CLI at a brand-new, empty cache directory:

$ npm view @anthropic-ai/claude-code dist-tags \
    --cache /tmp/fresh-cache
npm error code ETIMEDOUT
npm error network request to
  https://registry.npmjs.org/@anthropic-ai%2fclaude-code failed

With nothing to fall back on, the truth surfaced: on this machine, npm's network path was broken. Node could not reach the registry at all — while curl, resolving and routing on its own, reached it fine. The workstation had been in that state for nine days.

4An outage rendered as data

Every npm view in those nine days had failed. None of them looked failed: warm cache underneath, npm's fetch layer served the copy it had — well-formed JSON, no warning line, exit code 0. The outage did not render as an error. It rendered as data — old data, presented at the same confidence as live data.

Serving stale on a dead network is a defensible availability choice — an install that works offline beats one that does not. But an answer assembled from cache during an outage is evidence of what upstream looked like when the cache was written, presented as evidence of what upstream looks like now, with nothing in the output to tell the two apart. A version manager, a CI gate, or an agent reading that output has no way to know it is looking at the past.

This is the failure shape this dev log keeps returning to — refusals that look like answers, absences that render like presence. Here it is again wearing freshness: an answer that looks current. The near miss is the instructive part: the wrong story survived every check we ran through the same tool, and died on the first check that travelled a different road.

5Why the observatory was right

The registry the pipeline publishes never disagreed with npm, and the reason is structural rather than virtuous: the refresh runs on ephemeral hosted runners. A machine that is born, fetches, publishes and dies has no nine-day-old cache to be wrong from. The workstation — long-lived, warm caches, accumulated network state — is exactly where this class of error lives. The infrastructure out-freshed the operator.

That is also why nothing user-facing was ever wrong. ovm resolves versions from the published registry, not from a local npm's view of upstream. The only place the stale answer existed was in the hands of the humans and agents investigating — which is how it nearly became a home-page feature announcing a lag that did not exist.

6What changes

Observed with npm 11.11.0 on Node 24.14.1, macOS.

7Postscript: the actual break

Diagnosing the machine afterwards found the real fault, and it was not the registry, DNS, or the route. Since v20, Node connects with Happy Eyeballs (autoSelectFamily): it races address candidates and gives each attempt 250 ms before aborting it. The workstation was on a phone hotspot where a TCP connect takes 300–900 ms — so every candidate was killed just before it could complete, the whole list burned down in about three seconds, and the socket layer reported ETIMEDOUT. curl, with its own more patient fallback, connected fine; so did a plain Node socket given a literal IP, which skips the racing path entirely. A network that was merely slow rendered as one that was down.

$ node --network-family-autoselection-attempt-timeout=3000 \
    -e "fetch('https://registry.npmjs.org/-/ping')..."
OK 200

The fix is one flag — --network-family-autoselection-attempt-timeout=2000 in NODE_OPTIONS — which keeps the IPv6-to-IPv4 fallback and simply gives real links time to answer. The layering of the two failures is what made the original mystery: a too-eager timeout made Node's network look dead, and a too-quiet cache made the dead network look fine.

8References