Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 20 additions & 52 deletions src/cmd/shim/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
68 changes: 13 additions & 55 deletions src/cmd/which.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions src/internal/constants/platform.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ const (
const (
ExtExe = ".exe"
ExtCmd = ".cmd"
ExtBat = ".bat"
)
2 changes: 1 addition & 1 deletion src/internal/path/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions src/internal/shim/executable.go
Original file line number Diff line number Diff line change
@@ -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)
}
144 changes: 144 additions & 0 deletions src/internal/shim/executable_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading