Skip to content

feat(node): read .node-version and .nvmrc when resolving the version - #286

Merged
CalvinAllen merged 1 commit into
mainfrom
feat/node/version-files
Oct 22, 2026
Merged

CalvinAllen merged 1 commit into
mainfrom
feat/node/version-files

Conversation

@CalvinAllen

Copy link
Copy Markdown
Contributor

Description

dtvem now reads .node-version and .nvmrc when resolving the Node.js version. These are the files actions/setup-node (via node-version-file), fnm, nvm and nodenv use. A repository that pins Node for CI gets the same version on a dtvem machine, without a second, dtvem-specific file.

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 #283
Relates to #284, #285 (the same support for .python-version and .ruby-version, which can reuse this mechanism)

Changes Made

  • Where: the files are read in the same upward walk as .dtvem/runtimes.json, so subdirectories inherit them. The nearest directory that sets a version wins. Within one directory the order is .dtvem/runtimes.json, then .node-version, then .nvmrc. The global version is still the fallback.

  • Syntax: a file can hold an exact version (22.12.0) or a partial one (22, 22.12), with or without a leading v. Whitespace, blank lines and # comments are ignored, and the first version line is used. A partial version resolves to the highest installed match, compared numerically: 22 never matches 220.x.

  • Unsupported content: .nvmrc aliases (lts/*, lts/jod, node), anything else that isn't a version number, and empty files are reported, not silently replaced by the global version. The shim stops with:
    ✗ …\.nvmrc asks for "lts/*": only version numbers (x, x.y or x.y.z) are supported, not aliases

  • Not installed: the shim's error now includes Set by: <file> above the dtvem install node <version> hint. For a partial version that matches nothing installed, the hint is dtvem install node 22, which install already resolves to the latest 22.x.

  • Visibility: dtvem current has a new Source column naming the file each version came from, including the global runtimes.json. Its install prompt resolves a partial version against the available versions before installing. which and where print a version-file error as itself, not as "No version configured".

  • API: config.Resolve returns a Resolution{Version, Source}. Its errors fall into three groups:

    • wrapping config.ErrNoVersion: nothing is configured, so the shim falls back to PATH as before.
    • *config.VersionFileError: a file names something dtvem can't use.
    • anything else: the working directory or a version file couldn't be read.

    ResolveVersion and LocalVersion keep their signatures. Version files are listed per runtime in config.versionFiles, so Python and Ruby each need one entry (plus any runtime-specific parsing).

  • Docs: README feature line 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 with Node 22.0.0, 22.12.0 and 25.2.0 installed, 25.2.0 global

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: exact and partial versions, v prefix, CRLF, blank lines, comments, aliases, empty files, malformed versions.
    • resolution, using a temp DTVEM_ROOT and project tree: each file on its own, subdirectory inheritance, precedence within one directory, nearest directory wins in both directions, partial matching against installed versions, unsupported or empty files not falling back (even with a valid pin further up or a global version set), global fallback, ErrNoVersion, version files being per-runtime, and LocalVersion/ResolveVersion.
  3. New src/cmd/current_test.go covers installVersion: an exact version installs as given, a partial one installs the latest available match, and no match installs nothing.
  4. End-to-end: built the CLI and shim into a temp directory, put the shim there as node.exe, and ran it against the real install from temp project directories.

Test Results

In the directory node --version
no file v25.2.0 (global)
.node-version = 22.0.0 v22.0.0
.nvmrc = 22.0.0 v22.0.0
.node-version = v22.0.0, run from sub/deep v22.0.0
.node-version = 22 (blank lines + padding) v22.12.0
.nvmrc = 22.0 v22.0.0
.node-version = 22.0.0 + .nvmrc = 25 v22.0.0
.dtvem/runtimes.json = 22.12.0 + .node-version = 22.0.0 v22.12.0
parent runtimes.json = 22.12.0, child .nvmrc = 22.0.0 v22.0.0
.nvmrc = lts/* error naming the file, exit 1
empty .node-version error naming the file, exit 1
.node-version = 20.11.1 (not installed) "configured but not installed", Set by: the file, install hint, exit 1

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, but there are two behaviour changes worth knowing:

  • A .node-version or .nvmrc in any parent directory now affects Node shims, including one in the home directory. If such a file holds an alias like lts/*, node/npm/npx under that directory stop with an error where they used to run the global version. The issue asks for exactly this ("report them as unsupported rather than silently falling back"), but it may surprise nvm users who keep a ~/.nvmrc.
  • If the working directory can't be determined (for example, it was deleted), resolution now returns that error. Previously it quietly used the global version.

Additional Notes

  • Not in scope, left for follow-ups: .nvmrc aliases (lts/* etc.), 22.x-style ranges, and bulk dtvem install reading .node-version/.nvmrc. Bulk install still reads only .dtvem/runtimes.json.
  • .dtvem/runtimes.json keeps its exact-version semantics. Partial matching applies only to the ecosystem version files.

Version resolution now reads the files the rest of the Node ecosystem
uses to pin a project's version, so a repository that pins Node for CI
with actions/setup-node gets the same version on a dtvem machine without
a second, dtvem-specific file.

- The files are read in the same upward walk as .dtvem/runtimes.json; the
  nearest directory that sets a version wins. Within one directory,
  runtimes.json comes first, then .node-version, then .nvmrc.
- A file holds an exact (22.12.0) or partial (22, 22.12) version, with an
  optional leading v. Blank lines and # comments are skipped. A partial
  version resolves to the highest installed match.
- Anything else, such as an .nvmrc alias like lts/*, or an empty file, is
  reported rather than silently replaced by the global version.
- A version that is not installed names the file that asked for it.
- dtvem current shows the file each version came from, and installs a
  partial version as its latest available match.

config.Resolve returns the version together with its source file, and
config.ErrNoVersion separates "nothing configured" (the shim falls back to
PATH) from a version file it cannot use (the shim stops). Version files
are listed per runtime in config.versionFiles so other runtimes can add
theirs.
@CalvinAllen
CalvinAllen merged commit 0b48068 into main Oct 22, 2026
11 checks passed
@CalvinAllen
CalvinAllen deleted the feat/node/version-files branch October 22, 2026 18:06
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(node): read .node-version and .nvmrc when resolving the version

1 participant