Skip to content

Commit 9add90e

Browse files
authored
fix(shim): discover install-time shims from disk, not static provider (#270)
1 parent c095a8b commit 9add90e

5 files changed

Lines changed: 228 additions & 30 deletions

File tree

src/internal/shim/manager.go

Lines changed: 55 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"path/filepath"
99
"runtime"
10+
"sort"
1011

1112
"github.com/CodingWithCalvin/dtvem.cli/src/internal/config"
1213
"github.com/CodingWithCalvin/dtvem.cli/src/internal/constants"
@@ -282,33 +283,16 @@ func (m *Manager) RehashWithCallback(callback RehashCallback) (*RehashResult, er
282283
version := versionEntry.Name()
283284
versionDir := filepath.Join(runtimeVersionsDir, version)
284285

285-
// Scan the directories where runtime and package executables
286-
// live. Anything found is recorded as a shim provided by this
287-
// version. We deliberately do NOT pre-populate the provider's
288-
// declared core shims (e.g. python3, pip3) without checking
289-
// the filesystem: an embeddable Windows install may ship only
290-
// python.exe, and asserting that 3.8.9 "provides pip" when it
291-
// doesn't would corrupt the version-coverage data the shim
292-
// uses to give users an informed error.
293-
if execs, err := findExecutables(filepath.Join(versionDir, "bin")); err == nil {
294-
for _, exec := range execs {
295-
recordShim(exec, version)
296-
}
297-
}
298-
299-
// On Windows, also check the root version directory for .cmd/.bat files
300-
// and Scripts directory for Python packages
301-
if runtime.GOOS == constants.OSWindows {
302-
if execs, err := findExecutables(versionDir); err == nil {
303-
for _, exec := range execs {
304-
recordShim(exec, version)
305-
}
306-
}
307-
if execs, err := findExecutables(filepath.Join(versionDir, "Scripts")); err == nil {
308-
for _, exec := range execs {
309-
recordShim(exec, version)
310-
}
311-
}
286+
// Anything found on disk is recorded as a shim provided by
287+
// this version. We deliberately do NOT pre-populate the
288+
// provider's declared core shims (e.g. python3, pip3)
289+
// without checking the filesystem: an embeddable Windows
290+
// install may ship only python.exe, and asserting that
291+
// 3.8.9 "provides pip" when it doesn't would corrupt the
292+
// version-coverage data the shim uses to give users an
293+
// informed error.
294+
for _, exec := range DiscoverShimsForVersion(versionDir) {
295+
recordShim(exec, version)
312296
}
313297
}
314298
}
@@ -391,6 +375,50 @@ func RuntimeShims(runtimeName string) []string {
391375
return provider.Shims()
392376
}
393377

378+
// DiscoverShimsForVersion scans an installed runtime version's directory tree
379+
// and returns the executable base names that should be shimmed.
380+
//
381+
// versionDir is the absolute path to a single version's install directory
382+
// (e.g., ~/.dtvem/versions/python/3.14.2). The same directories `Rehash`
383+
// walks are scanned here: `bin/` always, plus the root and `Scripts/` on
384+
// Windows. Missing directories are not an error — they just contribute no
385+
// names — so callers can use this on any layout without pre-checking.
386+
//
387+
// The intersection-with-provider-declarations is deliberately omitted:
388+
// reshim registers every executable it finds (pip-installed tools, npm-
389+
// installed binaries, version-suffixed names like pip3.14), and install
390+
// should match that policy so reshim doesn't surprise the user by adding
391+
// or removing shims that the install command didn't.
392+
//
393+
// Returns names sorted alphabetically so callers get stable output across
394+
// runs (filesystem iteration order is not guaranteed).
395+
func DiscoverShimsForVersion(versionDir string) []string {
396+
seen := make(map[string]struct{})
397+
scan := func(dir string) {
398+
execs, err := findExecutables(dir)
399+
if err != nil {
400+
return
401+
}
402+
for _, e := range execs {
403+
seen[e] = struct{}{}
404+
}
405+
}
406+
407+
scan(filepath.Join(versionDir, "bin"))
408+
409+
if runtime.GOOS == constants.OSWindows {
410+
scan(versionDir)
411+
scan(filepath.Join(versionDir, "Scripts"))
412+
}
413+
414+
out := make([]string, 0, len(seen))
415+
for name := range seen {
416+
out = append(out, name)
417+
}
418+
sort.Strings(out)
419+
return out
420+
}
421+
394422
// findExecutables scans a directory for executable files and returns their base names
395423
func findExecutables(dir string) ([]string, error) {
396424
entries, err := os.ReadDir(dir)

src/internal/shim/manager_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,147 @@ func TestListShims_SkipsCmdFiles(t *testing.T) {
438438
}
439439
}
440440

441+
// writeExecutable writes a file with the executable bit set on Unix. The
442+
// content is irrelevant — findExecutables only looks at extension (Windows)
443+
// or the exec bit (Unix) — but a non-zero body keeps file readers happy.
444+
func writeExecutable(t *testing.T, path string) {
445+
t.Helper()
446+
if err := os.WriteFile(path, []byte("x"), 0755); err != nil {
447+
t.Fatalf("Failed to write %s: %v", path, err)
448+
}
449+
}
450+
451+
// platformExeName returns name with the OS-appropriate executable extension.
452+
// On Windows the .exe suffix is required for findExecutables to recognize
453+
// the file; on Unix the bare name is used and the exec bit drives detection.
454+
func platformExeName(name string) string {
455+
if runtime.GOOS == constants.OSWindows {
456+
return name + constants.ExtExe
457+
}
458+
return name
459+
}
460+
461+
func TestDiscoverShimsForVersion_EmptyDir(t *testing.T) {
462+
versionDir := t.TempDir()
463+
464+
got := DiscoverShimsForVersion(versionDir)
465+
if len(got) != 0 {
466+
t.Errorf("DiscoverShimsForVersion(empty) = %v, want []", got)
467+
}
468+
}
469+
470+
func TestDiscoverShimsForVersion_MissingVersionDir(t *testing.T) {
471+
// Caller may pass a path that doesn't exist (e.g., when called before
472+
// the install has moved files into place). The helper must not panic
473+
// or surface an error — it just returns no names.
474+
got := DiscoverShimsForVersion(filepath.Join(t.TempDir(), "does-not-exist"))
475+
if len(got) != 0 {
476+
t.Errorf("DiscoverShimsForVersion(missing) = %v, want []", got)
477+
}
478+
}
479+
480+
func TestDiscoverShimsForVersion_BinDir(t *testing.T) {
481+
versionDir := t.TempDir()
482+
binDir := filepath.Join(versionDir, "bin")
483+
if err := os.MkdirAll(binDir, 0755); err != nil {
484+
t.Fatalf("mkdir bin: %v", err)
485+
}
486+
487+
writeExecutable(t, filepath.Join(binDir, platformExeName("node")))
488+
writeExecutable(t, filepath.Join(binDir, platformExeName("npm")))
489+
490+
got := DiscoverShimsForVersion(versionDir)
491+
want := []string{"node", "npm"}
492+
if !reflect.DeepEqual(got, want) {
493+
t.Errorf("DiscoverShimsForVersion(bin) = %v, want %v", got, want)
494+
}
495+
}
496+
497+
// TestDiscoverShimsForVersion_PythonWindowsOnlyRoot models the python-build-
498+
// standalone Windows tarball before ensurepip runs: python.exe and
499+
// pythonw.exe live in the version root and Scripts/ is either absent or
500+
// only contains the upstream .empty placeholder. The discover helper must
501+
// not invent pip / python3 entries that don't exist on disk — that was
502+
// the root cause of issue #269 where install-time shim creation used a
503+
// static provider declaration instead of disk truth.
504+
func TestDiscoverShimsForVersion_PythonWindowsOnlyRoot(t *testing.T) {
505+
if runtime.GOOS != constants.OSWindows {
506+
t.Skip("Windows-specific layout (root .exe files)")
507+
}
508+
509+
versionDir := t.TempDir()
510+
writeExecutable(t, filepath.Join(versionDir, "python.exe"))
511+
writeExecutable(t, filepath.Join(versionDir, "pythonw.exe"))
512+
// Upstream ships an empty Scripts/.empty placeholder; recreate it
513+
// here so the test reflects the exact layout users see and proves
514+
// the helper does not surface the placeholder as a shim.
515+
if err := os.MkdirAll(filepath.Join(versionDir, "Scripts"), 0755); err != nil {
516+
t.Fatalf("mkdir Scripts: %v", err)
517+
}
518+
if err := os.WriteFile(filepath.Join(versionDir, "Scripts", ".empty"), nil, 0644); err != nil {
519+
t.Fatalf("write .empty: %v", err)
520+
}
521+
522+
got := DiscoverShimsForVersion(versionDir)
523+
want := []string{"python", "pythonw"}
524+
if !reflect.DeepEqual(got, want) {
525+
t.Errorf("got %v, want %v", got, want)
526+
}
527+
}
528+
529+
// TestDiscoverShimsForVersion_PythonWindowsAfterEnsurepip models the
530+
// post-ensurepip state: root has python.exe/pythonw.exe, Scripts/ has
531+
// pip.exe, pip3.exe, and the versioned pip3.14.exe. All five should be
532+
// discovered and the result should be sorted+deduplicated.
533+
func TestDiscoverShimsForVersion_PythonWindowsAfterEnsurepip(t *testing.T) {
534+
if runtime.GOOS != constants.OSWindows {
535+
t.Skip("Windows-specific layout (Scripts/*.exe)")
536+
}
537+
538+
versionDir := t.TempDir()
539+
writeExecutable(t, filepath.Join(versionDir, "python.exe"))
540+
writeExecutable(t, filepath.Join(versionDir, "pythonw.exe"))
541+
542+
scriptsDir := filepath.Join(versionDir, "Scripts")
543+
if err := os.MkdirAll(scriptsDir, 0755); err != nil {
544+
t.Fatalf("mkdir Scripts: %v", err)
545+
}
546+
writeExecutable(t, filepath.Join(scriptsDir, "pip.exe"))
547+
writeExecutable(t, filepath.Join(scriptsDir, "pip3.exe"))
548+
writeExecutable(t, filepath.Join(scriptsDir, "pip3.14.exe"))
549+
550+
got := DiscoverShimsForVersion(versionDir)
551+
want := []string{"pip", "pip3", "pip3.14", "python", "pythonw"}
552+
if !reflect.DeepEqual(got, want) {
553+
t.Errorf("got %v, want %v", got, want)
554+
}
555+
}
556+
557+
// TestDiscoverShimsForVersion_DedupesAcrossDirs proves the helper unions
558+
// names across bin/ root/ and Scripts/ rather than double-counting. This
559+
// matters because some runtimes (e.g., Ruby on Windows) place the same
560+
// command name in multiple search locations.
561+
func TestDiscoverShimsForVersion_DedupesAcrossDirs(t *testing.T) {
562+
if runtime.GOOS != constants.OSWindows {
563+
t.Skip("Multi-dir Windows scan (root + Scripts)")
564+
}
565+
566+
versionDir := t.TempDir()
567+
writeExecutable(t, filepath.Join(versionDir, "tool.exe"))
568+
569+
scriptsDir := filepath.Join(versionDir, "Scripts")
570+
if err := os.MkdirAll(scriptsDir, 0755); err != nil {
571+
t.Fatalf("mkdir Scripts: %v", err)
572+
}
573+
writeExecutable(t, filepath.Join(scriptsDir, "tool.exe"))
574+
575+
got := DiscoverShimsForVersion(versionDir)
576+
want := []string{"tool"}
577+
if !reflect.DeepEqual(got, want) {
578+
t.Errorf("got %v, want %v", got, want)
579+
}
580+
}
581+
441582
func TestRuntimeShims_AllKnownRuntimes(t *testing.T) {
442583
// Verify all known runtimes have shim mappings
443584
knownRuntimes := []string{"python", "node", "ruby", "go"}

src/runtimes/node/provider_full.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,21 @@ func (p *Provider) getDownloadURL(version string) (string, string, error) {
124124
// than falling back to the provider registry. The version is recorded in the
125125
// cache so the shim can detect when an active runtime version is one that
126126
// does not provide a given executable.
127+
//
128+
// The shim list is derived from disk (the same scan reshim uses), not from
129+
// the provider's static Shims() declaration, so install and reshim stay in
130+
// sync and only register executables that actually exist for this version.
127131
func (p *Provider) createShims(version string) error {
128132
manager, err := shim.NewManager()
129133
if err != nil {
130134
return err
131135
}
132136

133-
shimNames := shim.RuntimeShims("node")
137+
versionDir := config.RuntimeVersionPath("node", version)
138+
shimNames := shim.DiscoverShimsForVersion(versionDir)
139+
if len(shimNames) == 0 {
140+
return fmt.Errorf("no executables found in %s", versionDir)
141+
}
134142

135143
return manager.CreateShimsForRuntime("node", version, shimNames)
136144
}

src/runtimes/python/provider_full.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,13 +181,26 @@ func (p *Provider) getDownloadURL(version string) (string, string, error) {
181181
// than falling back to the provider registry. The version is recorded in the
182182
// cache so the shim can detect when an active runtime version is one that
183183
// does not provide a given executable.
184+
//
185+
// The shim list is derived from disk (the same scan reshim uses), not from
186+
// the provider's static Shims() declaration. That keeps install and reshim
187+
// honest about which executables actually exist: on Windows the upstream
188+
// python-build-standalone tarball ships only python.exe / pythonw.exe — no
189+
// python3.exe, no pip.exe — so the static list would create phantom shims
190+
// (python3, pip3) that error at invocation time. Pip executables only
191+
// appear in Scripts/ after installPipIfNeeded succeeds, and scanning here
192+
// ensures they're picked up when present and silently skipped when not.
184193
func (p *Provider) createShims(version string) error {
185194
manager, err := shim.NewManager()
186195
if err != nil {
187196
return err
188197
}
189198

190-
shimNames := shim.RuntimeShims("python")
199+
versionDir := config.RuntimeVersionPath("python", version)
200+
shimNames := shim.DiscoverShimsForVersion(versionDir)
201+
if len(shimNames) == 0 {
202+
return fmt.Errorf("no executables found in %s", versionDir)
203+
}
191204

192205
return manager.CreateShimsForRuntime("python", version, shimNames)
193206
}

src/runtimes/ruby/provider_full.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,13 +197,21 @@ func (p *Provider) getDownloadURL(version string) (string, string, error) {
197197
// than falling back to the provider registry. The version is recorded in the
198198
// cache so the shim can detect when an active runtime version is one that
199199
// does not provide a given executable.
200+
//
201+
// The shim list is derived from disk (the same scan reshim uses), not from
202+
// the provider's static Shims() declaration, so install and reshim stay in
203+
// sync and only register executables that actually exist for this version.
200204
func (p *Provider) createShims(version string) error {
201205
manager, err := shim.NewManager()
202206
if err != nil {
203207
return err
204208
}
205209

206-
shimNames := shim.RuntimeShims("ruby")
210+
versionDir := config.RuntimeVersionPath("ruby", version)
211+
shimNames := shim.DiscoverShimsForVersion(versionDir)
212+
if len(shimNames) == 0 {
213+
return fmt.Errorf("no executables found in %s", versionDir)
214+
}
207215

208216
return manager.CreateShimsForRuntime("ruby", version, shimNames)
209217
}

0 commit comments

Comments
 (0)