It started with a small thing: OpenCode displayed this message:
Update Complete
Successfully updated to OpenCode v1.18.25. Please restart the application.
I restarted it. It told me a new version was available again.
I updated and restarted again. Same message. The updater said it had succeeded, but the version number hadn’t budged.
Following that thread, I eventually cleared 12G from my machine. I also found five background services with a path hard-coded into their configuration—a path I was about to delete. I nearly broke all five.
This is a record of the investigation: what I saw, the evidence behind each decision, and the times I got it wrong.
First layer: the update went to A, but I was running B
First, I checked how many opencode executables the system could find:
which -a opencode
# /Users/webkubor/.local/bin/opencode
# /Users/webkubor/.nvm/versions/node/v24.15.0/bin/opencode
Two. The first was a symlink. I followed it:
~/.local/bin/opencode
→ /opt/homebrew/opt/node/bin/opencode
→ /opt/homebrew/Cellar/node/23.9.0/lib/node_modules/opencode-ai/bin/opencode.exe # 1.17.8
The global npm package, meanwhile, was at 1.18.25.
Here is the timeline I reconstructed: one day I installed node with homebrew, installed opencode with npm -g, and created a symlink in ~/.local/bin. Later, I switched to nvm for managing node. npm’s global prefix changed with it. Every update since then had gone into the nvm installation, while ~/.local/bin was 7th in PATH and nvm was 21st. I had been running the copy from June all along.
Here’s the counterintuitive part:
npm ls -gshows packages under npm’s current prefix. It accurately reported that 1.18.25 was installed. But that wasn’t the executable I invoked. “What is installed?” and “What am I running?” are separate questions. Most tools answer only the first.
Second layer: npm blocked postinstall
I removed the symlink so PATH would resolve to the nvm copy. Then I got:
Error: opencode-ai's postinstall script was not run.
On reinstall, npm explained why:
npm warn install-scripts 1 package had install scripts blocked
because they are not covered by allowScripts:
npm warn install-scripts [email protected] (postinstall: node ./postinstall.mjs)
The opencode npm package is just a wrapper; postinstall downloads the actual 129MB binary. Starting with npm 12, install scripts outside the allowlist are blocked by default. That’s a real supply chain security improvement, but it has a side effect:
- npm considered the installation successful (the package was installed)
- opencode’s updater considered it successful (npm exited with code 0)
- Only the binary hadn’t changed
Nobody was lying, but the outcome was wrong. This was the most useful lesson from the incident: every link in a chain can return success without the job actually being done. There was only one reliable check: did that binary’s version number change after installation?
The fix needed both commands:
npm i -g opencode-ai --allow-scripts=opencode-ai # 这次让它跑
npm config set allow-scripts=opencode-ai --location=user # 以后都让它跑(只放行这一个包)
Run only the first, and the next automatic update will fail the same way.
Third layer: there weren’t two installations, but four
The immediate problem was fixed, but I wondered how many other tools had the same issue.
I wrote a script to scan every directory on PATH for executables with the same name, using realpath to tell genuinely distinct files apart:
在多个位置各有一份的可执行文件:12 个
claude 2 处 ⚠️ 不同实体
wrangler 2 处 ⚠️ 不同实体
dsh 2 处 ⚠️ 不同实体
lark-cli 2 处 ⚠️ 不同实体
...
Digging further, I found four sets of global node packages:
| Location | Size | Origin |
|---|---|---|
~/.nvm/versions/node/v24.15.0/lib/node_modules |
1.6G | Currently in use |
/opt/homebrew/lib/node_modules |
1.0G | homebrew node |
/opt/homebrew/Cellar/node/23.9.0/lib/node_modules |
358M | Left over from a homebrew node upgrade |
~/Library/pnpm/global |
44M | pnpm global packages |
lark-cli was the most interesting: the versions were out of order:
npm 上最新: 1.0.92
在跑的(nvm): 1.0.39 ← PATH 命中这个
闲置(cellar): 1.0.69
实际命中: ~/Library/pnpm/bin/lark-cli ← 结果两个都不是,是第四份
It handles my Feishu notifications. The fact that nothing had broken was pure luck.
Another 8.5G cleared along the way
During the inventory, I found that ~/Library/pnpm occupied 9.1G. Broken down:
8.7G store/ ← 内容寻址存储
44M global/
20K bin/
And inside the store:
4.9G store/v11/ ← pnpm 11 在用
3.7G store/v10/ ← 旧版本残留
151M store/v3/ ← 更旧的
v10 and v3 were stores from earlier versions. pnpm 11 never looks at them. I deleted those directories and ran pnpm store prune on v11:
Removed 194015 files (4.16 GB)
Removed 3473 packages
8.7G → 243M.
The pnpm store is the source of hard links. Cleaning it doesn’t break existing projects; a future install can download the packages again. But
pruneonly removes packages “not referenced by any current project.” It leaves directories from older store versions alone, so those have to be removed manually.
Switching to mise: why I didn’t choose Volta
By this point, the root problem was clear: multiple sources of node coexisted on the machine. Unless I fixed that, it would keep happening.
I looked into Volta and mise:
| Volta | mise | |
|---|---|---|
| star | 13k | 33k |
| Latest release | 2024-12-05 | 2026-08-31 |
| Last commit | 2025-11 | That day |
| open issues | 341 | 53 |
| Scope | JS only | node/python/go/rust… |
I like Volta’s design: its shims let a global CLI remember which node version it should run with, so changing node versions doesn’t make the tool disappear. That’s exactly the problem I had. But its last release was a year and a half ago, and it had 341 open issues. I didn’t want to bet my toolchain on it.
I also have pipx for Python and separate Go projects on this machine. mise could manage them with one configuration, so I chose mise.
The real danger: scan for references before deleting anything
After installing mise and moving 20 global CLIs over, I was ready to delete nvm.
First I scanned the system for references to it. That step saved me:
=== 引用 nvm 的 LaunchAgent ===
ai.hermes.gateway 🟡 只在 PATH 环境变量里
com.github.facebook.watchman 🟡 只在 PATH 里(还指向早就删掉的 v22.19.0)
com.modelgo.followup 🔴 Program 直接写死
com.webkubor.claude-codex-patrol 🟡 只在 PATH 里
com.webkubor.cpu-watchdog 🟡 只在 PATH 里
Five background services. One had ~/.nvm/.../bin/node hard-coded directly in its Program field. Delete nvm, and it would fail to start immediately.
Three more references were hiding in scripts and documentation:
~/.dsh/serve.sh— launchd doesn’t load.zshrc, so the script resolves the nvm directory itself~/.claude/bin/cs-doctor— the path was hard-coded into PATH~/.claude/CLAUDE.md— the path appeared in a repair command (documentation counts as a reference; following it would fail)
That was the biggest lesson of this cleanup: before uninstalling a runtime, checking whether any process is currently using it isn’t enough. A launchd plist, cron job, serve.sh, or even your own documentation may have its path baked in. These references are completely harmless during normal operation. They break only after you remove their target, often later, when the service next restarts.
For DSH, I changed the script to resolve the path dynamically with mise where node, keeping an nvm fallback:
MISE_NODE="$(/opt/homebrew/bin/mise where node 2>/dev/null)"
if [ -n "$MISE_NODE" ] && [ -x "$MISE_NODE/bin/dsh" ]; then
NODE_BIN="$MISE_NODE/bin"
else
NODE_BIN="$HOME/.nvm/versions/node/$(ls ~/.nvm/versions/node | sort -V | tail -1)/bin"
fi
Both paths worked during migration. I removed the fallback after verification.
Some references aren’t in files
The references above could all be found with grep. But later that day, after deleting nvm, I got bitten again: git commit failed because a hook couldn’t find node.
The hook file was clean—no mention of nvm. The problem was its inherited PATH. I had opened that terminal session before deleting nvm. Entry 21 in $PATH still pointed to ~/.nvm/versions/node/v24.15.0/bin, a directory that no longer existed. Git hooks inherit their caller’s environment, hence node not found.
# 修法:把 mise 的 node 顶到最前,并剔除已失效的 nvm 路径
export PATH="$(mise where node)/bin:$(echo "$PATH" | tr ':' '\n' | grep -v '\.nvm/versions' | tr '\n' ':')"
Don’t bypass the failure with --no-verify. That would disguise “the environment is broken” as “the gate doesn’t matter.” By the time a real problem appears, the gate is already useless.
So a reference scan needs two layers: what’s on disk (hard-coded paths in plists, scripts, and docs) and what’s in processes (PATH inherited by already running shells and resident services). grep can’t find the second layer. The way to expose it is to open a fresh terminal after deletion and rerun critical operations.
A third kind: symlinks in application data directories
The next day, after deletion, DSH (a local service I keep running) showed empty plugin and model settings in its UI.
I found 106 dangling symlinks under ~/.dsh, all pointing to the deleted nvm installation:
~/.dsh/profiles/node_modules/zwitch
→ ~/.nvm/versions/node/v24.15.0/lib/node_modules/@deepseek-ai/dsh/node_modules/zwitch
These references are the hardest to catch because:
- They are not configuration files, so
grep -r "nvm"over the configuration directory won’t find them - They are not scripts containing readable path strings
- A symlink’s target exists only in filesystem metadata; you have to inspect each link with
find -type landrealpath
More awkwardly, dependency trees can differ even for the same package version. I reinstalled the same version, 0.1.1-rc.2. Both installations were 281M, but the nvm copy’s node_modules contained zwitch/shiki/katex while the mise copy didn’t. npm had resolved different dependency trees at different times. “Reinstall the same version” therefore doesn’t mean “restore the original state.”
The outcome was straightforward: I deleted all 106 dangling symlinks and restarted. The new logs had 0 MODULE_NOT_FOUND entries and 0 errors. The service started normally. Those links were remnants of an old installation, with no feature actually depending on them. The apparently empty UI was caused by the browser holding an old connection from before the restart; a refresh fixed it.
I nearly misread the logs here. They did contain
Cannot find module '@webkubor/dsh-bloom-theme', which looked like decisive evidence. But I checked the line numbers: the error was on lines 12~157, while the marker for the 8th startup was on line 164. It never appeared after the 8th startup. It was an old error from a package rename months earlier, preserved because the logs hadn’t rotated.Check which run a log entry belongs to. Seeing a block of red text in a tail and drawing a conclusion can easily put an old problem on today’s tab.
The full checklist, then, has four kinds of references: hard-coded paths in configuration files, dynamic resolution in scripts, PATH inherited by processes, and symlinks in data directories. grep can find the first two. The third requires a fresh terminal and another run; the fourth requires find -type l.
The most expensive lesson: I repeated the old tool’s mistake with the new tool
Everything so far was an operational mistake. This one was a mistake in how I thought about the problem, and I didn’t notice it at the time.
During migration, I changed the path in five LaunchAgents, serve.sh, and cs-doctor from:
~/.nvm/versions/node/v24.15.0/bin
to:
~/.local/share/mise/installs/node/24.15.0/bin
I was pleased with the result: zero nvm references, all pointing to mise. But both paths made the same mistake: they contained a version number.
Hard-coding 24.15.0 in the path binds “where the tool lives” to “which version it is.” The day node moves to 25, all seven references fail together. As before, it will be a delayed failure: only when a service next restarts, months after the migration that caused it.
I changed tools and carried the old tool’s trap straight over.
mise already had the answer; I hadn’t used it:
~/.local/share/mise/shims/node → /opt/homebrew/bin/mise
Each command under shims/ is a shim pointing to mise itself. mise chooses the version at execution time. The path contains no version number and never needs updating. The same idea appears in pyenv / rbenv / asdf’s shims/, Volta’s ~/.volta/bin, and homebrew’s opt/<formula>/bin (rather than Cellar/<version>/).
Once everything used shims, I could remove the ~/.nvm compatibility symlink I’d created to rescue the old session. It had only ever been a patch.
Someone pointed this out to me directly. My explanation at the time was “the root cause is an old process holding a dead PATH.” They replied: “Why don’t you fix the root cause? Why do you like patching things so much?”
Looking back, I had mistaken the symptom for the root cause. The old process being unable to find node was only what I observed. The actual root cause was the design choice to hard-code a version number in a path. That explains why nvm broke, why the migration could break too, and why the next node upgrade would break it again. See that, fix seven references once, and it stops recurring. Miss it, and you keep applying patches.
I later added this check to a tool (see below), because you can’t reliably prevent this kind of mistake through vigilance alone. It looks perfectly reasonable when you’re making it.
Three times I got it wrong
An account like this wouldn’t be honest if it only described how I found the answer. I made three wrong calls:
1. I activated mise in the wrong place. I put eval "$(mise activate zsh)" after nvm, which looked sensible. But later in .zshrc there was a path=(...) line. That assigns rather than appends, wiping out the shim path mise had just added. In a fresh shell, node was still homebrew’s v23.9.0.
Moving the activation to the end of the file fixed it.
2. I used the wrong verification method and almost drew a conclusion from it. I tested with zsh -l -c '...', saw the old node version, and briefly thought the migration had failed. In fact, zsh doesn’t load .zshrc in noninteractive mode. My test itself was wrong. With zsh -i -c, everything worked.
3. I installed someone else’s package named facet. During migration, I ran npm i -g facet based on the package name and found no executable. The facet on npm is “Configuration mixin for constructors.” The facet I use is my own Markdown layout tool; it was never published to npm and had been installed through a local npm link.
A reminder for tool builders: a package name isn’t an identity. When migrating global CLIs, inspect the
resolvedfield as well as the name. Did it come from a registry, or from a local link?
There was another operational slip: brew uninstall node triggered autoremove and uninstalled ripgrep too, even though it wasn’t a node dependency. I reinstalled it. Next time I uninstall a brew package, I’ll check what else brew plans to remove.
Results
mise ── node 24.15.0 ── 21 个 CLI ← 唯一 node 来源
brew ── mise / gh / wget / ripgrep … ← 不再有 node
pnpm ── ~/Library/pnpm
pipx ── Python CLI
| Item | Reclaimed |
|---|---|
| pnpm store (older versions + prune) | 8.5G |
| nvm | 2.0G |
| homebrew node itself + global packages | 1.27G |
| Expo/RN installation (long unused) | 180M |
| Old opencode copy | 123M |
| Total | ≈ 12G |
Along the way, I also upgraded lark-cli from 1.0.39 to 1.0.92 (the Feishu notification path), upgraded claude-code from 2.1.91 → 2.1.252, and fixed a dead alias pointing to a deleted node version.
Questions I still haven’t settled
1. Why isn’t there a good tool for this? I ended up writing one.
Existing version managers answer “what have I installed?” (npm ls -g, brew list, pip list). What actually bites is “what am I running, what is shadowing it, and do the installed and running versions match?” All of that can be computed: scan PATH, deduplicate by realpath, and compare versions across locations.
That became path-guard (written in Rust, with zero third-party runtime dependencies):
$ path-guard which opencode
命令 opencode
▶ 实际执行 ~/.local/share/mise/shims/opencode
运行时版本 1.18.25
所属包 [email protected]
✅ 没有其它副本,不存在遮蔽
On the day of the incident, that command would have shown 运行时版本 1.17.8 alongside 所属包 [email protected]. The mismatch between installed and running versions would have been obvious without digging through three layers.
Three decisions I made while building it are worth recording:
- “Multiple paths” doesn’t mean “multiple copies.” homebrew’s bin layout uses many symlinks to the same file. Counting paths alone would produce a screenful of false positives; you need to deduplicate by realpath.
- Get the runtime version and the declared version separately, and get both. A divergence between them is the signature of an update that didn’t take effect. Looking at only one side misses it.
- A package name isn’t an identity. That’s why the “owning package” line includes the name too. Otherwise, a different package with the same name, like
facet, would still slip through.
Of its four subcommands, only pinned is preventive: it scans PATH for path segments with hard-coded version numbers. It came directly from “the most expensive lesson” above. These mistakes look correct at the moment they’re made, so a tool has to catch them.
2. Should an updater verify its own result? opencode’s update logic equates “npm exit code 0” with success. That worked before npm added allowScripts. Should tools read their own version number back after an update? It costs little, but I haven’t seen many do it.
3. How often should I audit the machine? The oldest zombie on my machine was cordova from 2023 年 11 月. I had changed node managers and package managers in the meantime, leaving another layer behind each time. Without the opencode error, I might never have found them.
My current idea is to schedule path-guard doctor, but I haven’t settled on a frequency: too often means noise; too rarely is almost the same as never. Maybe the better trigger is an event, not a clock: run it whenever I switch version managers, run brew uninstall, or finish a migration.
4. Where is the risk when an AI agent helps with a migration like this? Claude and I worked through the whole process together. It scanned references, edited configuration, and ran checks quickly. But the two critical mistakes weren’t technical: treating the symptom as the root cause (old processes couldn’t find node → create a compatibility symlink) and changing tools while copying the old design (nvm’s versioned path → mise’s versioned path).
Both were corrected only when I noticed something was off and said so. It was very good at executing, but a human still had to judge whether the direction itself was right. The second time, I said, “Why don’t you fix the root cause? Why do you like patching things so much?” That led directly to fixing seven references at once and adding the check to the tool.
All of this is a sample of one machine: mine. It may not be representative. How many copies of node are on your development machine?
