Skip to content

Commit 56005c4

Browse files
authored
fix(shim): make shim-map cache version-aware (#257)
1 parent 0c49aaa commit 56005c4

8 files changed

Lines changed: 470 additions & 273 deletions

File tree

src/cmd/shim/main.go

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,20 @@ func runShim() error {
5959
}
6060
ui.Debug("Resolved version: %s", version)
6161

62+
// If this is a secondary executable (e.g. uv mapped to python) and the
63+
// shim-map cache knows which versions provide it, verify the active
64+
// version is one of them. This catches the case where reshim created
65+
// the shim because *some* installed runtime version provides it, but
66+
// the currently-active version does not.
67+
if shimName != runtimeName {
68+
if entry, ok := shim.Lookup(shimName); ok && len(entry.Versions) > 0 {
69+
if !versionProvides(entry.Versions, version) {
70+
ui.Debug("Active version %s not in providing-versions list %v", version, entry.Versions)
71+
return notAvailableInVersionError(shimName, runtimeName, provider.DisplayName(), version, entry.Versions)
72+
}
73+
}
74+
}
75+
6276
// Check if the version is installed
6377
installed, err := provider.IsInstalled(version)
6478
if err != nil {
@@ -228,16 +242,49 @@ func mapShimToRuntime(shimName string) string {
228242

229243
// secondaryExecutableError formats a user-facing error explaining that a
230244
// 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.
245+
// be located in the active runtime version. This is the catch-all when
246+
// the shim-map cache has no version coverage data (e.g., legacy cache,
247+
// or the shim entered the cache without a recorded version).
234248
func secondaryExecutableError(shimName, displayName, version string) error {
235249
ui.Error("'%s' is not available in %s %s", shimName, displayName, version)
236250
ui.Info("This shim exists because another installed %s version provides it.", displayName)
237251
ui.Info("Install '%s' for the active version, or switch to a version that has it.", shimName)
238252
return fmt.Errorf("%s not available in %s %s", shimName, displayName, version)
239253
}
240254

255+
// notAvailableInVersionError formats a richer error using the providing-
256+
// versions data recorded in the shim-map cache. Unlike
257+
// secondaryExecutableError, this can tell the user *which* installed
258+
// versions actually provide the executable so they can switch to one.
259+
func notAvailableInVersionError(shimName, runtimeName, displayName, activeVersion string, providingVersions []string) error {
260+
ui.Error("'%s' is not available in %s %s", shimName, displayName, activeVersion)
261+
262+
// "Available in: Python 3.9.9, Python 3.10.0"
263+
labeled := make([]string, len(providingVersions))
264+
for i, v := range providingVersions {
265+
labeled[i] = fmt.Sprintf("%s %s", displayName, v)
266+
}
267+
ui.Info("Available in: %s", strings.Join(labeled, ", "))
268+
269+
if len(providingVersions) == 1 {
270+
ui.Info("Switch with: dtvem global %s %s", runtimeName, providingVersions[0])
271+
} else {
272+
ui.Info("Switch with 'dtvem global %s <version>' or set a local version.", runtimeName)
273+
}
274+
275+
return fmt.Errorf("%s not available in %s %s", shimName, displayName, activeVersion)
276+
}
277+
278+
// versionProvides reports whether version is in the providing-versions list.
279+
func versionProvides(providingVersions []string, version string) bool {
280+
for _, v := range providingVersions {
281+
if v == version {
282+
return true
283+
}
284+
}
285+
return false
286+
}
287+
241288
// executeCommand executes a command with the given arguments and provider environment
242289
func executeCommand(execPath string, args []string, providerEnv map[string]string) error {
243290
// Build full args (executable name + arguments)

src/cmd/which.go

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"path/filepath"
77
goruntime "runtime"
8+
"strings"
89

910
"github.com/CodingWithCalvin/dtvem.cli/src/internal/config"
1011
"github.com/CodingWithCalvin/dtvem.cli/src/internal/constants"
@@ -70,6 +71,20 @@ Examples:
7071
return
7172
}
7273

74+
// If this is a secondary executable and the shim-map cache knows
75+
// which versions provide it, verify the active version is one of
76+
// them. This gives the user an informed "available in: X" message
77+
// instead of a generic "not found" when they're on a version that
78+
// doesn't include the command.
79+
if commandName != runtimeName {
80+
if entry, ok := shim.Lookup(commandName); ok && len(entry.Versions) > 0 {
81+
if !versionInList(version, entry.Versions) {
82+
reportNotAvailableInVersion(commandName, runtimeName, provider.DisplayName(), version, entry.Versions)
83+
return
84+
}
85+
}
86+
}
87+
7388
// Get the base executable path
7489
baseExecPath, err := provider.ExecutablePath(version)
7590
if err != nil {
@@ -102,15 +117,17 @@ Examples:
102117
},
103118
}
104119

105-
// mapCommandToRuntime maps a command name to its runtime
120+
// mapCommandToRuntime maps a command name to its runtime. It first consults
121+
// the shim-map cache (which records dynamically-installed packages such as
122+
// uv, tsc, black) and falls back to the registered providers' core shim
123+
// lists when the cache has no entry.
106124
func mapCommandToRuntime(commandName string) string {
107-
// Get all registered runtimes
108-
runtimes := runtime.List()
125+
if runtimeName, ok := shim.LookupRuntime(commandName); ok {
126+
return runtimeName
127+
}
109128

110-
// Check each runtime's shims
111-
for _, rt := range runtimes {
112-
shims := shim.RuntimeShims(rt)
113-
for _, shimName := range shims {
129+
for _, rt := range runtime.List() {
130+
for _, shimName := range shim.RuntimeShims(rt) {
114131
if shimName == commandName {
115132
return rt
116133
}
@@ -120,6 +137,35 @@ func mapCommandToRuntime(commandName string) string {
120137
return ""
121138
}
122139

140+
// versionInList reports whether version is in the providing-versions list.
141+
func versionInList(version string, providingVersions []string) bool {
142+
for _, v := range providingVersions {
143+
if v == version {
144+
return true
145+
}
146+
}
147+
return false
148+
}
149+
150+
// reportNotAvailableInVersion prints the user-facing "not available in this
151+
// runtime version" error for `dtvem which`, including the list of versions
152+
// that DO provide the executable so the user can switch to one.
153+
func reportNotAvailableInVersion(commandName, runtimeName, displayName, activeVersion string, providingVersions []string) {
154+
ui.Error("'%s' is not available in %s %s", commandName, displayName, activeVersion)
155+
156+
labeled := make([]string, len(providingVersions))
157+
for i, v := range providingVersions {
158+
labeled[i] = fmt.Sprintf("%s %s", displayName, v)
159+
}
160+
ui.Info("Available in: %s", strings.Join(labeled, ", "))
161+
162+
if len(providingVersions) == 1 {
163+
ui.Info("Switch with: dtvem global %s %s", runtimeName, providingVersions[0])
164+
} else {
165+
ui.Info("Switch with 'dtvem global %s <version>' or set a local version.", runtimeName)
166+
}
167+
}
168+
123169
func init() {
124170
rootCmd.AddCommand(whichCmd)
125171
}

src/internal/shim/cache.go

Lines changed: 118 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,38 @@ package shim
33

44
import (
55
"encoding/json"
6+
"fmt"
67
"os"
78
"sync"
89

910
"github.com/CodingWithCalvin/dtvem.cli/src/internal/config"
1011
)
1112

12-
// ShimMap represents the shim-to-runtime mapping cache
13-
// The map key is the shim name (e.g., "tsc", "npm", "black")
14-
// The map value is the runtime name (e.g., "node", "python")
15-
type ShimMap map[string]string
13+
// ShimEntry is the per-shim record stored in the shim-map cache. It binds
14+
// a shim name to the runtime that owns it AND to the set of installed
15+
// runtime versions that actually provide the executable on disk.
16+
//
17+
// The version data lets the shim distinguish between "command shimmed for
18+
// this runtime, also present in the active version" and "command shimmed
19+
// for this runtime, but the active version doesn't have it" — for example
20+
// when `uv` is installed via `pip install uv` against Python 3.9 but the
21+
// user switches to a 3.8 install that never received uv.
22+
type ShimEntry struct {
23+
// Runtime is the runtime name (e.g., "python", "node", "ruby").
24+
Runtime string `json:"runtime"`
25+
26+
// Versions is the set of installed runtime versions that provide the
27+
// executable. Empty / nil means "version coverage unknown" — typically
28+
// because the cache was loaded from the legacy schema or because the
29+
// caller did not supply version data — and callers should treat empty
30+
// as "skip the version check" rather than "no providing versions".
31+
Versions []string `json:"versions,omitempty"`
32+
}
33+
34+
// ShimMap represents the shim-to-runtime mapping cache. The map key is the
35+
// shim name (e.g., "tsc", "npm", "uv"); the value is a ShimEntry binding
36+
// it to a runtime and (optionally) the set of versions that provide it.
37+
type ShimMap map[string]ShimEntry
1638

1739
var (
1840
shimMapCache ShimMap
@@ -30,7 +52,11 @@ func LoadShimMap() (ShimMap, error) {
3052
return shimMapCache, shimMapCacheErr
3153
}
3254

33-
// loadShimMapFromDisk reads the shim map cache file from disk
55+
// loadShimMapFromDisk reads the shim map cache file from disk, tolerating
56+
// both the current schema (ShimEntry values with versions) and the legacy
57+
// flat schema (string values mapping shim → runtime). Legacy entries are
58+
// converted with empty Versions, signaling "version coverage unknown" to
59+
// callers; a subsequent `dtvem reshim` will re-populate the version data.
3460
func loadShimMapFromDisk() (ShimMap, error) {
3561
cachePath := config.ShimMapPath()
3662

@@ -39,12 +65,40 @@ func loadShimMapFromDisk() (ShimMap, error) {
3965
return nil, err
4066
}
4167

42-
var shimMap ShimMap
43-
if err := json.Unmarshal(data, &shimMap); err != nil {
44-
return nil, err
68+
// Try the current schema first.
69+
var current ShimMap
70+
if err := json.Unmarshal(data, &current); err == nil && schemaIsCurrent(current) {
71+
return current, nil
72+
}
73+
74+
// Fall back to the legacy schema (shim → runtime name).
75+
var legacy map[string]string
76+
if err := json.Unmarshal(data, &legacy); err == nil {
77+
converted := make(ShimMap, len(legacy))
78+
for shim, runtime := range legacy {
79+
converted[shim] = ShimEntry{Runtime: runtime}
80+
}
81+
return converted, nil
4582
}
4683

47-
return shimMap, nil
84+
return nil, fmt.Errorf("shim-map cache at %s is in an unrecognized format", cachePath)
85+
}
86+
87+
// schemaIsCurrent reports whether a successfully-unmarshalled ShimMap was
88+
// actually serialized with the current schema. A legacy file like
89+
// `{"uv": "python"}` will technically unmarshal into ShimMap (every entry
90+
// becomes a zero-valued ShimEntry), so a Runtime field that is empty for
91+
// every entry indicates the input was actually legacy.
92+
func schemaIsCurrent(m ShimMap) bool {
93+
if len(m) == 0 {
94+
return true
95+
}
96+
for _, entry := range m {
97+
if entry.Runtime != "" {
98+
return true
99+
}
100+
}
101+
return false
48102
}
49103

50104
// SaveShimMap writes the shim-to-runtime mapping to the cache file.
@@ -69,8 +123,11 @@ func SaveShimMap(shimMap ShimMap) error {
69123
// MergeShimMap merges the given entries into the on-disk shim map and persists it.
70124
//
71125
// If the cache does not exist yet (first-time install), a new map is created.
72-
// Existing entries with matching keys are overwritten. The in-memory cache is
73-
// reset so subsequent LoadShimMap calls read the updated state from disk.
126+
// For shims already in the cache, the runtime is overwritten with the new
127+
// value (typically the same) and the providing-versions set is unioned —
128+
// so installing a second runtime version that provides the same executable
129+
// extends the version list rather than clobbering it. The in-memory cache
130+
// is reset so subsequent LoadShimMap calls read the updated state from disk.
74131
//
75132
// This is the preferred path for install-time shim registration, where the
76133
// caller knows only the shims it just created and wants to register them
@@ -83,8 +140,14 @@ func MergeShimMap(entries ShimMap) error {
83140
existing = make(ShimMap, len(entries))
84141
}
85142

86-
for shim, runtime := range entries {
87-
existing[shim] = runtime
143+
for shim, entry := range entries {
144+
if cur, ok := existing[shim]; ok {
145+
cur.Runtime = entry.Runtime
146+
cur.Versions = unionVersions(cur.Versions, entry.Versions)
147+
existing[shim] = cur
148+
} else {
149+
existing[shim] = entry
150+
}
88151
}
89152

90153
// Force the next LoadShimMap to re-read from disk so the merged entries
@@ -94,16 +157,52 @@ func MergeShimMap(entries ShimMap) error {
94157
return SaveShimMap(existing)
95158
}
96159

97-
// LookupRuntime looks up the runtime for a given shim name using the cache.
98-
// Returns the runtime name and true if found, or empty string and false if not.
99-
func LookupRuntime(shimName string) (string, bool) {
160+
// unionVersions returns a slice containing every distinct version from a
161+
// and b, preserving the order in which versions are first seen.
162+
func unionVersions(a, b []string) []string {
163+
if len(a) == 0 && len(b) == 0 {
164+
return nil
165+
}
166+
seen := make(map[string]struct{}, len(a)+len(b))
167+
out := make([]string, 0, len(a)+len(b))
168+
for _, v := range a {
169+
if _, ok := seen[v]; ok {
170+
continue
171+
}
172+
seen[v] = struct{}{}
173+
out = append(out, v)
174+
}
175+
for _, v := range b {
176+
if _, ok := seen[v]; ok {
177+
continue
178+
}
179+
seen[v] = struct{}{}
180+
out = append(out, v)
181+
}
182+
return out
183+
}
184+
185+
// Lookup returns the full ShimEntry (runtime + providing versions) for a
186+
// shim, or zero-value and false if the shim is not in the cache. Use this
187+
// when you need the version coverage data; for callers that only need the
188+
// runtime name, use LookupRuntime.
189+
func Lookup(shimName string) (ShimEntry, bool) {
100190
shimMap, err := LoadShimMap()
101191
if err != nil {
102-
return "", false
192+
return ShimEntry{}, false
103193
}
194+
entry, ok := shimMap[shimName]
195+
return entry, ok
196+
}
104197

105-
runtime, ok := shimMap[shimName]
106-
return runtime, ok
198+
// LookupRuntime looks up the runtime for a given shim name using the cache.
199+
// Returns the runtime name and true if found, or empty string and false if not.
200+
//
201+
// This is a convenience wrapper around Lookup for callers that don't need
202+
// the providing-versions data.
203+
func LookupRuntime(shimName string) (string, bool) {
204+
entry, ok := Lookup(shimName)
205+
return entry.Runtime, ok
107206
}
108207

109208
// ResetShimMapCache resets the cached shim map, forcing a reload on next access.

0 commit comments

Comments
 (0)