Skip to content

feat(config): read .tool-versions when resolving versions - #291

Merged
CalvinAllen merged 1 commit into
mainfrom
feat/config/tool-versions
Oct 22, 2026
Merged

CalvinAllen merged 1 commit into
mainfrom
feat/config/tool-versions

Conversation

@CalvinAllen

Copy link
Copy Markdown
Contributor

Description

dtvem now reads .tool-versions, the multi-tool file asdf writes and mise reads. actions/setup-node, actions/setup-python and ruby/setup-ruby can all read it in CI. A repository whose versions live there gets the same Node.js, Python and Ruby on a dtvem machine as in CI and on its asdf/mise users' machines, with no extra file.

It builds on the version-file mechanism from #286, #288 and #290.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Code refactoring (no functional changes)
  • Performance improvement
  • Test coverage improvement
  • CI/CD or build process change

Related Issues

Resolves #287
Relates to #283, #284, #285, #289

Changes Made

  • Where and precedence: the same upward walk as the other version files; the nearest directory wins. Within one directory:

    1. .dtvem/runtimes.json
    2. .tool-versions
    3. the runtime's own files (.node-version/.nvmrc, .python-version, .ruby-version)

    This is asdf's order when it reads those files too. ruby/setup-ruby uses the opposite order. The global version stays the fallback.

  • Finding the runtime's line: by tool name: nodejs (asdf) or node (mise) → Node.js, python → Python, ruby → Ruby. Names are case-sensitive and must match exactly. Lines for tools dtvem doesn't manage (golang, terraform, …) are ignored.

  • A file without the runtime does not stop the walk. A .tool-versions with no line for the runtime is passed over. So a ruby-only file in a subproject doesn't hide the same directory's .python-version, or a nodejs pin in a parent directory.

  • Syntax:

    • Each line is <tool> <version> [<version>...].
    • Blank lines, # comments (including after a version) and tabs or CRLF are fine.
    • The first version on the line is used: asdf treats the rest as fallbacks, and setup-ruby also takes the first. It goes through the runtime's usual rules: exact or partial, optional v/python-/ruby- prefix, and Ruby pre-releases.
  • Unsupported forms are reported, not replaced by the global version, and the error names the tool, since one file pins several:

    • system, ref:…, path:…, aliases like lts
    • anything else the runtime's rules reject
    • a tool listed without a version

    For example: …\.tool-versions asks for python "system": dtvem supports only version numbers (x, x.y or x.y.z) and …\.tool-versions lists nodejs without a version. VersionFileError gains a Tool field for this. An unsupported first version is not skipped in favour of a later one on the line.

  • Not installed / visibility: as for the other files. The shim names .tool-versions and suggests dtvem install <runtime> <version>, and dtvem current shows it in the Source column.

  • Home directory: the walk reaches $HOME, so an asdf user's ~/.tool-versions (written by asdf set --home) applies everywhere under the home directory, ahead of dtvem's global config. That's asdf's own behaviour, and feat(node): read .node-version and .nvmrc when resolving the version #283 already treats a ~/.nvmrc the same way. The issue asked for this to be documented; it's now in CLAUDE.md, and the dtvem.io docs live in a separate repo.

  • Docs: README feature line, dtvem current --help, and the CLAUDE.md "Version Resolution" section.

Testing

Test Environment

  • OS: Windows 11
  • Go Version: 1.25.4 (module targets 1.23)
  • dtvem Version: built from this branch; resolved against a real 0.6.5 install (Node 22.0.0/22.12.0/25.2.0, Python 3.8.9/3.9.9/3.12.12, Ruby 3.3.9/3.4.7; globals 25.2.0, 3.12.12, 3.4.7)

