Skip to content

Commit a0a896f

Browse files
committed
fix(init): clean up stale dtvem shims entries from PATH
When XDG_DATA_HOME is set on Windows/macOS, dtvem honors it for both data storage and PATH configuration. However, users who upgraded from a pre-XDG-support version (or who set XDG_DATA_HOME after a prior install) end up with a stale `~/.dtvem/shims` entry in PATH pointing at an empty directory, while the real shims live at the XDG-resolved location. `dtvem init` now scans PATH for any entry that looks like a dtvem shims directory (matches `*/dtvem/shims` or `*/.dtvem/shims`, case-insensitive on Windows) and treats entries that don't equal the currently-resolved ShimsDir() as stale. On Windows, stale entries are removed from the registry during the existing PATH-modify pass and reported in the success output. On Unix, stale entries are surfaced as a warning with manual cleanup instructions (removing them automatically would risk clobbering user edits in .bashrc/.zshrc/etc).
1 parent 4eee411 commit a0a896f

4 files changed

Lines changed: 300 additions & 4 deletions

File tree

src/internal/path/path.go

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"path/filepath"
77
"runtime"
88
"strings"
9+
10+
"github.com/CodingWithCalvin/dtvem.cli/src/internal/constants"
911
)
1012

1113
// IsInPath checks if a directory is in the system PATH
@@ -14,7 +16,7 @@ func IsInPath(dir string) bool {
1416

1517
// Get the path separator for this OS
1618
separator := ":"
17-
if runtime.GOOS == "windows" {
19+
if runtime.GOOS == constants.OSWindows {
1820
separator = ";"
1921
}
2022

@@ -34,6 +36,79 @@ func IsInPath(dir string) bool {
3436
return false
3537
}
3638

39+
// IsDtvemShimsPath reports whether path looks like a dtvem shims directory.
40+
// It matches the standard installation patterns:
41+
// - <anything>/dtvem/shims (e.g., ~/.local/share/dtvem/shims under XDG_DATA_HOME)
42+
// - <anything>/.dtvem/shims (the default Windows/macOS layout, leading dot)
43+
//
44+
// Comparison is case-insensitive on Windows. Custom DTVEM_ROOT layouts whose
45+
// final two components don't match these patterns are not detected.
46+
func IsDtvemShimsPath(path string) bool {
47+
if path == "" {
48+
return false
49+
}
50+
51+
cleaned := filepath.Clean(path)
52+
leaf := filepath.Base(cleaned)
53+
parent := filepath.Base(filepath.Dir(cleaned))
54+
55+
leafEq := func(a, b string) bool { return a == b }
56+
if runtime.GOOS == constants.OSWindows {
57+
leafEq = strings.EqualFold
58+
}
59+
60+
if !leafEq(leaf, "shims") {
61+
return false
62+
}
63+
return leafEq(parent, "dtvem") || leafEq(parent, ".dtvem")
64+
}
65+
66+
// FindStaleShimsEntries scans pathEntries for entries that look like dtvem
67+
// shims directories but do not match currentShimsDir. The returned slice
68+
// preserves the order of appearance in pathEntries and has the original
69+
// (un-cleaned) entry strings, so callers can match them against registry
70+
// or config-file content.
71+
//
72+
// Comparison against currentShimsDir is case-insensitive on Windows.
73+
func FindStaleShimsEntries(pathEntries []string, currentShimsDir string) []string {
74+
if currentShimsDir == "" {
75+
return nil
76+
}
77+
currentClean := filepath.Clean(currentShimsDir)
78+
79+
var stale []string
80+
for _, entry := range pathEntries {
81+
trimmed := strings.TrimSpace(entry)
82+
if trimmed == "" {
83+
continue
84+
}
85+
if !IsDtvemShimsPath(trimmed) {
86+
continue
87+
}
88+
entryClean := filepath.Clean(trimmed)
89+
if runtime.GOOS == constants.OSWindows {
90+
if strings.EqualFold(entryClean, currentClean) {
91+
continue
92+
}
93+
} else {
94+
if entryClean == currentClean {
95+
continue
96+
}
97+
}
98+
stale = append(stale, entry)
99+
}
100+
return stale
101+
}
102+
103+
// SplitPath splits the PATH environment variable using the OS-appropriate separator.
104+
func SplitPath(pathEnv string) []string {
105+
separator := ":"
106+
if runtime.GOOS == constants.OSWindows {
107+
separator = ";"
108+
}
109+
return strings.Split(pathEnv, separator)
110+
}
111+
37112
// ShimsDir returns the path to the shims directory
38113
// This replicates the root directory logic from config package to avoid circular dependencies.
39114
// Must stay in sync with config.getRootDir().
@@ -103,7 +178,7 @@ func LookPathExcludingShims(execName string) string {
103178
// On Windows, it tries .exe, .cmd, .bat extensions.
104179
// On Unix, it checks if the file exists and has execute permission.
105180
func findExecutableInDir(dir, execName string) string {
106-
if runtime.GOOS == "windows" {
181+
if runtime.GOOS == constants.OSWindows {
107182
// Windows: try .exe, .cmd, .bat extensions
108183
for _, ext := range []string{".exe", ".cmd", ".bat"} {
109184
candidate := filepath.Join(dir, execName+ext)

src/internal/path/path_test.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,167 @@ func TestLookPathExcludingShims_SkipsShimsDir(t *testing.T) {
372372
})
373373
}
374374

375+
func TestIsDtvemShimsPath(t *testing.T) {
376+
// Platform-specific path separator handling: filepath.Join produces
377+
// backslashes on Windows and forward slashes on Unix, which matches what
378+
// real PATH entries look like on each platform.
379+
tests := []struct {
380+
name string
381+
path string
382+
want bool
383+
}{
384+
{
385+
name: "leading-dot dtvem under home",
386+
path: filepath.Join("C:", "Users", "testuser", ".dtvem", "shims"),
387+
want: true,
388+
},
389+
{
390+
name: "no-dot dtvem under XDG data home",
391+
path: filepath.Join("C:", "Users", "testuser", ".local", "share", "dtvem", "shims"),
392+
want: true,
393+
},
394+
{
395+
name: "unix style leading-dot",
396+
path: "/home/testuser/.dtvem/shims",
397+
want: true,
398+
},
399+
{
400+
name: "unix style XDG",
401+
path: "/home/testuser/.local/share/dtvem/shims",
402+
want: true,
403+
},
404+
{
405+
name: "trailing slash is normalized",
406+
path: "/home/testuser/.dtvem/shims/",
407+
want: true,
408+
},
409+
{
410+
name: "shims under non-dtvem parent does not match",
411+
path: "/home/testuser/something/shims",
412+
want: false,
413+
},
414+
{
415+
name: "dtvem dir without shims leaf does not match",
416+
path: "/home/testuser/.dtvem/bin",
417+
want: false,
418+
},
419+
{
420+
name: "empty string",
421+
path: "",
422+
want: false,
423+
},
424+
}
425+
426+
for _, tt := range tests {
427+
t.Run(tt.name, func(t *testing.T) {
428+
got := IsDtvemShimsPath(tt.path)
429+
if got != tt.want {
430+
t.Errorf("IsDtvemShimsPath(%q) = %v, want %v", tt.path, got, tt.want)
431+
}
432+
})
433+
}
434+
}
435+
436+
func TestIsDtvemShimsPath_WindowsCaseInsensitive(t *testing.T) {
437+
if runtime.GOOS != constants.OSWindows {
438+
t.Skip("Windows-only: case-insensitive path matching")
439+
}
440+
441+
cases := []string{
442+
`C:\Users\testuser\.DTVEM\Shims`,
443+
`C:\Users\testuser\.local\share\DTVEM\SHIMS`,
444+
`C:\Users\testuser\.Dtvem\shims`,
445+
}
446+
for _, p := range cases {
447+
if !IsDtvemShimsPath(p) {
448+
t.Errorf("IsDtvemShimsPath(%q) = false, want true (Windows case-insensitive)", p)
449+
}
450+
}
451+
}
452+
453+
func TestFindStaleShimsEntries(t *testing.T) {
454+
// Build paths that look right on the current platform so the
455+
// case-insensitive comparison logic exercises real separators.
456+
currentXDG := filepath.Join("C:", "Users", "testuser", ".local", "share", "dtvem", "shims")
457+
staleHome := filepath.Join("C:", "Users", "testuser", ".dtvem", "shims")
458+
unrelated := filepath.Join("C:", "Windows", "System32")
459+
460+
tests := []struct {
461+
name string
462+
entries []string
463+
current string
464+
want []string
465+
}{
466+
{
467+
name: "stale leading-dot entry alongside current XDG",
468+
entries: []string{currentXDG, unrelated, staleHome},
469+
current: currentXDG,
470+
want: []string{staleHome},
471+
},
472+
{
473+
name: "no stale entries when only current is present",
474+
entries: []string{currentXDG, unrelated},
475+
current: currentXDG,
476+
want: nil,
477+
},
478+
{
479+
name: "current dir is the leading-dot variant",
480+
entries: []string{staleHome, currentXDG, unrelated},
481+
current: staleHome,
482+
want: []string{currentXDG},
483+
},
484+
{
485+
name: "empty entries are skipped",
486+
entries: []string{"", staleHome, " "},
487+
current: currentXDG,
488+
want: []string{staleHome},
489+
},
490+
{
491+
name: "preserves original entry strings (not cleaned)",
492+
entries: []string{staleHome + string(filepath.Separator), unrelated},
493+
current: currentXDG,
494+
want: []string{staleHome + string(filepath.Separator)},
495+
},
496+
{
497+
name: "empty current shimsDir returns nil",
498+
entries: []string{staleHome},
499+
current: "",
500+
want: nil,
501+
},
502+
}
503+
504+
for _, tt := range tests {
505+
t.Run(tt.name, func(t *testing.T) {
506+
got := FindStaleShimsEntries(tt.entries, tt.current)
507+
if len(got) != len(tt.want) {
508+
t.Fatalf("FindStaleShimsEntries() = %v, want %v", got, tt.want)
509+
}
510+
for i := range got {
511+
if got[i] != tt.want[i] {
512+
t.Errorf("FindStaleShimsEntries()[%d] = %q, want %q", i, got[i], tt.want[i])
513+
}
514+
}
515+
})
516+
}
517+
}
518+
519+
func TestFindStaleShimsEntries_WindowsCaseInsensitive(t *testing.T) {
520+
if runtime.GOOS != constants.OSWindows {
521+
t.Skip("Windows-only: case-insensitive comparison")
522+
}
523+
524+
current := `C:\Users\testuser\.local\share\dtvem\shims`
525+
// Same logical path as `current` but with mixed casing — should NOT be
526+
// flagged stale.
527+
sameAsCurrentDifferentCase := `C:\Users\TESTUSER\.LOCAL\share\dtvem\SHIMS`
528+
stale := `C:\Users\testuser\.dtvem\shims`
529+
530+
got := FindStaleShimsEntries([]string{sameAsCurrentDifferentCase, stale}, current)
531+
if len(got) != 1 || got[0] != stale {
532+
t.Errorf("FindStaleShimsEntries() = %v, want exactly [%q]", got, stale)
533+
}
534+
}
535+
375536
func TestFindExecutableInDir(t *testing.T) {
376537
tempDir := t.TempDir()
377538

src/internal/path/path_unix.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ func AddToPath(shimsDir string, skipConfirmation bool, userInstall bool) error {
6868
return fmt.Errorf("could not determine config file for shell %s", shell)
6969
}
7070

71+
// Warn about any stale dtvem shims directories in PATH (e.g. left over
72+
// after switching XDG_DATA_HOME or upgrading from a pre-XDG install).
73+
// We don't auto-rewrite shell config files on Unix because users often
74+
// customize them heavily; surface the entries with manual cleanup steps.
75+
warnAboutStaleShimsEntries(shimsDir, configFile)
76+
7177
// Check if the directory is already in PATH
7278
if IsInPath(shimsDir) {
7379
ui.Info("%s is already in your PATH", shimsDir)
@@ -134,6 +140,25 @@ func AddToPath(shimsDir string, skipConfirmation bool, userInstall bool) error {
134140
return nil
135141
}
136142

143+
// warnAboutStaleShimsEntries scans the current PATH for dtvem shims directories
144+
// that don't match shimsDir and prints manual cleanup instructions for each.
145+
// We don't auto-rewrite shell config files on Unix to avoid clobbering user edits.
146+
func warnAboutStaleShimsEntries(shimsDir, configFile string) {
147+
stale := FindStaleShimsEntries(SplitPath(os.Getenv("PATH")), shimsDir)
148+
if len(stale) == 0 {
149+
return
150+
}
151+
152+
ui.Warning("Found stale dtvem shims entries in your PATH:")
153+
for _, s := range stale {
154+
ui.Info(" %s", s)
155+
}
156+
ui.Info("These were likely left over from a prior install or before XDG_DATA_HOME was set.")
157+
ui.Info("Edit %s and remove the export lines that reference the stale paths above.", ui.Highlight(configFile))
158+
ui.Info("After editing, restart your terminal or run: source %s", configFile)
159+
ui.Info("")
160+
}
161+
137162
// containsPathModification checks if the config file already has dtvem PATH modification
138163
func containsPathModification(configFile, shimsDir string) bool {
139164
f, err := os.Open(configFile)

0 commit comments

Comments
 (0)