Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (`<tool> <version>...` 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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 5 additions & 4 deletions src/cmd/current.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 17 additions & 4 deletions src/internal/config/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,30 @@ 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 != "" {
return Resolution{Version: version, Source: runtimesFile}, true, nil
}

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)
Expand Down
73 changes: 66 additions & 7 deletions src/internal/config/versionfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"

"github.com/CodingWithCalvin/dtvem.cli/src/internal/version"
Expand All @@ -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 "<tool> <version>..." 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
Expand All @@ -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
Expand Down Expand Up @@ -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").
Expand Down
Loading
Loading