Test Steps

  1. ./rnr check: gofmt, golangci-lint (0 issues), and all tests pass.
  2. New unit tests in src/internal/config/versionfile_test.go:
    • Parsing: nodejs/node, v prefix, a file mixing other tools, multiple versions on a line, inline comments, a Ruby preview, tabs/CRLF/comment lines, first matching line wins.
    • No line for the runtime: empty file, only other tools, commented-out line, case and exact-name matching.
    • Unsupported forms: system, ref:, path:, alias, a runtime-rule rejection, a missing version, an unsupported first version that isn't skipped. Plus the two new messages.
    • Resolution:
      • one file pinning all three runtimes, from a subdirectory
      • runtimes.json over .tool-versions, and .tool-versions over the runtime's own files in one directory
      • a file without the runtime falling through, both to the same directory's own file and to a parent's .tool-versions
      • a nearer .nvmrc beating a parent .tool-versions
      • an unsupported line not falling back to the global version
  3. End-to-end: built the CLI and shim into a temp directory, copied the shim as node.exe, python.exe and ruby.exe, and ran them against the real install from temp project directories.

Test Results

In the directory node / python / ruby
no files v25.2.0 / 3.12.12 / 3.4.7 (globals)
.tool-versions: nodejs 22, python 3.9, ruby 3.3.9 # CI, golang 1.22.0, run from packages/app v22.12.0 / 3.9.9 / 3.3.9
.tool-versions nodejs 22.0.0 + .node-version 22.12.0 v22.0.0
.dtvem/runtimes.json python 3.8.9 + .tool-versions python 3.9.9 3.8.9
parent .tool-versions nodejs 22.0.0, child .tool-versions ruby 3.3.9 v22.0.0 / (global) / 3.3.9
python system error naming the tool and file, exit 1
nodejs with no version … lists nodejs without a version, exit 1
python 3.11 (not installed) "configured but not installed", Set by: .tool-versions, dtvem install python 3.11, exit 1

dtvem current showed all three runtimes with .tool-versions as their Source.

Checklist

  • My code follows the Google Go Style Guide
  • I have run golangci-lint run and fixed all issues (includes gofmt, govet, and 40+ linters)
  • I have run go mod tidy to clean up dependencies (no dependency changes in this PR)
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes (go test ./...)
  • I have commented my code, particularly in hard-to-understand areas
  • I have updated the documentation (README.md, CONTRIBUTING.md, etc.) if needed
  • My changes generate no new warnings or errors
  • I have checked my code works on multiple platforms (Windows, macOS, Linux) if applicable (tested on Windows; CI covers macOS and Linux)
  • I have added appropriate error handling
  • I have followed conventional commit format in my commit messages

Breaking Changes

None intended. A ~/.tool-versions now applies under the home directory, as described above. If it lists system for a runtime, which asdf users commonly do, that runtime's shims under the home directory stop with an error instead of running the global version.

Additional Notes

  • Not done: the issue suggested dtvem could warn when .tool-versions and a runtime's own file in the same directory disagree. I left it out. Resolution lives in internal/config, which has no output path, so a warning needs plumbing through the shim and current. It could be a follow-up.
  • Out of scope, as the issue lists: mise.toml, asdf's ASDF_<TOOL>_VERSION environment variables, and ASDF_TOOL_VERSIONS_FILENAME.

Version resolution now reads .tool-versions, the file asdf writes and
mise reads to pin several tools at once, and which actions/setup-node,
actions/setup-python and ruby/setup-ruby can read in CI. It applies to
Node.js, Python and Ruby alike.

- It is read in the same upward walk as the other version files. Within
  one directory the order is .dtvem/runtimes.json, then .tool-versions,
  then the runtime's own files (.node-version/.nvmrc, .python-version,
  .ruby-version), which is asdf's order when it reads those files too.
- The runtime's line is found by tool name: nodejs (asdf) or node
  (mise), python, ruby. Lines for other tools are ignored, and a file
  with no line for the runtime is passed over, so it never hides a pin
  in the same directory's own file or further up.
- The first version on the line is used (asdf treats the rest as
  fallbacks), under the runtime's usual rules: exact or partial,
  optional prefix, Ruby pre-releases. Inline # comments are skipped.
- system, ref:, path:, anything else the runtime's rules reject, and a
  tool listed without a version are reported, naming the tool, instead
  of falling back to the global version.
@CalvinAllen
CalvinAllen merged commit 0f9f1f9 into main Oct 22, 2026
11 checks passed
@CalvinAllen
CalvinAllen deleted the feat/config/tool-versions branch October 22, 2026 19:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

feat(config): read .tool-versions when resolving versions

1 participant