From cdb883c44ba882a98d2d4395bc012fa5f88a77b0 Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Thu, 23 Apr 2026 13:50:08 -0400 Subject: [PATCH 1/3] fix(shim): strip .exe suffix case-insensitively when resolving shim name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, getShimName read os.Args[0] and stripped ".exe" via byte-exact strings.TrimSuffix. This broke whenever the invoker passed an uppercase extension — most notably Python's shutil.which, which returns paths like "mmdc.EXE" when the system PATHEXT contains ".EXE" (the common default on Windows). With the uppercase extension left attached, the shim name fell through the shim-map cache lookup (keyed by lowercase base names) AND the provider prefix-match (Shims() returns lowercase), and dtvem-shim surfaced a misleading "runtime provider not found: runtime provider 'mmdc.EXE' not found" error for a command that was otherwise correctly installed. Extract shimNameFromPath as a pure function and use filepath.Ext + strings.EqualFold to strip .exe / .EXE / .Exe regardless of case. Add a table-driven test that covers lowercase, uppercase, mixed-case, bare names, and non-.exe extensions (which must be preserved). Note: only the extension stripping is case-normalized — the base shim name itself is left as-is, consistent with the existing behavior of downstream lookups. --- src/cmd/shim/main.go | 21 ++++++++++++--- src/cmd/shim/main_test.go | 56 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 src/cmd/shim/main_test.go diff --git a/src/cmd/shim/main.go b/src/cmd/shim/main.go index 880e3d3..604cd5d 100644 --- a/src/cmd/shim/main.go +++ b/src/cmd/shim/main.go @@ -155,13 +155,26 @@ func handleNoConfiguredVersion(shimName, runtimeName string, provider runtime.Sh return fmt.Errorf("no version configured") } -// getShimName returns the name of this shim binary +// getShimName returns the name of this shim binary based on os.Args[0]. func getShimName() string { - shimPath := os.Args[0] + return shimNameFromPath(os.Args[0]) +} + +// shimNameFromPath derives the shim name from an invocation path. +// +// On Windows, the filename's .exe extension is stripped case-insensitively. +// This matters because Windows command resolution via PATHEXT can surface +// uppercase extensions (e.g., Python's shutil.which returns "mmdc.EXE" +// when PATHEXT contains ".EXE"). A case-sensitive TrimSuffix would leave +// the uppercase extension attached, breaking every downstream lookup in +// the shim-map cache and the provider registry. +func shimNameFromPath(shimPath string) string { shimName := filepath.Base(shimPath) - // Remove .exe extension on Windows - shimName = strings.TrimSuffix(shimName, ".exe") + // Strip .exe / .EXE / any mixed case on Windows-style paths. + if ext := filepath.Ext(shimName); strings.EqualFold(ext, constants.ExtExe) { + shimName = shimName[:len(shimName)-len(ext)] + } return shimName } diff --git a/src/cmd/shim/main_test.go b/src/cmd/shim/main_test.go new file mode 100644 index 0000000..2667f6d --- /dev/null +++ b/src/cmd/shim/main_test.go @@ -0,0 +1,56 @@ +package main + +import "testing" + +func TestShimNameFromPath(t *testing.T) { + tests := []struct { + name string + shimPath string + want string + }{ + { + name: "unix-style bare binary", + shimPath: "/home/user/.dtvem/shims/mmdc", + want: "mmdc", + }, + { + name: "windows lowercase .exe", + shimPath: `C:\Users\calvin\.dtvem\shims\mmdc.exe`, + want: "mmdc", + }, + { + name: "windows uppercase .EXE (PATHEXT-resolved)", + shimPath: `C:\Users\calvin\.dtvem\shims\mmdc.EXE`, + want: "mmdc", + }, + { + name: "windows mixed case .Exe", + shimPath: `C:\Users\calvin\.dtvem\shims\mmdc.Exe`, + want: "mmdc", + }, + { + name: "forward-slash path with uppercase extension", + shimPath: "C:/Users/calvin/.dtvem/shims/npm.EXE", + want: "npm", + }, + { + name: "bare shim name without extension", + shimPath: "mmdc", + want: "mmdc", + }, + { + name: "non-.exe extension is preserved (not stripped)", + shimPath: `C:\tools\something.bat`, + want: "something.bat", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shimNameFromPath(tt.shimPath) + if got != tt.want { + t.Errorf("shimNameFromPath(%q) = %q, want %q", tt.shimPath, got, tt.want) + } + }) + } +} From 7e2ec38b13fdb65b9f41e7314ba67fc41280f6ef Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Thu, 23 Apr 2026 13:51:07 -0400 Subject: [PATCH 2/3] fix(shim): seed shim-map cache during runtime install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime providers' createShims() only wrote shim files to disk — they did not update the shim-map cache. That left an install-time gap where the shim files for node/npm/npx (and python/pip, ruby/gem, etc.) existed, but the cache entry mapping them back to their runtime did not. In practice the gap was papered over by dtvem-shim's provider-prefix fallback in mapShimToRuntime: calling "npm" still resolved to "node" because the Node provider's Shims() list contains "npm". But the moment something invoked a shim whose name wasn't on any provider's Shims() list (e.g., a globally-installed npm package like mmdc), and the cache hadn't yet been rebuilt by "dtvem reshim" or the post-install reshim prompt, the shim errored with "runtime provider not found" — even though the shim file had been placed correctly. Fix by registering the core shims in the cache at install time: - Add MergeShimMap(entries) to cache.go — merges new entries into the on-disk map, creating it if absent, and resets the in-memory cache so subsequent LoadShimMap reflects the merged state. Preferred over Rehash() for install-time registration because it doesn't require re-scanning every installed runtime's bin directory. - Add Manager.CreateShimsForRuntime(runtimeName, shimNames) — creates the shim files AND calls MergeShimMap so files and cache stay in sync. - Update node, python, and ruby provider createShims() to use CreateShimsForRuntime instead of bare CreateShims. Bare CreateShims is kept for any caller that genuinely only wants the shim files (for example, tests that don't care about the cache), but the in-repo runtime providers all route through the new method. Tests: - 4 new MergeShimMap tests covering fresh cache creation, disjoint merge, overwrite semantics, and in-memory cache invalidation. - CreateShimsForRuntime is covered compositionally by existing shim creation tests + the new MergeShimMap tests, matching the codebase's convention of testing primitives rather than the manager layer (which requires os.Executable() plumbing). --- src/internal/shim/cache.go | 28 +++++ src/internal/shim/cache_test.go | 176 ++++++++++++++++++++++++++++++++ src/internal/shim/manager.go | 22 ++++ src/runtimes/node/provider.go | 8 +- src/runtimes/python/provider.go | 8 +- src/runtimes/ruby/provider.go | 8 +- 6 files changed, 241 insertions(+), 9 deletions(-) diff --git a/src/internal/shim/cache.go b/src/internal/shim/cache.go index fc5dd3c..a7f9d24 100644 --- a/src/internal/shim/cache.go +++ b/src/internal/shim/cache.go @@ -66,6 +66,34 @@ func SaveShimMap(shimMap ShimMap) error { return os.WriteFile(cachePath, data, 0644) } +// MergeShimMap merges the given entries into the on-disk shim map and persists it. +// +// If the cache does not exist yet (first-time install), a new map is created. +// Existing entries with matching keys are overwritten. The in-memory cache is +// reset so subsequent LoadShimMap calls read the updated state from disk. +// +// This is the preferred path for install-time shim registration, where the +// caller knows only the shims it just created and wants to register them +// without rebuilding the entire map (which would require scanning every +// installed runtime — `Rehash` does that). +func MergeShimMap(entries ShimMap) error { + existing, err := loadShimMapFromDisk() + if err != nil || existing == nil { + // Cache missing, unreadable, or empty — start a fresh map. + existing = make(ShimMap, len(entries)) + } + + for shim, runtime := range entries { + existing[shim] = runtime + } + + // Force the next LoadShimMap to re-read from disk so the merged entries + // are visible to any subsequent caller in the same process. + ResetShimMapCache() + + return SaveShimMap(existing) +} + // LookupRuntime looks up the runtime for a given shim name using the cache. // Returns the runtime name and true if found, or empty string and false if not. func LookupRuntime(shimName string) (string, bool) { diff --git a/src/internal/shim/cache_test.go b/src/internal/shim/cache_test.go index 0341e1b..1974439 100644 --- a/src/internal/shim/cache_test.go +++ b/src/internal/shim/cache_test.go @@ -218,3 +218,179 @@ func TestShimMapCacheOnlyLoadsOnce(t *testing.T) { t.Errorf("Cache should not have reloaded - 'new' entry should not exist") } } + +func TestMergeShimMap_CreatesWhenNoExistingCache(t *testing.T) { + tempDir := t.TempDir() + + originalRoot := os.Getenv("DTVEM_ROOT") + _ = os.Setenv("DTVEM_ROOT", tempDir) + defer func() { _ = os.Setenv("DTVEM_ROOT", originalRoot) }() + + config.ResetPathsCache() + defer config.ResetPathsCache() + + ResetShimMapCache() + defer ResetShimMapCache() + + // No cache directory pre-existing — MergeShimMap must create it from scratch. + entries := ShimMap{ + "node": "node", + "npm": "node", + "npx": "node", + } + + if err := MergeShimMap(entries); err != nil { + t.Fatalf("MergeShimMap returned error on fresh install: %v", err) + } + + loaded, err := LoadShimMap() + if err != nil { + t.Fatalf("LoadShimMap after MergeShimMap failed: %v", err) + } + + if len(loaded) != len(entries) { + t.Errorf("expected %d entries, got %d (%v)", len(entries), len(loaded), loaded) + } + for shim, runtime := range entries { + if got := loaded[shim]; got != runtime { + t.Errorf("entry %q: expected runtime %q, got %q", shim, runtime, got) + } + } +} + +func TestMergeShimMap_MergesIntoExistingCache(t *testing.T) { + tempDir := t.TempDir() + + originalRoot := os.Getenv("DTVEM_ROOT") + _ = os.Setenv("DTVEM_ROOT", tempDir) + defer func() { _ = os.Setenv("DTVEM_ROOT", originalRoot) }() + + config.ResetPathsCache() + defer config.ResetPathsCache() + + ResetShimMapCache() + defer ResetShimMapCache() + + cacheDir := filepath.Join(tempDir, "cache") + if err := os.MkdirAll(cacheDir, 0755); err != nil { + t.Fatalf("Failed to create cache directory: %v", err) + } + + // Seed an existing cache (simulates a prior install). + initial := ShimMap{ + "python": "python", + "pip": "python", + } + if err := SaveShimMap(initial); err != nil { + t.Fatalf("seed SaveShimMap failed: %v", err) + } + + // Merge in a disjoint set of entries (simulates installing a second runtime). + added := ShimMap{ + "node": "node", + "npm": "node", + } + if err := MergeShimMap(added); err != nil { + t.Fatalf("MergeShimMap failed: %v", err) + } + + loaded, err := LoadShimMap() + if err != nil { + t.Fatalf("LoadShimMap failed: %v", err) + } + + // All four entries should now be present. + wantAll := ShimMap{ + "python": "python", + "pip": "python", + "node": "node", + "npm": "node", + } + for shim, runtime := range wantAll { + if got := loaded[shim]; got != runtime { + t.Errorf("entry %q: expected runtime %q, got %q", shim, runtime, got) + } + } +} + +func TestMergeShimMap_OverwritesExistingKeys(t *testing.T) { + tempDir := t.TempDir() + + originalRoot := os.Getenv("DTVEM_ROOT") + _ = os.Setenv("DTVEM_ROOT", tempDir) + defer func() { _ = os.Setenv("DTVEM_ROOT", originalRoot) }() + + config.ResetPathsCache() + defer config.ResetPathsCache() + + ResetShimMapCache() + defer ResetShimMapCache() + + cacheDir := filepath.Join(tempDir, "cache") + if err := os.MkdirAll(cacheDir, 0755); err != nil { + t.Fatalf("Failed to create cache directory: %v", err) + } + + // Seed with a stale mapping (e.g., a shim that was previously attributed + // to the wrong runtime by some prior state). + stale := ShimMap{"corepack": "wrong"} + if err := SaveShimMap(stale); err != nil { + t.Fatalf("seed SaveShimMap failed: %v", err) + } + + // Merge should overwrite with the correct runtime. + if err := MergeShimMap(ShimMap{"corepack": "node"}); err != nil { + t.Fatalf("MergeShimMap failed: %v", err) + } + + loaded, err := LoadShimMap() + if err != nil { + t.Fatalf("LoadShimMap failed: %v", err) + } + + if got := loaded["corepack"]; got != "node" { + t.Errorf("expected corepack remapped to node, got %q", got) + } +} + +func TestMergeShimMap_ResetsInMemoryCache(t *testing.T) { + tempDir := t.TempDir() + + originalRoot := os.Getenv("DTVEM_ROOT") + _ = os.Setenv("DTVEM_ROOT", tempDir) + defer func() { _ = os.Setenv("DTVEM_ROOT", originalRoot) }() + + config.ResetPathsCache() + defer config.ResetPathsCache() + + ResetShimMapCache() + defer ResetShimMapCache() + + cacheDir := filepath.Join(tempDir, "cache") + if err := os.MkdirAll(cacheDir, 0755); err != nil { + t.Fatalf("Failed to create cache directory: %v", err) + } + + // Prime the in-memory cache with an initial map. + if err := SaveShimMap(ShimMap{"node": "node"}); err != nil { + t.Fatalf("SaveShimMap failed: %v", err) + } + if _, err := LoadShimMap(); err != nil { + t.Fatalf("initial LoadShimMap failed: %v", err) + } + + // Without ResetShimMapCache, the next Load would return the cached copy. + // MergeShimMap is supposed to reset it so callers see merged state. + if err := MergeShimMap(ShimMap{"npm": "node"}); err != nil { + t.Fatalf("MergeShimMap failed: %v", err) + } + + loaded, err := LoadShimMap() + if err != nil { + t.Fatalf("post-merge LoadShimMap failed: %v", err) + } + + if _, ok := loaded["npm"]; !ok { + t.Error("expected in-memory cache to be reset so the merged 'npm' entry is visible") + } +} diff --git a/src/internal/shim/manager.go b/src/internal/shim/manager.go index dcd8237..f4ff348 100644 --- a/src/internal/shim/manager.go +++ b/src/internal/shim/manager.go @@ -108,6 +108,28 @@ func (m *Manager) CreateShims(shimNames []string) error { return nil } +// CreateShimsForRuntime creates shim files for the given names and registers +// them in the shim map under the given runtime name. +// +// This is the preferred path for install-time shim creation (e.g., from a +// runtime provider's post-install hook). Bare CreateShims only writes the +// shim binaries to disk — it does not update the shim-map cache, which means +// subsequent shim invocations have to fall back to the provider registry +// lookup instead of the O(1) cache hit. Calling CreateShimsForRuntime keeps +// the shim files and the cache in sync from the moment they are created. +func (m *Manager) CreateShimsForRuntime(runtimeName string, shimNames []string) error { + if err := m.CreateShims(shimNames); err != nil { + return err + } + + entries := make(ShimMap, len(shimNames)) + for _, name := range shimNames { + entries[name] = runtimeName + } + + return MergeShimMap(entries) +} + // RemoveShim removes a shim func (m *Manager) RemoveShim(shimName string) error { shimPath := config.ShimPath(shimName) diff --git a/src/runtimes/node/provider.go b/src/runtimes/node/provider.go index 688a0ab..c137981 100644 --- a/src/runtimes/node/provider.go +++ b/src/runtimes/node/provider.go @@ -152,7 +152,9 @@ func (p *Provider) getDownloadURL(version string) (string, string, error) { return dl.URL, archiveName, nil } -// createShims creates shims for Node.js executables +// createShims creates shims for Node.js executables and registers them in the +// shim-map cache so subsequent shim invocations resolve via O(1) lookup rather +// than falling back to the provider registry. func (p *Provider) createShims() error { manager, err := shim.NewManager() if err != nil { @@ -162,8 +164,8 @@ func (p *Provider) createShims() error { // Get the list of shims for Node.js shimNames := shim.RuntimeShims("node") - // Create each shim - return manager.CreateShims(shimNames) + // Create each shim AND record them in the shim map cache + return manager.CreateShimsForRuntime("node", shimNames) } // Uninstall removes an installed version diff --git a/src/runtimes/python/provider.go b/src/runtimes/python/provider.go index 10086c8..5952d3c 100644 --- a/src/runtimes/python/provider.go +++ b/src/runtimes/python/provider.go @@ -209,7 +209,9 @@ func (p *Provider) getDownloadURL(version string) (string, string, error) { return dl.URL, archiveName, nil } -// createShims creates shims for Python executables +// createShims creates shims for Python executables and registers them in the +// shim-map cache so subsequent shim invocations resolve via O(1) lookup rather +// than falling back to the provider registry. func (p *Provider) createShims() error { manager, err := shim.NewManager() if err != nil { @@ -219,8 +221,8 @@ func (p *Provider) createShims() error { // Get the list of shims for Python shimNames := shim.RuntimeShims("python") - // Create each shim - return manager.CreateShims(shimNames) + // Create each shim AND record them in the shim map cache + return manager.CreateShimsForRuntime("python", shimNames) } // installPip ensures pip is properly installed with working executables. diff --git a/src/runtimes/ruby/provider.go b/src/runtimes/ruby/provider.go index 3730463..305b4f7 100644 --- a/src/runtimes/ruby/provider.go +++ b/src/runtimes/ruby/provider.go @@ -233,7 +233,9 @@ func (p *Provider) getDownloadURL(version string) (string, string, error) { return dl.URL, archiveName, nil } -// createShims creates shims for Ruby executables +// createShims creates shims for Ruby executables and registers them in the +// shim-map cache so subsequent shim invocations resolve via O(1) lookup rather +// than falling back to the provider registry. func (p *Provider) createShims() error { manager, err := shim.NewManager() if err != nil { @@ -243,8 +245,8 @@ func (p *Provider) createShims() error { // Get the list of shims for Ruby shimNames := shim.RuntimeShims("ruby") - // Create each shim - return manager.CreateShims(shimNames) + // Create each shim AND record them in the shim map cache + return manager.CreateShimsForRuntime("ruby", shimNames) } // Uninstall removes an installed version From 855d454a51424538dfbb1d675a022311756e0702 Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Thu, 23 Apr 2026 14:03:38 -0400 Subject: [PATCH 3/3] fix(shim): handle backslash separators in shim name resolution shimNameFromPath used filepath.Base, which only recognizes the host OS separator. On Linux, Windows-style test paths failed to split, leaving the directory attached to the name. --- src/cmd/shim/main.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/cmd/shim/main.go b/src/cmd/shim/main.go index 604cd5d..63ac615 100644 --- a/src/cmd/shim/main.go +++ b/src/cmd/shim/main.go @@ -169,14 +169,18 @@ func getShimName() string { // the uppercase extension attached, breaking every downstream lookup in // the shim-map cache and the provider registry. func shimNameFromPath(shimPath string) string { - shimName := filepath.Base(shimPath) + // Split on both separators so Windows-style paths resolve correctly even + // when this runs on a host where filepath.Base ignores backslashes. + if i := strings.LastIndexAny(shimPath, `/\`); i >= 0 { + shimPath = shimPath[i+1:] + } // Strip .exe / .EXE / any mixed case on Windows-style paths. - if ext := filepath.Ext(shimName); strings.EqualFold(ext, constants.ExtExe) { - shimName = shimName[:len(shimName)-len(ext)] + if ext := filepath.Ext(shimPath); strings.EqualFold(ext, constants.ExtExe) { + shimPath = shimPath[:len(shimPath)-len(ext)] } - return shimName + return shimPath } // mapShimToRuntime maps a shim name to its runtime