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
32 changes: 25 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,28 +205,46 @@ For detailed guidelines and examples, see [Commit Convention Guide](docs/COMMIT_
To add support for a new runtime (e.g., Ruby):

1. Create `src/runtimes/ruby/provider.go`
2. Implement the `runtime.Provider` interface:
2. Implement the `runtime.Provider` interface (which embeds `runtime.ShimProvider`):
```go
type Provider struct {}

// ShimProvider methods (used by the shim binary)
func (p *Provider) Name() string { return "ruby" }
func (p *Provider) DisplayName() string { return "Ruby" }
// ... implement all other methods
func (p *Provider) Shims() []string { return []string{"ruby", "gem", "bundle"} }
func (p *Provider) ExecutablePath(version string) (string, error) { ... }
func (p *Provider) IsInstalled(version string) (bool, error) { ... }
func (p *Provider) ShouldReshimAfter(shimName string, args []string) bool { ... }

// Provider-only methods (used by CLI, can include net/http)
func (p *Provider) Install(version string) error { ... }
func (p *Provider) ListAvailable() ([]runtime.AvailableVersion, error) { ... }
// ... implement remaining methods
```
3. Register the provider in the `init()` function:
```go
func init() {
runtime.Register(NewProvider())
}
```
4. Import the provider in `src/main.go`:
4. Import the provider in **both** files:
- `src/main.go` (for the CLI)
- `src/cmd/shim/main.go` (for the shim)
```go
_ "github.com/dtvem/dtvem/src/runtimes/ruby"
```
5. Implement the `Shims()` method to define which executables this runtime provides
6. Implement the `ShouldReshimAfter()` method for automatic reshim detection
7. Add tests using the provider contract harness
8. Update README.md to reflect the new runtime support
5. Add tests using the provider contract harness
6. Update README.md to reflect the new runtime support

### ShimProvider vs Provider

The `Provider` interface embeds `ShimProvider`. This separation exists for performance:

- **ShimProvider**: Minimal interface used by the shim binary. Methods here should NOT import `net/http` or other heavy dependencies.
- **Provider**: Full interface for CLI operations. Can use any dependencies.

Go's linker eliminates unused code, so keeping `ShimProvider` methods dependency-free results in a smaller, faster shim binary (~4.5MB vs ~10MB).

## Testing Guidelines

Expand Down
64 changes: 38 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,25 +208,23 @@ Reads .dtvem/runtimes.json → {"python": "3.11.0"}
Executes: ~/.dtvem/versions/python/3.11.0/bin/python --version
```

### Auto-Install Missing Versions
### Missing Version Handling

When you run a command that requires a version that isn't installed yet, dtvem will offer to install it automatically:
When you run a command that requires a version that isn't installed yet, dtvem will tell you how to install it:

```bash
$ python --version
⚠ Python 3.11.0 is not installed
→ Install it now? [Y/n]: y
→ Installing Python 3.11.0...
✓ Successfully installed Python 3.11.0
Python 3.11.0
✗ Python 3.11.0 is configured but not installed
→ To install, run: dtvem install python 3.11.0
```

This works seamlessly with bulk install - just clone a repo with a `.dtvem/runtimes.json` file and start using it. The first time you run a command, dtvem will offer to install the required version.

**Environment Variable Control:**
- `DTVEM_AUTO_INSTALL=false` - Disable auto-install in CI/automation
- `DTVEM_AUTO_INSTALL=true` - Auto-install without prompting
- Default - Interactive prompt
**Bulk Install from Config:**
Clone a repo with a `.dtvem/runtimes.json` file and run:
```bash
dtvem install # Prompts for confirmation
dtvem install --yes # Skip confirmation
```
This installs all configured versions at once.

### System PATH Fallback

Expand Down Expand Up @@ -369,10 +367,6 @@ System-wide default versions (JSON format):
### Environment Variables

- `DTVEM_ROOT`: Override default dtvem directory (default: `~/.dtvem`)
- `DTVEM_AUTO_INSTALL`: Control auto-install behavior when a configured version is missing
- `false` - Disable auto-install prompts (useful for CI/automation)
- `true` - Auto-install without prompting
- Not set - Interactive prompt (default)

## Supported Runtimes

Expand Down Expand Up @@ -462,31 +456,49 @@ Remove this installation? [y/N]: y

## Plugin System

dtvem uses an embedded plugin system with a registry pattern. Each runtime provider owns its shim definitions (executables it provides), making the architecture fully decentralized:
dtvem uses an embedded plugin system with a registry pattern. Each runtime provider owns its shim definitions (executables it provides), making the architecture fully decentralized.

### Provider Interfaces

Providers implement two interfaces - a lightweight `ShimProvider` for the shim binary, and the full `Provider` for the CLI:

```go
// Each runtime implements the Provider interface (19 methods)
type Provider interface {
// ShimProvider - minimal interface used by the shim binary
// Excludes heavy dependencies like net/http to keep shim fast and small
type ShimProvider interface {
Name() string // e.g., "python"
DisplayName() string // e.g., "Python"
Shims() []string // e.g., ["python", "python3", "pip", "pip3"]
ExecutablePath(version string) (string, error)
IsInstalled(version string) (bool, error)
ShouldReshimAfter(shimName, args) bool // e.g., detect "npm install -g"
}

// Provider - full interface for CLI operations (embeds ShimProvider)
type Provider interface {
ShimProvider
Install(version string) error
ListAvailable() ([]AvailableVersion, error) // requires net/http
DetectInstalled() ([]DetectedVersion, error)
// ... 13 more methods
// ... more methods
}
```

**Why two interfaces?** The shim binary runs on every `node`, `python`, `npm` command. By using `ShimProvider`, Go's linker eliminates unused code (like `net/http`), reducing shim size from ~10MB to ~4.5MB and improving startup time.

### Adding a New Runtime

1. Create `src/runtimes/<name>/provider.go`
2. Implement the `Provider` interface (which includes all `ShimProvider` methods)
3. Import in `src/main.go` and `src/cmd/shim/main.go`

```go
// Runtimes auto-register on startup
func init() {
runtime.Register(NewProvider())
}
```

**Adding a new runtime is as simple as:**
1. Create `src/runtimes/<name>/provider.go`
2. Implement the `Provider` interface (including `Shims()` and `ShouldReshimAfter()`)
3. Import in `src/main.go`

**That's it!** The shim mappings are automatically registered via the `Shims()` method, and automatic reshim detection works via `ShouldReshimAfter()`. No need to modify central mapping files.

## Development
Expand Down
55 changes: 18 additions & 37 deletions src/cmd/shim/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ func runShim() error {
// Determine which runtime this shim belongs to
runtimeName := mapShimToRuntime(shimName)

// Get the runtime provider
provider, err := runtime.Get(runtimeName)
// Get the runtime provider (using ShimProvider interface for minimal dependencies)
provider, err := runtime.GetShimProvider(runtimeName)
if err != nil {
return fmt.Errorf("runtime provider not found: %w", err)
}
Expand All @@ -47,35 +47,22 @@ func runShim() error {
return handleNoConfiguredVersion(shimName, runtimeName, provider)
}

// Get the path to the actual executable
execPath, err := provider.ExecutablePath(version)
// Check if the version is installed
installed, err := provider.IsInstalled(version)
if err != nil {
// Check if the version is not installed
if strings.Contains(err.Error(), "not found") {
// Offer to install it
if shouldAutoInstall(provider.DisplayName(), version) {
ui.Info("Installing %s %s...", provider.DisplayName(), version)

if installErr := provider.Install(version); installErr != nil {
ui.Error("Failed to install %s %s: %v", provider.DisplayName(), version, installErr)
ui.Info("Please install manually with: dtvem install %s %s", runtimeName, version)
return fmt.Errorf("installation failed")
}
return fmt.Errorf("could not check if %s %s is installed: %w", runtimeName, version, err)
}

ui.Success("Successfully installed %s %s", provider.DisplayName(), version)
if !installed {
ui.Error("%s %s is configured but not installed", provider.DisplayName(), version)
ui.Info("To install, run: dtvem install %s %s", runtimeName, version)
return fmt.Errorf("version not installed")
}

// Retry getting the executable path
execPath, err = provider.ExecutablePath(version)
if err != nil {
return fmt.Errorf("could not find %s %s after installation: %w", runtimeName, version, err)
}
} else {
ui.Info("To install manually, run: dtvem install %s %s", runtimeName, version)
return fmt.Errorf("installation declined")
}
} else {
return fmt.Errorf("could not find %s %s: %w", runtimeName, version, err)
}
// Get the path to the actual executable
execPath, err := provider.ExecutablePath(version)
if err != nil {
return fmt.Errorf("could not find %s %s executable: %w", runtimeName, version, err)
}

// If the shim name differs from the base runtime name,
Expand Down Expand Up @@ -109,7 +96,7 @@ func runShim() error {

// handleNoConfiguredVersion handles the case when no dtvem version is configured
// It attempts to fallback to system PATH or prompts for installation
func handleNoConfiguredVersion(shimName, runtimeName string, provider runtime.Provider) error {
func handleNoConfiguredVersion(shimName, runtimeName string, provider runtime.ShimProvider) error {
// Try to find the executable deeper in PATH (system installation)
systemPath := findInSystemPath(shimName)

Expand Down Expand Up @@ -194,12 +181,6 @@ func findInSystemPath(execName string) string {
return ""
}

// shouldAutoInstall prompts the user to install a missing version.
// Delegates to ui.PromptInstall for consistent behavior across CLI and shim.
func shouldAutoInstall(displayName, version string) bool {
return ui.PromptInstall(displayName, version)
}

// getShimName returns the name of this shim binary
func getShimName() string {
shimPath := os.Args[0]
Expand All @@ -216,8 +197,8 @@ func getShimName() string {
// This queries all registered providers for their shims, eliminating the need
// for a central hardcoded mapping.
func mapShimToRuntime(shimName string) string {
// Get all registered providers
providers := runtime.GetAll()
// Get all registered providers (using ShimProvider interface)
providers := runtime.GetAllShimProviders()

// Check each provider's shims for an exact match first
for _, provider := range providers {
Expand Down
38 changes: 23 additions & 15 deletions src/internal/runtime/provider.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// Package runtime defines the provider interface and registry for runtime managers
package runtime

// Provider defines the interface that all runtime providers must implement
type Provider interface {
// ShimProvider defines the minimal interface needed by the shim executable.
// This interface excludes heavy operations like Install() and ListAvailable()
// that require net/http and other dependencies not needed for shim execution.
type ShimProvider interface {
// Name returns the name of the runtime (e.g., "python", "node", "ruby")
Name() string

Expand All @@ -14,6 +16,25 @@ type Provider interface {
// Node.js returns ["node", "npm", "npx"]
Shims() []string

// ExecutablePath returns the path to the main executable for a given version
// For example, for Python 3.11.0, this might return "/path/to/python3.11"
ExecutablePath(version string) (string, error)

// IsInstalled checks if a specific version is installed
IsInstalled(version string) (bool, error)

// ShouldReshimAfter checks if the given command should trigger a reshim.
// Returns true if the command installs or uninstalls global packages that add/remove executables.
// The shimName parameter indicates which shim was invoked (e.g., "npm", "pip")
// The args parameter contains the command arguments (e.g., ["install", "-g", "typescript"])
ShouldReshimAfter(shimName string, args []string) bool
}

// Provider defines the full interface that all runtime providers must implement.
// It embeds ShimProvider and adds operations that require heavier dependencies.
type Provider interface {
ShimProvider

// Install downloads and installs a specific version of the runtime
Install(version string) error

Expand All @@ -27,13 +48,6 @@ type Provider interface {
// This might query online sources or use cached data
ListAvailable() ([]AvailableVersion, error)

// ExecutablePath returns the path to the main executable for a given version
// For example, for Python 3.11.0, this might return "/path/to/python3.11"
ExecutablePath(version string) (string, error)

// IsInstalled checks if a specific version is installed
IsInstalled(version string) (bool, error)

// InstallPath returns the installation directory for a given version
InstallPath(version string) (string, error)

Expand Down Expand Up @@ -72,10 +86,4 @@ type Provider interface {
// Used to provide help text to users if automatic package installation fails
// Returns empty string if the runtime doesn't support global packages
ManualPackageInstallCommand(packages []string) string

// ShouldReshimAfter checks if the given command should trigger a reshim.
// Returns true if the command installs or uninstalls global packages that add/remove executables.
// The shimName parameter indicates which shim was invoked (e.g., "npm", "pip")
// The args parameter contains the command arguments (e.g., ["install", "-g", "typescript"])
ShouldReshimAfter(shimName string, args []string) bool
}
17 changes: 17 additions & 0 deletions src/internal/runtime/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,20 @@ func Unregister(name string) error {
func GetRegistry() *Registry {
return globalRegistry
}

// GetShimProvider retrieves a provider as ShimProvider from the global registry.
// This returns only the minimal interface needed by the shim.
func GetShimProvider(name string) (ShimProvider, error) {
return globalRegistry.Get(name)
}

// GetAllShimProviders returns all providers as ShimProviders from the global registry.
// This returns only the minimal interface needed by the shim.
func GetAllShimProviders() []ShimProvider {
providers := globalRegistry.GetAll()
shimProviders := make([]ShimProvider, len(providers))
for i, p := range providers {
shimProviders[i] = p
}
return shimProviders
}