diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5867e3a..d64809c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -182,7 +182,7 @@ jobs: - name: Build shim executable run: | - go build -v -ldflags="-s -w" -trimpath -o dist/dtvem-shim${{ matrix.goos == 'windows' && '.exe' || '' }} ./src/cmd/shim + go build -v -ldflags="-s -w" -trimpath -tags shim -o dist/dtvem-shim${{ matrix.goos == 'windows' && '.exe' || '' }} ./src/cmd/shim shell: bash env: GOOS: ${{ matrix.goos }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a86c6e..daf1ed3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -187,7 +187,7 @@ jobs: - name: Build shim executable run: | - go build -v -ldflags="-s -w" -trimpath -o dist/dtvem-shim${{ matrix.goos == 'windows' && '.exe' || '' }} ./src/cmd/shim + go build -v -ldflags="-s -w" -trimpath -tags shim -o dist/dtvem-shim${{ matrix.goos == 'windows' && '.exe' || '' }} ./src/cmd/shim shell: bash env: GOOS: ${{ matrix.goos }} diff --git a/rnr.yaml b/rnr.yaml index 3986b94..05ee56e 100644 --- a/rnr.yaml +++ b/rnr.yaml @@ -28,7 +28,7 @@ build-cli: build-shim: description: Build the shim executable - cmd: go build -v -ldflags="-s -w" -trimpath -o dist/dtvem-shim.exe ./src/cmd/shim + cmd: go build -v -ldflags="-s -w" -trimpath -tags shim -o dist/dtvem-shim.exe ./src/cmd/shim # Deployment (Windows) deploy-local: diff --git a/src/internal/runtime/provider.go b/src/internal/runtime/provider.go index 5848cf0..a6b0a89 100644 --- a/src/internal/runtime/provider.go +++ b/src/internal/runtime/provider.go @@ -34,61 +34,3 @@ type ShimProvider interface { // Returns an empty map if no special environment is needed. GetEnvironment(version string) (map[string]string, error) } - -// 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 - - // Uninstall removes an installed version of the runtime - Uninstall(version string) error - - // ListInstalled returns all installed versions of this runtime - ListInstalled() ([]InstalledVersion, error) - - // ListAvailable returns all available versions that can be installed - // This might query online sources or use cached data - ListAvailable() ([]AvailableVersion, error) - - // InstallPath returns the installation directory for a given version - InstallPath(version string) (string, error) - - // GlobalVersion returns the globally configured version, if any - GlobalVersion() (string, error) - - // SetGlobalVersion sets the global default version - SetGlobalVersion(version string) error - - // LocalVersion returns the locally configured version for the current directory - // This reads from dtvem.config.json - LocalVersion() (string, error) - - // SetLocalVersion sets the local version for the current directory - SetLocalVersion(version string) error - - // CurrentVersion returns the currently active version - // (checks local first, then global) - CurrentVersion() (string, error) - - // DetectInstalled scans the system for existing installations of this runtime - // Returns a list of detected versions with their paths and sources - DetectInstalled() ([]DetectedVersion, error) - - // GlobalPackages detects globally installed packages for a specific installation - // Takes the installation path and returns a list of package names - // Returns empty slice if the runtime doesn't support global packages - GlobalPackages(installPath string) ([]string, error) - - // InstallGlobalPackages reinstalls global packages to a specific version - // Takes the version and list of package names to install - // Returns nil if the runtime doesn't support global packages - InstallGlobalPackages(version string, packages []string) error - - // ManualPackageInstallCommand returns the command string for manually installing packages - // 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 -} diff --git a/src/internal/runtime/provider_full.go b/src/internal/runtime/provider_full.go new file mode 100644 index 0000000..16d30bf --- /dev/null +++ b/src/internal/runtime/provider_full.go @@ -0,0 +1,64 @@ +//go:build !shim + +package runtime + +// Provider defines the full interface that all runtime providers must implement. +// It embeds ShimProvider and adds operations that require heavier dependencies +// (HTTP, archive extraction, manifest fetching). These methods are not compiled +// into the shim binary; in shim builds, Provider is an alias for ShimProvider +// (see provider_shim.go). +type Provider interface { + ShimProvider + + // Install downloads and installs a specific version of the runtime + Install(version string) error + + // Uninstall removes an installed version of the runtime + Uninstall(version string) error + + // ListInstalled returns all installed versions of this runtime + ListInstalled() ([]InstalledVersion, error) + + // ListAvailable returns all available versions that can be installed + // This might query online sources or use cached data + ListAvailable() ([]AvailableVersion, error) + + // InstallPath returns the installation directory for a given version + InstallPath(version string) (string, error) + + // GlobalVersion returns the globally configured version, if any + GlobalVersion() (string, error) + + // SetGlobalVersion sets the global default version + SetGlobalVersion(version string) error + + // LocalVersion returns the locally configured version for the current directory + // This reads from dtvem.config.json + LocalVersion() (string, error) + + // SetLocalVersion sets the local version for the current directory + SetLocalVersion(version string) error + + // CurrentVersion returns the currently active version + // (checks local first, then global) + CurrentVersion() (string, error) + + // DetectInstalled scans the system for existing installations of this runtime + // Returns a list of detected versions with their paths and sources + DetectInstalled() ([]DetectedVersion, error) + + // GlobalPackages detects globally installed packages for a specific installation + // Takes the installation path and returns a list of package names + // Returns empty slice if the runtime doesn't support global packages + GlobalPackages(installPath string) ([]string, error) + + // InstallGlobalPackages reinstalls global packages to a specific version + // Takes the version and list of package names to install + // Returns nil if the runtime doesn't support global packages + InstallGlobalPackages(version string, packages []string) error + + // ManualPackageInstallCommand returns the command string for manually installing packages + // 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 +} diff --git a/src/internal/runtime/provider_shim.go b/src/internal/runtime/provider_shim.go new file mode 100644 index 0000000..5c9e353 --- /dev/null +++ b/src/internal/runtime/provider_shim.go @@ -0,0 +1,13 @@ +//go:build shim + +package runtime + +// Provider is the public type used throughout the codebase for registered +// runtime implementations. In default builds Provider is the full interface +// declared in provider_full.go; in shim builds it collapses to ShimProvider +// so the heavy methods (Install, ListAvailable, etc.) are never referenced +// in the shim binary's import graph. +// +// The registry stores values of type Provider, so this alias keeps the +// registry and its callers (e.g. internal/shim, cmd/shim) unchanged. +type Provider = ShimProvider diff --git a/src/internal/runtime/provider_test_harness.go b/src/internal/runtime/provider_test_harness.go index dff9da4..2ee5c34 100644 --- a/src/internal/runtime/provider_test_harness.go +++ b/src/internal/runtime/provider_test_harness.go @@ -1,3 +1,5 @@ +//go:build !shim + package runtime import ( diff --git a/src/runtimes/node/lifecycle.go b/src/runtimes/node/lifecycle.go index 58ad977..f0d6bd2 100644 --- a/src/runtimes/node/lifecycle.go +++ b/src/runtimes/node/lifecycle.go @@ -1,3 +1,8 @@ +//go:build !shim + +// Lifecycle computation is only used by the dtvem CLI (e.g., `list-all`) and +// embeds ~50KB of schedule.json. Tagged !shim so neither the code nor the +// embedded data is linked into the shim binary. package node import ( diff --git a/src/runtimes/node/provider.go b/src/runtimes/node/provider.go index f42cb34..bcfe639 100644 --- a/src/runtimes/node/provider.go +++ b/src/runtimes/node/provider.go @@ -1,250 +1,54 @@ -// Package node implements the Node.js runtime provider for dtvem +// Package node implements the Node.js runtime provider for dtvem. +// +// This file holds the "shim half" of the provider: the methods invoked by the +// shim binary at runtime (Name, DisplayName, Shims, ExecutablePath, IsInstalled, +// InstallPath, ShouldReshimAfter, GetEnvironment) plus init() registration. +// The heavy install/list/migrate methods, along with their dependencies on +// HTTP, manifests, and archive extraction, live in provider_full.go behind a +// //go:build !shim tag so the shim binary never links them. package node import ( - "encoding/json" "fmt" "os" - "os/exec" "path/filepath" goruntime "runtime" - "strings" "github.com/CodingWithCalvin/dtvem.cli/src/internal/config" "github.com/CodingWithCalvin/dtvem.cli/src/internal/constants" - "github.com/CodingWithCalvin/dtvem.cli/src/internal/download" - "github.com/CodingWithCalvin/dtvem.cli/src/internal/manifest" "github.com/CodingWithCalvin/dtvem.cli/src/internal/runtime" - "github.com/CodingWithCalvin/dtvem.cli/src/internal/shim" - "github.com/CodingWithCalvin/dtvem.cli/src/internal/ui" ) -// Provider implements the runtime.Provider interface for Node.js -type Provider struct { - // Configuration and state will go here -} +// Provider implements the runtime.Provider interface for Node.js. +type Provider struct{} -// NewProvider creates a new Node.js runtime provider +// NewProvider creates a new Node.js runtime provider. func NewProvider() *Provider { return &Provider{} } -// Name returns the runtime name +// Name returns the runtime name. func (p *Provider) Name() string { return "node" } -// DisplayName returns the human-readable name +// DisplayName returns the human-readable name. func (p *Provider) DisplayName() string { return "Node.js" } -// Shims returns the list of shim executables for Node.js +// Shims returns the list of shim executables for Node.js. func (p *Provider) Shims() []string { return []string{"node", "npm", "npx"} } -// Install downloads and installs a specific version -func (p *Provider) Install(version string) error { - // Ensure dtvem directories exist - if err := config.EnsureDirectories(); err != nil { - return fmt.Errorf("failed to create dtvem directories: %w", err) - } - - // Check if already installed - if installed, _ := p.IsInstalled(version); installed { - return fmt.Errorf("Node.js %s is already installed", version) - } - - ui.Header("Installing Node.js v%s...", version) - - // Get platform-specific download URL - downloadURL, archiveName, err := p.getDownloadURL(version) - if err != nil { - return fmt.Errorf("failed to get download URL: %w", err) - } - - ui.Progress("Downloading from %s", downloadURL) - - // Create temporary directory for download - tempDir := filepath.Join(os.TempDir(), fmt.Sprintf("dtvem-node-%s", version)) - if err := os.MkdirAll(tempDir, 0755); err != nil { - return fmt.Errorf("failed to create temp directory: %w", err) - } - defer func() { _ = os.RemoveAll(tempDir) }() - - // Download archive - archivePath := filepath.Join(tempDir, archiveName) - if err := download.File(downloadURL, archivePath); err != nil { - return fmt.Errorf("failed to download: %w", err) - } - - // Get install path - installPath := config.RuntimeVersionPath("node", version) - - // Extract archive with spinner - extractDir := filepath.Join(tempDir, "extracted") - spinner := ui.NewSpinner("Extracting archive...") - spinner.Start() - - var extractErr error - if strings.HasSuffix(archiveName, ".zip") { - extractErr = download.ExtractZip(archivePath, extractDir) - } else if strings.HasSuffix(archiveName, ".tar.gz") { - extractErr = download.ExtractTarGz(archivePath, extractDir) - } else { - extractErr = fmt.Errorf("unsupported archive format: %s", archiveName) - } - - if extractErr == nil { - // Strip top-level directory (Node.js archives have node-v18.16.0/ at the top) - extractErr = download.StripTopLevelDir(extractDir) - } - - if extractErr != nil { - spinner.Error("Extraction failed") - return fmt.Errorf("failed to extract: %w", extractErr) - } - spinner.Success("Extraction complete") - - // Move extracted directory to install location - if err := os.MkdirAll(filepath.Dir(installPath), 0755); err != nil { - return fmt.Errorf("failed to create install directory: %w", err) - } - - if err := os.Rename(extractDir, installPath); err != nil { - return fmt.Errorf("failed to move to install location: %w", err) - } - - // Create shims with spinner - shimSpinner := ui.NewSpinner("Creating shims...") - shimSpinner.Start() - if err := p.createShims(version); err != nil { - shimSpinner.Error("Failed to create shims") - return fmt.Errorf("failed to create shims: %w", err) - } - shimSpinner.Success("Shims created") - - ui.Success("Node.js v%s installed successfully", version) - ui.Info("Location: %s", installPath) - - return nil -} - -// getDownloadURL returns the download URL and archive name for a given version -func (p *Provider) getDownloadURL(version string) (string, string, error) { - // Get the manifest (uses cached remote with embedded fallback) - m, err := manifest.DefaultSource().GetManifest("node") - if err != nil { - return "", "", fmt.Errorf("failed to load manifest: %w", err) - } - - // Get the download info for this version and platform - platform := manifest.CurrentPlatform() - dl := m.GetDownload(version, platform) - if dl == nil { - return "", "", fmt.Errorf("Node.js %s is not available for %s", version, platform) - } - - // Extract archive name from URL - archiveName := filepath.Base(dl.URL) - - return dl.URL, archiveName, nil -} - -// createShims creates shims for Node.js executables and registers them in the -// shim-map cache so subsequent shim invocations resolve via O(1) lookup rather -// than falling back to the provider registry. The version is recorded in the -// cache so the shim can detect when an active runtime version is one that -// does not provide a given executable. -func (p *Provider) createShims(version string) error { - manager, err := shim.NewManager() - if err != nil { - return err - } - - // Get the list of shims for Node.js - shimNames := shim.RuntimeShims("node") - - // Create each shim AND record them in the shim map cache - return manager.CreateShimsForRuntime("node", version, shimNames) -} - -// Uninstall removes an installed version -func (p *Provider) Uninstall(version string) error { - // TODO: Implement Node.js uninstallation - return fmt.Errorf("not yet implemented") -} - -// ListInstalled returns all installed Node.js versions -func (p *Provider) ListInstalled() ([]runtime.InstalledVersion, error) { - paths := config.DefaultPaths() - nodeVersionsDir := filepath.Join(paths.Versions, "node") - - // Check if directory exists - if _, err := os.Stat(nodeVersionsDir); os.IsNotExist(err) { - return []runtime.InstalledVersion{}, nil - } - - // Read directory - entries, err := os.ReadDir(nodeVersionsDir) - if err != nil { - return nil, fmt.Errorf("failed to read versions directory: %w", err) - } - - // Build list of installed versions - versions := make([]runtime.InstalledVersion, 0) - for _, entry := range entries { - if entry.IsDir() { - versions = append(versions, runtime.InstalledVersion{ - Version: runtime.NewVersion(entry.Name()), - InstallPath: filepath.Join(nodeVersionsDir, entry.Name()), - IsGlobal: false, // TODO: Check if this is the global version - }) - } - } - - return versions, nil -} - -// ListAvailable returns all available Node.js versions -func (p *Provider) ListAvailable() ([]runtime.AvailableVersion, error) { - // Get the manifest (uses cached remote with embedded fallback) - m, err := manifest.DefaultSource().GetManifest("node") - if err != nil { - return nil, fmt.Errorf("failed to load manifest: %w", err) - } - - // Get versions available for current platform - platform := manifest.CurrentPlatform() - versionStrings := m.ListAvailableVersions(platform) - - // Build lifecycle provider for status labels - lp := newLifecycleProvider() - - // Convert to AvailableVersion format and sort by semantic version (newest first) - versions := make([]runtime.AvailableVersion, 0, len(versionStrings)) - for _, v := range versionStrings { - versions = append(versions, runtime.AvailableVersion{ - Version: runtime.NewVersion(v), - LifecycleStatus: lp.VersionStatus(v), - }) - } - - // Sort by version descending (newest first) - runtime.SortVersionsDesc(versions) - - return versions, nil -} - -// ExecutablePath returns the path to the Node.js executable +// ExecutablePath returns the path to the Node.js executable for a version. func (p *Provider) ExecutablePath(version string) (string, error) { installPath, err := p.InstallPath(version) if err != nil { return "", err } - // Determine executable name and path based on platform var nodePath string if goruntime.GOOS == constants.OSWindows { nodePath = filepath.Join(installPath, "node.exe") @@ -252,7 +56,6 @@ func (p *Provider) ExecutablePath(version string) (string, error) { nodePath = filepath.Join(installPath, "bin", "node") } - // Verify executable exists if _, err := os.Stat(nodePath); os.IsNotExist(err) { return "", fmt.Errorf("node executable not found at %s", nodePath) } @@ -260,7 +63,7 @@ func (p *Provider) ExecutablePath(version string) (string, error) { return nodePath, nil } -// IsInstalled checks if a version is installed +// IsInstalled checks if a version is installed. func (p *Provider) IsInstalled(version string) (bool, error) { installPath := config.RuntimeVersionPath("node", version) _, err := os.Stat(installPath) @@ -273,176 +76,22 @@ func (p *Provider) IsInstalled(version string) (bool, error) { return true, nil } -// GetInstallPath returns the installation directory for a version +// InstallPath returns the installation directory for a version. func (p *Provider) InstallPath(version string) (string, error) { return config.RuntimeVersionPath("node", version), nil } -// GlobalVersion returns the globally configured version -func (p *Provider) GlobalVersion() (string, error) { - return config.GlobalVersion("node") -} - -// SetGlobalVersion sets the global default version -func (p *Provider) SetGlobalVersion(version string) error { - return config.SetGlobalVersion("node", version) -} - -// GetLocalVersion returns the locally configured version -func (p *Provider) LocalVersion() (string, error) { - // Try to find local version file - version, err := config.ResolveVersion("node") - if err != nil { - return "", err - } - return version, nil -} - -// SetLocalVersion sets the local version for current directory -func (p *Provider) SetLocalVersion(version string) error { - return config.SetLocalVersion("node", version) -} - -// GetCurrentVersion returns the currently active version -func (p *Provider) CurrentVersion() (string, error) { - return config.ResolveVersion("node") -} - -// DetectInstalled scans the system for existing Node.js installations. -// Note: This method is deprecated. Use migration providers instead -// (nvm, fnm, system) for detecting existing installations. -func (p *Provider) DetectInstalled() ([]runtime.DetectedVersion, error) { - // Detection is now handled by migration providers in src/migrations/ - // This method returns empty to avoid duplicate code - return []runtime.DetectedVersion{}, nil -} - -// GetGlobalPackages detects globally installed npm packages -func (p *Provider) GlobalPackages(installPath string) ([]string, error) { - // Find npm executable in the installation - npmPath := findNpmInInstall(installPath) - if npmPath == "" { - return nil, fmt.Errorf("npm not found in installation") - } - - // Run npm list -g --depth=0 --json - cmd := exec.Command(npmPath, "list", "-g", "--depth=0", "--json") - output, err := cmd.Output() - if err != nil { - // npm list returns exit code 1 if there are issues, but might still have output - // Try to parse anyway - if len(output) == 0 { - return []string{}, nil - } - } - - // Parse JSON output - var result struct { - Dependencies map[string]interface{} `json:"dependencies"` - } - - if err := json.Unmarshal(output, &result); err != nil { - return nil, fmt.Errorf("failed to parse npm list output: %w", err) - } - - // Extract package names (exclude npm itself) - packages := make([]string, 0) - for name := range result.Dependencies { - if name != "npm" { - packages = append(packages, name) - } - } - - return packages, nil -} - -// InstallGlobalPackages reinstalls global packages to a specific version -func (p *Provider) InstallGlobalPackages(version string, packages []string) error { - if len(packages) == 0 { - return nil - } - - // Get executable path for this version - execPath, err := p.ExecutablePath(version) - if err != nil { - return err - } - - // Find npm in the same installation - installDir := filepath.Dir(execPath) - npmPath := findNpmInInstall(installDir) - if npmPath == "" { - return fmt.Errorf("npm not found in installation") - } - - // Install all packages at once - args := append([]string{"install", "-g"}, packages...) - cmd := exec.Command(npmPath, args...) - - // Capture output for errors - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("npm install failed: %w\n%s", err, string(output)) - } - - return nil -} - -// GetManualPackageInstallCommand returns the command for manually installing packages -func (p *Provider) ManualPackageInstallCommand(packages []string) string { - if len(packages) == 0 { - return "" - } - return fmt.Sprintf("npm install -g %s", strings.Join(packages, " ")) -} - -// findNpmInInstall finds the npm executable in an installation directory -func findNpmInInstall(installDir string) string { - // Common locations to check - searchPaths := []string{ - installDir, // Same directory - filepath.Join(installDir, "bin"), // Unix bin/ - } - - // On Windows, try with .cmd extension (npm uses .cmd on Windows) - if goruntime.GOOS == constants.OSWindows { - for _, searchPath := range searchPaths { - cmdPath := filepath.Join(searchPath, "npm.cmd") - if _, err := os.Stat(cmdPath); err == nil { - return cmdPath - } - exePath := filepath.Join(searchPath, "npm.exe") - if _, err := os.Stat(exePath); err == nil { - return exePath - } - } - } else { - // On Unix, check without extension - for _, searchPath := range searchPaths { - execPath := filepath.Join(searchPath, "npm") - if _, err := os.Stat(execPath); err == nil { - return execPath - } - } - } - - return "" -} - -// ShouldReshimAfter checks if the given command should trigger a reshim. -// Returns true if the command installs or uninstalls global packages. +// ShouldReshimAfter returns true if the command installs or uninstalls global +// packages that add/remove executables. func (p *Provider) ShouldReshimAfter(shimName string, args []string) bool { - // Only npm installs global packages if shimName != "npm" { return false } - // Need at least one argument (the command) if len(args) == 0 { return false } - // Check if this is an install or uninstall command cmd := args[0] isPackageCommand := cmd == "install" || cmd == "i" || cmd == "uninstall" || cmd == "remove" || cmd == "rm" || cmd == "un" @@ -451,7 +100,6 @@ func (p *Provider) ShouldReshimAfter(shimName string, args []string) bool { return false } - // Check for -g or --global flag for _, arg := range args { if arg == "-g" || arg == "--global" { return true @@ -467,7 +115,7 @@ func (p *Provider) GetEnvironment(_ string) (map[string]string, error) { return map[string]string{}, nil } -// init registers the Node.js provider on package load +// init registers the Node.js provider on package load. func init() { if err := runtime.Register(NewProvider()); err != nil { panic(fmt.Sprintf("failed to register Node.js provider: %v", err)) diff --git a/src/runtimes/node/provider_full.go b/src/runtimes/node/provider_full.go new file mode 100644 index 0000000..5afcbb9 --- /dev/null +++ b/src/runtimes/node/provider_full.go @@ -0,0 +1,331 @@ +//go:build !shim + +// This file holds the "full half" of the Node.js provider: methods that +// install, list, migrate, and otherwise touch the network or extract archives. +// Excluded from shim builds so the shim binary doesn't link net/http, sevenzip, +// klauspost/compress, brotli, lz4, xz, embedded manifests, etc. +package node + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + goruntime "runtime" + "strings" + + "github.com/CodingWithCalvin/dtvem.cli/src/internal/config" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/constants" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/download" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/manifest" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/runtime" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/shim" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/ui" +) + +// Install downloads and installs a specific version. +func (p *Provider) Install(version string) error { + if err := config.EnsureDirectories(); err != nil { + return fmt.Errorf("failed to create dtvem directories: %w", err) + } + + if installed, _ := p.IsInstalled(version); installed { + return fmt.Errorf("Node.js %s is already installed", version) + } + + ui.Header("Installing Node.js v%s...", version) + + downloadURL, archiveName, err := p.getDownloadURL(version) + if err != nil { + return fmt.Errorf("failed to get download URL: %w", err) + } + + ui.Progress("Downloading from %s", downloadURL) + + tempDir := filepath.Join(os.TempDir(), fmt.Sprintf("dtvem-node-%s", version)) + if err := os.MkdirAll(tempDir, 0755); err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer func() { _ = os.RemoveAll(tempDir) }() + + archivePath := filepath.Join(tempDir, archiveName) + if err := download.File(downloadURL, archivePath); err != nil { + return fmt.Errorf("failed to download: %w", err) + } + + installPath := config.RuntimeVersionPath("node", version) + + extractDir := filepath.Join(tempDir, "extracted") + spinner := ui.NewSpinner("Extracting archive...") + spinner.Start() + + var extractErr error + if strings.HasSuffix(archiveName, ".zip") { + extractErr = download.ExtractZip(archivePath, extractDir) + } else if strings.HasSuffix(archiveName, ".tar.gz") { + extractErr = download.ExtractTarGz(archivePath, extractDir) + } else { + extractErr = fmt.Errorf("unsupported archive format: %s", archiveName) + } + + if extractErr == nil { + extractErr = download.StripTopLevelDir(extractDir) + } + + if extractErr != nil { + spinner.Error("Extraction failed") + return fmt.Errorf("failed to extract: %w", extractErr) + } + spinner.Success("Extraction complete") + + if err := os.MkdirAll(filepath.Dir(installPath), 0755); err != nil { + return fmt.Errorf("failed to create install directory: %w", err) + } + + if err := os.Rename(extractDir, installPath); err != nil { + return fmt.Errorf("failed to move to install location: %w", err) + } + + shimSpinner := ui.NewSpinner("Creating shims...") + shimSpinner.Start() + if err := p.createShims(version); err != nil { + shimSpinner.Error("Failed to create shims") + return fmt.Errorf("failed to create shims: %w", err) + } + shimSpinner.Success("Shims created") + + ui.Success("Node.js v%s installed successfully", version) + ui.Info("Location: %s", installPath) + + return nil +} + +// getDownloadURL returns the download URL and archive name for a given version. +func (p *Provider) getDownloadURL(version string) (string, string, error) { + m, err := manifest.DefaultSource().GetManifest("node") + if err != nil { + return "", "", fmt.Errorf("failed to load manifest: %w", err) + } + + platform := manifest.CurrentPlatform() + dl := m.GetDownload(version, platform) + if dl == nil { + return "", "", fmt.Errorf("Node.js %s is not available for %s", version, platform) + } + + archiveName := filepath.Base(dl.URL) + + return dl.URL, archiveName, nil +} + +// createShims creates shims for Node.js executables and registers them in the +// shim-map cache so subsequent shim invocations resolve via O(1) lookup rather +// than falling back to the provider registry. The version is recorded in the +// cache so the shim can detect when an active runtime version is one that +// does not provide a given executable. +func (p *Provider) createShims(version string) error { + manager, err := shim.NewManager() + if err != nil { + return err + } + + shimNames := shim.RuntimeShims("node") + + return manager.CreateShimsForRuntime("node", version, shimNames) +} + +// Uninstall removes an installed version. +func (p *Provider) Uninstall(version string) error { + // TODO: Implement Node.js uninstallation + return fmt.Errorf("not yet implemented") +} + +// ListInstalled returns all installed Node.js versions. +func (p *Provider) ListInstalled() ([]runtime.InstalledVersion, error) { + paths := config.DefaultPaths() + nodeVersionsDir := filepath.Join(paths.Versions, "node") + + if _, err := os.Stat(nodeVersionsDir); os.IsNotExist(err) { + return []runtime.InstalledVersion{}, nil + } + + entries, err := os.ReadDir(nodeVersionsDir) + if err != nil { + return nil, fmt.Errorf("failed to read versions directory: %w", err) + } + + versions := make([]runtime.InstalledVersion, 0) + for _, entry := range entries { + if entry.IsDir() { + versions = append(versions, runtime.InstalledVersion{ + Version: runtime.NewVersion(entry.Name()), + InstallPath: filepath.Join(nodeVersionsDir, entry.Name()), + IsGlobal: false, // TODO: Check if this is the global version + }) + } + } + + return versions, nil +} + +// ListAvailable returns all available Node.js versions. +func (p *Provider) ListAvailable() ([]runtime.AvailableVersion, error) { + m, err := manifest.DefaultSource().GetManifest("node") + if err != nil { + return nil, fmt.Errorf("failed to load manifest: %w", err) + } + + platform := manifest.CurrentPlatform() + versionStrings := m.ListAvailableVersions(platform) + + lp := newLifecycleProvider() + + versions := make([]runtime.AvailableVersion, 0, len(versionStrings)) + for _, v := range versionStrings { + versions = append(versions, runtime.AvailableVersion{ + Version: runtime.NewVersion(v), + LifecycleStatus: lp.VersionStatus(v), + }) + } + + runtime.SortVersionsDesc(versions) + + return versions, nil +} + +// GlobalVersion returns the globally configured version. +func (p *Provider) GlobalVersion() (string, error) { + return config.GlobalVersion("node") +} + +// SetGlobalVersion sets the global default version. +func (p *Provider) SetGlobalVersion(version string) error { + return config.SetGlobalVersion("node", version) +} + +// LocalVersion returns the locally configured version. +func (p *Provider) LocalVersion() (string, error) { + version, err := config.ResolveVersion("node") + if err != nil { + return "", err + } + return version, nil +} + +// SetLocalVersion sets the local version for current directory. +func (p *Provider) SetLocalVersion(version string) error { + return config.SetLocalVersion("node", version) +} + +// CurrentVersion returns the currently active version. +func (p *Provider) CurrentVersion() (string, error) { + return config.ResolveVersion("node") +} + +// DetectInstalled scans the system for existing Node.js installations. +// Detection is handled by migration providers in src/migrations/; this +// method returns empty to avoid duplicate code. +func (p *Provider) DetectInstalled() ([]runtime.DetectedVersion, error) { + return []runtime.DetectedVersion{}, nil +} + +// GlobalPackages detects globally installed npm packages. +func (p *Provider) GlobalPackages(installPath string) ([]string, error) { + npmPath := findNpmInInstall(installPath) + if npmPath == "" { + return nil, fmt.Errorf("npm not found in installation") + } + + cmd := exec.Command(npmPath, "list", "-g", "--depth=0", "--json") + output, err := cmd.Output() + if err != nil { + // npm list returns exit code 1 if there are issues, but might still have output + if len(output) == 0 { + return []string{}, nil + } + } + + var result struct { + Dependencies map[string]interface{} `json:"dependencies"` + } + + if err := json.Unmarshal(output, &result); err != nil { + return nil, fmt.Errorf("failed to parse npm list output: %w", err) + } + + packages := make([]string, 0) + for name := range result.Dependencies { + if name != "npm" { + packages = append(packages, name) + } + } + + return packages, nil +} + +// InstallGlobalPackages reinstalls global packages to a specific version. +func (p *Provider) InstallGlobalPackages(version string, packages []string) error { + if len(packages) == 0 { + return nil + } + + execPath, err := p.ExecutablePath(version) + if err != nil { + return err + } + + installDir := filepath.Dir(execPath) + npmPath := findNpmInInstall(installDir) + if npmPath == "" { + return fmt.Errorf("npm not found in installation") + } + + args := append([]string{"install", "-g"}, packages...) + cmd := exec.Command(npmPath, args...) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("npm install failed: %w\n%s", err, string(output)) + } + + return nil +} + +// ManualPackageInstallCommand returns the command for manually installing packages. +func (p *Provider) ManualPackageInstallCommand(packages []string) string { + if len(packages) == 0 { + return "" + } + return fmt.Sprintf("npm install -g %s", strings.Join(packages, " ")) +} + +// findNpmInInstall finds the npm executable in an installation directory. +func findNpmInInstall(installDir string) string { + searchPaths := []string{ + installDir, + filepath.Join(installDir, "bin"), + } + + if goruntime.GOOS == constants.OSWindows { + for _, searchPath := range searchPaths { + cmdPath := filepath.Join(searchPath, "npm.cmd") + if _, err := os.Stat(cmdPath); err == nil { + return cmdPath + } + exePath := filepath.Join(searchPath, "npm.exe") + if _, err := os.Stat(exePath); err == nil { + return exePath + } + } + } else { + for _, searchPath := range searchPaths { + execPath := filepath.Join(searchPath, "npm") + if _, err := os.Stat(execPath); err == nil { + return execPath + } + } + } + + return "" +} diff --git a/src/runtimes/python/provider.go b/src/runtimes/python/provider.go index 5133b37..b7a087b 100644 --- a/src/runtimes/python/provider.go +++ b/src/runtimes/python/provider.go @@ -1,416 +1,61 @@ -// Package python implements the Python runtime provider for dtvem +// Package python implements the Python runtime provider for dtvem. +// +// This file holds the "shim half" of the provider: the methods invoked by the +// shim binary at runtime (Name, DisplayName, Shims, ExecutablePath, IsInstalled, +// InstallPath, ShouldReshimAfter, GetEnvironment) plus init() registration. +// The heavy install/list/migrate methods, along with their dependencies on +// HTTP, manifests, and archive extraction, live in provider_full.go behind a +// //go:build !shim tag so the shim binary never links them. package python import ( - "encoding/json" "fmt" "os" - "os/exec" "path/filepath" goruntime "runtime" - "strconv" - "strings" "github.com/CodingWithCalvin/dtvem.cli/src/internal/config" "github.com/CodingWithCalvin/dtvem.cli/src/internal/constants" - "github.com/CodingWithCalvin/dtvem.cli/src/internal/download" - "github.com/CodingWithCalvin/dtvem.cli/src/internal/manifest" "github.com/CodingWithCalvin/dtvem.cli/src/internal/runtime" - "github.com/CodingWithCalvin/dtvem.cli/src/internal/shim" - "github.com/CodingWithCalvin/dtvem.cli/src/internal/ui" ) -// Provider implements the runtime.Provider interface for Python -type Provider struct { - // Configuration and state will go here -} +// Provider implements the runtime.Provider interface for Python. +type Provider struct{} -// NewProvider creates a new Python runtime provider +// NewProvider creates a new Python runtime provider. func NewProvider() *Provider { return &Provider{} } -// Name returns the runtime name +// Name returns the runtime name. func (p *Provider) Name() string { return "python" } -// DisplayName returns the human-readable name +// DisplayName returns the human-readable name. func (p *Provider) DisplayName() string { return "Python" } -// Shims returns the list of shim executables for Python +// Shims returns the list of shim executables for Python. func (p *Provider) Shims() []string { return []string{"python", "python3", "pip", "pip3"} } -// Install downloads and installs a specific version -// downloadAndExtract downloads and extracts the Python archive -func (p *Provider) downloadAndExtract(version, downloadURL, archiveName string) (extractDir string, cleanup func(), err error) { - ui.Progress("Downloading from %s", downloadURL) - - // Create temporary directory - tempDir := filepath.Join(os.TempDir(), fmt.Sprintf("dtvem-python-%s", version)) - if err := os.MkdirAll(tempDir, 0755); err != nil { - return "", nil, fmt.Errorf("failed to create temp directory: %w", err) - } - - cleanupFunc := func() { _ = os.RemoveAll(tempDir) } - - // Download archive - archivePath := filepath.Join(tempDir, archiveName) - if err := download.File(downloadURL, archivePath); err != nil { - cleanupFunc() - return "", nil, fmt.Errorf("failed to download: %w", err) - } - - // Extract archive - extractDir = filepath.Join(tempDir, "extracted") - spinner := ui.NewSpinner("Extracting archive...") - spinner.Start() - - var extractErr error - if strings.HasSuffix(archiveName, ".zip") { - extractErr = download.ExtractZip(archivePath, extractDir) - } else if strings.HasSuffix(archiveName, ".tar.gz") { - extractErr = download.ExtractTarGz(archivePath, extractDir) - } else { - extractErr = fmt.Errorf("unsupported archive format: %s", archiveName) - } - - if extractErr != nil { - spinner.Error("Extraction failed") - cleanupFunc() - return "", nil, fmt.Errorf("failed to extract: %w", extractErr) - } - - spinner.Success("Extraction complete") - return extractDir, cleanupFunc, nil -} - -// determineSourceDir determines the source directory from extracted archive -func determineSourceDir(extractDir string) string { - // python-build-standalone: files are in python/ subdirectory (all platforms) - pythonSubdir := filepath.Join(extractDir, "python") - if _, err := os.Stat(pythonSubdir); err == nil { - return pythonSubdir - } - - // Fallback: use extractDir if python/ doesn't exist - // (e.g., Windows embeddable packages from python.org have files in root) - return extractDir -} - -// installPipIfNeeded ensures pip is properly installed and accessible. -// On Windows, pip may be missing (python.org embeddable) or have broken -// executables (python-build-standalone with embedded build paths). -// Running ensurepip --default-pip --upgrade creates working pip executables. -func (p *Provider) installPipIfNeeded(version string) { - if goruntime.GOOS == constants.OSWindows { - pipSpinner := ui.NewSpinner("Configuring pip...") - pipSpinner.Start() - if err := p.installPip(version); err != nil { - pipSpinner.Warning("Failed to configure pip") - ui.Info("To install pip manually, run:") - ui.Info(" python -m ensurepip --default-pip --upgrade") - } else { - pipSpinner.Success("pip configured successfully") - } - } else { - // python-build-standalone includes pip on Unix - ui.Success("pip included") - } -} - -func (p *Provider) Install(version string) error { - ui.Debug("Starting Python installation for version %s", version) - - // Ensure dtvem directories exist - if err := config.EnsureDirectories(); err != nil { - return fmt.Errorf("failed to create dtvem directories: %w", err) - } - - // Check if already installed - if installed, _ := p.IsInstalled(version); installed { - return fmt.Errorf("Python %s is already installed", version) - } - - ui.Header("Installing Python v%s...", version) - - // Get platform-specific download URL - downloadURL, archiveName, err := p.getDownloadURL(version) - if err != nil { - return fmt.Errorf("failed to get download URL: %w", err) - } - ui.Debug("Download URL: %s", downloadURL) - ui.Debug("Archive name: %s", archiveName) - - // Download and extract - extractDir, cleanup, err := p.downloadAndExtract(version, downloadURL, archiveName) - if err != nil { - return err - } - defer cleanup() - - // Determine source directory - sourceDir := determineSourceDir(extractDir) - ui.Debug("Source directory: %s", sourceDir) - - // Get install path and move files - installPath := config.RuntimeVersionPath("python", version) - ui.Debug("Install path: %s", installPath) - - if err := os.MkdirAll(filepath.Dir(installPath), 0755); err != nil { - return fmt.Errorf("failed to create install directory: %w", err) - } - - ui.Debug("Moving files from %s to %s", sourceDir, installPath) - if err := os.Rename(sourceDir, installPath); err != nil { - return fmt.Errorf("failed to move to install location: %w", err) - } - - // Install/configure pip first (so executables exist before creating shims) - p.installPipIfNeeded(version) - - // Create shims (after pip is installed, all executables now exist) - shimSpinner := ui.NewSpinner("Creating shims...") - shimSpinner.Start() - if err := p.createShims(version); err != nil { - shimSpinner.Error("Failed to create shims") - return fmt.Errorf("failed to create shims: %w", err) - } - shimSpinner.Success("Shims created") - - ui.Success("Python v%s installed successfully", version) - ui.Info("Location: %s", installPath) - - return nil -} - -// getDownloadURL returns the download URL and archive name for a given version -func (p *Provider) getDownloadURL(version string) (string, string, error) { - // Get the manifest (uses cached remote with embedded fallback) - m, err := manifest.DefaultSource().GetManifest("python") - if err != nil { - return "", "", fmt.Errorf("failed to load manifest: %w", err) - } - - // Get the download info for this version and platform - platform := manifest.CurrentPlatform() - dl := m.GetDownload(version, platform) - if dl == nil { - return "", "", fmt.Errorf("Python %s is not available for %s", version, platform) - } - - // Extract archive name from URL - archiveName := filepath.Base(dl.URL) - - return dl.URL, archiveName, nil -} - -// createShims creates shims for Python executables and registers them in the -// shim-map cache so subsequent shim invocations resolve via O(1) lookup rather -// than falling back to the provider registry. The version is recorded in the -// cache so the shim can detect when an active runtime version is one that -// does not provide a given executable. -func (p *Provider) createShims(version string) error { - manager, err := shim.NewManager() - if err != nil { - return err - } - - // Get the list of shims for Python - shimNames := shim.RuntimeShims("python") - - // Create each shim AND record them in the shim map cache - return manager.CreateShimsForRuntime("python", version, shimNames) -} - -// installPip ensures pip is properly installed with working executables. -// This handles two scenarios: -// 1. python.org embeddable packages: pip is not included, needs ensurepip -// 2. python-build-standalone: pip module exists but pip.exe has broken paths -// -// Running "python -m ensurepip --default-pip --upgrade" handles both cases -// by (re)installing pip and creating working pip/pip3/pipX.Y executables. -func (p *Provider) installPip(version string) error { - pythonPath, err := p.ExecutablePath(version) - if err != nil { - return fmt.Errorf("could not find python executable: %w", err) - } - - installPath := config.RuntimeVersionPath("python", version) - - // For python.org embeddable packages, enable site-packages first. - // This file doesn't exist in python-build-standalone, so errors are ignored. - pthFile := filepath.Join(installPath, fmt.Sprintf("python%s._pth", strings.Join(strings.Split(version, ".")[:2], ""))) - _ = p.enableSitePackages(pthFile) // Best effort - ignore errors - - // Run ensurepip to install/reinstall pip with working executables. - // --default-pip: creates pip.exe in addition to pipX.exe and pipX.Y.exe - // --upgrade: reinstalls even if pip module already exists (fixes broken executables) - cmd := exec.Command(pythonPath, "-m", "ensurepip", "--default-pip", "--upgrade") - cmd.Dir = installPath - output, err := cmd.CombinedOutput() - if err != nil { - ui.Debug("ensurepip failed: %v\nOutput: %s", err, string(output)) - // Fall back to get-pip.py for older Python versions or edge cases - return p.installPipWithGetPip(version, pythonPath, installPath) - } - - return nil -} - -// installPipWithGetPip is a fallback method that downloads and runs get-pip.py. -// Used when ensurepip fails (e.g., ensurepip module missing or corrupted). -func (p *Provider) installPipWithGetPip(version, pythonPath, installPath string) error { - ui.Debug("Falling back to get-pip.py") - - getPipURL := p.getPipURL(version) - getPipPath := filepath.Join(installPath, "get-pip.py") - if err := download.File(getPipURL, getPipPath); err != nil { - return fmt.Errorf("failed to download get-pip.py: %w", err) - } - defer func() { _ = os.Remove(getPipPath) }() - - cmd := exec.Command(pythonPath, getPipPath) - cmd.Dir = installPath - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to run get-pip.py: %w\nOutput: %s", err, string(output)) - } - - return nil -} - -// getPipURL returns the appropriate get-pip.py URL for the given Python version. -// Older Python versions (3.8 and below) require version-specific URLs since the -// main get-pip.py no longer supports end-of-life Python versions. -func (p *Provider) getPipURL(version string) string { - parts := strings.Split(version, ".") - if len(parts) >= 2 && parts[0] == "3" { - minor, err := strconv.Atoi(parts[1]) - if err == nil && minor <= 8 { - // Use version-specific URL for Python 3.8 and below - return fmt.Sprintf("https://bootstrap.pypa.io/pip/%s.%s/get-pip.py", parts[0], parts[1]) - } - } - // Default URL for Python 3.9+ - return "https://bootstrap.pypa.io/get-pip.py" -} - -// enableSitePackages modifies the ._pth file to enable site-packages -func (p *Provider) enableSitePackages(pthFile string) error { - // Read the file - content, err := os.ReadFile(pthFile) - if err != nil { - return err - } - - // Uncomment "import site" line or add it if missing - lines := strings.Split(string(content), "\n") - found := false - for i, line := range lines { - if strings.Contains(line, "import site") { - // Uncomment if commented - lines[i] = "import site" - found = true - break - } - } - - // If not found, add it - if !found { - lines = append(lines, "import site") - } - - // Write back - newContent := strings.Join(lines, "\n") - return os.WriteFile(pthFile, []byte(newContent), 0644) -} - -// Uninstall removes an installed version -func (p *Provider) Uninstall(version string) error { - // TODO: Implement Python uninstallation - return fmt.Errorf("not yet implemented") -} - -// ListInstalled returns all installed Python versions -func (p *Provider) ListInstalled() ([]runtime.InstalledVersion, error) { - paths := config.DefaultPaths() - pythonVersionsDir := filepath.Join(paths.Versions, "python") - - // Check if directory exists - if _, err := os.Stat(pythonVersionsDir); os.IsNotExist(err) { - return []runtime.InstalledVersion{}, nil - } - - // Read directory - entries, err := os.ReadDir(pythonVersionsDir) - if err != nil { - return nil, fmt.Errorf("failed to read versions directory: %w", err) - } - - // Build list of installed versions - versions := make([]runtime.InstalledVersion, 0) - for _, entry := range entries { - if entry.IsDir() { - versions = append(versions, runtime.InstalledVersion{ - Version: runtime.NewVersion(entry.Name()), - InstallPath: filepath.Join(pythonVersionsDir, entry.Name()), - IsGlobal: false, // TODO: Check if this is the global version - }) - } - } - - return versions, nil -} - -// ListAvailable returns all available Python versions -func (p *Provider) ListAvailable() ([]runtime.AvailableVersion, error) { - // Get the manifest (uses cached remote with embedded fallback) - m, err := manifest.DefaultSource().GetManifest("python") - if err != nil { - return nil, fmt.Errorf("failed to load manifest: %w", err) - } - - // Get versions available for current platform - platform := manifest.CurrentPlatform() - versionStrings := m.ListAvailableVersions(platform) - - // Convert to AvailableVersion format and sort by semantic version (newest first) - versions := make([]runtime.AvailableVersion, 0, len(versionStrings)) - for _, v := range versionStrings { - versions = append(versions, runtime.AvailableVersion{ - Version: runtime.NewVersion(v), - }) - } - - // Sort by version descending (newest first) - runtime.SortVersionsDesc(versions) - - return versions, nil -} - -// ExecutablePath returns the path to the Python executable +// ExecutablePath returns the path to the Python executable for a version. func (p *Provider) ExecutablePath(version string) (string, error) { installPath, err := p.InstallPath(version) if err != nil { return "", err } - // Determine executable name and path based on platform var pythonPath string if goruntime.GOOS == constants.OSWindows { - // Windows: python.exe is in the installation root pythonPath = filepath.Join(installPath, "python.exe") } else { - // Unix: python is in bin/ subdirectory pythonPath = filepath.Join(installPath, "bin", "python") } - // Verify executable exists if _, err := os.Stat(pythonPath); os.IsNotExist(err) { return "", fmt.Errorf("python executable not found at %s", pythonPath) } @@ -418,7 +63,7 @@ func (p *Provider) ExecutablePath(version string) (string, error) { return pythonPath, nil } -// IsInstalled checks if a version is installed +// IsInstalled checks if a version is installed. func (p *Provider) IsInstalled(version string) (bool, error) { installPath := config.RuntimeVersionPath("python", version) _, err := os.Stat(installPath) @@ -431,172 +76,21 @@ func (p *Provider) IsInstalled(version string) (bool, error) { return true, nil } -// GetInstallPath returns the installation directory for a version +// InstallPath returns the installation directory for a version. func (p *Provider) InstallPath(version string) (string, error) { return config.RuntimeVersionPath("python", version), nil } -// GlobalVersion returns the globally configured version -func (p *Provider) GlobalVersion() (string, error) { - return config.GlobalVersion("python") -} - -// SetGlobalVersion sets the global default version -func (p *Provider) SetGlobalVersion(version string) error { - return config.SetGlobalVersion("python", version) -} - -// GetLocalVersion returns the locally configured version -func (p *Provider) LocalVersion() (string, error) { - // Try to find local version file - version, err := config.ResolveVersion("python") - if err != nil { - return "", err - } - return version, nil -} - -// SetLocalVersion sets the local version for current directory -func (p *Provider) SetLocalVersion(version string) error { - return config.SetLocalVersion("python", version) -} - -// GetCurrentVersion returns the currently active version -func (p *Provider) CurrentVersion() (string, error) { - return config.ResolveVersion("python") -} - -// DetectInstalled scans the system for existing Python installations. -// Note: This method is deprecated. Use migration providers instead -// (pyenv, system) for detecting existing installations. -func (p *Provider) DetectInstalled() ([]runtime.DetectedVersion, error) { - // Detection is now handled by migration providers in src/migrations/ - // This method returns empty to avoid duplicate code - return []runtime.DetectedVersion{}, nil -} - -// GetGlobalPackages detects globally installed pip packages -func (p *Provider) GlobalPackages(installPath string) ([]string, error) { - // Find pip executable in the installation - pipPath := findPipInInstall(installPath) - if pipPath == "" { - return nil, fmt.Errorf("pip not found in installation") - } - - // Run pip list --format=json - cmd := exec.Command(pipPath, "list", "--format=json") - output, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("failed to list pip packages: %w", err) - } - - // Parse JSON output - var packages []struct { - Name string `json:"name"` - Version string `json:"version"` - } - - if err := json.Unmarshal(output, &packages); err != nil { - return nil, fmt.Errorf("failed to parse pip list output: %w", err) - } - - // Extract package names (exclude pip and setuptools which are built-in) - packageNames := make([]string, 0, len(packages)) - for _, pkg := range packages { - name := strings.ToLower(pkg.Name) - if name != "pip" && name != "setuptools" && name != "wheel" { - packageNames = append(packageNames, pkg.Name) - } - } - - return packageNames, nil -} - -// InstallGlobalPackages reinstalls global packages to a specific version -func (p *Provider) InstallGlobalPackages(version string, packages []string) error { - if len(packages) == 0 { - return nil - } - - // Get executable path for this version - execPath, err := p.ExecutablePath(version) - if err != nil { - return err - } - - // Find pip in the same installation - installDir := filepath.Dir(execPath) - pipPath := findPipInInstall(installDir) - if pipPath == "" { - return fmt.Errorf("pip not found in installation") - } - - // Install all packages at once - args := append([]string{"install"}, packages...) - cmd := exec.Command(pipPath, args...) - - // Capture output for errors - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("pip install failed: %w\n%s", err, string(output)) - } - - return nil -} - -// GetManualPackageInstallCommand returns the command for manually installing packages -func (p *Provider) ManualPackageInstallCommand(packages []string) string { - if len(packages) == 0 { - return "" - } - return fmt.Sprintf("pip install %s", strings.Join(packages, " ")) -} - -// findPipInInstall finds the pip executable in an installation directory -func findPipInInstall(installDir string) string { - // Common locations to check - searchPaths := []string{ - installDir, // Same directory - filepath.Join(installDir, "bin"), // Unix bin/ - filepath.Join(installDir, "Scripts"), // Python Scripts/ (Windows) - filepath.Join(installDir, "..", "Scripts"), // Alternative Scripts location - } - - // On Windows, try with .exe extension - if goruntime.GOOS == constants.OSWindows { - for _, searchPath := range searchPaths { - exePath := filepath.Join(searchPath, "pip.exe") - if _, err := os.Stat(exePath); err == nil { - return exePath - } - } - } else { - // On Unix, check without extension - for _, searchPath := range searchPaths { - execPath := filepath.Join(searchPath, "pip") - if _, err := os.Stat(execPath); err == nil { - return execPath - } - } - } - - return "" -} - -// ShouldReshimAfter checks if the given command should trigger a reshim. -// Returns true if the command installs or uninstalls packages. +// ShouldReshimAfter returns true if the command installs or uninstalls packages. func (p *Provider) ShouldReshimAfter(shimName string, args []string) bool { - // pip, pip3 can install packages with executables if shimName != "pip" && shimName != "pip3" { return false } - // Need at least one argument (the command) if len(args) == 0 { return false } - // Check if this is an install or uninstall command cmd := args[0] return cmd == "install" || cmd == "uninstall" } @@ -608,7 +102,7 @@ func (p *Provider) GetEnvironment(_ string) (map[string]string, error) { return map[string]string{}, nil } -// init registers the Python provider on package load +// init registers the Python provider on package load. func init() { if err := runtime.Register(NewProvider()); err != nil { panic(fmt.Sprintf("failed to register Python provider: %v", err)) diff --git a/src/runtimes/python/provider_full.go b/src/runtimes/python/provider_full.go new file mode 100644 index 0000000..256e7e8 --- /dev/null +++ b/src/runtimes/python/provider_full.go @@ -0,0 +1,475 @@ +//go:build !shim + +// This file holds the "full half" of the Python provider: methods that +// install, list, migrate, and otherwise touch the network or extract archives. +// Excluded from shim builds so the shim binary doesn't link net/http, sevenzip, +// klauspost/compress, brotli, lz4, xz, embedded manifests, etc. +package python + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + goruntime "runtime" + "strconv" + "strings" + + "github.com/CodingWithCalvin/dtvem.cli/src/internal/config" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/constants" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/download" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/manifest" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/runtime" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/shim" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/ui" +) + +// downloadAndExtract downloads and extracts the Python archive. +func (p *Provider) downloadAndExtract(version, downloadURL, archiveName string) (extractDir string, cleanup func(), err error) { + ui.Progress("Downloading from %s", downloadURL) + + tempDir := filepath.Join(os.TempDir(), fmt.Sprintf("dtvem-python-%s", version)) + if err := os.MkdirAll(tempDir, 0755); err != nil { + return "", nil, fmt.Errorf("failed to create temp directory: %w", err) + } + + cleanupFunc := func() { _ = os.RemoveAll(tempDir) } + + archivePath := filepath.Join(tempDir, archiveName) + if err := download.File(downloadURL, archivePath); err != nil { + cleanupFunc() + return "", nil, fmt.Errorf("failed to download: %w", err) + } + + extractDir = filepath.Join(tempDir, "extracted") + spinner := ui.NewSpinner("Extracting archive...") + spinner.Start() + + var extractErr error + if strings.HasSuffix(archiveName, ".zip") { + extractErr = download.ExtractZip(archivePath, extractDir) + } else if strings.HasSuffix(archiveName, ".tar.gz") { + extractErr = download.ExtractTarGz(archivePath, extractDir) + } else { + extractErr = fmt.Errorf("unsupported archive format: %s", archiveName) + } + + if extractErr != nil { + spinner.Error("Extraction failed") + cleanupFunc() + return "", nil, fmt.Errorf("failed to extract: %w", extractErr) + } + + spinner.Success("Extraction complete") + return extractDir, cleanupFunc, nil +} + +// determineSourceDir determines the source directory from extracted archive. +func determineSourceDir(extractDir string) string { + // python-build-standalone: files are in python/ subdirectory (all platforms) + pythonSubdir := filepath.Join(extractDir, "python") + if _, err := os.Stat(pythonSubdir); err == nil { + return pythonSubdir + } + + // Fallback: use extractDir if python/ doesn't exist + // (e.g., Windows embeddable packages from python.org have files in root) + return extractDir +} + +// installPipIfNeeded ensures pip is properly installed and accessible. +// On Windows, pip may be missing (python.org embeddable) or have broken +// executables (python-build-standalone with embedded build paths). +// Running ensurepip --default-pip --upgrade creates working pip executables. +func (p *Provider) installPipIfNeeded(version string) { + if goruntime.GOOS == constants.OSWindows { + pipSpinner := ui.NewSpinner("Configuring pip...") + pipSpinner.Start() + if err := p.installPip(version); err != nil { + pipSpinner.Warning("Failed to configure pip") + ui.Info("To install pip manually, run:") + ui.Info(" python -m ensurepip --default-pip --upgrade") + } else { + pipSpinner.Success("pip configured successfully") + } + } else { + // python-build-standalone includes pip on Unix + ui.Success("pip included") + } +} + +// Install downloads and installs a specific version. +func (p *Provider) Install(version string) error { + ui.Debug("Starting Python installation for version %s", version) + + if err := config.EnsureDirectories(); err != nil { + return fmt.Errorf("failed to create dtvem directories: %w", err) + } + + if installed, _ := p.IsInstalled(version); installed { + return fmt.Errorf("Python %s is already installed", version) + } + + ui.Header("Installing Python v%s...", version) + + downloadURL, archiveName, err := p.getDownloadURL(version) + if err != nil { + return fmt.Errorf("failed to get download URL: %w", err) + } + ui.Debug("Download URL: %s", downloadURL) + ui.Debug("Archive name: %s", archiveName) + + extractDir, cleanup, err := p.downloadAndExtract(version, downloadURL, archiveName) + if err != nil { + return err + } + defer cleanup() + + sourceDir := determineSourceDir(extractDir) + ui.Debug("Source directory: %s", sourceDir) + + installPath := config.RuntimeVersionPath("python", version) + ui.Debug("Install path: %s", installPath) + + if err := os.MkdirAll(filepath.Dir(installPath), 0755); err != nil { + return fmt.Errorf("failed to create install directory: %w", err) + } + + ui.Debug("Moving files from %s to %s", sourceDir, installPath) + if err := os.Rename(sourceDir, installPath); err != nil { + return fmt.Errorf("failed to move to install location: %w", err) + } + + // Install/configure pip first (so executables exist before creating shims) + p.installPipIfNeeded(version) + + shimSpinner := ui.NewSpinner("Creating shims...") + shimSpinner.Start() + if err := p.createShims(version); err != nil { + shimSpinner.Error("Failed to create shims") + return fmt.Errorf("failed to create shims: %w", err) + } + shimSpinner.Success("Shims created") + + ui.Success("Python v%s installed successfully", version) + ui.Info("Location: %s", installPath) + + return nil +} + +// getDownloadURL returns the download URL and archive name for a given version. +func (p *Provider) getDownloadURL(version string) (string, string, error) { + m, err := manifest.DefaultSource().GetManifest("python") + if err != nil { + return "", "", fmt.Errorf("failed to load manifest: %w", err) + } + + platform := manifest.CurrentPlatform() + dl := m.GetDownload(version, platform) + if dl == nil { + return "", "", fmt.Errorf("Python %s is not available for %s", version, platform) + } + + archiveName := filepath.Base(dl.URL) + + return dl.URL, archiveName, nil +} + +// createShims creates shims for Python executables and registers them in the +// shim-map cache so subsequent shim invocations resolve via O(1) lookup rather +// than falling back to the provider registry. The version is recorded in the +// cache so the shim can detect when an active runtime version is one that +// does not provide a given executable. +func (p *Provider) createShims(version string) error { + manager, err := shim.NewManager() + if err != nil { + return err + } + + shimNames := shim.RuntimeShims("python") + + return manager.CreateShimsForRuntime("python", version, shimNames) +} + +// installPip ensures pip is properly installed with working executables. +// This handles two scenarios: +// 1. python.org embeddable packages: pip is not included, needs ensurepip +// 2. python-build-standalone: pip module exists but pip.exe has broken paths +// +// Running "python -m ensurepip --default-pip --upgrade" handles both cases +// by (re)installing pip and creating working pip/pip3/pipX.Y executables. +func (p *Provider) installPip(version string) error { + pythonPath, err := p.ExecutablePath(version) + if err != nil { + return fmt.Errorf("could not find python executable: %w", err) + } + + installPath := config.RuntimeVersionPath("python", version) + + // For python.org embeddable packages, enable site-packages first. + // This file doesn't exist in python-build-standalone, so errors are ignored. + pthFile := filepath.Join(installPath, fmt.Sprintf("python%s._pth", strings.Join(strings.Split(version, ".")[:2], ""))) + _ = p.enableSitePackages(pthFile) // Best effort - ignore errors + + // Run ensurepip to install/reinstall pip with working executables. + cmd := exec.Command(pythonPath, "-m", "ensurepip", "--default-pip", "--upgrade") + cmd.Dir = installPath + output, err := cmd.CombinedOutput() + if err != nil { + ui.Debug("ensurepip failed: %v\nOutput: %s", err, string(output)) + return p.installPipWithGetPip(version, pythonPath, installPath) + } + + return nil +} + +// installPipWithGetPip is a fallback method that downloads and runs get-pip.py. +// Used when ensurepip fails (e.g., ensurepip module missing or corrupted). +func (p *Provider) installPipWithGetPip(version, pythonPath, installPath string) error { + ui.Debug("Falling back to get-pip.py") + + getPipURL := p.getPipURL(version) + getPipPath := filepath.Join(installPath, "get-pip.py") + if err := download.File(getPipURL, getPipPath); err != nil { + return fmt.Errorf("failed to download get-pip.py: %w", err) + } + defer func() { _ = os.Remove(getPipPath) }() + + cmd := exec.Command(pythonPath, getPipPath) + cmd.Dir = installPath + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to run get-pip.py: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +// getPipURL returns the appropriate get-pip.py URL for the given Python version. +// Older Python versions (3.8 and below) require version-specific URLs since the +// main get-pip.py no longer supports end-of-life Python versions. +func (p *Provider) getPipURL(version string) string { + parts := strings.Split(version, ".") + if len(parts) >= 2 && parts[0] == "3" { + minor, err := strconv.Atoi(parts[1]) + if err == nil && minor <= 8 { + return fmt.Sprintf("https://bootstrap.pypa.io/pip/%s.%s/get-pip.py", parts[0], parts[1]) + } + } + return "https://bootstrap.pypa.io/get-pip.py" +} + +// enableSitePackages modifies the ._pth file to enable site-packages. +func (p *Provider) enableSitePackages(pthFile string) error { + content, err := os.ReadFile(pthFile) + if err != nil { + return err + } + + lines := strings.Split(string(content), "\n") + found := false + for i, line := range lines { + if strings.Contains(line, "import site") { + lines[i] = "import site" + found = true + break + } + } + + if !found { + lines = append(lines, "import site") + } + + newContent := strings.Join(lines, "\n") + return os.WriteFile(pthFile, []byte(newContent), 0644) +} + +// Uninstall removes an installed version. +func (p *Provider) Uninstall(version string) error { + // TODO: Implement Python uninstallation + return fmt.Errorf("not yet implemented") +} + +// ListInstalled returns all installed Python versions. +func (p *Provider) ListInstalled() ([]runtime.InstalledVersion, error) { + paths := config.DefaultPaths() + pythonVersionsDir := filepath.Join(paths.Versions, "python") + + if _, err := os.Stat(pythonVersionsDir); os.IsNotExist(err) { + return []runtime.InstalledVersion{}, nil + } + + entries, err := os.ReadDir(pythonVersionsDir) + if err != nil { + return nil, fmt.Errorf("failed to read versions directory: %w", err) + } + + versions := make([]runtime.InstalledVersion, 0) + for _, entry := range entries { + if entry.IsDir() { + versions = append(versions, runtime.InstalledVersion{ + Version: runtime.NewVersion(entry.Name()), + InstallPath: filepath.Join(pythonVersionsDir, entry.Name()), + IsGlobal: false, // TODO: Check if this is the global version + }) + } + } + + return versions, nil +} + +// ListAvailable returns all available Python versions. +func (p *Provider) ListAvailable() ([]runtime.AvailableVersion, error) { + m, err := manifest.DefaultSource().GetManifest("python") + if err != nil { + return nil, fmt.Errorf("failed to load manifest: %w", err) + } + + platform := manifest.CurrentPlatform() + versionStrings := m.ListAvailableVersions(platform) + + versions := make([]runtime.AvailableVersion, 0, len(versionStrings)) + for _, v := range versionStrings { + versions = append(versions, runtime.AvailableVersion{ + Version: runtime.NewVersion(v), + }) + } + + runtime.SortVersionsDesc(versions) + + return versions, nil +} + +// GlobalVersion returns the globally configured version. +func (p *Provider) GlobalVersion() (string, error) { + return config.GlobalVersion("python") +} + +// SetGlobalVersion sets the global default version. +func (p *Provider) SetGlobalVersion(version string) error { + return config.SetGlobalVersion("python", version) +} + +// LocalVersion returns the locally configured version. +func (p *Provider) LocalVersion() (string, error) { + version, err := config.ResolveVersion("python") + if err != nil { + return "", err + } + return version, nil +} + +// SetLocalVersion sets the local version for current directory. +func (p *Provider) SetLocalVersion(version string) error { + return config.SetLocalVersion("python", version) +} + +// CurrentVersion returns the currently active version. +func (p *Provider) CurrentVersion() (string, error) { + return config.ResolveVersion("python") +} + +// DetectInstalled scans the system for existing Python installations. +// Detection is handled by migration providers in src/migrations/; this +// method returns empty to avoid duplicate code. +func (p *Provider) DetectInstalled() ([]runtime.DetectedVersion, error) { + return []runtime.DetectedVersion{}, nil +} + +// GlobalPackages detects globally installed pip packages. +func (p *Provider) GlobalPackages(installPath string) ([]string, error) { + pipPath := findPipInInstall(installPath) + if pipPath == "" { + return nil, fmt.Errorf("pip not found in installation") + } + + cmd := exec.Command(pipPath, "list", "--format=json") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to list pip packages: %w", err) + } + + var packages []struct { + Name string `json:"name"` + Version string `json:"version"` + } + + if err := json.Unmarshal(output, &packages); err != nil { + return nil, fmt.Errorf("failed to parse pip list output: %w", err) + } + + packageNames := make([]string, 0, len(packages)) + for _, pkg := range packages { + name := strings.ToLower(pkg.Name) + if name != "pip" && name != "setuptools" && name != "wheel" { + packageNames = append(packageNames, pkg.Name) + } + } + + return packageNames, nil +} + +// InstallGlobalPackages reinstalls global packages to a specific version. +func (p *Provider) InstallGlobalPackages(version string, packages []string) error { + if len(packages) == 0 { + return nil + } + + execPath, err := p.ExecutablePath(version) + if err != nil { + return err + } + + installDir := filepath.Dir(execPath) + pipPath := findPipInInstall(installDir) + if pipPath == "" { + return fmt.Errorf("pip not found in installation") + } + + args := append([]string{"install"}, packages...) + cmd := exec.Command(pipPath, args...) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("pip install failed: %w\n%s", err, string(output)) + } + + return nil +} + +// ManualPackageInstallCommand returns the command for manually installing packages. +func (p *Provider) ManualPackageInstallCommand(packages []string) string { + if len(packages) == 0 { + return "" + } + return fmt.Sprintf("pip install %s", strings.Join(packages, " ")) +} + +// findPipInInstall finds the pip executable in an installation directory. +func findPipInInstall(installDir string) string { + searchPaths := []string{ + installDir, + filepath.Join(installDir, "bin"), + filepath.Join(installDir, "Scripts"), + filepath.Join(installDir, "..", "Scripts"), + } + + if goruntime.GOOS == constants.OSWindows { + for _, searchPath := range searchPaths { + exePath := filepath.Join(searchPath, "pip.exe") + if _, err := os.Stat(exePath); err == nil { + return exePath + } + } + } else { + for _, searchPath := range searchPaths { + execPath := filepath.Join(searchPath, "pip") + if _, err := os.Stat(execPath); err == nil { + return execPath + } + } + } + + return "" +} diff --git a/src/runtimes/ruby/provider.go b/src/runtimes/ruby/provider.go index daa553e..fcd219f 100644 --- a/src/runtimes/ruby/provider.go +++ b/src/runtimes/ruby/provider.go @@ -1,336 +1,61 @@ -// Package ruby implements the Ruby runtime provider for dtvem +// Package ruby implements the Ruby runtime provider for dtvem. +// +// This file holds the "shim half" of the provider: the methods invoked by the +// shim binary at runtime (Name, DisplayName, Shims, ExecutablePath, IsInstalled, +// InstallPath, ShouldReshimAfter, GetEnvironment) plus init() registration. +// The heavy install/list/migrate methods, along with their dependencies on +// HTTP, manifests, and archive extraction, live in provider_full.go behind a +// //go:build !shim tag so the shim binary never links them. package ruby import ( "fmt" "os" - "os/exec" "path/filepath" - "regexp" goruntime "runtime" - "strings" "github.com/CodingWithCalvin/dtvem.cli/src/internal/config" "github.com/CodingWithCalvin/dtvem.cli/src/internal/constants" - "github.com/CodingWithCalvin/dtvem.cli/src/internal/download" - "github.com/CodingWithCalvin/dtvem.cli/src/internal/manifest" "github.com/CodingWithCalvin/dtvem.cli/src/internal/runtime" - "github.com/CodingWithCalvin/dtvem.cli/src/internal/shim" - "github.com/CodingWithCalvin/dtvem.cli/src/internal/ui" ) -// Provider implements the runtime.Provider interface for Ruby -type Provider struct { - // Configuration and state will go here -} +// Provider implements the runtime.Provider interface for Ruby. +type Provider struct{} -// NewProvider creates a new Ruby runtime provider +// NewProvider creates a new Ruby runtime provider. func NewProvider() *Provider { return &Provider{} } -// Name returns the runtime name +// Name returns the runtime name. func (p *Provider) Name() string { return "ruby" } -// DisplayName returns the human-readable name +// DisplayName returns the human-readable name. func (p *Provider) DisplayName() string { return "Ruby" } -// Shims returns the list of shim executables for Ruby +// Shims returns the list of shim executables for Ruby. func (p *Provider) Shims() []string { return []string{"ruby", "gem", "irb", "bundle", "rake", "rdoc", "ri"} } -// Install downloads and installs a specific version -func (p *Provider) Install(version string) error { - ui.Debug("Starting Ruby installation for version %s", version) - - // Ensure dtvem directories exist - if err := config.EnsureDirectories(); err != nil { - return fmt.Errorf("failed to create dtvem directories: %w", err) - } - - // Check if already installed - if installed, _ := p.IsInstalled(version); installed { - return fmt.Errorf("Ruby %s is already installed", version) - } - - ui.Header("Installing Ruby v%s...", version) - - // Get platform-specific download URL - downloadURL, archiveName, err := p.getDownloadURL(version) - if err != nil { - return fmt.Errorf("failed to get download URL: %w", err) - } - ui.Debug("Download URL: %s", downloadURL) - ui.Debug("Archive name: %s", archiveName) - - // Download and extract - extractDir, cleanup, err := p.downloadAndExtract(version, downloadURL, archiveName) - if err != nil { - return err - } - defer cleanup() - - // Determine source directory - sourceDir := p.determineSourceDir(extractDir) - ui.Debug("Source directory: %s", sourceDir) - - // Get install path and move files - installPath := config.RuntimeVersionPath("ruby", version) - ui.Debug("Install path: %s", installPath) - - if err := os.MkdirAll(filepath.Dir(installPath), 0755); err != nil { - return fmt.Errorf("failed to create install directory: %w", err) - } - - ui.Debug("Moving files from %s to %s", sourceDir, installPath) - if err := os.Rename(sourceDir, installPath); err != nil { - return fmt.Errorf("failed to move to install location: %w", err) - } - - // Create shims - shimSpinner := ui.NewSpinner("Creating shims...") - shimSpinner.Start() - if err := p.createShims(version); err != nil { - shimSpinner.Error("Failed to create shims") - return fmt.Errorf("failed to create shims: %w", err) - } - shimSpinner.Success("Shims created") - - ui.Success("Ruby v%s installed successfully", version) - ui.Info("Location: %s", installPath) - - return nil -} - -// downloadAndExtract downloads and extracts the Ruby archive -func (p *Provider) downloadAndExtract(version, downloadURL, archiveName string) (extractDir string, cleanup func(), err error) { - ui.Progress("Downloading from %s", downloadURL) - - // Create temporary directory - tempDir := filepath.Join(os.TempDir(), fmt.Sprintf("dtvem-ruby-%s", version)) - if err := os.MkdirAll(tempDir, 0755); err != nil { - return "", nil, fmt.Errorf("failed to create temp directory: %w", err) - } - - cleanupFunc := func() { _ = os.RemoveAll(tempDir) } - - // Download archive - archivePath := filepath.Join(tempDir, archiveName) - if err := download.File(downloadURL, archivePath); err != nil { - cleanupFunc() - return "", nil, fmt.Errorf("failed to download: %w", err) - } - - // Handle .exe installer specially (Windows RubyInstaller) - if strings.HasSuffix(archiveName, ".exe") { - return p.runWindowsInstaller(version, archivePath, tempDir, cleanupFunc) - } - - // Extract archive - extractDir = filepath.Join(tempDir, "extracted") - spinner := ui.NewSpinner("Extracting archive...") - spinner.Start() - - var extractErr error - if strings.HasSuffix(archiveName, ".zip") { - extractErr = download.ExtractZip(archivePath, extractDir) - } else if strings.HasSuffix(archiveName, ".tar.gz") || strings.HasSuffix(archiveName, ".tar.xz") { - extractErr = download.ExtractTarGz(archivePath, extractDir) - } else if strings.HasSuffix(archiveName, ".7z") { - extractErr = download.Extract7z(archivePath, extractDir) - } else { - extractErr = fmt.Errorf("unsupported archive format: %s", archiveName) - } - - if extractErr != nil { - spinner.Error("Extraction failed") - cleanupFunc() - return "", nil, fmt.Errorf("failed to extract: %w", extractErr) - } - - spinner.Success("Extraction complete") - return extractDir, cleanupFunc, nil -} - -// runWindowsInstaller runs the RubyInstaller .exe in silent mode -func (p *Provider) runWindowsInstaller(version, installerPath, tempDir string, cleanupFunc func()) (string, func(), error) { - // Install to a temporary location, then we'll move it - extractDir := filepath.Join(tempDir, "installed") - - spinner := ui.NewSpinner("Running installer (silent mode)...") - spinner.Start() - - // Run the installer in very silent mode with: - // - /VERYSILENT: no UI at all - // - /SUPPRESSMSGBOXES: suppress message boxes - // - /NORESTART: don't restart - // - /CURRENTUSER: per-user install (no admin required) - // - /DIR=...: custom install directory - // - /TASKS="": no additional tasks (no PATH modification, no file associations) - cmd := exec.Command(installerPath, - "/VERYSILENT", - "/SUPPRESSMSGBOXES", - "/NORESTART", - "/CURRENTUSER", - "/DIR="+extractDir, - "/TASKS=", - ) - - output, err := cmd.CombinedOutput() - if err != nil { - spinner.Error("Installation failed") - cleanupFunc() - ui.Debug("Installer output: %s", string(output)) - return "", nil, fmt.Errorf("installer failed: %w", err) - } - - spinner.Success("Installation complete") - return extractDir, cleanupFunc, nil -} - -// determineSourceDir determines the source directory from extracted archive -func (p *Provider) determineSourceDir(extractDir string) string { - // Check for ruby-build format (ruby/ subdirectory) - rubySubdir := filepath.Join(extractDir, "ruby") - if _, err := os.Stat(rubySubdir); err == nil { - return rubySubdir - } - - // Check for RubyInstaller format on Windows (rubyXX-version directory) - entries, err := os.ReadDir(extractDir) - if err == nil && len(entries) == 1 && entries[0].IsDir() { - // Single directory - use it - return filepath.Join(extractDir, entries[0].Name()) - } - - // Fallback: use extractDir if nothing else matches - return extractDir -} - -// getDownloadURL returns the download URL and archive name for a given version -func (p *Provider) getDownloadURL(version string) (string, string, error) { - // Get the manifest (uses cached remote with embedded fallback) - m, err := manifest.DefaultSource().GetManifest("ruby") - if err != nil { - return "", "", fmt.Errorf("failed to load manifest: %w", err) - } - - // Get the download info for this version and platform - platform := manifest.CurrentPlatform() - dl := m.GetDownload(version, platform) - if dl == nil { - return "", "", fmt.Errorf("Ruby %s is not available for %s", version, platform) - } - - // Extract archive name from URL - archiveName := filepath.Base(dl.URL) - - return dl.URL, archiveName, nil -} - -// createShims creates shims for Ruby executables and registers them in the -// shim-map cache so subsequent shim invocations resolve via O(1) lookup rather -// than falling back to the provider registry. The version is recorded in the -// cache so the shim can detect when an active runtime version is one that -// does not provide a given executable. -func (p *Provider) createShims(version string) error { - manager, err := shim.NewManager() - if err != nil { - return err - } - - // Get the list of shims for Ruby - shimNames := shim.RuntimeShims("ruby") - - // Create each shim AND record them in the shim map cache - return manager.CreateShimsForRuntime("ruby", version, shimNames) -} - -// Uninstall removes an installed version -func (p *Provider) Uninstall(version string) error { - return fmt.Errorf("not yet implemented") -} - -// ListInstalled returns all installed Ruby versions -func (p *Provider) ListInstalled() ([]runtime.InstalledVersion, error) { - paths := config.DefaultPaths() - rubyVersionsDir := filepath.Join(paths.Versions, "ruby") - - // Check if directory exists - if _, err := os.Stat(rubyVersionsDir); os.IsNotExist(err) { - return []runtime.InstalledVersion{}, nil - } - - // Read directory - entries, err := os.ReadDir(rubyVersionsDir) - if err != nil { - return nil, fmt.Errorf("failed to read versions directory: %w", err) - } - - // Build list of installed versions - versions := make([]runtime.InstalledVersion, 0) - for _, entry := range entries { - if entry.IsDir() { - versions = append(versions, runtime.InstalledVersion{ - Version: runtime.NewVersion(entry.Name()), - InstallPath: filepath.Join(rubyVersionsDir, entry.Name()), - IsGlobal: false, - }) - } - } - - return versions, nil -} - -// ListAvailable returns all available Ruby versions -func (p *Provider) ListAvailable() ([]runtime.AvailableVersion, error) { - // Get the manifest (uses cached remote with embedded fallback) - m, err := manifest.DefaultSource().GetManifest("ruby") - if err != nil { - return nil, fmt.Errorf("failed to load manifest: %w", err) - } - - // Get versions available for current platform - platform := manifest.CurrentPlatform() - versionStrings := m.ListAvailableVersions(platform) - - // Convert to AvailableVersion format and sort by semantic version (newest first) - versions := make([]runtime.AvailableVersion, 0, len(versionStrings)) - for _, v := range versionStrings { - versions = append(versions, runtime.AvailableVersion{ - Version: runtime.NewVersion(v), - }) - } - - // Sort by version descending (newest first) - runtime.SortVersionsDesc(versions) - - return versions, nil -} - -// ExecutablePath returns the path to the Ruby executable +// ExecutablePath returns the path to the Ruby executable for a version. func (p *Provider) ExecutablePath(version string) (string, error) { installPath, err := p.InstallPath(version) if err != nil { return "", err } - // Determine executable name and path based on platform var rubyPath string if goruntime.GOOS == constants.OSWindows { - // Windows has ruby.exe in bin/ rubyPath = filepath.Join(installPath, "bin", "ruby.exe") } else { - // Unix has ruby in bin/ rubyPath = filepath.Join(installPath, "bin", "ruby") } - // Verify executable exists if _, err := os.Stat(rubyPath); os.IsNotExist(err) { return "", fmt.Errorf("ruby executable not found at %s", rubyPath) } @@ -338,7 +63,7 @@ func (p *Provider) ExecutablePath(version string) (string, error) { return rubyPath, nil } -// IsInstalled checks if a version is installed +// IsInstalled checks if a version is installed. func (p *Provider) IsInstalled(version string) (bool, error) { installPath := config.RuntimeVersionPath("ruby", version) _, err := os.Stat(installPath) @@ -351,253 +76,13 @@ func (p *Provider) IsInstalled(version string) (bool, error) { return true, nil } -// InstallPath returns the installation directory for a version +// InstallPath returns the installation directory for a version. func (p *Provider) InstallPath(version string) (string, error) { return config.RuntimeVersionPath("ruby", version), nil } -// GlobalVersion returns the globally configured version -func (p *Provider) GlobalVersion() (string, error) { - return config.GlobalVersion("ruby") -} - -// SetGlobalVersion sets the global default version -func (p *Provider) SetGlobalVersion(version string) error { - return config.SetGlobalVersion("ruby", version) -} - -// LocalVersion returns the locally configured version -func (p *Provider) LocalVersion() (string, error) { - version, err := config.ResolveVersion("ruby") - if err != nil { - return "", err - } - return version, nil -} - -// SetLocalVersion sets the local version for current directory -func (p *Provider) SetLocalVersion(version string) error { - return config.SetLocalVersion("ruby", version) -} - -// CurrentVersion returns the currently active version -func (p *Provider) CurrentVersion() (string, error) { - return config.ResolveVersion("ruby") -} - -// DetectInstalled scans the system for existing Ruby installations. -// Note: This method is deprecated. Use migration providers instead -// (rbenv, rvm, chruby, system) for detecting existing installations. -func (p *Provider) DetectInstalled() ([]runtime.DetectedVersion, error) { - // Detection is now handled by migration providers in src/migrations/ - // This method returns empty to avoid duplicate code - return []runtime.DetectedVersion{}, nil -} - -// GlobalPackages detects globally installed gems -func (p *Provider) GlobalPackages(installPath string) ([]string, error) { - // Find gem executable in the installation - gemPath := findGemInInstall(installPath) - if gemPath == "" { - return nil, fmt.Errorf("gem not found in installation") - } - - // Run gem list --no-details - cmd := exec.Command(gemPath, "list", "--no-details") - output, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("failed to list gems: %w", err) - } - - // Parse output - each line is "gemname (version)" or just "gemname" - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - packages := make([]string, 0, len(lines)) - - // Skip default/bundled gems - skipGems := map[string]bool{ - "bundler": true, - "rake": true, - "rdoc": true, - "irb": true, - "reline": true, - "io-console": true, - "psych": true, - "json": true, - "bigdecimal": true, - "date": true, - "delegate": true, - "did_you_mean": true, - "error_highlight": true, - "fileutils": true, - "getoptlong": true, - "minitest": true, - "net-ftp": true, - "net-http": true, - "net-imap": true, - "net-pop": true, - "net-protocol": true, - "net-smtp": true, - "observer": true, - "open-uri": true, - "open3": true, - "optparse": true, - "ostruct": true, - "power_assert": true, - "pp": true, - "prettyprint": true, - "pstore": true, - "racc": true, - "readline": true, - "resolv": true, - "resolv-replace": true, - "rinda": true, - "rss": true, - "securerandom": true, - "set": true, - "shellwords": true, - "singleton": true, - "stringio": true, - "strscan": true, - "syslog": true, - "tempfile": true, - "test-unit": true, - "time": true, - "timeout": true, - "tmpdir": true, - "tsort": true, - "un": true, - "uri": true, - "weakref": true, - "webrick": true, - "yaml": true, - "zlib": true, - "abbrev": true, - "base64": true, - "benchmark": true, - "cgi": true, - "csv": true, - "debug": true, - "digest": true, - "drb": true, - "english": true, - "erb": true, - "etc": true, - "fcntl": true, - "fiddle": true, - "forwardable": true, - "ipaddr": true, - "logger": true, - "matrix": true, - "mutex_m": true, - "nkf": true, - "openssl": true, - "pathname": true, - "prime": true, - "readline-ext": true, - "rexml": true, - "rubygems-update": true, - } - - gemRegex := regexp.MustCompile(`^([a-zA-Z0-9_-]+)`) - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - matches := gemRegex.FindStringSubmatch(line) - if len(matches) >= 2 { - gemName := matches[1] - if !skipGems[gemName] { - packages = append(packages, gemName) - } - } - } - - return packages, nil -} - -// InstallGlobalPackages reinstalls global gems to a specific version -func (p *Provider) InstallGlobalPackages(version string, packages []string) error { - if len(packages) == 0 { - return nil - } - - // Get executable path for this version - execPath, err := p.ExecutablePath(version) - if err != nil { - return err - } - - // Find gem in the same installation - installDir := filepath.Dir(execPath) - gemPath := findGemInInstall(installDir) - if gemPath == "" { - return fmt.Errorf("gem not found in installation") - } - - // Install all gems at once - args := append([]string{"install"}, packages...) - cmd := exec.Command(gemPath, args...) - - // Capture output for errors - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("gem install failed: %w\n%s", err, string(output)) - } - - return nil -} - -// ManualPackageInstallCommand returns the command for manually installing gems -func (p *Provider) ManualPackageInstallCommand(packages []string) string { - if len(packages) == 0 { - return "" - } - return fmt.Sprintf("gem install %s", strings.Join(packages, " ")) -} - -// findGemInInstall finds the gem executable in an installation directory -func findGemInInstall(installDir string) string { - // Common locations to check - searchPaths := []string{ - installDir, // Same directory - filepath.Join(installDir, "bin"), // Unix/Windows bin/ - } - - // On Windows, try with .cmd or .bat extension - if goruntime.GOOS == constants.OSWindows { - for _, searchPath := range searchPaths { - cmdPath := filepath.Join(searchPath, "gem.cmd") - if _, err := os.Stat(cmdPath); err == nil { - return cmdPath - } - batPath := filepath.Join(searchPath, "gem.bat") - if _, err := os.Stat(batPath); err == nil { - return batPath - } - exePath := filepath.Join(searchPath, "gem.exe") - if _, err := os.Stat(exePath); err == nil { - return exePath - } - } - } else { - // On Unix, check without extension - for _, searchPath := range searchPaths { - execPath := filepath.Join(searchPath, "gem") - if _, err := os.Stat(execPath); err == nil { - return execPath - } - } - } - - return "" -} - -// ShouldReshimAfter checks if the given command should trigger a reshim. -// Returns true if the command installs or uninstalls gems. +// ShouldReshimAfter returns true if the command installs or uninstalls gems. func (p *Provider) ShouldReshimAfter(shimName string, args []string) bool { - // gem install/uninstall can add/remove executables if shimName == "gem" { if len(args) == 0 { return false @@ -606,7 +91,6 @@ func (p *Provider) ShouldReshimAfter(shimName string, args []string) bool { return cmd == "install" || cmd == "uninstall" } - // bundle install/update can add/remove executables via binstubs if shimName == "bundle" { if len(args) == 0 { return false @@ -622,35 +106,29 @@ func (p *Provider) ShouldReshimAfter(shimName string, args []string) bool { // On Unix systems, Ruby from ruby-builder needs LD_LIBRARY_PATH (Linux) or // DYLD_LIBRARY_PATH (macOS) set to find libruby.so. func (p *Provider) GetEnvironment(version string) (map[string]string, error) { - // Windows RubyInstaller binaries are self-contained, no special environment needed if goruntime.GOOS == constants.OSWindows { return map[string]string{}, nil } - // Get the install path for this version installPath, err := p.InstallPath(version) if err != nil { return nil, err } - // The lib directory contains libruby.so libPath := filepath.Join(installPath, "lib") env := make(map[string]string) - // Set the appropriate library path based on platform if goruntime.GOOS == constants.OSDarwin { - // macOS uses DYLD_LIBRARY_PATH env["DYLD_LIBRARY_PATH"] = libPath } else { - // Linux uses LD_LIBRARY_PATH env["LD_LIBRARY_PATH"] = libPath } return env, nil } -// init registers the Ruby provider on package load +// init registers the Ruby provider on package load. func init() { if err := runtime.Register(NewProvider()); err != nil { panic(fmt.Sprintf("failed to register Ruby provider: %v", err)) diff --git a/src/runtimes/ruby/provider_full.go b/src/runtimes/ruby/provider_full.go new file mode 100644 index 0000000..21242ff --- /dev/null +++ b/src/runtimes/ruby/provider_full.go @@ -0,0 +1,489 @@ +//go:build !shim + +// This file holds the "full half" of the Ruby provider: methods that +// install, list, migrate, and otherwise touch the network or extract archives. +// Excluded from shim builds so the shim binary doesn't link net/http, sevenzip, +// klauspost/compress, brotli, lz4, xz, embedded manifests, etc. +package ruby + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + goruntime "runtime" + "strings" + + "github.com/CodingWithCalvin/dtvem.cli/src/internal/config" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/constants" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/download" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/manifest" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/runtime" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/shim" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/ui" +) + +// Install downloads and installs a specific version. +func (p *Provider) Install(version string) error { + ui.Debug("Starting Ruby installation for version %s", version) + + if err := config.EnsureDirectories(); err != nil { + return fmt.Errorf("failed to create dtvem directories: %w", err) + } + + if installed, _ := p.IsInstalled(version); installed { + return fmt.Errorf("Ruby %s is already installed", version) + } + + ui.Header("Installing Ruby v%s...", version) + + downloadURL, archiveName, err := p.getDownloadURL(version) + if err != nil { + return fmt.Errorf("failed to get download URL: %w", err) + } + ui.Debug("Download URL: %s", downloadURL) + ui.Debug("Archive name: %s", archiveName) + + extractDir, cleanup, err := p.downloadAndExtract(version, downloadURL, archiveName) + if err != nil { + return err + } + defer cleanup() + + sourceDir := p.determineSourceDir(extractDir) + ui.Debug("Source directory: %s", sourceDir) + + installPath := config.RuntimeVersionPath("ruby", version) + ui.Debug("Install path: %s", installPath) + + if err := os.MkdirAll(filepath.Dir(installPath), 0755); err != nil { + return fmt.Errorf("failed to create install directory: %w", err) + } + + ui.Debug("Moving files from %s to %s", sourceDir, installPath) + if err := os.Rename(sourceDir, installPath); err != nil { + return fmt.Errorf("failed to move to install location: %w", err) + } + + shimSpinner := ui.NewSpinner("Creating shims...") + shimSpinner.Start() + if err := p.createShims(version); err != nil { + shimSpinner.Error("Failed to create shims") + return fmt.Errorf("failed to create shims: %w", err) + } + shimSpinner.Success("Shims created") + + ui.Success("Ruby v%s installed successfully", version) + ui.Info("Location: %s", installPath) + + return nil +} + +// downloadAndExtract downloads and extracts the Ruby archive. +func (p *Provider) downloadAndExtract(version, downloadURL, archiveName string) (extractDir string, cleanup func(), err error) { + ui.Progress("Downloading from %s", downloadURL) + + tempDir := filepath.Join(os.TempDir(), fmt.Sprintf("dtvem-ruby-%s", version)) + if err := os.MkdirAll(tempDir, 0755); err != nil { + return "", nil, fmt.Errorf("failed to create temp directory: %w", err) + } + + cleanupFunc := func() { _ = os.RemoveAll(tempDir) } + + archivePath := filepath.Join(tempDir, archiveName) + if err := download.File(downloadURL, archivePath); err != nil { + cleanupFunc() + return "", nil, fmt.Errorf("failed to download: %w", err) + } + + // Handle .exe installer specially (Windows RubyInstaller) + if strings.HasSuffix(archiveName, ".exe") { + return p.runWindowsInstaller(version, archivePath, tempDir, cleanupFunc) + } + + extractDir = filepath.Join(tempDir, "extracted") + spinner := ui.NewSpinner("Extracting archive...") + spinner.Start() + + var extractErr error + if strings.HasSuffix(archiveName, ".zip") { + extractErr = download.ExtractZip(archivePath, extractDir) + } else if strings.HasSuffix(archiveName, ".tar.gz") || strings.HasSuffix(archiveName, ".tar.xz") { + extractErr = download.ExtractTarGz(archivePath, extractDir) + } else if strings.HasSuffix(archiveName, ".7z") { + extractErr = download.Extract7z(archivePath, extractDir) + } else { + extractErr = fmt.Errorf("unsupported archive format: %s", archiveName) + } + + if extractErr != nil { + spinner.Error("Extraction failed") + cleanupFunc() + return "", nil, fmt.Errorf("failed to extract: %w", extractErr) + } + + spinner.Success("Extraction complete") + return extractDir, cleanupFunc, nil +} + +// runWindowsInstaller runs the RubyInstaller .exe in silent mode. +func (p *Provider) runWindowsInstaller(version, installerPath, tempDir string, cleanupFunc func()) (string, func(), error) { + extractDir := filepath.Join(tempDir, "installed") + + spinner := ui.NewSpinner("Running installer (silent mode)...") + spinner.Start() + + // /VERYSILENT, /SUPPRESSMSGBOXES, /NORESTART, /CURRENTUSER (no admin), /DIR=..., + // /TASKS="" (no PATH modification, no file associations). + cmd := exec.Command(installerPath, + "/VERYSILENT", + "/SUPPRESSMSGBOXES", + "/NORESTART", + "/CURRENTUSER", + "/DIR="+extractDir, + "/TASKS=", + ) + + output, err := cmd.CombinedOutput() + if err != nil { + spinner.Error("Installation failed") + cleanupFunc() + ui.Debug("Installer output: %s", string(output)) + return "", nil, fmt.Errorf("installer failed: %w", err) + } + + spinner.Success("Installation complete") + return extractDir, cleanupFunc, nil +} + +// determineSourceDir determines the source directory from extracted archive. +func (p *Provider) determineSourceDir(extractDir string) string { + // Check for ruby-build format (ruby/ subdirectory) + rubySubdir := filepath.Join(extractDir, "ruby") + if _, err := os.Stat(rubySubdir); err == nil { + return rubySubdir + } + + // Check for RubyInstaller format on Windows (rubyXX-version directory) + entries, err := os.ReadDir(extractDir) + if err == nil && len(entries) == 1 && entries[0].IsDir() { + return filepath.Join(extractDir, entries[0].Name()) + } + + return extractDir +} + +// getDownloadURL returns the download URL and archive name for a given version. +func (p *Provider) getDownloadURL(version string) (string, string, error) { + m, err := manifest.DefaultSource().GetManifest("ruby") + if err != nil { + return "", "", fmt.Errorf("failed to load manifest: %w", err) + } + + platform := manifest.CurrentPlatform() + dl := m.GetDownload(version, platform) + if dl == nil { + return "", "", fmt.Errorf("Ruby %s is not available for %s", version, platform) + } + + archiveName := filepath.Base(dl.URL) + + return dl.URL, archiveName, nil +} + +// createShims creates shims for Ruby executables and registers them in the +// shim-map cache so subsequent shim invocations resolve via O(1) lookup rather +// than falling back to the provider registry. The version is recorded in the +// cache so the shim can detect when an active runtime version is one that +// does not provide a given executable. +func (p *Provider) createShims(version string) error { + manager, err := shim.NewManager() + if err != nil { + return err + } + + shimNames := shim.RuntimeShims("ruby") + + return manager.CreateShimsForRuntime("ruby", version, shimNames) +} + +// Uninstall removes an installed version. +func (p *Provider) Uninstall(version string) error { + return fmt.Errorf("not yet implemented") +} + +// ListInstalled returns all installed Ruby versions. +func (p *Provider) ListInstalled() ([]runtime.InstalledVersion, error) { + paths := config.DefaultPaths() + rubyVersionsDir := filepath.Join(paths.Versions, "ruby") + + if _, err := os.Stat(rubyVersionsDir); os.IsNotExist(err) { + return []runtime.InstalledVersion{}, nil + } + + entries, err := os.ReadDir(rubyVersionsDir) + if err != nil { + return nil, fmt.Errorf("failed to read versions directory: %w", err) + } + + versions := make([]runtime.InstalledVersion, 0) + for _, entry := range entries { + if entry.IsDir() { + versions = append(versions, runtime.InstalledVersion{ + Version: runtime.NewVersion(entry.Name()), + InstallPath: filepath.Join(rubyVersionsDir, entry.Name()), + IsGlobal: false, + }) + } + } + + return versions, nil +} + +// ListAvailable returns all available Ruby versions. +func (p *Provider) ListAvailable() ([]runtime.AvailableVersion, error) { + m, err := manifest.DefaultSource().GetManifest("ruby") + if err != nil { + return nil, fmt.Errorf("failed to load manifest: %w", err) + } + + platform := manifest.CurrentPlatform() + versionStrings := m.ListAvailableVersions(platform) + + versions := make([]runtime.AvailableVersion, 0, len(versionStrings)) + for _, v := range versionStrings { + versions = append(versions, runtime.AvailableVersion{ + Version: runtime.NewVersion(v), + }) + } + + runtime.SortVersionsDesc(versions) + + return versions, nil +} + +// GlobalVersion returns the globally configured version. +func (p *Provider) GlobalVersion() (string, error) { + return config.GlobalVersion("ruby") +} + +// SetGlobalVersion sets the global default version. +func (p *Provider) SetGlobalVersion(version string) error { + return config.SetGlobalVersion("ruby", version) +} + +// LocalVersion returns the locally configured version. +func (p *Provider) LocalVersion() (string, error) { + version, err := config.ResolveVersion("ruby") + if err != nil { + return "", err + } + return version, nil +} + +// SetLocalVersion sets the local version for current directory. +func (p *Provider) SetLocalVersion(version string) error { + return config.SetLocalVersion("ruby", version) +} + +// CurrentVersion returns the currently active version. +func (p *Provider) CurrentVersion() (string, error) { + return config.ResolveVersion("ruby") +} + +// DetectInstalled scans the system for existing Ruby installations. +// Detection is handled by migration providers in src/migrations/; this +// method returns empty to avoid duplicate code. +func (p *Provider) DetectInstalled() ([]runtime.DetectedVersion, error) { + return []runtime.DetectedVersion{}, nil +} + +// GlobalPackages detects globally installed gems. +func (p *Provider) GlobalPackages(installPath string) ([]string, error) { + gemPath := findGemInInstall(installPath) + if gemPath == "" { + return nil, fmt.Errorf("gem not found in installation") + } + + cmd := exec.Command(gemPath, "list", "--no-details") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to list gems: %w", err) + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + packages := make([]string, 0, len(lines)) + + skipGems := map[string]bool{ + "bundler": true, + "rake": true, + "rdoc": true, + "irb": true, + "reline": true, + "io-console": true, + "psych": true, + "json": true, + "bigdecimal": true, + "date": true, + "delegate": true, + "did_you_mean": true, + "error_highlight": true, + "fileutils": true, + "getoptlong": true, + "minitest": true, + "net-ftp": true, + "net-http": true, + "net-imap": true, + "net-pop": true, + "net-protocol": true, + "net-smtp": true, + "observer": true, + "open-uri": true, + "open3": true, + "optparse": true, + "ostruct": true, + "power_assert": true, + "pp": true, + "prettyprint": true, + "pstore": true, + "racc": true, + "readline": true, + "resolv": true, + "resolv-replace": true, + "rinda": true, + "rss": true, + "securerandom": true, + "set": true, + "shellwords": true, + "singleton": true, + "stringio": true, + "strscan": true, + "syslog": true, + "tempfile": true, + "test-unit": true, + "time": true, + "timeout": true, + "tmpdir": true, + "tsort": true, + "un": true, + "uri": true, + "weakref": true, + "webrick": true, + "yaml": true, + "zlib": true, + "abbrev": true, + "base64": true, + "benchmark": true, + "cgi": true, + "csv": true, + "debug": true, + "digest": true, + "drb": true, + "english": true, + "erb": true, + "etc": true, + "fcntl": true, + "fiddle": true, + "forwardable": true, + "ipaddr": true, + "logger": true, + "matrix": true, + "mutex_m": true, + "nkf": true, + "openssl": true, + "pathname": true, + "prime": true, + "readline-ext": true, + "rexml": true, + "rubygems-update": true, + } + + gemRegex := regexp.MustCompile(`^([a-zA-Z0-9_-]+)`) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + matches := gemRegex.FindStringSubmatch(line) + if len(matches) >= 2 { + gemName := matches[1] + if !skipGems[gemName] { + packages = append(packages, gemName) + } + } + } + + return packages, nil +} + +// InstallGlobalPackages reinstalls global gems to a specific version. +func (p *Provider) InstallGlobalPackages(version string, packages []string) error { + if len(packages) == 0 { + return nil + } + + execPath, err := p.ExecutablePath(version) + if err != nil { + return err + } + + installDir := filepath.Dir(execPath) + gemPath := findGemInInstall(installDir) + if gemPath == "" { + return fmt.Errorf("gem not found in installation") + } + + args := append([]string{"install"}, packages...) + cmd := exec.Command(gemPath, args...) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("gem install failed: %w\n%s", err, string(output)) + } + + return nil +} + +// ManualPackageInstallCommand returns the command for manually installing gems. +func (p *Provider) ManualPackageInstallCommand(packages []string) string { + if len(packages) == 0 { + return "" + } + return fmt.Sprintf("gem install %s", strings.Join(packages, " ")) +} + +// findGemInInstall finds the gem executable in an installation directory. +func findGemInInstall(installDir string) string { + searchPaths := []string{ + installDir, + filepath.Join(installDir, "bin"), + } + + if goruntime.GOOS == constants.OSWindows { + for _, searchPath := range searchPaths { + cmdPath := filepath.Join(searchPath, "gem.cmd") + if _, err := os.Stat(cmdPath); err == nil { + return cmdPath + } + batPath := filepath.Join(searchPath, "gem.bat") + if _, err := os.Stat(batPath); err == nil { + return batPath + } + exePath := filepath.Join(searchPath, "gem.exe") + if _, err := os.Stat(exePath); err == nil { + return exePath + } + } + } else { + for _, searchPath := range searchPaths { + execPath := filepath.Join(searchPath, "gem") + if _, err := os.Stat(execPath); err == nil { + return execPath + } + } + } + + return "" +}