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
53 changes: 50 additions & 3 deletions src/cmd/shim/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ func runShim() error {
}
ui.Debug("Resolved version: %s", version)

// If this is a secondary executable (e.g. uv mapped to python) and the
// shim-map cache knows which versions provide it, verify the active
// version is one of them. This catches the case where reshim created
// the shim because *some* installed runtime version provides it, but
// the currently-active version does not.
if shimName != runtimeName {
if entry, ok := shim.Lookup(shimName); ok && len(entry.Versions) > 0 {
if !versionProvides(entry.Versions, version) {
ui.Debug("Active version %s not in providing-versions list %v", version, entry.Versions)
return notAvailableInVersionError(shimName, runtimeName, provider.DisplayName(), version, entry.Versions)
}
}
}

// Check if the version is installed
installed, err := provider.IsInstalled(version)
if err != nil {
Expand Down Expand Up @@ -228,16 +242,49 @@ func mapShimToRuntime(shimName string) string {

// 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.
// be located in the active runtime version. This is the catch-all when
// the shim-map cache has no version coverage data (e.g., legacy cache,
// or the shim entered the cache without a recorded version).
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)
}

// notAvailableInVersionError formats a richer error using the providing-
// versions data recorded in the shim-map cache. Unlike
// secondaryExecutableError, this can tell the user *which* installed
// versions actually provide the executable so they can switch to one.
func notAvailableInVersionError(shimName, runtimeName, displayName, activeVersion string, providingVersions []string) error {
ui.Error("'%s' is not available in %s %s", shimName, displayName, activeVersion)

// "Available in: Python 3.9.9, Python 3.10.0"
labeled := make([]string, len(providingVersions))
for i, v := range providingVersions {
labeled[i] = fmt.Sprintf("%s %s", displayName, v)
}
ui.Info("Available in: %s", strings.Join(labeled, ", "))

if len(providingVersions) == 1 {
ui.Info("Switch with: dtvem global %s %s", runtimeName, providingVersions[0])
} else {
ui.Info("Switch with 'dtvem global %s <version>' or set a local version.", runtimeName)
}

return fmt.Errorf("%s not available in %s %s", shimName, displayName, activeVersion)
}

// versionProvides reports whether version is in the providing-versions list.
func versionProvides(providingVersions []string, version string) bool {
for _, v := range providingVersions {
if v == version {
return true
}
}
return false
}

// executeCommand executes a command with the given arguments and provider environment
func executeCommand(execPath string, args []string, providerEnv map[string]string) error {
// Build full args (executable name + arguments)
Expand Down
60 changes: 53 additions & 7 deletions src/cmd/which.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
goruntime "runtime"
"strings"

"github.com/CodingWithCalvin/dtvem.cli/src/internal/config"
"github.com/CodingWithCalvin/dtvem.cli/src/internal/constants"
Expand Down Expand Up @@ -70,6 +71,20 @@ Examples:
return
}

// If this is a secondary executable and the shim-map cache knows
// which versions provide it, verify the active version is one of
// them. This gives the user an informed "available in: X" message
// instead of a generic "not found" when they're on a version that
// doesn't include the command.
if commandName != runtimeName {
if entry, ok := shim.Lookup(commandName); ok && len(entry.Versions) > 0 {
if !versionInList(version, entry.Versions) {
reportNotAvailableInVersion(commandName, runtimeName, provider.DisplayName(), version, entry.Versions)
return
}
}
}

// Get the base executable path
baseExecPath, err := provider.ExecutablePath(version)
if err != nil {
Expand Down Expand Up @@ -102,15 +117,17 @@ Examples:
},
}

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

