Skip to content

Commit 2033a80

Browse files
authored
refactor(migration): plugin-style architecture for providers (#129)
1 parent 04dc845 commit 2033a80

27 files changed

Lines changed: 2337 additions & 528 deletions

src/cmd/migrate.go

Lines changed: 50 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import (
55
"fmt"
66
"os"
77
"os/exec"
8-
"runtime"
98
"strconv"
109
"strings"
1110

11+
"github.com/dtvem/dtvem/src/internal/migration"
1212
internalRuntime "github.com/dtvem/dtvem/src/internal/runtime"
1313
"github.com/dtvem/dtvem/src/internal/ui"
1414
"github.com/spf13/cobra"
@@ -41,14 +41,27 @@ Examples:
4141
spinner := ui.NewSpinner(fmt.Sprintf("Scanning for %s installations...", provider.DisplayName()))
4242
spinner.Start()
4343

44-
// Detect existing installations
45-
detected, err := provider.DetectInstalled()
46-
if err != nil {
47-
spinner.Error("Scan failed")
48-
ui.Error("Error detecting installations: %v", err)
49-
return
44+
// Get migration providers for this runtime
45+
migrationProviders := migration.GetByRuntime(runtimeName)
46+
47+
// Collect all detected versions from all migration providers
48+
detected := make([]detectedVersionWithProvider, 0)
49+
for _, mp := range migrationProviders {
50+
versions, err := mp.DetectVersions()
51+
if err != nil {
52+
continue // Skip providers that fail
53+
}
54+
for _, v := range versions {
55+
detected = append(detected, detectedVersionWithProvider{
56+
DetectedVersion: v,
57+
MigrationProvider: mp,
58+
})
59+
}
5060
}
5161

62+
// Deduplicate by path
63+
detected = deduplicateByPath(detected)
64+
5265
if len(detected) == 0 {
5366
spinner.Warning("No installations found")
5467
ui.Info("Use 'dtvem install %s <version>' to install a version", runtimeName)
@@ -62,7 +75,7 @@ Examples:
6275
for i, dv := range detected {
6376
validatedMark := ""
6477
if dv.Validated {
65-
validatedMark = " " + ui.Highlight("")
78+
validatedMark = " " + ui.Highlight("\u2713")
6679
}
6780
fmt.Printf(" [%d] %s (%s) %s%s\n",
6881
i+1,
@@ -97,7 +110,7 @@ Examples:
97110
}
98111

99112
// Get selected versions
100-
selectedVersions := make([]internalRuntime.DetectedVersion, 0)
113+
selectedVersions := make([]detectedVersionWithProvider, 0)
101114
for _, idx := range selectedIndices {
102115
selectedVersions = append(selectedVersions, detected[idx])
103116
}
@@ -126,11 +139,6 @@ Examples:
126139
}
127140
}
128141

129-
// TODO: Detect and preserve configuration files/settings
130-
// For Node.js: Check for .npmrc in installation dir or ~/.npmrc
131-
// For Python: Check for pip.conf/pip.ini
132-
// Handle sensitive data (auth tokens) appropriately
133-
134142
// Call the provider's Install method
135143
if err := provider.Install(dv.Version); err != nil {
136144
ui.Error("%v", err)
@@ -150,9 +158,6 @@ Examples:
150158
ui.Success("Reinstalled %d global package(s)", len(globalPackages))
151159
}
152160
}
153-
154-
// TODO: Copy/merge configuration files to new installation
155-
// Ensure settings like registry URLs, proxies, etc. are preserved
156161
}
157162
fmt.Println()
158163
}
@@ -210,6 +215,27 @@ Examples:
210215
},
211216
}
212217

