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.

How runtime drift produces a build failure A left-to-right flow from an identical git clone through a shell resolving a different Node version to a parse error at build time. Same Code, Different Runtime git clone identical bytes shell resolves node v18 vs v20 transpile step parses new syntax outcome pass or throw The only variable that changed between machines is the resolved runtime.
Drift enters at the shell's version resolution step, not in the repository, which is why the same commit fails on only some machines.

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.

  1. 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 --version
  2. Add 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 list
  3. Ask your teammate for their exact versions, or read them off the working machine. The asdf current command 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.2
  4. Write the pinned .tool-versions at 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.2
  5. Install the pinned versions and let asdf activate them. Running asdf install with no arguments reads .tool-versions and fetches every version listed.

    #!/usr/bin/env bash
    set -euo pipefail
    asdf install
    asdf reshim
    asdf current
  6. Commit 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"
Ambient global runtimes versus a committed tool-versions file A comparison of relying on globally installed runtimes against a committed tool-versions file read by a version manager, across four properties. Global Install vs .tool-versions Global runtimes version set per machine engines field only warns setup lives in a wiki drift discovered at build Committed .tool-versions version set per checkout manager enforces it setup is in version control drift caught on cd
Committing the runtime declaration moves version selection from each workstation into the repository, where a reviewer can see it change.

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.

  1. 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
    fi
  2. Enforce 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)"
  3. Bump versions as a reviewable change. Editing a version in .tool-versions becomes 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.

Onboarding failures before and after pinning the toolchain Bar chart comparing the share of new-clone build failures attributed to runtime drift before pinning, after pinning locally, and after adding a CI gate. Clone-to-Build Failures From Drift no pinning 41% .tool-versions 12% + CI gate 3%
Committing the file removes most drift on its own; a CI gate closes the remaining gap where someone skips the local install.

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 with xcode-select --install, and for older Python versions that predate universal2 wheels, set PYTHON_CONFIGURE_OPTS="--enable-framework" before asdf install.

macOS (Homebrew): a Homebrew-installed node earlier on your PATH will shadow the asdf shim. Confirm resolution with which 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-versions read across the /mnt/c boundary 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.