Migrating a Team from nvm to asdf
Your team standardised on nvm years ago, but now half the repositories also pin Python, Ruby, and Terraform, and you want one manager that reads a single .tool-versions file — so you reach for asdf or mise, run asdf current nodejs, and get No version is set for command node even though a .nvmrc sits right there in the project root. This guide, part of the toolchain version management parent topic, walks a whole team from nvm to asdf (or its faster drop-in, mise) without a single developer losing a working shell mid-migration. The core move is mechanical: translate every .nvmrc into a .tool-versions entry, run both managers side by side during a transition window, then remove nvm from shell startup only after the new shims are proven.
The reason this feels dangerous is that a version manager owns the node command on every shell you open. Swap the owner carelessly and node -v, npm install, and your editor's integrated terminal all break at once, for everyone, on the same afternoon. Done in the right order the swap is invisible: each engineer keeps a working node at every step, and the cutover is a one-line change to a shell profile that can be reverted in seconds.
Diagnostic
Confirm exactly which manager currently resolves node, and prove that asdf is not yet reading the project's .nvmrc. Run these in a project directory that contains a .nvmrc:
#!/usr/bin/env bash
set -euo pipefail
# Who owns `node` right now, and does asdf see the pin?
echo "node path : $(command -v node)"
echo "node ver : $(node -v 2>/dev/null || echo none)"
echo "nvm type : $(type nvm 2>/dev/null | head -n1 || echo 'nvm not loaded')"
echo "nvmrc : $(cat .nvmrc 2>/dev/null || echo 'no .nvmrc here')"
echo "asdf says : $(asdf current nodejs 2>&1 | head -n1)"
The BAD output that sends people here shows nvm still owning the binary while asdf refuses to resolve a version, because it has never heard of .nvmrc:
node path : /home/dev/.nvm/versions/node/v18.20.4/bin/node
node ver : v18.20.4
nvm type : nvm is a shell function
asdf says : No version is set for command node
you might want to add one of the following versions in your config file at
nodejs 20.11.1
Two facts fall out of this. First, node resolves through ~/.nvm/..., so nvm's PATH entry is winning. Second, asdf has the plugin installed and 20.11.1 available, but it will not use .nvmrc on its own — it only reads .tool-versions unless you explicitly opt into legacy files. The migration is precisely the work of closing both gaps: get asdf to honour the pin, then take node ownership away from nvm.
Root cause
nvm and asdf take opposite approaches to owning a command, and the conflict is entirely about PATH precedence. nvm is a shell function: when you run nvm use 18, it prepends ~/.nvm/versions/node/v18.20.4/bin to PATH for that shell only. The switch is imperative and per-session — open a new terminal and you are back to nvm's default until something runs nvm use again. Crucially, nvm reads .nvmrc only when you (or an nvm use shell hook) explicitly ask it to; the file is nvm's own convention, not a cross-tool standard.
asdf and mise are shim-based. Installation puts a single directory of tiny wrapper scripts — the shims — near the front of PATH once, permanently. When you run node, you are actually running ~/.asdf/shims/node, which walks up from the current directory looking for a .tool-versions file, reads the pinned nodejs version, and execs the matching real binary. Resolution happens at execution time and per directory, so cd-ing between projects changes the effective version with no use command at all. This is the behaviour you want on a team, because the pin travels with the repository instead of living in each person's muscle memory.
The No version is set error, then, is not a bug. asdf's shim found no .tool-versions on the path from the current directory to the filesystem root, and by default it does not fall back to .nvmrc. Meanwhile node still resolves through nvm because nvm's PATH entry — injected by your ~/.zshrc or ~/.bashrc at shell startup — sits ahead of the asdf shims. Both conditions are fixable in place: turn on legacy-file support so asdf reads .nvmrc during the transition, and reorder (then remove) nvm's startup so the shims win. Getting the same interpreter on every machine is the same discipline described in debugging works-on-my-machine runtime drift — a pinned runtime is a version-controlled dependency, not a personal setting.
Resolution
Work top to bottom. Every step leaves you with a working node, and nvm is not removed until the final step, so there is no window where a developer is stranded.
- Inventory the versions in play across every repository before touching anything.
- Install asdf (or mise) alongside nvm and add the Node plugin.
- Install every Node version the team currently pins.
- Convert each
.nvmrcinto a committed.tool-versions. - Enable legacy-file support so asdf keeps honouring
.nvmrcduring the transition window. - Reorder PATH so the shims win, verify, then remove nvm from shell startup.
Step 1 — inventory. Find every pin so nothing is missed. Aliases like lts/* or 18 need to become concrete versions later, so surface them now:
#!/usr/bin/env bash
set -euo pipefail
# List every .nvmrc under the current tree with its raw contents.
find . -name .nvmrc -not -path '*/node_modules/*' -print0 \
| while IFS= read -r -d '' f; do
printf '%s -> %s\n' "$f" "$(tr -d '[:space:]' < "$f")"
done
Step 2 — install asdf beside nvm. Do not uninstall nvm. Clone asdf and source it after nvm in your profile for now, so nvm still owns node until you deliberately switch:
#!/usr/bin/env bash
set -euo pipefail
git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.14.1
# Append asdf init AFTER any existing nvm lines for now.
{
echo '. "$HOME/.asdf/asdf.sh"'
} >> ~/.bashrc
asdf plugin add nodejs || true # idempotent
If you prefer mise (a faster, single-binary reimplementation that reads the same .tool-versions), install it instead and skip the plugin step, since Node support is built in:
#!/usr/bin/env bash
set -euo pipefail
curl https://mise.run | sh
echo 'eval "$("$HOME/.local/bin/mise" activate bash)"' >> ~/.bashrc
Step 3 — install the pinned versions. Feed the inventory from step 1 into the new manager so every version your repos reference exists locally before the cutover:
#!/usr/bin/env bash
set -euo pipefail
for v in 18.20.4 20.11.1 22.2.0; do
asdf install nodejs "$v" # or: mise install "node@$v"
done
Step 4 — convert .nvmrc to .tool-versions. This is the heart of the migration. A .nvmrc holds one bare Node version or alias; .tool-versions holds nodejs <version> (and any other tools) one per line. Resolve aliases to concrete versions so the file is deterministic:
#!/usr/bin/env bash
set -euo pipefail
# Convert a .nvmrc in the current dir into a .tool-versions nodejs line.
raw="$(tr -d '[:space:]' < .nvmrc)"
case "$raw" in
lts/*|node|"") ver="$(asdf latest nodejs)";; # resolve floating aliases
v*) ver="${raw#v}";; # strip a leading v
*) ver="$raw";;
esac
# Only pin a version asdf can actually resolve.
asdf list nodejs | tr -d ' ' | grep -qx "$ver" || asdf install nodejs "$ver"
if grep -q '^nodejs ' .tool-versions 2>/dev/null; then
sed -i "s/^nodejs .*/nodejs $ver/" .tool-versions
else
printf 'nodejs %s\n' "$ver" >> .tool-versions
fi
echo "pinned nodejs $ver"
Run that in each repository, review the resulting .tool-versions, and commit it. The file is now the single source of truth both the workstation and CI consume — the same pinning contract that keeps runtime parity checks between local and staging honest.
Step 5 — keep reading .nvmrc during the window. Not every repo gets its .tool-versions on day one, and contributors on feature branches still have .nvmrc. Turn on legacy-file support so asdf honours both formats until the conversion is complete:
#!/usr/bin/env bash
set -euo pipefail
echo 'legacy_version_file = yes' >> ~/.asdfrc # asdf reads .nvmrc, .ruby-version, etc.
mise reads idiomatic version files too, but gate it per tool to avoid surprises:
# ~/.config/mise/config.toml
[settings]
idiomatic_version_file_enable_tools = ["node"]
Step 6 — cut over PATH, then remove nvm. With versions installed and pins converted, make the asdf shims win. The shims must sit ahead of nvm's injected directory. The cleanest cut is to delete the nvm init lines from your profile so nvm no longer prepends anything:
#!/usr/bin/env bash
set -euo pipefail
# Comment out nvm's startup so its PATH entry is no longer injected.
sed -i 's/^\(export NVM_DIR=.*\)/# \1/' ~/.bashrc
sed -i 's/^\(\[ -s "\$NVM_DIR\/nvm.sh" \].*\)/# \1/' ~/.bashrc
exec "$SHELL" -l # reload the login shell so the new PATH takes effect
Deciding when a repository is ready to drop .nvmrc and rely solely on .tool-versions comes down to one question: has the converted pin been committed and does asdf resolve it without legacy fallback?
Expected output
After the cutover, node resolves through the asdf shim, and asdf current nodejs reports the version straight from .tool-versions with no error:
$ command -v node
/home/dev/.asdf/shims/node
$ asdf current nodejs
nodejs 20.11.1 /home/dev/project/.tool-versions
$ node -v
v20.11.1
$ type nvm
bash: type: nvm: not found
nvm is gone from the shell, the shim owns node, and the reported version's source column points at the repository's committed .tool-versions. cd into a sibling project pinned to 18.20.4 and node -v follows the file automatically — no use command, no per-shell state. Open a brand-new terminal and repeat the check: because the shims are injected once at login rather than per invocation, the result is identical without running anything by hand. That reproducibility is the whole reason to leave nvm behind — the version a command resolves to is now a property of the directory you stand in, committed to the repository and identical for every engineer who clones it.
Prevention
Migrations regress when a stray .nvmrc reappears or a developer's profile silently reintroduces nvm. Lock the outcome in.
- Add a pre-commit check that fails if a repository still ships a
.nvmrcwithout a matching.tool-versions, so every future clone gets the pin:
#!/usr/bin/env bash
set -euo pipefail
if [ -f .nvmrc ] && [ ! -f .tool-versions ]; then
echo "found .nvmrc but no .tool-versions — run the conversion" >&2
exit 1
fi
- Install
.tool-versionsin CI the same way the workstation does, so the runner and the laptop resolve the identical interpreter. This is the toolchain half of a broader CI/local parity effort — see reproducing CI-only test failures locally with act. - Fold
asdf installand a version assertion into your onboarding health-check script so a new hire's firstmake bootstrapproves the toolchain before any test runs.
Platform caveats
macOS (Docker Desktop): if you installed nvm via Homebrew, its init lives in
/opt/homebrew/opt/nvm/nvm.shrather than~/.nvm; grep your~/.zprofileand~/.zshrcfor both paths before assuming nvm is fully removed. asdf compiles Node from source by default, which pulls in Xcode Command Line Tools — installnodejs's prebuilt binaries by exportingASDF_NODEJS_FORCE_COMPILE=false(the default) or the build can take several minutes on a cold cache. WSL2: shell startup is often duplicated between~/.profileand~/.bashrc; remove nvm's lines from both, or a new WSL terminal will silently re-prepend nvm ahead of the shims. Keep the asdf clone on the Linux filesystem (~/.asdf), never under/mnt/c, or shim resolution crawls. Apple Silicon (ARM64): a Node version that only ships an x86_64 prebuilt for older releases will compile from source under arm64; pin to a version with a native arm64 binary (Node 16+), or expect a slow firstasdf install.
Rollback
If the cutover breaks a workflow — a global npm CLI installed only under nvm, say — restore nvm's ownership in one step by un-commenting its startup and reloading the shell. Because you never uninstalled nvm, the versions are still on disk:
#!/usr/bin/env bash
set -euo pipefail
sed -i 's/^# \(export NVM_DIR=.*\)/\1/' ~/.bashrc
sed -i 's/^# \(\[ -s "\$NVM_DIR\/nvm.sh" \].*\)/\1/' ~/.bashrc
exec "$SHELL" -l # nvm's PATH entry is injected again ahead of the shims
Frequently Asked Questions
Does asdf read .nvmrc automatically?
No, not by default. asdf only reads .tool-versions out of the box. To make it honour .nvmrc during a migration, set legacy_version_file = yes in ~/.asdfrc; with that flag the nodejs plugin will parse .nvmrc (and other tools their idiomatic files). Treat it as a transition aid — once every repo has a committed .tool-versions, you can turn it back off.
Can I run nvm and asdf at the same time during the migration?
Yes, and you should. Install asdf without removing nvm, and keep nvm's init ahead of the shims so nvm keeps owning node until you are ready. Whichever manager's directory sits first on PATH wins, so nothing changes until you deliberately reorder or remove nvm's startup lines. This is what makes the migration reversible at every step.
Should the team switch to mise instead of asdf?
mise is a single Go binary that reads the same .tool-versions, resolves versions faster, and needs no separate shim-rehash step, so it is a reasonable target — the migration steps are identical apart from installation. If you already scripted around asdf plugins or need a specific asdf plugin mise lacks, stay on asdf. Either way the committed .tool-versions is portable between them.
What happens to lts/* and other floating aliases in .nvmrc?
.tool-versions wants a concrete version, so resolve floating aliases at conversion time. Map lts/*, node, or a bare major like 18 to the exact installed version (for example asdf latest nodejs) and write that number into .tool-versions. Pinning the exact version is the point — a floating alias reintroduces the drift you are migrating away from.