Skip to content

Commit f039b06

Browse files
committed
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 <runtime> <version>" message instead of falling back.
1 parent 80b3b69 commit f039b06

4 files changed

Lines changed: 240 additions & 107 deletions

File tree

src/cmd/shim/main.go

Lines changed: 20 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,16 @@ func runShim() error {
7979
}
8080
ui.Debug("Base executable path: %s", execPath)
8181

82-
// If the shim name differs from the base runtime name,
83-
// we might need to adjust the executable path
84-
// (e.g., python3 -> python3, pip -> pip, npm -> npm)
85-
execPath = adjustExecutablePath(execPath, shimName, runtimeName)
82+
// If the shim name differs from the base runtime name, find the
83+
// secondary executable in the runtime install (e.g. pip, uv, npm).
84+
if shimName != runtimeName {
85+
resolved, err := shim.FindSecondaryExecutable(execPath, shimName)
86+
if err != nil {
87+
ui.Debug("Secondary executable lookup failed: %v", err)
88+
return secondaryExecutableError(shimName, provider.DisplayName(), version)
89+
}
90+
execPath = resolved
91+
}
8692
ui.Debug("Final executable path: %s", execPath)
8793

8894
// Get provider-specific environment variables (e.g., LD_LIBRARY_PATH for Ruby)
@@ -220,54 +226,16 @@ func mapShimToRuntime(shimName string) string {
220226
return shimName
221227
}
222228

223-
// adjustExecutablePath adjusts the executable path based on the shim name
224-
// For example, if shim is "pip" but base executable is "python",
225-
// we need to find "pip" in the same directory or Scripts subdirectory
226-
func adjustExecutablePath(execPath, shimName, runtimeName string) string {
227-
// If shim name matches runtime name, use the path as-is
228-
if shimName == runtimeName {
229-
return execPath
230-
}
231-
232-
// Otherwise, try to find the related executable
233-
// For example: if execPath is /path/to/python and shimName is pip,
234-
// look for /path/to/pip
235-
dir := filepath.Dir(execPath)
236-
237-
// Directories to search (in order)
238-
searchDirs := []string{
239-
dir, // Same directory as runtime executable
240-
filepath.Join(dir, "Scripts"), // Python Scripts directory (Windows)
241-
filepath.Join(dir, "..", "Scripts"), // Alternative Python Scripts location
242-
}
243-
244-
// On Windows, try multiple extensions
245-
if os.PathSeparator == '\\' {
246-
for _, searchDir := range searchDirs {
247-
newExec := filepath.Join(searchDir, shimName)
248-
249-
// Try .cmd first (npm, npx use .cmd on Windows)
250-
if _, err := os.Stat(newExec + ".cmd"); err == nil {
251-
return newExec + ".cmd"
252-
}
253-
// Try .exe
254-
if _, err := os.Stat(newExec + ".exe"); err == nil {
255-
return newExec + ".exe"
256-
}
257-
}
258-
} else {
259-
// On Unix, check if the file exists as-is
260-
for _, searchDir := range searchDirs {
261-
newExec := filepath.Join(searchDir, shimName)
262-
if _, err := os.Stat(newExec); err == nil {
263-
return newExec
264-
}
265-
}
266-
}
267-
268-
// If not found, return original path
269-
// The runtime provider should have returned the correct path
270-
return execPath
229+
// secondaryExecutableError formats a user-facing error explaining that a
230+
// secondary executable shim (e.g., uv, pip) exists but the binary cannot
231+
// be located in the active runtime version. This typically happens when
232+
// the shim was created by a `dtvem reshim` that scanned a different
233+
// installed version which had the executable available.
234+
func secondaryExecutableError(shimName, displayName, version string) error {
235+
ui.Error("'%s' is not available in %s %s", shimName, displayName, version)
236+
ui.Info("This shim exists because another installed %s version provides it.", displayName)
237+
ui.Info("Install '%s' for the active version, or switch to a version that has it.", shimName)
238+
return fmt.Errorf("%s not available in %s %s", shimName, displayName, version)
271239
}
272240

273241
// executeCommand executes a command with the given arguments and provider environment

src/cmd/which.go

Lines changed: 13 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,19 @@ Examples:
7777
return
7878
}
7979

