From 5d9ee1f21d30957ee1e58626969058a04d0eba49 Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Fri, 6 Nov 2026 19:31:34 -0500 Subject: [PATCH] fix(uninstall): allow removing the last installed version Install makes the first version of a runtime the global version, and uninstall refused to remove the global version, so the last installed version of a runtime could never be uninstalled. Uninstalling the global version is now allowed when it is the only installed version; the runtime's entry is removed from the global config after the files are deleted. With other versions installed it is still refused, and the message lists them. The "active local version" warning used provider.LocalVersion, which falls back to the global version, so it fired for every global version and named the obsolete dtvem.config.json. It now uses config.Resolve, warns only when a project file pins the version, and names that file. --- src/cmd/uninstall.go | 74 +++++++++++++--- src/cmd/uninstall_test.go | 129 ++++++++++++++++++++++++++++ src/internal/config/version.go | 34 ++++++++ src/internal/config/version_test.go | 104 ++++++++++++++++++++++ 4 files changed, 331 insertions(+), 10 deletions(-) diff --git a/src/cmd/uninstall.go b/src/cmd/uninstall.go index f24a92e..4441ea4 100644 --- a/src/cmd/uninstall.go +++ b/src/cmd/uninstall.go @@ -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 @@ -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 ", 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 ", 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") @@ -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() @@ -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) diff --git a/src/cmd/uninstall_test.go b/src/cmd/uninstall_test.go index 96db50e..6a77664 100644 --- a/src/cmd/uninstall_test.go +++ b/src/cmd/uninstall_test.go @@ -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) { @@ -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) +} diff --git a/src/internal/config/version.go b/src/internal/config/version.go index bb0a9dc..fd69ce8 100644 --- a/src/internal/config/version.go +++ b/src/internal/config/version.go @@ -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() diff --git a/src/internal/config/version_test.go b/src/internal/config/version_test.go index c133be1..3c02d74 100644 --- a/src/internal/config/version_test.go +++ b/src/internal/config/version_test.go @@ -482,6 +482,110 @@ func TestGlobalVersion_RuntimeNotInConfig(t *testing.T) { } } +func TestUnsetGlobalVersion_RemovesOnlyThatRuntime(t *testing.T) { + useTempRoot(t) + + for runtimeName, version := range map[string]string{"ruby": "3.4.7", "node": "22.12.0"} { + if err := SetGlobalVersion(runtimeName, version); err != nil { + t.Fatalf("SetGlobalVersion(%q) setup error: %v", runtimeName, err) + } + } + + if err := UnsetGlobalVersion("ruby"); err != nil { + t.Fatalf("UnsetGlobalVersion() error: %v", err) + } + + if version, err := GlobalVersion("ruby"); err != nil || version != "" { + t.Errorf("GlobalVersion(ruby) after unset = (%q, %v), want (\"\", nil)", version, err) + } + if version, err := GlobalVersion("node"); err != nil || version != "22.12.0" { + t.Errorf("GlobalVersion(node) after unsetting ruby = (%q, %v), want (%q, nil)", version, err, "22.12.0") + } +} + +func TestUnsetGlobalVersion_LastRuntimeLeavesEmptyConfig(t *testing.T) { + useTempRoot(t) + + if err := SetGlobalVersion("ruby", "3.4.7"); err != nil { + t.Fatalf("SetGlobalVersion() setup error: %v", err) + } + if err := UnsetGlobalVersion("ruby"); err != nil { + t.Fatalf("UnsetGlobalVersion() error: %v", err) + } + + data, err := os.ReadFile(GlobalConfigPath()) + if err != nil { + t.Fatalf("Failed to read config file: %v", err) + } + var config RuntimesConfig + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("Config is not valid JSON after unset: %v\n%s", err, data) + } + if len(config) != 0 { + t.Errorf("Config after unsetting the only runtime = %v, want empty", config) + } +} + +func TestUnsetGlobalVersion_NoConfigFile(t *testing.T) { + useTempRoot(t) + + if err := UnsetGlobalVersion("ruby"); err != nil { + t.Errorf("UnsetGlobalVersion() with no config file error: %v", err) + } + if _, err := os.Stat(GlobalConfigPath()); !os.IsNotExist(err) { + t.Errorf("UnsetGlobalVersion() with no config file should not create one (stat err: %v)", err) + } +} + +func TestUnsetGlobalVersion_RuntimeNotInConfig(t *testing.T) { + useTempRoot(t) + + if err := SetGlobalVersion("python", "3.12.12"); err != nil { + t.Fatalf("SetGlobalVersion() setup error: %v", err) + } + before, err := os.ReadFile(GlobalConfigPath()) + if err != nil { + t.Fatalf("Failed to read config file: %v", err) + } + + if err := UnsetGlobalVersion("ruby"); err != nil { + t.Errorf("UnsetGlobalVersion() for a runtime not in config error: %v", err) + } + + after, err := os.ReadFile(GlobalConfigPath()) + if err != nil { + t.Fatalf("Failed to read config file: %v", err) + } + if string(after) != string(before) { + t.Errorf("Config changed when unsetting a runtime not in it:\nbefore: %s\nafter: %s", before, after) + } +} + +func TestUnsetGlobalVersion_CorruptConfigIsNotRewritten(t *testing.T) { + useTempRoot(t) + + configPath := GlobalConfigPath() + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + corrupt := []byte(`{"ruby": "3.4.7", `) + if err := os.WriteFile(configPath, corrupt, 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + if err := UnsetGlobalVersion("ruby"); err == nil { + t.Error("UnsetGlobalVersion() with corrupt config should return an error") + } + + after, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("Failed to read config file: %v", err) + } + if string(after) != string(corrupt) { + t.Errorf("Corrupt config was rewritten: got %s", after) + } +} + func TestSetLocalVersion_CreatesDirectoryAndFile(t *testing.T) { // Create temp directory and change to it tmpRoot := t.TempDir()