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
74 changes: 64 additions & 10 deletions src/cmd/uninstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ This command removes a specific version of a runtime from your system.
The version directory and all its contents will be deleted.

Safety features:
- Cannot uninstall the currently active global version
- Cannot uninstall the global version while other versions are installed
(set a different global version first)
- Uninstalling the only installed version clears the global version, so
the runtime's commands fall back to your system PATH
- Prompts for confirmation before deletion
- Automatically regenerates shims after uninstall

Expand Down Expand Up @@ -58,23 +61,41 @@ Examples:
return
}

// Check if this is the currently active global version
// The global version can only go while nothing else could replace
// it: with other versions installed the user picks the new global
// first, and with none the global setting is cleared afterwards.
// Install sets the first version as global, so refusing outright
// would leave the last version impossible to remove.
clearGlobal := false
globalVersion, err := provider.GlobalVersion()
if err == nil && globalVersion == version {
ui.Error("Cannot uninstall the currently active global version")
ui.Info("Current global version: v%s", globalVersion)
ui.Info("Set a different global version first: dtvem global %s <version>", runtimeName)
return
installed, err := provider.ListInstalled()
if err != nil {
ui.Error("Could not list installed %s versions: %v", provider.DisplayName(), err)
return
}
if others := otherVersions(installed, version); len(others) > 0 {
ui.Error("Cannot uninstall the global version while other versions are installed")
ui.Info("Current global version: v%s", globalVersion)
ui.Info("Other installed versions: %s", strings.Join(others, ", "))
ui.Info("Set a different global version first: dtvem global %s <version>", runtimeName)
return
}
clearGlobal = true
}

// Check if this is the currently active local version
localVersion, err := provider.LocalVersion()
if err == nil && localVersion == version {
// Check if a project file pins this version for the working directory
if source := localPinSource(provider.Name(), version); source != "" {
ui.Warning("This is the currently active local version in this directory")
ui.Info("Local version file: dtvem.config.json")
ui.Info("Local version file: %s", source)
ui.Info("You may need to update or remove it after uninstalling")
}

if clearGlobal {
ui.Warning("v%s is the global %s version and the only one installed", version, provider.DisplayName())
ui.Info("The global %s version will be cleared", provider.DisplayName())
}

// Prompt for confirmation (unless --yes flag is provided)
if !uninstallYes {
fmt.Printf("\n")
Expand Down Expand Up @@ -104,6 +125,15 @@ Examples:

spinner.Success(fmt.Sprintf("%s v%s removed", provider.DisplayName(), version))

if clearGlobal {
if err := config.UnsetGlobalVersion(provider.Name()); err != nil {
ui.Warning("Could not clear the global %s version: %v", provider.DisplayName(), err)
ui.Info("Remove the %q entry from %s", provider.Name(), config.GlobalConfigPath())
} else {
ui.Info("Cleared the global %s version", provider.DisplayName())
}
}

// Regenerate shims
shimSpinner := ui.NewSpinner("Regenerating shims...")
shimSpinner.Start()
Expand All @@ -125,6 +155,30 @@ Examples:
},
}

// localPinSource returns the file that sets version for runtimeName in the
// working directory or one of its parents, or "" when none does. The global
// config does not count: provider.LocalVersion falls back to it, which made
// the global version look pinned locally everywhere.
func localPinSource(runtimeName, version string) string {
res, err := config.Resolve(runtimeName)
if err != nil || res.Version != version || res.Source == config.GlobalConfigPath() {
return ""
}
return res.Source
}

// otherVersions returns the installed versions other than version, in the
// order given.
func otherVersions(installed []runtime.InstalledVersion, version string) []string {
var others []string
for _, iv := range installed {
if iv.Version.Raw != version {
others = append(others, iv.Version.Raw)
}
}
return others
}

func init() {
uninstallCmd.Flags().BoolVarP(&uninstallYes, "yes", "y", false, "Skip confirmation prompt")
rootCmd.AddCommand(uninstallCmd)
Expand Down
129 changes: 129 additions & 0 deletions src/cmd/uninstall_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package cmd
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"

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

func TestUninstallCommand_VersionStripping(t *testing.T) {
Expand Down Expand Up @@ -208,3 +210,130 @@ func TestUninstallCommand_ConfirmationResponse(t *testing.T) {
})
}
}