80-
// Adjust path for secondary executables (pip, npm, etc.)
81-
execPath := adjustExecutablePath(baseExecPath, commandName, runtimeName)
82-
83-
// Check if the actual executable exists
84-
if _, err := os.Stat(execPath); os.IsNotExist(err) {
85-
ui.Error("Executable not found: %s", execPath)
86-
ui.Warning("Version %s may not be properly installed", version)
87-
return
80+
// Resolve secondary executables (pip, npm, uv, etc.) by searching
81+
// the runtime install. If the shim name matches the runtime name,
82+
// the runtime executable itself is the answer.
83+
execPath := baseExecPath
84+
if commandName != runtimeName {
85+
resolved, err := shim.FindSecondaryExecutable(baseExecPath, commandName)
86+
if err != nil {
87+
ui.Error("'%s' is not available in %s %s", commandName, provider.DisplayName(), version)
88+
ui.Info("This shim exists because another installed %s version provides it.", provider.DisplayName())
89+
ui.Info("Install '%s' for the active version, or switch to a version that has it.", commandName)
90+
return
91+
}
92+
execPath = resolved
8893
}
8994

9095
// Display the information
@@ -115,53 +120,6 @@ func mapCommandToRuntime(commandName string) string {
115120
return ""
116121
}
117122

118-
// adjustExecutablePath adjusts the executable path based on the command name
119-
// For example, if command is "pip" but base executable is "python",
120-
// we need to find "pip" in the same directory or Scripts subdirectory
121-
func adjustExecutablePath(execPath, commandName, runtimeName string) string {
122-
// If command name matches runtime name, use the path as-is
123-
if commandName == runtimeName {
124-
return execPath
125-
}
126-
127-
// Otherwise, try to find the related executable
128-
dir := filepath.Dir(execPath)
129-
130-
// Directories to search (in order)
131-
searchDirs := []string{
132-
dir, // Same directory as runtime executable
133-
filepath.Join(dir, "Scripts"), // Python Scripts directory (Windows)
134-
filepath.Join(dir, "..", "Scripts"), // Alternative Python Scripts location
135-
}
136-
137-
// On Windows, try multiple extensions
138-
if goruntime.GOOS == "windows" {
139-
for _, searchDir := range searchDirs {
140-
newExec := filepath.Join(searchDir, commandName)
141-
142-
// Try .cmd first (npm, npx use .cmd on Windows)
143-
if _, err := os.Stat(newExec + ".cmd"); err == nil {
144-
return newExec + ".cmd"
145-
}
146-
// Try .exe
147-
if _, err := os.Stat(newExec + ".exe"); err == nil {
148-
return newExec + ".exe"
149-
}
150-
}
151-
} else {
152-
// On Unix, check if the file exists as-is
153-
for _, searchDir := range searchDirs {
154-
newExec := filepath.Join(searchDir, commandName)
155-
if _, err := os.Stat(newExec); err == nil {
156-
return newExec
157-
}
158-
}
159-
}
160-
161-
// If not found, return original path
162-
return execPath
163-
}
164-
165123
func init() {
166124
rootCmd.AddCommand(whichCmd)
167125
}

src/internal/shim/executable.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package shim
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"runtime"
8+
9+
"github.com/CodingWithCalvin/dtvem.cli/src/internal/constants"
10+
)
11+
12+
// ErrSecondaryExecutableNotFound indicates that a secondary executable
13+
// (e.g., "uv" given the python runtime path) could not be located in the
14+
// runtime's install tree. Callers should surface this as a user-visible
15+
// error rather than silently falling back to the runtime binary.
16+
var ErrSecondaryExecutableNotFound = fmt.Errorf("secondary executable not found")
17+
18+
// FindSecondaryExecutable searches a runtime's install tree for a named
19+
// secondary executable (e.g., "pip" or "uv" for python, "npm" for node).
20+
//
21+
// runtimeExePath is the absolute path to the primary runtime executable
22+
// (e.g., python.exe, node, ruby). The function searches sibling directories
23+
// commonly used for runtime-installed scripts: the runtime's own directory,
24+
// a Scripts/ subdirectory (Python on Windows), and a parent-level Scripts/
25+
// directory (alternate Python layout).
26+
//
27+
// On Windows, .cmd is preferred over .exe because tools like npm install
28+
// .cmd shims that wrap Node scripts.
29+
//
30+
// Returns the absolute path on success, or ErrSecondaryExecutableNotFound
31+
// (wrapped with the requested name) if no candidate exists. Callers should
32+
// not fall back to runtimeExePath — doing so silently runs the runtime
33+
// binary as if it were the requested command.
34+
func FindSecondaryExecutable(runtimeExePath, name string) (string, error) {
35+
dir := filepath.Dir(runtimeExePath)
36+
37+
searchDirs := []string{
38+
dir,
39+
filepath.Join(dir, "Scripts"),
40+
filepath.Join(dir, "..", "Scripts"),
41+
}
42+
43+
if runtime.GOOS == constants.OSWindows {
44+
for _, searchDir := range searchDirs {
45+
candidate := filepath.Join(searchDir, name)
46+
if _, err := os.Stat(candidate + constants.ExtCmd); err == nil {
47+
return candidate + constants.ExtCmd, nil
48+
}
49+
if _, err := os.Stat(candidate + constants.ExtExe); err == nil {
50+
return candidate + constants.ExtExe, nil
51+
}
52+
}
53+
} else {
54+
for _, searchDir := range searchDirs {
55+
candidate := filepath.Join(searchDir, name)
56+
if _, err := os.Stat(candidate); err == nil {
57+
return candidate, nil
58+
}
59+
}
60+
}
61+
62+
return "", fmt.Errorf("%w: %s", ErrSecondaryExecutableNotFound, name)
63+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package shim
2+
3+
import (
4+
"errors"
5+
"os"
6+
"path/filepath"
7+
"runtime"
8+
"testing"
9+
10+
"github.com/CodingWithCalvin/dtvem.cli/src/internal/constants"
11+
)
12+
13+
// touch creates an empty file at path, making any missing parent directories.
14+
// On Unix it sets the executable bit so callers can rely on the file behaving
15+
// like a real binary for path-resolution tests.
16+
func touch(t *testing.T, path string) {
17+
t.Helper()
18+
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
19+
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
20+
}
21+
f, err := os.Create(path)
22+
if err != nil {
23+
t.Fatalf("create %s: %v", path, err)
24+
}
25+
_ = f.Close()
26+
if runtime.GOOS != constants.OSWindows {
27+
if err := os.Chmod(path, 0755); err != nil {
28+
t.Fatalf("chmod %s: %v", path, err)
29+
}
30+
}
31+
}
32+
33+
// runtimeBin returns the conventional name for a primary runtime binary on the
34+
// current platform — e.g. "python.exe" on Windows, "python" on Unix.
35+
func runtimeBin(name string) string {
36+
if runtime.GOOS == constants.OSWindows {
37+
return name + constants.ExtExe
38+
}
39+
return name
40+
}
41+
42+
// secondaryBin returns the conventional name for a secondary executable on the
43+
// current platform. The .ext argument is the Windows extension to use; on Unix
44+
// the extension is dropped because Unix scripts are typically extensionless.
45+
func secondaryBin(name, ext string) string {
46+
if runtime.GOOS == constants.OSWindows {
47+
return name + ext
48+
}
49+
return name
50+
}
51+
52+
func TestFindSecondaryExecutable_FoundAlongsideRuntime(t *testing.T) {
53+
dir := t.TempDir()
54+
runtimePath := filepath.Join(dir, runtimeBin("python"))
55+
touch(t, runtimePath)
56+
secondary := filepath.Join(dir, secondaryBin("pip", constants.ExtExe))
57+
touch(t, secondary)
58+
59+
got, err := FindSecondaryExecutable(runtimePath, "pip")
60+
if err != nil {
61+
t.Fatalf("unexpected error: %v", err)
62+
}
63+
if got != secondary {
64+
t.Errorf("got %q, want %q", got, secondary)
65+
}
66+
}
67+
68+
func TestFindSecondaryExecutable_FoundInScriptsSubdir(t *testing.T) {
69+
if runtime.GOOS != constants.OSWindows {
70+
t.Skip("Scripts/ subdirectory layout is Windows-specific (Python on Windows)")
71+
}
72+
dir := t.TempDir()
73+
runtimePath := filepath.Join(dir, runtimeBin("python"))
74+
touch(t, runtimePath)
75+
secondary := filepath.Join(dir, "Scripts", secondaryBin("uv", constants.ExtExe))
76+
touch(t, secondary)
77+
78+
got, err := FindSecondaryExecutable(runtimePath, "uv")
79+
if err != nil {
80+
t.Fatalf("unexpected error: %v", err)
81+
}
82+
if got != secondary {
83+
t.Errorf("got %q, want %q", got, secondary)
84+
}
85+
}
86+
87+
func TestFindSecondaryExecutable_FoundInParentScriptsSubdir(t *testing.T) {
88+
if runtime.GOOS != constants.OSWindows {
89+
t.Skip("Scripts/ subdirectory layout is Windows-specific")
90+
}
91+
root := t.TempDir()
92+
binDir := filepath.Join(root, "bin")
93+
runtimePath := filepath.Join(binDir, runtimeBin("python"))
94+
touch(t, runtimePath)
95+
secondary := filepath.Join(root, "Scripts", secondaryBin("uv", constants.ExtExe))
96+
touch(t, secondary)
97+
98+
got, err := FindSecondaryExecutable(runtimePath, "uv")
99+
if err != nil {
100+
t.Fatalf("unexpected error: %v", err)
101+
}
102+
// filepath.Clean should have collapsed the "..". Compare on cleaned form.
103+
if filepath.Clean(got) != filepath.Clean(secondary) {
104+
t.Errorf("got %q, want %q", got, secondary)
105+
}
106+
}
107+
108+
func TestFindSecondaryExecutable_PrefersCmdOverExeOnWindows(t *testing.T) {
109+
if runtime.GOOS != constants.OSWindows {
110+
t.Skip("Windows extension preference is Windows-specific")
111+
}
112+
dir := t.TempDir()
113+
runtimePath := filepath.Join(dir, runtimeBin("node"))
114+
touch(t, runtimePath)
115+
cmdPath := filepath.Join(dir, "npm"+constants.ExtCmd)
116+
exePath := filepath.Join(dir, "npm"+constants.ExtExe)
117+
touch(t, cmdPath)
118+
touch(t, exePath)
119+
120+
got, err := FindSecondaryExecutable(runtimePath, "npm")
121+
if err != nil {
122+
t.Fatalf("unexpected error: %v", err)
123+
}
124+
if got != cmdPath {
125+
t.Errorf("got %q, want %q (.cmd should be preferred)", got, cmdPath)
126+
}
127+
}
128+
129+
func TestFindSecondaryExecutable_NotFoundReturnsError(t *testing.T) {
130+
dir := t.TempDir()
131+
runtimePath := filepath.Join(dir, runtimeBin("python"))
132+
touch(t, runtimePath)
133+
134+
got, err := FindSecondaryExecutable(runtimePath, "uv")
135+
if err == nil {
136+
t.Fatalf("expected error, got nil and path %q", got)
137+
}
138+
if !errors.Is(err, ErrSecondaryExecutableNotFound) {
139+
t.Errorf("expected ErrSecondaryExecutableNotFound, got %v", err)
140+
}
141+
if got != "" {
142+
t.Errorf("expected empty path on error, got %q", got)
143+
}
144+
}

0 commit comments

Comments
 (0)