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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,11 @@ The shim mappings are automatically registered via `Shims()`.
## Version Resolution

**Priority order:**
1. **Local**: Walk up from `pwd` looking for `.dtvem/runtimes.json` (stops at filesystem root)
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`)
2. **Global**: `~/.dtvem/config/runtimes.json`
3. **Error**: No version configured
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 an optional leading `v`; blank lines and `#` comments are skipped. A partial version resolves to the highest installed match. Anything else (e.g. `.nvmrc` aliases like `lts/*`), 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
✅ **Per-Directory Versions**: `.dtvem/runtimes.json` for project-specific versions, plus `.node-version` and `.nvmrc` for Node.js

✅ **No Shell Hooks**: Works in cmd.exe, PowerShell, bash, zsh, fish, etc.

Expand Down
71 changes: 48 additions & 23 deletions src/cmd/current.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package cmd

import (
"errors"
"fmt"

"github.com/CodingWithCalvin/dtvem.cli/src/internal/config"
"github.com/CodingWithCalvin/dtvem.cli/src/internal/runtime"
"github.com/CodingWithCalvin/dtvem.cli/src/internal/tui"
"github.com/CodingWithCalvin/dtvem.cli/src/internal/ui"
Expand All @@ -18,6 +20,7 @@ var (
type runtimeStatus struct {
provider runtime.Provider
version string
source string // file that set the version
installed bool
}

Expand All @@ -26,6 +29,9 @@ 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, for Node.js, from
.node-version and .nvmrc, 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 Expand Up @@ -54,35 +60,44 @@ func showAllVersions(yes, noInstall bool) {

// Collect status for all configured runtimes
var configured []runtimeStatus
failed := false
for _, provider := range providers {
version, err := provider.CurrentVersion()
if err != nil {
res, err := config.Resolve(provider.Name())
if errors.Is(err, config.ErrNoVersion) {
// Not configured - skip it
continue
}
installed, _ := provider.IsInstalled(version)
if err != nil {
ui.Error("%s: %v", provider.DisplayName(), err)
failed = true
continue
}
installed, _ := provider.IsInstalled(res.Version)
configured = append(configured, runtimeStatus{
provider: provider,
version: version,
version: res.Version,
source: res.Source,
installed: installed,
})
}

if len(configured) == 0 {
ui.Info("No runtimes configured")
if !failed {
ui.Info("No runtimes configured")
}
return
}

// Display all configured versions
table := tui.NewTable("Runtime", "Version", "Status")
table := tui.NewTable("Runtime", "Version", "Status", "Source")
table.SetTitle("Active Versions")
var missing []runtimeStatus

for _, rs := range configured {
if rs.installed {
table.AddActiveRow(rs.provider.DisplayName(), rs.version, tui.CheckMark+" installed")
table.AddActiveRow(rs.provider.DisplayName(), rs.version, tui.CheckMark+" installed", rs.source)
} else {
table.AddRow(rs.provider.DisplayName(), rs.version, tui.CrossMark+" not installed")
table.AddRow(rs.provider.DisplayName(), rs.version, tui.CrossMark+" not installed", rs.source)
missing = append(missing, rs)
}
}
Expand All @@ -100,12 +115,7 @@ func showAllVersions(yes, noInstall bool) {
shouldInstall := yes || ui.PromptInstallMissing(missing)
if shouldInstall {
for _, rs := range missing {
ui.Info("Installing %s %s...", rs.provider.DisplayName(), rs.version)
if err := rs.provider.Install(rs.version); err != nil {
ui.Error("Failed to install %s %s: %v", rs.provider.DisplayName(), rs.version, err)
} else {
ui.Success("%s %s installed successfully", rs.provider.DisplayName(), rs.version)
}
installVersion(rs.provider, rs.version)
}
}
}
Expand All @@ -122,23 +132,24 @@ func showSingleVersion(runtimeName string, yes, noInstall bool) {
return
}

version, err := provider.CurrentVersion()
res, err := config.Resolve(provider.Name())
if err != nil {
ui.Error("%v", err)
return
}
version := res.Version

installed, _ := provider.IsInstalled(version)

table := tui.NewTable("Runtime", "Version", "Status")
table := tui.NewTable("Runtime", "Version", "Status", "Source")
if installed {
table.AddActiveRow(provider.DisplayName(), version, tui.CheckMark+" installed")
table.AddActiveRow(provider.DisplayName(), version, tui.CheckMark+" installed", res.Source)
fmt.Println(table.Render())
return
}

// Not installed - show with warning and prompt
table.AddRow(provider.DisplayName(), version, tui.CrossMark+" not installed")
table.AddRow(provider.DisplayName(), version, tui.CrossMark+" not installed", res.Source)
fmt.Println(table.Render())

// Skip install prompts if --no-install flag is set
Expand All @@ -149,12 +160,26 @@ func showSingleVersion(runtimeName string, yes, noInstall bool) {
fmt.Println()
shouldInstall := yes || ui.PromptInstall(provider.DisplayName(), version)
if shouldInstall {
if err := provider.Install(version); err != nil {
ui.Error("Failed to install %s %s: %v", provider.DisplayName(), version, err)
return
}
ui.Success("%s %s installed successfully", provider.DisplayName(), version)
installVersion(provider, version)
}
}

// installVersion installs a configured version that is not installed yet.
// A version file can ask for a partial version ("22"), which is resolved to
// the latest available match first.
func installVersion(provider runtime.Provider, version string) {
resolved, err := resolveVersionForProvider(provider, version)
if err != nil {
ui.Error("Failed to install %s %s: %v", provider.DisplayName(), version, err)
return
}

ui.Info("Installing %s %s...", provider.DisplayName(), resolved)
if err := provider.Install(resolved); err != nil {
ui.Error("Failed to install %s %s: %v", provider.DisplayName(), resolved, err)
return
}
ui.Success("%s %s installed successfully", provider.DisplayName(), resolved)
}

func init() {
Expand Down
44 changes: 44 additions & 0 deletions src/cmd/current_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package cmd

import (
"slices"
"testing"

"github.com/CodingWithCalvin/dtvem.cli/src/internal/runtime"
)

func TestInstallVersion(t *testing.T) {
available := []runtime.AvailableVersion{
makeAvailableVersion("22.15.1"),
makeAvailableVersion("22.15.0"),
makeAvailableVersion("22.0.0"),
makeAvailableVersion("20.18.0"),
}

tests := []struct {
name string
version string
want []string
}{
{name: "exact version installs as is", version: "22.0.0", want: []string{"22.0.0"}},
{name: "partial version installs latest match", version: "22", want: []string{"22.15.1"}},
{name: "partial major.minor installs latest patch", version: "22.15", want: []string{"22.15.1"}},
{name: "partial with no match installs nothing", version: "21", want: nil},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider := &mockProvider{
name: "node",
displayName: "Node.js",
availableVersions: available,
}

installVersion(provider, tt.version)

if !slices.Equal(provider.installCalls, tt.want) {
t.Errorf("installVersion(%q) installed %v, want %v", tt.version, provider.installCalls, tt.want)
}
})
}
}
8 changes: 6 additions & 2 deletions src/cmd/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type mockProvider struct {
setGlobalCalls []string
availableVersions []runtime.AvailableVersion
listAvailableErr error
installCalls []string
}

func (m *mockProvider) Name() string { return m.name }
Expand All @@ -25,8 +26,11 @@ func (m *mockProvider) Shims() []string {
func (m *mockProvider) ExecutablePath(version string) (string, error) { return "", nil }
func (m *mockProvider) IsInstalled(version string) (bool, error) { return false, nil }
func (m *mockProvider) ShouldReshimAfter(shimName string, args []string) bool { return false }
func (m *mockProvider) Install(version string) error { return nil }
func (m *mockProvider) Uninstall(version string) error { return nil }
func (m *mockProvider) Install(version string) error {
m.installCalls = append(m.installCalls, version)
return nil
}
func (m *mockProvider) Uninstall(version string) error { return nil }
func (m *mockProvider) ListInstalled() ([]runtime.InstalledVersion, error) {
return nil, nil
}
Expand Down
15 changes: 12 additions & 3 deletions src/cmd/shim/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,21 @@ func runShim() error {
}

// Resolve which version to use
version, err := config.ResolveVersion(runtimeName)
if err != nil {
resolution, err := config.Resolve(runtimeName)
if errors.Is(err, config.ErrNoVersion) {
ui.Debug("Version resolution failed: %v", err)
// No dtvem version configured - try to fallback to system PATH
return handleNoConfiguredVersion(shimName, runtimeName, provider)
}
ui.Debug("Resolved version: %s", version)
if err != nil {
// A version file could not be read or asks for something dtvem
// cannot use. Running another version in its place would hide
// that the project's pin is not being honored.
ui.Error("%v", err)
return fmt.Errorf("could not resolve the %s version", provider.DisplayName())
}
version := resolution.Version
ui.Debug("Resolved version: %s (from %s)", version, resolution.Source)

// If this is a secondary executable (e.g. uv mapped to python) and the
// shim-map cache knows which versions provide it, verify the active
Expand All @@ -100,6 +108,7 @@ func runShim() error {
if !installed {
ui.Debug("Version %s is not installed", version)
ui.Error("%s %s is configured but not installed", provider.DisplayName(), version)
ui.Info("Set by: %s", resolution.Source)
ui.Info("To install, run: dtvem install %s %s", runtimeName, version)
return fmt.Errorf("version not installed")
}
Expand Down
5 changes: 5 additions & 0 deletions src/cmd/where.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"errors"
"fmt"
"os"

Expand Down Expand Up @@ -39,6 +40,10 @@ Examples:
// If version not specified, use current version
if len(args) == 1 {
version, err = provider.CurrentVersion()
if err != nil && !errors.Is(err, config.ErrNoVersion) {
ui.Error("%v", err)
return
}
if err != nil {
ui.Error("No version configured for %s", runtimeName)
ui.Info("Set a version with: dtvem global %s <version>", runtimeName)
Expand Down
5 changes: 5 additions & 0 deletions src/cmd/which.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"errors"
"fmt"
"os"
"path/filepath"
Expand Down Expand Up @@ -65,6 +66,10 @@ Examples:

// Get the current version
version, err := provider.CurrentVersion()
if err != nil && !errors.Is(err, config.ErrNoVersion) {
ui.Error("%v", err)
return
}
if err != nil {
ui.Error("No version configured for %s", runtimeName)
ui.Info("Set a version with: dtvem global %s <version>", runtimeName)
Expand Down
Loading
Loading