218+
// detectedVersionWithProvider pairs a detected version with its migration provider.
219+
type detectedVersionWithProvider struct {
220+
migration.DetectedVersion
221+
MigrationProvider migration.Provider
222+
}
223+
224+
// deduplicateByPath removes duplicate versions based on their path.
225+
func deduplicateByPath(versions []detectedVersionWithProvider) []detectedVersionWithProvider {
226+
seen := make(map[string]bool)
227+
result := make([]detectedVersionWithProvider, 0)
228+
229+
for _, v := range versions {
230+
if !seen[v.Path] {
231+
seen[v.Path] = true
232+
result = append(result, v)
233+
}
234+
}
235+
236+
return result
237+
}
238+
213239
// parseSelection parses user selection input like "1,3,5" or "all"
214240
func parseSelection(input string, maxCount int) []int {
215241
indices := make([]int, 0, maxCount)
@@ -237,7 +263,7 @@ func parseSelection(input string, maxCount int) []int {
237263
}
238264

239265
// promptCleanupOldInstallations prompts the user to clean up old installations after successful migration
240-
func promptCleanupOldInstallations(versions []internalRuntime.DetectedVersion, runtimeDisplayName string) {
266+
func promptCleanupOldInstallations(versions []detectedVersionWithProvider, runtimeDisplayName string) {
241267
ui.Header("Cleanup Old Installations")
242268
ui.Info("You have successfully migrated to dtvem. Would you like to clean up the old installations?")
243269
ui.Info("This helps prevent PATH conflicts and version confusion.")
@@ -251,10 +277,12 @@ func promptCleanupOldInstallations(versions []internalRuntime.DetectedVersion, r
251277
fmt.Printf("Old installation: %s %s\n", ui.HighlightVersion("v"+dv.Version), ui.Highlight("("+dv.Source+")"))
252278
fmt.Printf(" Location: %s\n", dv.Path)
253279

254-
// Get uninstall instructions/command based on source
255-
instructions, command, automatable := getUninstallInstructions(dv, runtimeDisplayName)
280+
mp := dv.MigrationProvider
281+
canAuto := mp.CanAutoUninstall()
282+
command := mp.UninstallCommand(dv.Version)
283+
instructions := mp.ManualInstructions()
256284

257-
if automatable {
285+
if canAuto && command != "" {
258286
fmt.Printf("\nRemove this installation? [y/N]: ")
259287
input, err := reader.ReadString('\n')
260288
if err != nil || strings.ToLower(strings.TrimSpace(input)) != "y" {
@@ -277,7 +305,7 @@ func promptCleanupOldInstallations(versions []internalRuntime.DetectedVersion, r
277305
removedCount++
278306
}
279307
} else {
280-
// System installs - provide instructions only
308+
// System installs or version managers without auto-uninstall - provide instructions only
281309
ui.Warning("Manual removal required")
282310
ui.Info("%s", instructions)
283311
skippedCount++
@@ -297,63 +325,6 @@ func promptCleanupOldInstallations(versions []internalRuntime.DetectedVersion, r
297325
}
298326
}
299327

300-
// getUninstallInstructions returns instructions, command, and whether it's automatable
301-
func getUninstallInstructions(dv internalRuntime.DetectedVersion, runtimeDisplayName string) (instructions string, command string, automatable bool) {
302-
version := dv.Version
303-
source := strings.ToLower(dv.Source)
304-
305-
switch source {
306-
case "nvm":
307-
return "",
308-
fmt.Sprintf("nvm uninstall %s", version),
309-
true
310-
case "pyenv":
311-
return "",
312-
fmt.Sprintf("pyenv uninstall %s", version),
313-
true
314-
case "fnm":
315-
return "",
316-
fmt.Sprintf("fnm uninstall %s", version),
317-
true
318-
case "rbenv":
319-
return "",
320-
fmt.Sprintf("rbenv uninstall %s", version),
321-
true
322-
case "system":
323-
// OS-specific instructions
324-
instructions := getSystemUninstallInstructions(runtimeDisplayName, dv.Path)
325-
return instructions, "", false
326-
default:
327-
// Unknown source - provide generic instructions
328-
return fmt.Sprintf("Manually remove the installation directory:\n %s", dv.Path), "", false
329-
}
330-
}
331-
332-
// getSystemUninstallInstructions provides OS-specific uninstall instructions for system packages
333-
func getSystemUninstallInstructions(runtimeDisplayName string, path string) string {
334-
switch runtime.GOOS {
335-
case "windows":
336-
return "To uninstall:\n" +
337-
" 1. Open Settings → Apps → Installed apps\n" +
338-
" 2. Search for " + runtimeDisplayName + "\n" +
339-
" 3. Click Uninstall\n" +
340-
" Or use PowerShell to find the uninstaller"
341-
case "darwin":
342-
return "To uninstall:\n" +
343-
" If installed via Homebrew: brew uninstall " + strings.ToLower(runtimeDisplayName) + "\n" +
344-
" If installed via package: check /Applications or use the installer's uninstaller\n" +
345-
" Manual removal: sudo rm -rf " + path
346-
case "linux":
347-
return "To uninstall:\n" +
348-
" If installed via apt: sudo apt remove " + strings.ToLower(runtimeDisplayName) + "\n" +
349-
" If installed via yum: sudo yum remove " + strings.ToLower(runtimeDisplayName) + "\n" +
350-
" If installed via dnf: sudo dnf remove " + strings.ToLower(runtimeDisplayName) + "\n" +
351-
" Manual removal: sudo rm -rf " + path
352-
default:
353-
return "Manually remove the installation directory:\n " + path
354-
}
355-
}
356-
357328
// executeUninstallCommand executes the uninstall command for automated cleanup
358329
func executeUninstallCommand(command string) error {
359330
// Parse the command into parts

src/internal/migration/provider.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Package migration provides a plugin-style architecture for migrating from
2+
// other version managers (nvm, pyenv, rbenv, etc.) to dtvem.
3+
package migration
4+
5+
// Provider defines the interface that all migration providers must implement.
6+
// Each provider handles detection and cleanup for a specific version manager.
7+
type Provider interface {
8+
// Name returns the identifier for this version manager (e.g., "nvm", "pyenv", "rbenv")
9+
Name() string
10+
11+
// DisplayName returns the human-readable name (e.g., "Node Version Manager (nvm)")
12+
DisplayName() string
13+
14+
// Runtime returns the runtime this provider manages (e.g., "node", "python", "ruby")
15+
Runtime() string
16+
17+
// IsPresent checks if this version manager is installed on the system
18+
IsPresent() bool
19+
20+
// DetectVersions finds all versions installed by this version manager
21+
DetectVersions() ([]DetectedVersion, error)
22+
23+
// CanAutoUninstall returns true if versions can be uninstalled automatically
24+
CanAutoUninstall() bool
25+
26+
// UninstallCommand returns the command to uninstall a specific version
27+
// Returns empty string if automatic uninstall is not supported
28+
UninstallCommand(version string) string
29+
30+
// ManualInstructions returns instructions for manual removal
31+
// Used when automatic uninstall is not available
32+
ManualInstructions() string
33+
}
34+
35+
// DetectedVersion represents a runtime version found by a migration provider.
36+
type DetectedVersion struct {
37+
Version string // Version string (e.g., "22.0.0", "3.11.0")
38+
Path string // Path to the executable
39+
Source string // Source/version manager name (e.g., "nvm", "pyenv")
40+
Validated bool // Whether we've verified this version works
41+
}
42+
43+
// String returns a formatted string representation
44+
func (dv DetectedVersion) String() string {
45+
return "v" + dv.Version + " (" + dv.Source + ") " + dv.Path
46+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package migration
2+
3+
import (
4+
"testing"
5+
)
6+
7+
// ProviderTestHarness provides a standardized way to test migration provider implementations.
8+
// Each provider package should use this harness to ensure consistent behavior.
9+
type ProviderTestHarness struct {
10+
Provider Provider
11+
ExpectedName string
12+
Runtime string
13+
}
14+
15+
// RunAll runs all standard provider tests.
16+
func (h *ProviderTestHarness) RunAll(t *testing.T) {
17+
t.Run("Name", h.TestName)
18+
t.Run("DisplayName", h.TestDisplayName)
19+
t.Run("Runtime", h.TestRuntime)
20+
t.Run("DetectVersions", h.TestDetectVersions)
21+
t.Run("CanAutoUninstall", h.TestCanAutoUninstall)
22+
t.Run("ManualInstructions", h.TestManualInstructions)
23+
}
24+
25+
// TestName verifies the provider returns a valid name.
26+
func (h *ProviderTestHarness) TestName(t *testing.T) {
27+
name := h.Provider.Name()
28+
if name == "" {
29+
t.Error("Name() returned empty string")
30+
}
31+
if name != h.ExpectedName {
32+
t.Errorf("Name() = %q, want %q", name, h.ExpectedName)
33+
}
34+
}
35+
36+
// TestDisplayName verifies the provider returns a valid display name.
37+
func (h *ProviderTestHarness) TestDisplayName(t *testing.T) {
38+
displayName := h.Provider.DisplayName()
39+
if displayName == "" {
40+
t.Error("DisplayName() returned empty string")
41+
}
42+
}
43+
44+
// TestRuntime verifies the provider returns the expected runtime.
45+
func (h *ProviderTestHarness) TestRuntime(t *testing.T) {
46+
runtime := h.Provider.Runtime()
47+
if runtime != h.Runtime {
48+
t.Errorf("Runtime() = %q, want %q", runtime, h.Runtime)
49+
}
50+
}
51+
52+
// TestDetectVersions verifies DetectVersions doesn't error.
53+
func (h *ProviderTestHarness) TestDetectVersions(t *testing.T) {
54+
versions, err := h.Provider.DetectVersions()
55+
if err != nil {
56+
t.Errorf("DetectVersions() error = %v, want nil", err)
57+
}
58+
if versions == nil {
59+
t.Error("DetectVersions() returned nil, want empty slice")
60+
}
61+
}
62+
63+
// TestCanAutoUninstall verifies the method returns a boolean.
64+
func (h *ProviderTestHarness) TestCanAutoUninstall(t *testing.T) {
65+
canAuto := h.Provider.CanAutoUninstall()
66+
67+
// If auto uninstall is supported, UninstallCommand should return non-empty
68+
if canAuto {
69+
cmd := h.Provider.UninstallCommand("1.0.0")
70+
if cmd == "" {
71+
t.Error("CanAutoUninstall() returns true but UninstallCommand() returns empty")
72+
}
73+
}
74+
}
75+
76+
// TestManualInstructions verifies manual instructions are provided.
77+
func (h *ProviderTestHarness) TestManualInstructions(t *testing.T) {
78+
instructions := h.Provider.ManualInstructions()
79+
if instructions == "" {
80+
t.Error("ManualInstructions() returned empty string")
81+
}
82+
}

0 commit comments

Comments
 (0)