func TestOtherVersions(t *testing.T) {
installed := func(versions ...string) []runtime.InstalledVersion {
out := make([]runtime.InstalledVersion, 0, len(versions))
for _, v := range versions {
out = append(out, runtime.InstalledVersion{Version: runtime.NewVersion(v)})
}
return out
}

tests := []struct {
name string
installed []runtime.InstalledVersion
version string
want []string
}{
{
name: "Only version installed",
installed: installed("3.4.7"),
version: "3.4.7",
want: nil,
},
{
name: "Other versions installed",
installed: installed("22.0.0", "22.12.0", "25.2.0"),
version: "25.2.0",
want: []string{"22.0.0", "22.12.0"},
},
{
name: "Version not in the list",
installed: installed("3.12.12", "3.9.9"),
version: "3.8.9",
want: []string{"3.12.12", "3.9.9"},
},
{
name: "Nothing installed",
installed: nil,
version: "3.4.7",
want: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := otherVersions(tt.installed, tt.version)
if !slices.Equal(got, tt.want) {
t.Errorf("otherVersions(%v, %q) = %v, want %v", tt.installed, tt.version, got, tt.want)
}
})
}
}

func TestLocalPinSource(t *testing.T) {
tmp := t.TempDir()
t.Setenv("DTVEM_ROOT", filepath.Join(tmp, "dtvem"))
config.ResetPathsCache()
t.Cleanup(config.ResetPathsCache)

if err := os.MkdirAll(config.RuntimeVersionPath("ruby", "3.4.7"), 0755); err != nil {
t.Fatalf("Failed to create installed version: %v", err)
}
if err := config.SetGlobalVersion("ruby", "3.4.7"); err != nil {
t.Fatalf("SetGlobalVersion() setup error: %v", err)
}

pinned := filepath.Join(tmp, "pinned")
unpinned := filepath.Join(tmp, "unpinned")
for _, dir := range []string{pinned, unpinned} {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("Failed to create %s: %v", dir, err)
}
}
versionFile := filepath.Join(pinned, ".ruby-version")
if err := os.WriteFile(versionFile, []byte("3.4\n"), 0644); err != nil {
t.Fatalf("Failed to write .ruby-version: %v", err)
}

originalDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(originalDir) })

chdir := func(dir string) {
t.Helper()
if err := os.Chdir(dir); err != nil {
t.Fatalf("Failed to change directory: %v", err)
}
}

t.Run("Global version is not a local pin", func(t *testing.T) {
chdir(unpinned)
if got := localPinSource("ruby", "3.4.7"); got != "" {
t.Errorf("localPinSource() with only a global version = %q, want \"\"", got)
}
})

t.Run("Project file pins the version", func(t *testing.T) {
chdir(pinned)
got := localPinSource("ruby", "3.4.7")
if got == "" || !sameFile(t, got, versionFile) {
t.Errorf("localPinSource() = %q, want %q", got, versionFile)
}
})

t.Run("Project file pins a different version", func(t *testing.T) {
chdir(pinned)
if got := localPinSource("ruby", "3.3.0"); got != "" {
t.Errorf("localPinSource() for another version = %q, want \"\"", got)
}
})
}

// sameFile reports whether two paths name the same file. Temp paths can
// differ only by symlinks (macOS reports /var/... as /private/var/...).
func sameFile(t *testing.T, a, b string) bool {
t.Helper()
ai, err := os.Stat(a)
if err != nil {
t.Fatalf("Failed to stat %s: %v", a, err)
}
bi, err := os.Stat(b)
if err != nil {
t.Fatalf("Failed to stat %s: %v", b, err)
}
return os.SameFile(ai, bi)
}
34 changes: 34 additions & 0 deletions src/internal/config/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,40 @@ func SetGlobalVersion(runtimeName, version string) error {
return os.WriteFile(configPath, data, 0644)
}

// UnsetGlobalVersion removes a runtime's entry from the global config, so
// the runtime has no global version. Other runtimes' entries are kept.
// It does nothing when there is no global config or no entry for the
// runtime, and returns an error rather than rewriting a config it cannot
// parse.
func UnsetGlobalVersion(runtimeName string) error {
configPath := GlobalConfigPath()

data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to read global config: %w", err)
}

config := make(RuntimesConfig)
if err := json.Unmarshal(data, &config); err != nil {
return fmt.Errorf("failed to parse global config %s: %w", configPath, err)
}

if _, ok := config[runtimeName]; !ok {
return nil
}
delete(config, runtimeName)

data, err = json.MarshalIndent(config, "", " ")
if err != nil {
return err
}

return os.WriteFile(configPath, data, 0644)
}

// SetLocalVersion sets the local version for a runtime in the current directory
func SetLocalVersion(runtimeName, version string) error {
configDir := LocalConfigDir()
Expand Down
Loading
Loading