From 1c117ea0956e7179c4ff76c2b6bc2c93887f8aae Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Thu, 22 Oct 2026 15:36:38 -0400 Subject: [PATCH] feat(config): read .tool-versions when resolving versions 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. --- CLAUDE.md | 4 +- README.md | 2 +- src/cmd/current.go | 9 +- src/internal/config/version.go | 21 ++- src/internal/config/versionfile.go | 73 +++++++- src/internal/config/versionfile_test.go | 219 ++++++++++++++++++++++++ 6 files changed, 310 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 32fbf36..c646c86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -223,11 +223,11 @@ The shim mappings are automatically registered via `Shims()`. ## Version Resolution **Priority order:** -1. **Local**: Walk up from `pwd` to the filesystem root; the nearest directory that sets a version wins. Within one directory, `.dtvem/runtimes.json` comes first, then the runtime's ecosystem version files (`versionFiles` in `internal/config/versionfile.go`; Node.js: `.node-version`, then `.nvmrc`; Python: `.python-version`; Ruby: `.ruby-version`) +1. **Local**: Walk up from `pwd` to the filesystem root; the nearest directory that sets a version wins. Within one directory, `.dtvem/runtimes.json` comes first, then asdf/mise `.tool-versions`, then the runtime's own version files (`versionFiles` in `internal/config/versionfile.go`; Node.js: `.node-version`, then `.nvmrc`; Python: `.python-version`; Ruby: `.ruby-version`). A `runtimes.json` or `.tool-versions` with no entry for the runtime is passed over, so it never hides a pin further up. The walk also reaches files in `$HOME`, so an asdf user's `~/.tool-versions` (or a `~/.nvmrc`) applies everywhere under the home directory, ahead of the global version 2. **Global**: `~/.dtvem/config/runtimes.json` 3. **Error**: No version configured (`config.ErrNoVersion`; the shim falls back to the system PATH) -**Ecosystem version files** hold an exact (`22.12.0`) or partial (`22`, `22.12`) version, with the runtime's optional prefix (`v` for Node.js, `python-` for Python, `ruby-` for Ruby); Ruby also accepts an exact pre-release (`4.0.0-preview2`, `3.4.0-rc1`). Blank lines and `#` comments are skipped, and the first word of the first remaining line is used (as pyenv and rbenv do). A partial version resolves to the highest installed match; a release ranks above its own pre-releases (`internal/version`), so `4.0` picks `4.0.0` over `4.0.0-preview2`. Anything else (e.g. `.nvmrc` aliases like `lts/*`, pyenv's `system` or virtualenv names), or an empty file, is a `*config.VersionFileError`: the shim reports it instead of falling back to another version. `config.Resolve` returns the version and the file that set it (`dtvem current` shows it). +**Ecosystem version files** hold an exact (`22.12.0`) or partial (`22`, `22.12`) version, with the runtime's optional prefix (`v` for Node.js, `python-` for Python, `ruby-` for Ruby); Ruby also accepts an exact pre-release (`4.0.0-preview2`, `3.4.0-rc1`). Blank lines and `#` comments are skipped, and the first word of the first remaining line is used (as pyenv and rbenv do). In `.tool-versions` (` ...` lines), the runtime's line is found by tool name (`nodejs` or `node`, `python`, `ruby`; other tools are ignored) and its first version is used, under the same rules; `system`, `ref:` and `path:` are unsupported. A partial version resolves to the highest installed match; a release ranks above its own pre-releases (`internal/version`), so `4.0` picks `4.0.0` over `4.0.0-preview2`. Anything else (e.g. `.nvmrc` aliases like `lts/*`, pyenv's `system` or virtualenv names), or an empty file, is a `*config.VersionFileError`: the shim reports it instead of falling back to another version. `config.Resolve` returns the version and the file that set it (`dtvem current` shows it). **Config format** (both local and global): ```json diff --git a/README.md b/README.md index 663cbc0..4ee6060 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Managing multiple versions of Python, Node.js, Ruby, and other runtimes across d ✅ **Migration Tool**: Import existing installations from nvm, pyenv, etc. -✅ **Per-Directory Versions**: `.dtvem/runtimes.json` for project-specific versions, plus `.node-version` / `.nvmrc` for Node.js, `.python-version` for Python, and `.ruby-version` for Ruby +✅ **Per-Directory Versions**: `.dtvem/runtimes.json` for project-specific versions, plus asdf/mise `.tool-versions`, `.node-version` / `.nvmrc` for Node.js, `.python-version` for Python, and `.ruby-version` for Ruby ✅ **No Shell Hooks**: Works in cmd.exe, PowerShell, bash, zsh, fish, etc. diff --git a/src/cmd/current.go b/src/cmd/current.go index aae0411..2fe10fa 100644 --- a/src/cmd/current.go +++ b/src/cmd/current.go @@ -29,10 +29,11 @@ var currentCmd = &cobra.Command{ Short: "Show the currently active version(s)", Long: `Show the currently active version for a specific runtime or all runtimes. The active version is determined by checking local settings first, then global settings. -Local settings are read from .dtvem/runtimes.json and from each runtime's own -version files (Node.js: .node-version and .nvmrc; Python: .python-version; -Ruby: .ruby-version), in the current directory or the nearest parent that has -one. The Source column shows which file set each version. +Local settings are read from .dtvem/runtimes.json, asdf/mise .tool-versions, +and each runtime's own version files (Node.js: .node-version and .nvmrc; +Python: .python-version; Ruby: .ruby-version), in the current directory or +the nearest parent that has one. The Source column shows which file set each +version. Examples: dtvem current # Show all active versions diff --git a/src/internal/config/version.go b/src/internal/config/version.go index b4eb03a..feb26be 100644 --- a/src/internal/config/version.go +++ b/src/internal/config/version.go @@ -78,10 +78,12 @@ func findLocal(runtimeName string) (Resolution, bool, error) { } // resolveInDir returns the version dir sets for runtimeName, if any. It -// checks .dtvem/runtimes.json first, then the runtime's version files (see -// versionFiles). A runtimes.json that is unreadable or lacks the runtime is -// passed over; a version file that exists but cannot be used is an error, -// so a project's pin is never silently replaced by another version. +// checks .dtvem/runtimes.json first, then .tool-versions, then the +// runtime's own version files (see versionFiles). A runtimes.json that is +// unreadable or lacks the runtime, or a .tool-versions without a line for +// it, is passed over; a version file that names a version dtvem cannot use +// is an error, so a project's pin is never silently replaced by another +// version. func resolveInDir(dir, runtimeName string) (Resolution, bool, error) { runtimesFile := filepath.Join(dir, LocalConfigDirName, RuntimesFileName) if version, err := readVersionFile(runtimesFile, runtimeName); err == nil && version != "" { @@ -89,6 +91,17 @@ func resolveInDir(dir, runtimeName string) (Resolution, bool, error) { } rules := versionFiles[runtimeName] + if len(rules.toolNames) > 0 { + path := filepath.Join(dir, toolVersionsFile) + spec, found, err := readToolVersion(path, rules) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return Resolution{}, false, err + } + if found { + return Resolution{Version: resolveSpec(runtimeName, spec), Source: path}, true, nil + } + } + for _, name := range rules.names { path := filepath.Join(dir, name) spec, err := readVersionSpec(path, rules) diff --git a/src/internal/config/versionfile.go b/src/internal/config/versionfile.go index c1ef1b3..f4ee2e5 100644 --- a/src/internal/config/versionfile.go +++ b/src/internal/config/versionfile.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "github.com/CodingWithCalvin/dtvem.cli/src/internal/version" @@ -12,17 +13,25 @@ import ( // versionFiles describes, for each runtime, the files other tools in its // ecosystem use to pin a project's version. var versionFiles = map[string]versionFileRules{ - "node": {names: []string{".node-version", ".nvmrc"}, prefix: "v"}, - "python": {names: []string{".python-version"}, prefix: "python-"}, - "ruby": {names: []string{".ruby-version"}, prefix: "ruby-", prereleases: true}, + "node": {names: []string{".node-version", ".nvmrc"}, toolNames: []string{"nodejs", "node"}, prefix: "v"}, + "python": {names: []string{".python-version"}, toolNames: []string{"python"}, prefix: "python-"}, + "ruby": {names: []string{".ruby-version"}, toolNames: []string{"ruby"}, prefix: "ruby-", prereleases: true}, } +// toolVersionsFile is the file asdf writes and mise reads to pin several +// tools at once, one " ..." line per tool. +const toolVersionsFile = ".tool-versions" + // versionFileRules describes a runtime's version files and how versions are // written in them. type versionFileRules struct { - // names are the files, consulted after .dtvem/runtimes.json in the - // order listed when several are in one directory. + // names are the runtime's own files, consulted after + // .dtvem/runtimes.json and .tool-versions in the order listed when + // several are in one directory. names []string + // toolNames are the names a .tool-versions line may give the runtime, + // such as asdf's "nodejs" and mise's "node". + toolNames []string // prefix is an optional prefix written before a version, such as the // "v" in "v22.12.0" or the "python-" in "python-3.12.4". prefix string @@ -34,14 +43,22 @@ type versionFileRules struct { // VersionFileError reports a version file whose contents dtvem cannot use. type VersionFileError struct { Path string // the version file + Tool string // the tool whose line is at fault in a .tool-versions file; "" otherwise Value string // the version it asks for; "" when it names none } func (e *VersionFileError) Error() string { - if e.Value == "" { + const supported = "dtvem supports only version numbers (x, x.y or x.y.z)" + switch { + case e.Tool != "" && e.Value == "": + return fmt.Sprintf("%s lists %s without a version", e.Path, e.Tool) + case e.Tool != "": + return fmt.Sprintf("%s asks for %s %q: %s", e.Path, e.Tool, e.Value, supported) + case e.Value == "": return fmt.Sprintf("%s does not contain a version", e.Path) + default: + return fmt.Sprintf("%s asks for %q: %s", e.Path, e.Value, supported) } - return fmt.Sprintf("%s asks for %q: dtvem supports only version numbers (x, x.y or x.y.z)", e.Path, e.Value) } // readVersionSpec reads a version file such as .node-version or @@ -72,6 +89,48 @@ func parseVersionSpec(path, content string, rules versionFileRules) (string, err return "", &VersionFileError{Path: path} } +// readToolVersion reads the version a .tool-versions file pins for the +// runtime rules describe. The bool is false when no line names the +// runtime. The error satisfies errors.Is(err, fs.ErrNotExist) when the file +// does not exist. +func readToolVersion(path string, rules versionFileRules) (string, bool, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", false, fmt.Errorf("could not read %s: %w", path, err) + } + return parseToolVersion(path, string(data), rules) +} + +// parseToolVersion finds the runtime's line in the contents of the +// .tool-versions file at path. It skips blank lines and # comments, ignores +// lines for tools dtvem does not manage, and takes the first version on the +// first line that names the runtime; asdf treats any further versions as +// fallbacks. The version must pass the same rules as the runtime's own +// version files. +func parseToolVersion(path, content string, rules versionFileRules) (string, bool, error) { + for _, line := range strings.Split(content, "\n") { + if i := strings.IndexByte(line, '#'); i >= 0 { + line = line[:i] + } + fields := strings.Fields(line) + if len(fields) == 0 || !slices.Contains(rules.toolNames, fields[0]) { + continue + } + + tool := fields[0] + if len(fields) < 2 { + return "", true, &VersionFileError{Path: path, Tool: tool} + } + spec, err := rules.version(path, fields[1]) + if err != nil { + return "", true, &VersionFileError{Path: path, Tool: tool, Value: fields[1]} + } + return spec, true, nil + } + + return "", false, nil +} + // version returns the version raw asks for, without the runtime's prefix: // an exact ("22.12.0") or partial ("22", "22.12") version number, or, if // the runtime allows them, an exact pre-release ("4.0.0-preview2"). diff --git a/src/internal/config/versionfile_test.go b/src/internal/config/versionfile_test.go index bb0d8e4..a1e45de 100644 --- a/src/internal/config/versionfile_test.go +++ b/src/internal/config/versionfile_test.go @@ -218,6 +218,111 @@ func TestParseVersionSpec_PrereleasesAreRubyOnly(t *testing.T) { } } +func TestParseToolVersion(t *testing.T) { + tests := []struct { + name string + runtimeName string + content string + want string + }{ + {name: "asdf nodejs", runtimeName: "node", content: "nodejs 22.12.0\n", want: "22.12.0"}, + {name: "mise node", runtimeName: "node", content: "node 22\n", want: "22"}, + {name: "node v prefix", runtimeName: "node", content: "nodejs v22.12.0", want: "22.12.0"}, + {name: "python among other tools", runtimeName: "python", content: "golang 1.22.0\npython 3.12.4\nruby 3.3.6\n", want: "3.12.4"}, + {name: "first of several versions", runtimeName: "python", content: "python 3.12.4 3.11.9 system\n", want: "3.12.4"}, + {name: "ruby with inline comment", runtimeName: "ruby", content: "ruby 3.3.6 # This is a comment\n", want: "3.3.6"}, + {name: "ruby preview", runtimeName: "ruby", content: "ruby 4.0.0-preview2", want: "4.0.0-preview2"}, + {name: "tabs, CRLF and comment lines", runtimeName: "node", content: "# tools\r\n\r\nnodejs\t\t22.12.0\r\n", want: "22.12.0"}, + {name: "first line for the runtime wins", runtimeName: "node", content: "nodejs 22.12.0\nnode 20.0.0\n", want: "22.12.0"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, found, err := parseToolVersion(".tool-versions", tt.content, versionFiles[tt.runtimeName]) + if err != nil { + t.Fatalf("parseToolVersion(%q) unexpected error: %v", tt.content, err) + } + if !found { + t.Fatalf("parseToolVersion(%q) found = false, want true", tt.content) + } + if got != tt.want { + t.Errorf("parseToolVersion(%q) = %q, want %q", tt.content, got, tt.want) + } + }) + } +} + +func TestParseToolVersion_NoLineForRuntime(t *testing.T) { + tests := []struct { + name string + runtimeName string + content string + }{ + {name: "empty file", runtimeName: "node", content: ""}, + {name: "only other tools", runtimeName: "node", content: "golang 1.22.0\nterraform 1.7.0\n"}, + {name: "other dtvem runtimes only", runtimeName: "python", content: "nodejs 22.12.0\nruby 3.3.6\n"}, + {name: "commented-out line", runtimeName: "ruby", content: "# ruby 3.3.6\n"}, + {name: "tool name is case-sensitive", runtimeName: "python", content: "Python 3.12.4\n"}, + {name: "tool name must match exactly", runtimeName: "node", content: "nodejs-lts 22.12.0\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, found, err := parseToolVersion(".tool-versions", tt.content, versionFiles[tt.runtimeName]) + if err != nil || found || got != "" { + t.Errorf("parseToolVersion(%q) = (%q, %v, %v), want (\"\", false, nil)", tt.content, got, found, err) + } + }) + } +} + +func TestParseToolVersion_Unsupported(t *testing.T) { + tests := []struct { + name string + runtimeName string + content string + wantTool string + wantValue string + }{ + {name: "system", runtimeName: "python", content: "python system\n", wantTool: "python", wantValue: "system"}, + {name: "ref", runtimeName: "ruby", content: "ruby ref:v3_3_6\n", wantTool: "ruby", wantValue: "ref:v3_3_6"}, + {name: "path", runtimeName: "node", content: "nodejs path:/opt/node\n", wantTool: "nodejs", wantValue: "path:/opt/node"}, + {name: "alias", runtimeName: "node", content: "node lts\n", wantTool: "node", wantValue: "lts"}, + {name: "runtime rules apply", runtimeName: "python", content: "python 3.13.0-rc1\n", wantTool: "python", wantValue: "3.13.0-rc1"}, + {name: "no version", runtimeName: "ruby", content: "ruby\n", wantTool: "ruby", wantValue: ""}, + {name: "unsupported first version is not skipped", runtimeName: "python", content: "python system 3.12.4\n", wantTool: "python", wantValue: "system"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, found, err := parseToolVersion(".tool-versions", tt.content, versionFiles[tt.runtimeName]) + + var fileErr *VersionFileError + if !errors.As(err, &fileErr) { + t.Fatalf("parseToolVersion(%q) error = %v, want *VersionFileError", tt.content, err) + } + if !found { + t.Error("parseToolVersion() found = false, want true for a line that names the runtime") + } + if fileErr.Tool != tt.wantTool || fileErr.Value != tt.wantValue { + t.Errorf("VersionFileError{Tool: %q, Value: %q}, want {Tool: %q, Value: %q}", fileErr.Tool, fileErr.Value, tt.wantTool, tt.wantValue) + } + }) + } +} + +func TestVersionFileError_ToolMessage(t *testing.T) { + unsupported := (&VersionFileError{Path: ".tool-versions", Tool: "python", Value: "system"}).Error() + if want := `.tool-versions asks for python "system": dtvem supports only version numbers (x, x.y or x.y.z)`; unsupported != want { + t.Errorf("Error() = %q, want %q", unsupported, want) + } + + missing := (&VersionFileError{Path: ".tool-versions", Tool: "ruby"}).Error() + if want := ".tool-versions lists ruby without a version"; missing != want { + t.Errorf("Error() = %q, want %q", missing, want) + } +} + // versionFixture is a dtvem root and a project directory to resolve from. type versionFixture struct { t *testing.T @@ -623,6 +728,120 @@ func TestResolve_RubyPrecedence(t *testing.T) { }) } +func TestResolve_ToolVersions(t *testing.T) { + f := newVersionFixture(t) + f.install("node", "22.1.0", "22.12.0") + f.install("ruby", "3.3.9") + source := f.write(".tool-versions", "nodejs 22\npython 3.12.4\nruby ruby-3.3.9\ngolang 1.22.0\n") + f.chdir(filepath.Join("packages", "app")) + + tests := []struct { + runtimeName string + want string + }{ + {runtimeName: "node", want: "22.12.0"}, + {runtimeName: "python", want: "3.12.4"}, + {runtimeName: "ruby", want: "3.3.9"}, + } + for _, tt := range tests { + t.Run(tt.runtimeName, func(t *testing.T) { + res, err := Resolve(tt.runtimeName) + if err != nil { + t.Fatalf("Resolve(%q) error: %v", tt.runtimeName, err) + } + assertResolution(t, res, tt.want, source) + }) + } +} + +func TestResolve_ToolVersionsPrecedence(t *testing.T) { + t.Run("runtimes.json before .tool-versions", func(t *testing.T) { + f := newVersionFixture(t) + source := f.write(filepath.Join(".dtvem", "runtimes.json"), `{"node": "20.0.0"}`) + f.write(".tool-versions", "nodejs 22.12.0\n") + f.chdir(".") + + res, err := Resolve("node") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "20.0.0", source) + }) + + t.Run(".tool-versions before the runtime's own files", func(t *testing.T) { + f := newVersionFixture(t) + source := f.write(".tool-versions", "nodejs 22.12.0\n") + f.write(".node-version", "20.0.0") + f.write(".nvmrc", "18.0.0") + f.chdir(".") + + res, err := Resolve("node") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "22.12.0", source) + }) + + t.Run("no line for the runtime falls through to its own file", func(t *testing.T) { + f := newVersionFixture(t) + f.write(".tool-versions", "ruby 3.3.9\n") + source := f.write(".python-version", "3.12.4") + f.chdir(".") + + res, err := Resolve("python") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "3.12.4", source) + }) + + t.Run("no line for the runtime does not hide a parent's pin", func(t *testing.T) { + f := newVersionFixture(t) + source := f.write(".tool-versions", "nodejs 22.12.0\n") + f.write(filepath.Join("ruby-app", ".tool-versions"), "ruby 3.3.9\n") + f.chdir("ruby-app") + + res, err := Resolve("node") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "22.12.0", source) + }) + + t.Run("nearer runtime file over parent .tool-versions", func(t *testing.T) { + f := newVersionFixture(t) + f.write(".tool-versions", "nodejs 22.12.0\n") + source := f.write(filepath.Join("legacy", ".nvmrc"), "18.20.0") + f.chdir("legacy") + + res, err := Resolve("node") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "18.20.0", source) + }) +} + +func TestResolve_ToolVersionsUnsupportedDoesNotFallBack(t *testing.T) { + f := newVersionFixture(t) + if err := SetGlobalVersion("python", "3.12.4"); err != nil { + t.Fatalf("SetGlobalVersion() error: %v", err) + } + f.write(".tool-versions", "python 3.7.2 2.7.15 system\nnodejs path:/opt/node\n") + // A valid .python-version in the same directory ranks below .tool-versions. + f.write(".python-version", "3.12.4") + f.chdir(".") + + if res, err := Resolve("python"); err != nil || res.Version != "3.7.2" { + t.Errorf("Resolve(\"python\") = (%q, %v), want 3.7.2 from the first version on the line", res.Version, err) + } + + var fileErr *VersionFileError + if _, err := Resolve("node"); !errors.As(err, &fileErr) || fileErr.Tool != "nodejs" { + t.Errorf("Resolve(\"node\") error = %v, want a *VersionFileError for the nodejs line", err) + } +} + func TestResolve_PythonPrecedence(t *testing.T) { t.Run("runtimes.json before .python-version", func(t *testing.T) { f := newVersionFixture(t)