Reproducing a Teammate's Toolchain with a .tool-versions File
A teammate's branch builds cleanly on their laptop, but on yours the same npm run build dies with error Unexpected token '||=' or npm warn EBADENGINE Unsupported engine ... Expected {"node":">=20.11.0"} Got {"node":"v18.19.0"} — the code is identical, so the difference is the runtime, not the repository. This guide is part of toolchain version management within the broader work of onboarding architecture and friction mapping, and it fixes runtime drift permanently by committing a single .tool-versions file that pins every interpreter and CLI the project needs. Once that file is in the repository, "which Node am I on?" stops being a question anyone has to ask, because the version manager reads the answer out of version control the moment you cd into the directory.
Diagnostic
First confirm the failure is a version mismatch and not a genuine code error. Print the runtime your shell is actually resolving, then compare it against what the project declares.
#!/usr/bin/env bash
set -euo pipefail
echo "node: $(node --version)"
echo "python: $(python --version 2>&1)"
echo "declared engines:"
node -e 'console.log(JSON.stringify((require("./package.json").engines)||{},null,2))'
On the machine that fails you will see the drift plainly — a runtime older than the code expects:
node: v18.19.0
python: Python 3.10.12
declared engines:
{
"node": ">=20.11.0"
}
The build error itself is a downstream symptom. Node 18 does not parse the logical-assignment and newer syntax the code was written against, so the transpile step throws before it ever reaches your changes. Reproduce it directly to be certain the interpreter is the culprit rather than a stale node_modules:
#!/usr/bin/env bash
set -euo pipefail
rm -rf node_modules
npm ci
npm run build
If npm ci prints npm warn EBADENGINE and the build fails the same way on a clean install, the dependency tree is fine and the interpreter is wrong. The failure travels with the machine, not the branch — which is the defining signature of runtime drift. A quick way to prove that to yourself, and to anyone who insists the branch is broken, is to run the identical commit on the machine that works and watch it pass: the delta is not in the files git tracked, it is in the runtime git never saw.
The same diagnostic generalizes beyond Node. A Python service that imports tomllib runs on 3.11 and up but raises ModuleNotFoundError: No module named 'tomllib' on 3.10, and a Ruby project using pattern matching fails to parse on an interpreter a minor version behind. In every case the shape is the same — an interpreter older than the syntax or standard-library feature the code assumes — so the first move is always to print the resolved version and hold it against what the project expects rather than to start bisecting application code.
Root cause
Nothing in the repository tells your shell which Node or Python to use, so each machine falls back to whatever the developer happened to install globally — a Homebrew node@18 here, a pyenv default of 3.10 there, a system Python somewhere else. The engines field in package.json is only advisory: npm prints a warning and continues, it does not switch interpreters. Without a version manager reading a committed declaration, the runtime is an ambient property of the workstation rather than a property of the project, and ambient properties drift the moment two people set up their machines on different days.
The drift is invisible precisely because every developer's setup looked correct at the time they did it. Someone installed the current Node when they joined six months ago; a newer hire installed the current Node last week; those are different patch versions, and neither person did anything wrong. The problem is that "the current Node" is a function of the calendar, not the project, so the toolchain silently forks the longer the team runs. A committed .tool-versions file removes the ambiguity by making the required versions part of the checkout, and a version manager such as asdf activates them automatically the moment you cd into the directory — turning the runtime from something each person remembers into something the repository declares.
Resolution
Install a version manager, add the plugins for each runtime, pin exact versions in a committed .tool-versions, and let every developer resolve the same toolchain from the file. The steps below use asdf because a single one of its .tool-versions files covers every language a polyglot repository needs; the same idea applies with mise or rtx, which read the identical file format, so nothing here locks you to one manager.
Install asdf and confirm it is on your PATH. Use the package manager for your OS, then source it into the shell.
#!/usr/bin/env bash set -euo pipefail git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.14.1 # shellcheck source=/dev/null . "$HOME/.asdf/asdf.sh" asdf --versionAdd a plugin for each runtime the project uses. Plugins teach asdf how to build or download a given tool.
#!/usr/bin/env bash set -euo pipefail asdf plugin add nodejs || true asdf plugin add python || true asdf plugin listAsk your teammate for their exact versions, or read them off the working machine. The
asdf currentcommand prints the resolved version and the file it came from; on the machine that builds, run it to capture the source of truth.#!/usr/bin/env bash set -euo pipefail node --version # e.g. v20.11.1 python --version # e.g. Python 3.12.2Write the pinned
.tool-versionsat the repository root. One tool per line, name then exact version — no ranges, because ranges reintroduce the drift you are removing.nodejs 20.11.1 python 3.12.2Install the pinned versions and let asdf activate them. Running
asdf installwith no arguments reads.tool-versionsand fetches every version listed.#!/usr/bin/env bash set -euo pipefail asdf install asdf reshim asdf currentCommit the file so it becomes part of the checkout. From this point every clone carries the runtime contract.
#!/usr/bin/env bash set -euo pipefail git add .tool-versions git commit -m "Pin Node and Python via .tool-versions"
The order of these steps matters. Adding the plugins before writing .tool-versions means asdf install in step five has everything it needs to resolve every line in one pass; if you write the file first and a plugin is missing, asdf reports No such plugin and stops, which is a confusing failure for a new hire who copied the file from a teammate but has an empty plugin list. The reshim in step five is easy to skip and easy to regret: shims are the small executables asdf puts on your PATH, and a freshly installed version has none until you reshim, so node may keep resolving to the previous version until you do. Running asdf current last is the confirmation that the whole chain worked — it prints the resolved version and the file that decided it, which is the single most useful line of output when you are debugging why a command ran the wrong interpreter.
Expected output
After asdf install, re-run the diagnostic. The runtime now matches the file rather than the machine, and asdf current names .tool-versions as its source:
python 3.12.2 /home/dev/project/.tool-versions
nodejs 20.11.1 /home/dev/project/.tool-versions
node --version prints v20.11.1, the EBADENGINE warning is gone, and the build completes:
$ node --version
v20.11.1
$ npm run build
> build
> tsc -p tsconfig.json
Build succeeded in 4.2s
The critical detail is the third column of asdf current: it points at the committed file, proving the version came from the repository and not from a global default. When a teammate on a different OS runs the same commands, they land on byte-identical version numbers because they read the same line.
Prevention
Committing the file fixes today's drift, but nothing yet stops tomorrow's: a developer who never installs asdf still runs a global Node, and the pinned file sits in the repository doing nothing for them. Prevention means making a mismatched runtime loud instead of silent — pin the versions once, then add a check at commit time and a check in CI so an un-pinned or divergent runtime cannot slip through unnoticed.
Fail fast in a pre-commit hook. Reject a commit whose resolved runtime does not match
.tool-versions, so drift never reaches history.#!/usr/bin/env bash set -euo pipefail # .git/hooks/pre-commit (or a pre-commit framework hook) expected_node="$(awk '/^nodejs/{print $2}' .tool-versions)" actual_node="$(node --version | sed 's/^v//')" if [ "$expected_node" != "$actual_node" ]; then echo "Runtime drift: .tool-versions wants nodejs $expected_node, shell has $actual_node" >&2 echo "Run: asdf install && asdf reshim" >&2 exit 1 fiEnforce the same versions in CI. Install asdf on the runner and let it read the committed file, so local and CI share one source of truth.
# .github/workflows/toolchain.yml name: Toolchain Parity on: [push, pull_request] jobs: verify: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: asdf-vm/actions/install@v3 - name: Confirm resolved runtime matches .tool-versions run: | asdf current test "$(node --version | sed 's/^v//')" = "$(awk '/^nodejs/{print $2}' .tool-versions)"Bump versions as a reviewable change. Editing a version in
.tool-versionsbecomes a one-line diff on a pull request rather than a silent, per-laptop upgrade, so a runtime bump is discussed and dated like any other code change.
The pre-commit hook and the CI job are deliberately redundant, and that redundancy is the point. The local hook gives fast feedback and keeps the mismatch out of history in the first place, but hooks live outside the repository and a developer can bypass one with --no-verify or a fresh clone that never ran git config core.hooksPath. The CI job is the backstop that no one can skip: it runs on the shared runner, reads the same committed file, and fails the pull request if the resolved runtime disagrees. Together they cover both the person who has asdf and forgot to reinstall after a version bump, and the person who never adopted asdf at all — the two populations that keep drift alive on a team. Wiring this into a one-command bootstrap is covered in writing a make bootstrap target for one-command setup, so a new hire installs the plugins and the pinned versions without reading a wiki.
Platform caveats
macOS (Apple Silicon, ARM64): some plugins compile the runtime from source. If a Node or Python build fails on
arm64, install the toolchain first withxcode-select --install, and for older Python versions that predate universal2 wheels, setPYTHON_CONFIGURE_OPTS="--enable-framework"beforeasdf install.
macOS (Homebrew): a Homebrew-installed
nodeearlier on yourPATHwill shadow the asdf shim. Confirm resolution withwhich node— it must point inside~/.asdf/shims, not/opt/homebrew/bin.
WSL2: install asdf inside the Linux distribution, not on the Windows host, and keep the repository on the native Linux filesystem. A
.tool-versionsread across the/mnt/cboundary works but the source builds are dramatically slower over the 9p mount.
Rollback
If a newly pinned version breaks something, revert the commit and reinstall the previous runtime from the restored file — every version lives in git history, so recovery is a checkout, not a reinstall from memory.
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- .tool-versions
asdf install
asdf reshim
asdf current
Frequently Asked Questions
Does .tool-versions replace package.json engines or an .nvmrc file?
It supersedes them for version selection, but keep engines as a documented guardrail. .tool-versions is the only file that a version manager actively enforces — it switches the interpreter when you enter the directory — whereas engines merely warns and .nvmrc covers Node alone. A single .tool-versions pins Node, Python, and any CLI at once, so you can retire per-tool files and keep engines purely as human-readable intent that CI can cross-check.
Why pin an exact version like 20.11.1 instead of a range such as 20?
A range reintroduces the drift you are removing. nodejs 20 resolves to whatever 20.x each developer last installed, so two machines can both satisfy the range and still run different patch versions with different bug fixes. An exact 20.11.1 makes every checkout resolve to identical bytes, which is the entire point of committing the file. Bump the exact version deliberately as a reviewed pull request when you want to move.
The command still runs the old version after asdf install. What is wrong?
Almost always a stale shim or a shadowing binary. Run asdf reshim to regenerate the shims for the newly installed version, then check which node resolves inside ~/.asdf/shims rather than a Homebrew, system, or nvm path earlier on your PATH. If another manager owns the PATH entry, remove its initialization from your shell profile so asdf's shims win.
Do teammates need to install every plugin manually before asdf install works?
Yes — plugins are per-machine because they contain the build or download logic, and .tool-versions only lists versions, not plugins. Smooth this over by adding the plugin commands to a bootstrap script so a new hire runs one command. A short loop that reads the tool names out of .tool-versions and calls asdf plugin add for each removes the manual step entirely.