'npm is not available' — except npm is right there on PATH
A dashboard service started crash-looping after an overnight reboot. The journal was emphatic:
Frontend not built and npm is not available.
Install Node.js, then run: npm install && npm run build
dashboard.service: Main process exited, code=exited, status=1/FAILURE
dashboard.service: Scheduled restart job, restart counter is at 5.
Straightforward, apparently. Install Node. Except Node was installed — the app ships its own copy — and both binaries were sitting on the service’s PATH:
$ which npm node
/home/appuser/.dashboard/node/bin/npm
/home/appuser/.dashboard/node/bin/node
The error was flatly false. And the reason it was false turned out to be more interesting than the error itself.
What was actually wrong
Run the binary directly and the real failure surfaces immediately:
$ /home/appuser/.dashboard/node/bin/node --version
node: error while loading shared libraries: libatomic.so.1:
cannot open shared object file: No such file or directory
node could not start. Not “wasn’t found” — found, and unable to execute.
The app resolves npm with something shaped like this:
npm := findNodeExecutable("npm") // validates by running it
if npm == "" {
say("Frontend not built and npm is not available.")
}
That validation-by-execution is a good design. It exists because a bare which check is genuinely insufficient — a dangling symlink still resolves, then fails at exec with a confusing 127. So the app runs its candidate and only trusts it if it actually works.
But the failure message never distinguished “I couldn’t find npm” from “I found npm and it wouldn’t run.” Those need completely different fixes, and the one it printed sent me toward installing software that was already there.
Why libatomic was missing
libatomic is the GCC atomics runtime. Node.js is linked against it.
- On Debian/Ubuntu, it arrives as part of the base GCC runtime. Effectively always present, which is why nobody thinks about it.
- On RHEL/CentOS/Rocky minimal, it is not installed. It’s one
dnf install libatomicaway in baseos, but nothing pulls it in by default.
This host had been migrated Debian → CentOS Stream about three weeks earlier. The migration was careful, validated, reboot-tested. And it still planted this.
Why it took three weeks to go off
This is the part worth internalising. dnf history showed libatomic had never been installed — not removed, never present. So the bundled Node had been incapable of running since the migration.
The service didn’t care, because it had a prebuilt dist/ carried over from the old host — built on Debian, where Node worked fine. Serving static assets needs no Node at all. The broken runtime sat there, unexercised, for three weeks.
Then an app update replaced the frontend sources and invalidated that build. Its own log even said so:
Stopping dashboard process: the running backend no longer
matches the updated frontend. Restart it when you're ready.
systemd’s Restart=always brought it straight back, the app tried to rebuild, needed npm for the first time since the migration — and the bomb went off.
A dependency that is only used on a rebuild path is invisible until something forces a rebuild. Your migration validation almost certainly does not force one.
And then a second fault
Installing libatomic got node --version working. It did not get the service working. The error simply changed:
npm install failed
The npm debug log had the real reason:
error notsup Not compatible with your version of node/npm: app@1.0.0
error notsup Required: {"node":">=26.0.0","npm":">=12.0.0"}
error notsup Actual: {"node":"v26.5.1","npm":"11.17.0"}
Node was fine. npm was one major version short. Upstream had bumped the requirement to npm 12 the previous day, but the bundled Node 26.5.1 ships npm 11.17. The runtime was internally inconsistent with its own package.json.
One trap while fixing that: npm config get prefix returned ~/.local, but the service resolves npm from .dashboard/node/bin first. A plain npm i -g npm@12 would have installed to a directory nothing reads. The prefix has to be aimed at the runtime you’re actually repairing:
npm install -g --prefix /home/appuser/.dashboard/node npm@12.0.2
Worth taking a backup of the old lib/node_modules/npm first — it’s 17 MB and it’s your rollback if the upgrade goes sideways.
Fixing it for good
The one-off fix is a package install. The durable fix is putting it in whatever converges your hosts, because the next bundled-Node application will hit exactly the same wall. Package names differ, so key it off the OS family:
- name: Ensure baseline packages are present
ansible.builtin.package:
name: "{{ baseline_packages }}"
state: present
vars:
libatomic_pkg:
RedHat: [libatomic]
Debian: [libatomic1]
baseline_packages: >-
{{ ['chrony', 'curl', 'ca-certificates']
+ libatomic_pkg.get(ansible_os_family, []) }}
Unknown families add nothing rather than guess at a name that doesn’t exist.
The takeaway
Two things I’d want to remember.
“X is not available” from a tool that validates by execution means “X is not runnable.” Those are different sentences and only one of them is actionable. If you see this class of message, skip straight to running the binary yourself — ldd and --version will out-diagnose the error text in seconds.
Cross-distro migrations inherit the source distro’s implicit assumptions. Anything shipping a bundled runtime — Node, Python, Electron, a vendored toolchain — was linked on a machine whose base image quietly satisfied its dependencies. The new box may not, and you will not find out during cutover validation, because the thing that needs it hasn’t been asked to run yet. If you migrate a host, it’s worth explicitly exec’ing every bundled interpreter you shipped, before something else does it for you at 03:00.
Hostnames, paths, identifiers and product-specific strings in this post are illustrative. The failure modes, commands and fixes are real.