From f039b06ec9309f1edf8b8877522e995e70d6844e Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Fri, 8 May 2026 12:35:27 -0400 Subject: [PATCH 1/2] fix(shim): exit with error when secondary executable is missing When a secondary executable shim (e.g. uv, pip, npm) cannot be located in the active runtime version's install tree, the shim previously fell back to the primary runtime binary, silently turning `uv --version` into `python --version`. Extract the lookup into a shared `shim.FindSecondaryExecutable` helper that returns an explicit error, and have both the shim entrypoint and `dtvem which` surface a clear "not available in " message instead of falling back. --- src/cmd/shim/main.go | 72 ++++---------- src/cmd/which.go | 68 +++---------- src/internal/shim/executable.go | 63 ++++++++++++ src/internal/shim/executable_test.go | 144 +++++++++++++++++++++++++++ 4 files changed, 240 insertions(+), 107 deletions(-) create mode 100644 src/internal/shim/executable.go create mode 100644 src/internal/shim/executable_test.go diff --git a/src/cmd/shim/main.go b/src/cmd/shim/main.go index 63ac615..a91fd6a 100644 --- a/src/cmd/shim/main.go +++ b/src/cmd/shim/main.go @@ -79,10 +79,16 @@ func runShim() error { } ui.Debug("Base executable path: %s", execPath) - // If the shim name differs from the base runtime name, - // we might need to adjust the executable path - // (e.g., python3 -> python3, pip -> pip, npm -> npm) - execPath = adjustExecutablePath(execPath, shimName, runtimeName) + // If the shim name differs from the base runtime name, find the + // secondary executable in the runtime install (e.g. pip, uv, npm). + if shimName != runtimeName { + resolved, err := shim.FindSecondaryExecutable(execPath, shimName) + if err != nil { + ui.Debug("Secondary executable lookup failed: %v", err) + return secondaryExecutableError(shimName, provider.DisplayName(), version) + } + execPath = resolved + } ui.Debug("Final executable path: %s", execPath) // Get provider-specific environment variables (e.g., LD_LIBRARY_PATH for Ruby) @@ -220,54 +226,16 @@ func mapShimToRuntime(shimName string) string { return shimName } -// adjustExecutablePath adjusts the executable path based on the shim name -// For example, if shim is "pip" but base executable is "python", -// we need to find "pip" in the same directory or Scripts subdirectory -func adjustExecutablePath(execPath, shimName, runtimeName string) string { - // If shim name matches runtime name, use the path as-is - if shimName == runtimeName { - return execPath - } - - // Otherwise, try to find the related executable - // For example: if execPath is /path/to/python and shimName is pip, - // look for /path/to/pip - dir := filepath.Dir(execPath) - - // Directories to search (in order) - searchDirs := []string{ - dir, // Same directory as runtime executable - filepath.Join(dir, "Scripts"), // Python Scripts directory (Windows) - filepath.Join(dir, "..", "Scripts"), // Alternative Python Scripts location - } - - // On Windows, try multiple extensions - if os.PathSeparator == '\\' { - for _, searchDir := range searchDirs { - newExec := filepath.Join(searchDir, shimName) - - // Try .cmd first (npm, npx use .cmd on Windows) - if _, err := os.Stat(newExec + ".cmd"); err == nil { - return newExec + ".cmd" - } - // Try .exe - if _, err := os.Stat(newExec + ".exe"); err == nil { - return newExec + ".exe" - } - } - } else { - // On Unix, check if the file exists as-is - for _, searchDir := range searchDirs { - newExec := filepath.Join(searchDir, shimName) - if _, err := os.Stat(newExec); err == nil { - return newExec - } - } - } - - // If not found, return original path - // The runtime provider should have returned the correct path - return execPath +// secondaryExecutableError formats a user-facing error explaining that a +// secondary executable shim (e.g., uv, pip) exists but the binary cannot +// be located in the active runtime version. This typically happens when +// the shim was created by a `dtvem reshim` that scanned a different +// installed version which had the executable available. +func secondaryExecutableError(shimName, displayName, version string) error { + ui.Error("'%s' is not available in %s %s", shimName, displayName, version) + ui.Info("This shim exists because another installed %s version provides it.", displayName) + ui.Info("Install '%s' for the active version, or switch to a version that has it.", shimName) + return fmt.Errorf("%s not available in %s %s", shimName, displayName, version) } // executeCommand executes a command with the given arguments and provider environment diff --git a/src/cmd/which.go b/src/cmd/which.go index 9c5a9f4..f00c854 100644 --- a/src/cmd/which.go +++ b/src/cmd/which.go @@ -77,14 +77,19 @@ Examples: return } - // Adjust path for secondary executables (pip, npm, etc.) - execPath := adjustExecutablePath(baseExecPath, commandName, runtimeName) - - // Check if the actual executable exists - if _, err := os.Stat(execPath); os.IsNotExist(err) { - ui.Error("Executable not found: %s", execPath) - ui.Warning("Version %s may not be properly installed", version) - return + // Resolve secondary executables (pip, npm, uv, etc.) by searching + // the runtime install. If the shim name matches the runtime name, + // the runtime executable itself is the answer. + execPath := baseExecPath + if commandName != runtimeName { + resolved, err := shim.FindSecondaryExecutable(baseExecPath, commandName) + if err != nil { + ui.Error("'%s' is not available in %s %s", commandName, provider.DisplayName(), version) + ui.Info("This shim exists because another installed %s version provides it.", provider.DisplayName()) + ui.Info("Install '%s' for the active version, or switch to a version that has it.", commandName) + return + } + execPath = resolved } // Display the information @@ -115,53 +120,6 @@ func mapCommandToRuntime(commandName string) string { return "" } -// adjustExecutablePath adjusts the executable path based on the command name -// For example, if command is "pip" but base executable is "python", -// we need to find "pip" in the same directory or Scripts subdirectory -func adjustExecutablePath(execPath, commandName, runtimeName string) string { - // If command name matches runtime name, use the path as-is - if commandName == runtimeName { - return execPath - } - - // Otherwise, try to find the related executable - dir := filepath.Dir(execPath) - - // Directories to search (in order) - searchDirs := []string{ - dir, // Same directory as runtime executable - filepath.Join(dir, "Scripts"), // Python Scripts directory (Windows) - filepath.Join(dir, "..", "Scripts"), // Alternative Python Scripts location - } - - // On Windows, try multiple extensions - if goruntime.GOOS == "windows" { - for _, searchDir := range searchDirs { - newExec := filepath.Join(searchDir, commandName) - - // Try .cmd first (npm, npx use .cmd on Windows) - if _, err := os.Stat(newExec + ".cmd"); err == nil { - return newExec + ".cmd" - } - // Try .exe - if _, err := os.Stat(newExec + ".exe"); err == nil { - return newExec + ".exe" - } - } - } else { - // On Unix, check if the file exists as-is - for _, searchDir := range searchDirs { - newExec := filepath.Join(searchDir, commandName) - if _, err := os.Stat(newExec); err == nil { - return newExec - } - } - } - - // If not found, return original path - return execPath -} - func init() { rootCmd.AddCommand(whichCmd) } diff --git a/src/internal/shim/executable.go b/src/internal/shim/executable.go new file mode 100644 index 0000000..ffb2885 --- /dev/null +++ b/src/internal/shim/executable.go @@ -0,0 +1,63 @@ +package shim + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/CodingWithCalvin/dtvem.cli/src/internal/constants" +) + +// ErrSecondaryExecutableNotFound indicates that a secondary executable +// (e.g., "uv" given the python runtime path) could not be located in the +// runtime's install tree. Callers should surface this as a user-visible +// error rather than silently falling back to the runtime binary. +var ErrSecondaryExecutableNotFound = fmt.Errorf("secondary executable not found") + +// FindSecondaryExecutable searches a runtime's install tree for a named +// secondary executable (e.g., "pip" or "uv" for python, "npm" for node). +// +// runtimeExePath is the absolute path to the primary runtime executable +// (e.g., python.exe, node, ruby). The function searches sibling directories +// commonly used for runtime-installed scripts: the runtime's own directory, +// a Scripts/ subdirectory (Python on Windows), and a parent-level Scripts/ +// directory (alternate Python layout). +// +// On Windows, .cmd is preferred over .exe because tools like npm install +// .cmd shims that wrap Node scripts. +// +// Returns the absolute path on success, or ErrSecondaryExecutableNotFound +// (wrapped with the requested name) if no candidate exists. Callers should +// not fall back to runtimeExePath — doing so silently runs the runtime +// binary as if it were the requested command. +func FindSecondaryExecutable(runtimeExePath, name string) (string, error) { + dir := filepath.Dir(runtimeExePath) + + searchDirs := []string{ + dir, + filepath.Join(dir, "Scripts"), + filepath.Join(dir, "..", "Scripts"), + } + + if runtime.GOOS == constants.OSWindows { + for _, searchDir := range searchDirs { + candidate := filepath.Join(searchDir, name) + if _, err := os.Stat(candidate + constants.ExtCmd); err == nil { + return candidate + constants.ExtCmd, nil + } + if _, err := os.Stat(candidate + constants.ExtExe); err == nil { + return candidate + constants.ExtExe, nil + } + } + } else { + for _, searchDir := range searchDirs { + candidate := filepath.Join(searchDir, name) + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + } + } + + return "", fmt.Errorf("%w: %s", ErrSecondaryExecutableNotFound, name) +} diff --git a/src/internal/shim/executable_test.go b/src/internal/shim/executable_test.go new file mode 100644 index 0000000..678429a --- /dev/null +++ b/src/internal/shim/executable_test.go @@ -0,0 +1,144 @@ +package shim + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/CodingWithCalvin/dtvem.cli/src/internal/constants" +) + +// touch creates an empty file at path, making any missing parent directories. +// On Unix it sets the executable bit so callers can rely on the file behaving +// like a real binary for path-resolution tests. +func touch(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + f, err := os.Create(path) + if err != nil { + t.Fatalf("create %s: %v", path, err) + } + _ = f.Close() + if runtime.GOOS != constants.OSWindows { + if err := os.Chmod(path, 0755); err != nil { + t.Fatalf("chmod %s: %v", path, err) + } + } +} + +// runtimeBin returns the conventional name for a primary runtime binary on the +// current platform — e.g. "python.exe" on Windows, "python" on Unix. +func runtimeBin(name string) string { + if runtime.GOOS == constants.OSWindows { + return name + constants.ExtExe + } + return name +} + +// secondaryBin returns the conventional name for a secondary executable on the +// current platform. The .ext argument is the Windows extension to use; on Unix +// the extension is dropped because Unix scripts are typically extensionless. +func secondaryBin(name, ext string) string { + if runtime.GOOS == constants.OSWindows { + return name + ext + } + return name +} + +func TestFindSecondaryExecutable_FoundAlongsideRuntime(t *testing.T) { + dir := t.TempDir() + runtimePath := filepath.Join(dir, runtimeBin("python")) + touch(t, runtimePath) + secondary := filepath.Join(dir, secondaryBin("pip", constants.ExtExe)) + touch(t, secondary) + + got, err := FindSecondaryExecutable(runtimePath, "pip") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != secondary { + t.Errorf("got %q, want %q", got, secondary) + } +} + +func TestFindSecondaryExecutable_FoundInScriptsSubdir(t *testing.T) { + if runtime.GOOS != constants.OSWindows { + t.Skip("Scripts/ subdirectory layout is Windows-specific (Python on Windows)") + } + dir := t.TempDir() + runtimePath := filepath.Join(dir, runtimeBin("python")) + touch(t, runtimePath) + secondary := filepath.Join(dir, "Scripts", secondaryBin("uv", constants.ExtExe)) + touch(t, secondary) + + got, err := FindSecondaryExecutable(runtimePath, "uv") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != secondary { + t.Errorf("got %q, want %q", got, secondary) + } +} + +func TestFindSecondaryExecutable_FoundInParentScriptsSubdir(t *testing.T) { + if runtime.GOOS != constants.OSWindows { + t.Skip("Scripts/ subdirectory layout is Windows-specific") + } + root := t.TempDir() + binDir := filepath.Join(root, "bin") + runtimePath := filepath.Join(binDir, runtimeBin("python")) + touch(t, runtimePath) + secondary := filepath.Join(root, "Scripts", secondaryBin("uv", constants.ExtExe)) + touch(t, secondary) + + got, err := FindSecondaryExecutable(runtimePath, "uv") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // filepath.Clean should have collapsed the "..". Compare on cleaned form. + if filepath.Clean(got) != filepath.Clean(secondary) { + t.Errorf("got %q, want %q", got, secondary) + } +} + +func TestFindSecondaryExecutable_PrefersCmdOverExeOnWindows(t *testing.T) { + if runtime.GOOS != constants.OSWindows { + t.Skip("Windows extension preference is Windows-specific") + } + dir := t.TempDir() + runtimePath := filepath.Join(dir, runtimeBin("node")) + touch(t, runtimePath) + cmdPath := filepath.Join(dir, "npm"+constants.ExtCmd) + exePath := filepath.Join(dir, "npm"+constants.ExtExe) + touch(t, cmdPath) + touch(t, exePath) + + got, err := FindSecondaryExecutable(runtimePath, "npm") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != cmdPath { + t.Errorf("got %q, want %q (.cmd should be preferred)", got, cmdPath) + } +} + +func TestFindSecondaryExecutable_NotFoundReturnsError(t *testing.T) { + dir := t.TempDir() + runtimePath := filepath.Join(dir, runtimeBin("python")) + touch(t, runtimePath) + + got, err := FindSecondaryExecutable(runtimePath, "uv") + if err == nil { + t.Fatalf("expected error, got nil and path %q", got) + } + if !errors.Is(err, ErrSecondaryExecutableNotFound) { + t.Errorf("expected ErrSecondaryExecutableNotFound, got %v", err) + } + if got != "" { + t.Errorf("expected empty path on error, got %q", got) + } +} From 50769f6b88435bf6ea93bfd77bb2c5b318e88a2f Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Fri, 8 May 2026 13:19:12 -0400 Subject: [PATCH 2/2] chore(constants): introduce ExtBat and replace bare ".bat" literals The `goconst` linter flagged ".bat" as a repeated string literal across manager.go, manager_test.go, and path.go. Add `constants.ExtBat` alongside the existing ExtExe / ExtCmd constants and replace the bare literals (and the bare ".exe"/".cmd" alongside them in the same files) with the named constants. --- src/internal/constants/platform.go | 1 + src/internal/path/path.go | 2 +- src/internal/shim/manager.go | 4 ++-- src/internal/shim/manager_test.go | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/internal/constants/platform.go b/src/internal/constants/platform.go index 48cde44..2c2bdb1 100644 --- a/src/internal/constants/platform.go +++ b/src/internal/constants/platform.go @@ -34,4 +34,5 @@ const ( const ( ExtExe = ".exe" ExtCmd = ".cmd" + ExtBat = ".bat" ) diff --git a/src/internal/path/path.go b/src/internal/path/path.go index 2eb04c8..a97e10a 100644 --- a/src/internal/path/path.go +++ b/src/internal/path/path.go @@ -180,7 +180,7 @@ func LookPathExcludingShims(execName string) string { func findExecutableInDir(dir, execName string) string { if runtime.GOOS == constants.OSWindows { // Windows: try .exe, .cmd, .bat extensions - for _, ext := range []string{".exe", ".cmd", ".bat"} { + for _, ext := range []string{constants.ExtExe, constants.ExtCmd, constants.ExtBat} { candidate := filepath.Join(dir, execName+ext) if info, err := os.Stat(candidate); err == nil && !info.IsDir() { return candidate diff --git a/src/internal/shim/manager.go b/src/internal/shim/manager.go index f4ff348..a7b0731 100644 --- a/src/internal/shim/manager.go +++ b/src/internal/shim/manager.go @@ -169,7 +169,7 @@ func (m *Manager) ListShims() ([]string, error) { if runtime.GOOS == constants.OSWindows { ext := filepath.Ext(name) // Skip .cmd/.bat wrappers — only list .exe shims - if ext == constants.ExtCmd || ext == ".bat" { + if ext == constants.ExtCmd || ext == constants.ExtBat { continue } name = name[:len(name)-len(ext)] @@ -397,7 +397,7 @@ func findExecutables(dir string) ([]string, error) { // On Windows, check for executable extensions if runtime.GOOS == constants.OSWindows { ext := filepath.Ext(name) - if ext == ".exe" || ext == ".cmd" || ext == ".bat" { + if ext == constants.ExtExe || ext == constants.ExtCmd || ext == constants.ExtBat { // Remove extension for shim name baseName := name[:len(name)-len(ext)] executables = append(executables, baseName) diff --git a/src/internal/shim/manager_test.go b/src/internal/shim/manager_test.go index 2f516c9..e2b818d 100644 --- a/src/internal/shim/manager_test.go +++ b/src/internal/shim/manager_test.go @@ -426,7 +426,7 @@ func TestListShims_SkipsCmdFiles(t *testing.T) { } name := entry.Name() ext := filepath.Ext(name) - if ext == constants.ExtCmd || ext == ".bat" { + if ext == constants.ExtCmd || ext == constants.ExtBat { continue } shims = append(shims, name[:len(name)-len(ext)])