// Check each runtime's shims
for _, rt := range runtimes {
shims := shim.RuntimeShims(rt)
for _, shimName := range shims {
for _, rt := range runtime.List() {
for _, shimName := range shim.RuntimeShims(rt) {
if shimName == commandName {
return rt
}
Expand All @@ -120,6 +137,35 @@ func mapCommandToRuntime(commandName string) string {
return ""
}

// versionInList reports whether version is in the providing-versions list.
func versionInList(version string, providingVersions []string) bool {
for _, v := range providingVersions {
if v == version {
return true
}
}
return false
}

// reportNotAvailableInVersion prints the user-facing "not available in this
// runtime version" error for `dtvem which`, including the list of versions
// that DO provide the executable so the user can switch to one.
func reportNotAvailableInVersion(commandName, runtimeName, displayName, activeVersion string, providingVersions []string) {
ui.Error("'%s' is not available in %s %s", commandName, displayName, activeVersion)

labeled := make([]string, len(providingVersions))
for i, v := range providingVersions {
labeled[i] = fmt.Sprintf("%s %s", displayName, v)
}
ui.Info("Available in: %s", strings.Join(labeled, ", "))

if len(providingVersions) == 1 {
ui.Info("Switch with: dtvem global %s %s", runtimeName, providingVersions[0])
} else {
ui.Info("Switch with 'dtvem global %s <version>' or set a local version.", runtimeName)
}
}

func init() {
rootCmd.AddCommand(whichCmd)
}
137 changes: 118 additions & 19 deletions src/internal/shim/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,38 @@ package shim

import (
"encoding/json"
"fmt"
"os"
"sync"

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

// ShimMap represents the shim-to-runtime mapping cache
// The map key is the shim name (e.g., "tsc", "npm", "black")
// The map value is the runtime name (e.g., "node", "python")
type ShimMap map[string]string
// ShimEntry is the per-shim record stored in the shim-map cache. It binds
// a shim name to the runtime that owns it AND to the set of installed
// runtime versions that actually provide the executable on disk.
//
// The version data lets the shim distinguish between "command shimmed for
// this runtime, also present in the active version" and "command shimmed
// for this runtime, but the active version doesn't have it" — for example
// when `uv` is installed via `pip install uv` against Python 3.9 but the
// user switches to a 3.8 install that never received uv.
type ShimEntry struct {
// Runtime is the runtime name (e.g., "python", "node", "ruby").
Runtime string `json:"runtime"`

// Versions is the set of installed runtime versions that provide the
// executable. Empty / nil means "version coverage unknown" — typically
// because the cache was loaded from the legacy schema or because the
// caller did not supply version data — and callers should treat empty
// as "skip the version check" rather than "no providing versions".
Versions []string `json:"versions,omitempty"`
}

// ShimMap represents the shim-to-runtime mapping cache. The map key is the
// shim name (e.g., "tsc", "npm", "uv"); the value is a ShimEntry binding
// it to a runtime and (optionally) the set of versions that provide it.
type ShimMap map[string]ShimEntry

var (
shimMapCache ShimMap
Expand All @@ -30,7 +52,11 @@ func LoadShimMap() (ShimMap, error) {
return shimMapCache, shimMapCacheErr
}

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

Expand All @@ -39,12 +65,40 @@ func loadShimMapFromDisk() (ShimMap, error) {
return nil, err
}

var shimMap ShimMap
if err := json.Unmarshal(data, &shimMap); err != nil {
return nil, err
// Try the current schema first.
var current ShimMap
if err := json.Unmarshal(data, &current); err == nil && schemaIsCurrent(current) {
return current, nil
}

// Fall back to the legacy schema (shim → runtime name).
var legacy map[string]string
if err := json.Unmarshal(data, &legacy); err == nil {
converted := make(ShimMap, len(legacy))
for shim, runtime := range legacy {
converted[shim] = ShimEntry{Runtime: runtime}
}
return converted, nil
}

return shimMap, nil
return nil, fmt.Errorf("shim-map cache at %s is in an unrecognized format", cachePath)
}

// schemaIsCurrent reports whether a successfully-unmarshalled ShimMap was
// actually serialized with the current schema. A legacy file like
// `{"uv": "python"}` will technically unmarshal into ShimMap (every entry
// becomes a zero-valued ShimEntry), so a Runtime field that is empty for
// every entry indicates the input was actually legacy.
func schemaIsCurrent(m ShimMap) bool {
if len(m) == 0 {
return true
}
for _, entry := range m {
if entry.Runtime != "" {
return true
}
}
return false
}

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

for shim, runtime := range entries {
existing[shim] = runtime
for shim, entry := range entries {
if cur, ok := existing[shim]; ok {
cur.Runtime = entry.Runtime
cur.Versions = unionVersions(cur.Versions, entry.Versions)
existing[shim] = cur
} else {
existing[shim] = entry
}
}

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

// LookupRuntime looks up the runtime for a given shim name using the cache.
// Returns the runtime name and true if found, or empty string and false if not.
func LookupRuntime(shimName string) (string, bool) {
// unionVersions returns a slice containing every distinct version from a
// and b, preserving the order in which versions are first seen.
func unionVersions(a, b []string) []string {
if len(a) == 0 && len(b) == 0 {
return nil
}
seen := make(map[string]struct{}, len(a)+len(b))
out := make([]string, 0, len(a)+len(b))
for _, v := range a {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
for _, v := range b {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}

// Lookup returns the full ShimEntry (runtime + providing versions) for a
// shim, or zero-value and false if the shim is not in the cache. Use this
// when you need the version coverage data; for callers that only need the
// runtime name, use LookupRuntime.
func Lookup(shimName string) (ShimEntry, bool) {
shimMap, err := LoadShimMap()
if err != nil {
return "", false
return ShimEntry{}, false
}
entry, ok := shimMap[shimName]
return entry, ok
}

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

// ResetShimMapCache resets the cached shim map, forcing a reload on next access.
Expand Down
Loading
Loading