diff --git a/.github/workflows/integration-test-init.yml b/.github/workflows/integration-test-init.yml new file mode 100644 index 0000000..262fce5 --- /dev/null +++ b/.github/workflows/integration-test-init.yml @@ -0,0 +1,33 @@ +name: Integration Tests - Init Command + +on: + workflow_dispatch: + # Manual trigger for testing init command flags + push: + branches: [main] + paths: + - 'src/cmd/init.go' + - 'src/internal/config/settings.go' + - 'src/internal/path/path_windows.go' + - 'src/internal/path/path_unix.go' + - 'install.ps1' + - 'install.sh' + - '.github/workflows/integration-test-init.yml' + pull_request: + branches: [main] + paths: + - 'src/cmd/init.go' + - 'src/internal/config/settings.go' + - 'src/internal/path/path_windows.go' + - 'src/internal/path/path_unix.go' + - 'install.ps1' + - 'install.sh' + - '.github/workflows/integration-test-init.yml' + +permissions: + contents: read + +jobs: + init: + name: Init Command + uses: CodingWithCalvin/.github/.github/workflows/dtvem-integration-test-init.yml@main diff --git a/install.ps1 b/install.ps1 index 569b89a..5295e5c 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,5 +1,11 @@ # dtvem installer for Windows -# Usage: irm https://raw.githubusercontent.com/CodingWithCalvin/dtvem.cli/main/install.ps1 | iex +# Usage: +# Standard (admin required): irm https://raw.githubusercontent.com/CodingWithCalvin/dtvem.cli/main/install.ps1 | iex +# User install (no admin): iex "& { $(irm https://raw.githubusercontent.com/CodingWithCalvin/dtvem.cli/main/install.ps1) } -UserInstall" + +param( + [switch]$UserInstall +) $ErrorActionPreference = "Stop" @@ -117,6 +123,7 @@ function Test-Checksum { } function Main { + param([switch]$UserInstall) Write-Host "" Write-Host "========================================" -ForegroundColor Blue Write-Host " dtvem installer" -ForegroundColor Blue @@ -269,7 +276,13 @@ function Main { # Temporarily add to PATH for this session $env:Path = "$INSTALL_DIR;$env:Path" - & $dtvemPath init + if ($UserInstall) { + Write-Info "Using user-level PATH (no admin required)" + & $dtvemPath init --user -y + } + else { + & $dtvemPath init + } Write-Success "dtvem is ready to use!" Write-Info "Both $INSTALL_DIR and $SHIMS_DIR have been added to PATH" } @@ -298,4 +311,4 @@ function Main { } } -Main +Main -UserInstall:$UserInstall diff --git a/install.sh b/install.sh index 7cb6143..e2163c0 100644 --- a/install.sh +++ b/install.sh @@ -2,9 +2,25 @@ set -e # dtvem installer for macOS and Linux -# Usage: curl -fsSL https://raw.githubusercontent.com/CodingWithCalvin/dtvem.cli/main/install.sh | bash +# Usage: +# Standard: curl -fsSL https://raw.githubusercontent.com/CodingWithCalvin/dtvem.cli/main/install.sh | bash +# User install: curl -fsSL https://raw.githubusercontent.com/CodingWithCalvin/dtvem.cli/main/install.sh | bash -s -- --user-install REPO="CodingWithCalvin/dtvem.cli" +USER_INSTALL=false + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --user-install) + USER_INSTALL=true + shift + ;; + *) + shift + ;; + esac +done # This will be replaced with the actual version during release # Format: DTVEM_RELEASE_VERSION="1.0.0" @@ -366,11 +382,21 @@ main() { # Run init to add shims directory to PATH echo "" info "Running dtvem init to add shims directory to PATH..." - if "$INSTALL_DIR/dtvem" init; then - success "dtvem is ready to use!" - info "Both $INSTALL_DIR and $SHIMS_DIR have been added to PATH" + if [ "$USER_INSTALL" = true ]; then + info "Using user-level PATH" + if "$INSTALL_DIR/dtvem" init --user -y; then + success "dtvem is ready to use!" + info "Both $INSTALL_DIR and $SHIMS_DIR have been added to PATH" + else + warning "dtvem init failed - you may need to run it manually" + fi else - warning "dtvem init failed - you may need to run it manually" + if "$INSTALL_DIR/dtvem" init; then + success "dtvem is ready to use!" + info "Both $INSTALL_DIR and $SHIMS_DIR have been added to PATH" + else + warning "dtvem init failed - you may need to run it manually" + fi fi echo "" diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json new file mode 100644 index 0000000..800633c --- /dev/null +++ b/schemas/settings.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/CodingWithCalvin/dtvem.cli/main/schemas/settings.schema.json", + "title": "dtvem Settings Configuration", + "description": "Settings file for dtvem (Development Tool Virtual Environment Manager) installation preferences", + "type": "object", + "properties": { + "installType": { + "type": "string", + "description": "The type of dtvem installation. 'system' uses System PATH (requires admin on Windows), 'user' uses User PATH (no admin required).", + "enum": ["system", "user"], + "default": "system" + } + }, + "required": ["installType"], + "additionalProperties": false, + "examples": [ + { + "installType": "system" + }, + { + "installType": "user" + } + ] +} diff --git a/src/cmd/init.go b/src/cmd/init.go index 14b97f1..05e9f30 100644 --- a/src/cmd/init.go +++ b/src/cmd/init.go @@ -1,13 +1,19 @@ package cmd import ( + "runtime" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/config" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/constants" "github.com/CodingWithCalvin/dtvem.cli/src/internal/path" "github.com/CodingWithCalvin/dtvem.cli/src/internal/ui" "github.com/spf13/cobra" ) -var initYes bool +var ( + initYes bool + initUser bool +) var initCmd = &cobra.Command{ Use: "init", @@ -18,10 +24,15 @@ This command: - Creates the ~/.dtvem directory structure - Adds ~/.dtvem/shims to your PATH (with your permission) +Options: + --user Use User PATH instead of System PATH on Windows (no admin required) + Note: System-installed runtimes will take priority over dtvem shims + Run this command after installing dtvem for the first time. Example: - dtvem init`, + dtvem init + dtvem init --user # Windows: use User PATH (no admin)`, Run: func(cmd *cobra.Command, args []string) { ui.Header("Initializing dtvem...") @@ -37,16 +48,46 @@ Example: spinner.Success("Directories created") + // Determine install type and check for switching + userInstall := determineInstallType(cmd) + previousSettings, _ := config.LoadSettings() + isSwitching := cmd.Flags().Changed("user") && previousSettings != nil && + ((userInstall && previousSettings.InstallType == config.InstallTypeSystem) || + (!userInstall && previousSettings.InstallType == config.InstallTypeUser)) + + // Warn about switching install types on Windows + if isSwitching && runtime.GOOS == constants.OSWindows { + warnAboutInstallTypeSwitch(userInstall, previousSettings.InstallType) + } + // Setup PATH - AddToPath handles checking position and moving if needed shimsDir := path.ShimsDir() - if err := path.AddToPath(shimsDir, initYes); err != nil { + if err := path.AddToPath(shimsDir, initYes, userInstall); err != nil { ui.Error("Failed to configure PATH: %v", err) ui.Info("You can manually add %s to your PATH", shimsDir) return } + // Save settings for future reference + installType := config.InstallTypeSystem + if userInstall { + installType = config.InstallTypeUser + } + settings := &config.Settings{InstallType: installType} + if err := config.SaveSettings(settings); err != nil { + ui.Warning("Failed to save settings: %v", err) + } + ui.Success("dtvem initialized successfully!") + + // Show reminder for user-level installations on Windows + if userInstall && runtime.GOOS == constants.OSWindows { + ui.Info("") + ui.Warning("Note: Using User PATH. System-installed runtimes may take priority.") + ui.Info("Run 'dtvem init' as administrator for system-level PATH if needed.") + } + ui.Info("\nNext steps:") ui.Info(" 1. Restart your terminal (required for PATH changes)") ui.Info(" 2. Run: dtvem install ") @@ -54,7 +95,62 @@ Example: }, } +// determineInstallType determines whether to use user-level or system-level installation. +// Priority: flag > saved settings > default (system) +func determineInstallType(cmd *cobra.Command) bool { + // If --user flag was explicitly set, use it + if cmd.Flags().Changed("user") { + return initUser + } + + // Check saved settings + settings, err := config.LoadSettings() + if err == nil && settings.InstallType == config.InstallTypeUser { + return true + } + + // Default to system install + return false +} + +// warnAboutInstallTypeSwitch warns the user about switching install types +// and provides instructions for cleaning up the old PATH entry. +func warnAboutInstallTypeSwitch(toUser bool, previousType config.InstallType) { + shimsDir := path.ShimsDir() + + ui.Warning("Switching install type from %s to %s", previousType, map[bool]string{true: "user", false: "system"}[toUser]) + ui.Info("") + + if toUser { + // Switching from system to user + ui.Info("Your previous system-level PATH entry may still exist.") + ui.Info("To avoid conflicts, you may want to remove the old System PATH entry:") + ui.Info("") + ui.Info(" Manual removal steps:") + ui.Info(" 1. Open System Properties > Environment Variables") + ui.Info(" 2. Under 'System variables', select 'Path' and click 'Edit'") + ui.Info(" 3. Remove the entry: %s", ui.Highlight(shimsDir)) + ui.Info(" 4. Click OK to save") + ui.Info("") + ui.Info(" Or run as administrator:") + ui.Info(" dtvem init (without --user)") + ui.Info(" This will move the entry to System PATH properly.") + } else { + // Switching from user to system + ui.Info("Your previous user-level PATH entry may still exist.") + ui.Info("To avoid conflicts, you may want to remove the old User PATH entry:") + ui.Info("") + ui.Info(" Manual removal steps:") + ui.Info(" 1. Open System Properties > Environment Variables") + ui.Info(" 2. Under 'User variables', select 'Path' and click 'Edit'") + ui.Info(" 3. Remove the entry: %s", ui.Highlight(shimsDir)) + ui.Info(" 4. Click OK to save") + } + ui.Info("") +} + func init() { initCmd.Flags().BoolVarP(&initYes, "yes", "y", false, "Skip confirmation prompts") + initCmd.Flags().BoolVar(&initUser, "user", false, "Use User PATH instead of System PATH (Windows: no admin required)") rootCmd.AddCommand(initCmd) } diff --git a/src/cmd/init_test.go b/src/cmd/init_test.go new file mode 100644 index 0000000..f3c087d --- /dev/null +++ b/src/cmd/init_test.go @@ -0,0 +1,353 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/CodingWithCalvin/dtvem.cli/src/internal/config" +) + +func TestDetermineInstallType_FromSettings(t *testing.T) { + // Create a temp directory for test + tmpDir := t.TempDir() + originalRoot := os.Getenv("DTVEM_ROOT") + defer func() { + if originalRoot != "" { + _ = os.Setenv("DTVEM_ROOT", originalRoot) + } else { + _ = os.Unsetenv("DTVEM_ROOT") + } + config.ResetPathsCache() + }() + + _ = os.Setenv("DTVEM_ROOT", tmpDir) + config.ResetPathsCache() + + tests := []struct { + name string + installType config.InstallType + expectedUser bool + }{ + { + name: "Settings with system install type", + installType: config.InstallTypeSystem, + expectedUser: false, + }, + { + name: "Settings with user install type", + installType: config.InstallTypeUser, + expectedUser: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save settings + settings := &config.Settings{InstallType: tt.installType} + if err := config.SaveSettings(settings); err != nil { + t.Fatalf("Failed to save settings: %v", err) + } + + // Load settings and check install type + loadedSettings, err := config.LoadSettings() + if err != nil { + t.Fatalf("Failed to load settings: %v", err) + } + + result := loadedSettings.InstallType == config.InstallTypeUser + if result != tt.expectedUser { + t.Errorf("determineInstallType from settings %q = %v, want %v", + tt.installType, result, tt.expectedUser) + } + }) + } +} + +func TestDetermineInstallType_NoSettings(t *testing.T) { + // Create a temp directory without settings file + tmpDir := t.TempDir() + originalRoot := os.Getenv("DTVEM_ROOT") + defer func() { + if originalRoot != "" { + _ = os.Setenv("DTVEM_ROOT", originalRoot) + } else { + _ = os.Unsetenv("DTVEM_ROOT") + } + config.ResetPathsCache() + }() + + _ = os.Setenv("DTVEM_ROOT", tmpDir) + config.ResetPathsCache() + + // Load settings (should return defaults) + settings, err := config.LoadSettings() + if err != nil { + t.Fatalf("LoadSettings returned unexpected error: %v", err) + } + + // Default should be system install + if settings.InstallType != config.InstallTypeSystem { + t.Errorf("Default install type = %q, want %q", + settings.InstallType, config.InstallTypeSystem) + } +} + +func TestSaveSettingsAfterInit(t *testing.T) { + // Create a temp directory for test + tmpDir := t.TempDir() + originalRoot := os.Getenv("DTVEM_ROOT") + defer func() { + if originalRoot != "" { + _ = os.Setenv("DTVEM_ROOT", originalRoot) + } else { + _ = os.Unsetenv("DTVEM_ROOT") + } + config.ResetPathsCache() + }() + + _ = os.Setenv("DTVEM_ROOT", tmpDir) + config.ResetPathsCache() + + // Simulate saving settings after init (user install) + settings := &config.Settings{InstallType: config.InstallTypeUser} + if err := config.SaveSettings(settings); err != nil { + t.Fatalf("SaveSettings failed: %v", err) + } + + // Verify settings file was created + settingsPath := config.SettingsPath() + if _, err := os.Stat(settingsPath); os.IsNotExist(err) { + t.Error("Settings file should exist after save") + } + + // Verify settings can be loaded + loaded, err := config.LoadSettings() + if err != nil { + t.Fatalf("LoadSettings failed: %v", err) + } + + if loaded.InstallType != config.InstallTypeUser { + t.Errorf("Loaded InstallType = %q, want %q", + loaded.InstallType, config.InstallTypeUser) + } +} + +func TestSettingsPersistence(t *testing.T) { + // Test that settings persist across multiple init calls + tmpDir := t.TempDir() + originalRoot := os.Getenv("DTVEM_ROOT") + defer func() { + if originalRoot != "" { + _ = os.Setenv("DTVEM_ROOT", originalRoot) + } else { + _ = os.Unsetenv("DTVEM_ROOT") + } + config.ResetPathsCache() + }() + + _ = os.Setenv("DTVEM_ROOT", tmpDir) + config.ResetPathsCache() + + // First "init" with user install + settings := &config.Settings{InstallType: config.InstallTypeUser} + if err := config.SaveSettings(settings); err != nil { + t.Fatalf("First save failed: %v", err) + } + + // Verify first save + loaded, _ := config.LoadSettings() + if loaded.InstallType != config.InstallTypeUser { + t.Errorf("First load: InstallType = %q, want %q", + loaded.InstallType, config.InstallTypeUser) + } + + // Simulate second "init" without flag - should use saved settings + loaded2, _ := config.LoadSettings() + if loaded2.InstallType != config.InstallTypeUser { + t.Errorf("Second load (no change): InstallType = %q, want %q", + loaded2.InstallType, config.InstallTypeUser) + } +} + +func TestInitCommandFlags(t *testing.T) { + // Verify that the init command has the expected flags + t.Run("--yes flag exists", func(t *testing.T) { + flag := initCmd.Flags().Lookup("yes") + if flag == nil { + t.Error("--yes flag should exist on init command") + return + } + if flag.Shorthand != "y" { + t.Errorf("--yes flag shorthand = %q, want %q", flag.Shorthand, "y") + } + }) + + t.Run("--user flag exists", func(t *testing.T) { + flag := initCmd.Flags().Lookup("user") + if flag == nil { + t.Error("--user flag should exist on init command") + } + }) +} + +func TestDirectoriesCreation(t *testing.T) { + // Test that EnsureDirectories creates all expected directories + tmpDir := t.TempDir() + originalRoot := os.Getenv("DTVEM_ROOT") + defer func() { + if originalRoot != "" { + _ = os.Setenv("DTVEM_ROOT", originalRoot) + } else { + _ = os.Unsetenv("DTVEM_ROOT") + } + config.ResetPathsCache() + }() + + _ = os.Setenv("DTVEM_ROOT", tmpDir) + config.ResetPathsCache() + + // Create directories + if err := config.EnsureDirectories(); err != nil { + t.Fatalf("EnsureDirectories failed: %v", err) + } + + // Verify expected directories exist + expectedDirs := []string{ + tmpDir, + filepath.Join(tmpDir, "shims"), + filepath.Join(tmpDir, "versions"), + filepath.Join(tmpDir, "config"), + filepath.Join(tmpDir, "cache"), + } + + for _, dir := range expectedDirs { + if _, err := os.Stat(dir); os.IsNotExist(err) { + t.Errorf("Directory %q should exist after EnsureDirectories", dir) + } + } +} + +func TestInstallTypeConstants(t *testing.T) { + // Verify constant values match expected strings + if config.InstallTypeSystem != "system" { + t.Errorf("InstallTypeSystem = %q, want %q", config.InstallTypeSystem, "system") + } + if config.InstallTypeUser != "user" { + t.Errorf("InstallTypeUser = %q, want %q", config.InstallTypeUser, "user") + } +} + +func TestInstallTypeSwitchDetection(t *testing.T) { + tmpDir := t.TempDir() + originalRoot := os.Getenv("DTVEM_ROOT") + defer func() { + if originalRoot != "" { + _ = os.Setenv("DTVEM_ROOT", originalRoot) + } else { + _ = os.Unsetenv("DTVEM_ROOT") + } + config.ResetPathsCache() + }() + + _ = os.Setenv("DTVEM_ROOT", tmpDir) + config.ResetPathsCache() + + t.Run("Detects switch from system to user", func(t *testing.T) { + // Save system install settings + settings := &config.Settings{InstallType: config.InstallTypeSystem} + _ = config.SaveSettings(settings) + + // Load and verify + loaded, _ := config.LoadSettings() + if loaded.InstallType != config.InstallTypeSystem { + t.Error("Should have system install type") + } + + // Check if switching to user would be detected + // (The actual isSwitching check happens in the command handler) + newType := config.InstallTypeUser + isSwitching := loaded.InstallType != newType + if !isSwitching { + t.Error("Should detect switch from system to user") + } + }) + + t.Run("Detects switch from user to system", func(t *testing.T) { + // Save user install settings + settings := &config.Settings{InstallType: config.InstallTypeUser} + _ = config.SaveSettings(settings) + + // Load and verify + loaded, _ := config.LoadSettings() + if loaded.InstallType != config.InstallTypeUser { + t.Error("Should have user install type") + } + + // Check if switching to system would be detected + newType := config.InstallTypeSystem + isSwitching := loaded.InstallType != newType + if !isSwitching { + t.Error("Should detect switch from user to system") + } + }) + + t.Run("No switch when types match", func(t *testing.T) { + // Save user install settings + settings := &config.Settings{InstallType: config.InstallTypeUser} + _ = config.SaveSettings(settings) + + // Load and verify + loaded, _ := config.LoadSettings() + if loaded.InstallType != config.InstallTypeUser { + t.Error("Should have user install type") + } + + // Check that same type is not a switch + newType := config.InstallTypeUser + isSwitching := loaded.InstallType != newType + if isSwitching { + t.Error("Should not detect switch when types match") + } + }) +} + +func TestIsUserInstallFunction(t *testing.T) { + tmpDir := t.TempDir() + originalRoot := os.Getenv("DTVEM_ROOT") + defer func() { + if originalRoot != "" { + _ = os.Setenv("DTVEM_ROOT", originalRoot) + } else { + _ = os.Unsetenv("DTVEM_ROOT") + } + config.ResetPathsCache() + }() + + _ = os.Setenv("DTVEM_ROOT", tmpDir) + config.ResetPathsCache() + + t.Run("Returns false with no settings", func(t *testing.T) { + if config.IsUserInstall() { + t.Error("IsUserInstall should return false when no settings file exists") + } + }) + + t.Run("Returns false with system install", func(t *testing.T) { + settings := &config.Settings{InstallType: config.InstallTypeSystem} + _ = config.SaveSettings(settings) + + if config.IsUserInstall() { + t.Error("IsUserInstall should return false for system install type") + } + }) + + t.Run("Returns true with user install", func(t *testing.T) { + settings := &config.Settings{InstallType: config.InstallTypeUser} + _ = config.SaveSettings(settings) + + if !config.IsUserInstall() { + t.Error("IsUserInstall should return true for user install type") + } + }) +} diff --git a/src/internal/config/settings.go b/src/internal/config/settings.go new file mode 100644 index 0000000..dacb8b6 --- /dev/null +++ b/src/internal/config/settings.go @@ -0,0 +1,87 @@ +package config + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +// InstallType represents the type of dtvem installation +type InstallType string + +const ( + // InstallTypeSystem uses System PATH (requires admin on Windows) + InstallTypeSystem InstallType = "system" + // InstallTypeUser uses User PATH (no admin required) + InstallTypeUser InstallType = "user" +) + +// SettingsFileName is the name of the settings configuration file +const SettingsFileName = "settings.json" + +// Settings holds dtvem installation settings +type Settings struct { + InstallType InstallType `json:"installType"` +} + +// SettingsPath returns the path to the settings file +func SettingsPath() string { + paths := DefaultPaths() + return filepath.Join(paths.Config, SettingsFileName) +} + +// LoadSettings loads settings from the settings file. +// Returns default settings (system install type) if the file doesn't exist. +func LoadSettings() (*Settings, error) { + settingsPath := SettingsPath() + + data, err := os.ReadFile(settingsPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + // Return default settings if file doesn't exist + return &Settings{InstallType: InstallTypeSystem}, nil + } + return nil, err + } + + var settings Settings + if err := json.Unmarshal(data, &settings); err != nil { + return nil, err + } + + // Validate install type + if settings.InstallType != InstallTypeSystem && settings.InstallType != InstallTypeUser { + // Default to system if invalid value + settings.InstallType = InstallTypeSystem + } + + return &settings, nil +} + +// SaveSettings saves settings to the settings file +func SaveSettings(settings *Settings) error { + settingsPath := SettingsPath() + + // Ensure the config directory exists + configDir := filepath.Dir(settingsPath) + if err := os.MkdirAll(configDir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + + return os.WriteFile(settingsPath, data, 0644) +} + +// IsUserInstall checks if the current installation is a user-level install +func IsUserInstall() bool { + settings, err := LoadSettings() + if err != nil { + return false + } + return settings.InstallType == InstallTypeUser +} diff --git a/src/internal/config/settings_test.go b/src/internal/config/settings_test.go new file mode 100644 index 0000000..b24160e --- /dev/null +++ b/src/internal/config/settings_test.go @@ -0,0 +1,297 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSettingsPath(t *testing.T) { + result := SettingsPath() + + // Should not be empty + if result == "" { + t.Error("SettingsPath() returned empty string") + } + + // Should end with settings.json + if !hasSettingsSuffix(result) { + t.Errorf("SettingsPath() = %q, should end with %q", result, SettingsFileName) + } + + // Should be absolute path + if !filepath.IsAbs(result) { + t.Errorf("SettingsPath() = %q, should be absolute", result) + } +} + +func hasSettingsSuffix(path string) bool { + return filepath.Base(path) == SettingsFileName +} + +func TestLoadSettings_FileNotExists(t *testing.T) { + // Create a temp directory for test + tmpDir := t.TempDir() + originalRoot := os.Getenv("DTVEM_ROOT") + defer func() { + if originalRoot != "" { + _ = os.Setenv("DTVEM_ROOT", originalRoot) + } else { + _ = os.Unsetenv("DTVEM_ROOT") + } + resetPathsForTesting() + }() + + _ = os.Setenv("DTVEM_ROOT", tmpDir) + resetPathsForTesting() + + // LoadSettings should return default settings when file doesn't exist + settings, err := LoadSettings() + if err != nil { + t.Fatalf("LoadSettings() unexpected error: %v", err) + } + + if settings == nil { + t.Fatal("LoadSettings() returned nil settings") + } + + // Default should be system install type + if settings.InstallType != InstallTypeSystem { + t.Errorf("LoadSettings() default InstallType = %q, want %q", + settings.InstallType, InstallTypeSystem) + } +} + +func TestSaveAndLoadSettings(t *testing.T) { + // Create a temp directory for test + tmpDir := t.TempDir() + originalRoot := os.Getenv("DTVEM_ROOT") + defer func() { + if originalRoot != "" { + _ = os.Setenv("DTVEM_ROOT", originalRoot) + } else { + _ = os.Unsetenv("DTVEM_ROOT") + } + resetPathsForTesting() + }() + + _ = os.Setenv("DTVEM_ROOT", tmpDir) + resetPathsForTesting() + + // Test saving and loading system install type + t.Run("system install type", func(t *testing.T) { + settings := &Settings{InstallType: InstallTypeSystem} + if err := SaveSettings(settings); err != nil { + t.Fatalf("SaveSettings() error = %v", err) + } + + loaded, err := LoadSettings() + if err != nil { + t.Fatalf("LoadSettings() error = %v", err) + } + + if loaded.InstallType != InstallTypeSystem { + t.Errorf("LoadSettings() InstallType = %q, want %q", + loaded.InstallType, InstallTypeSystem) + } + }) + + // Test saving and loading user install type + t.Run("user install type", func(t *testing.T) { + settings := &Settings{InstallType: InstallTypeUser} + if err := SaveSettings(settings); err != nil { + t.Fatalf("SaveSettings() error = %v", err) + } + + loaded, err := LoadSettings() + if err != nil { + t.Fatalf("LoadSettings() error = %v", err) + } + + if loaded.InstallType != InstallTypeUser { + t.Errorf("LoadSettings() InstallType = %q, want %q", + loaded.InstallType, InstallTypeUser) + } + }) +} + +func TestLoadSettings_InvalidInstallType(t *testing.T) { + // Create a temp directory for test + tmpDir := t.TempDir() + originalRoot := os.Getenv("DTVEM_ROOT") + defer func() { + if originalRoot != "" { + _ = os.Setenv("DTVEM_ROOT", originalRoot) + } else { + _ = os.Unsetenv("DTVEM_ROOT") + } + resetPathsForTesting() + }() + + _ = os.Setenv("DTVEM_ROOT", tmpDir) + resetPathsForTesting() + + // Create config directory and settings file with invalid install type + configDir := filepath.Join(tmpDir, "config") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + settingsPath := filepath.Join(configDir, SettingsFileName) + invalidJSON := `{"installType": "invalid"}` + if err := os.WriteFile(settingsPath, []byte(invalidJSON), 0644); err != nil { + t.Fatalf("Failed to write test settings file: %v", err) + } + + // LoadSettings should return default install type for invalid values + settings, err := LoadSettings() + if err != nil { + t.Fatalf("LoadSettings() unexpected error: %v", err) + } + + // Should default to system for invalid values + if settings.InstallType != InstallTypeSystem { + t.Errorf("LoadSettings() with invalid value should default to %q, got %q", + InstallTypeSystem, settings.InstallType) + } +} + +func TestLoadSettings_MalformedJSON(t *testing.T) { + // Create a temp directory for test + tmpDir := t.TempDir() + originalRoot := os.Getenv("DTVEM_ROOT") + defer func() { + if originalRoot != "" { + _ = os.Setenv("DTVEM_ROOT", originalRoot) + } else { + _ = os.Unsetenv("DTVEM_ROOT") + } + resetPathsForTesting() + }() + + _ = os.Setenv("DTVEM_ROOT", tmpDir) + resetPathsForTesting() + + // Create config directory and settings file with malformed JSON + configDir := filepath.Join(tmpDir, "config") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + settingsPath := filepath.Join(configDir, SettingsFileName) + malformedJSON := `{not valid json` + if err := os.WriteFile(settingsPath, []byte(malformedJSON), 0644); err != nil { + t.Fatalf("Failed to write test settings file: %v", err) + } + + // LoadSettings should return an error for malformed JSON + _, err := LoadSettings() + if err == nil { + t.Error("LoadSettings() with malformed JSON should return an error") + } +} + +func TestIsUserInstall(t *testing.T) { + // Create a temp directory for test + tmpDir := t.TempDir() + originalRoot := os.Getenv("DTVEM_ROOT") + defer func() { + if originalRoot != "" { + _ = os.Setenv("DTVEM_ROOT", originalRoot) + } else { + _ = os.Unsetenv("DTVEM_ROOT") + } + resetPathsForTesting() + }() + + _ = os.Setenv("DTVEM_ROOT", tmpDir) + resetPathsForTesting() + + // Test with no settings file (should return false) + t.Run("no settings file", func(t *testing.T) { + if IsUserInstall() { + t.Error("IsUserInstall() with no settings file should return false") + } + }) + + // Test with system install type + t.Run("system install type", func(t *testing.T) { + settings := &Settings{InstallType: InstallTypeSystem} + if err := SaveSettings(settings); err != nil { + t.Fatalf("SaveSettings() error = %v", err) + } + + if IsUserInstall() { + t.Error("IsUserInstall() with system install type should return false") + } + }) + + // Test with user install type + t.Run("user install type", func(t *testing.T) { + settings := &Settings{InstallType: InstallTypeUser} + if err := SaveSettings(settings); err != nil { + t.Fatalf("SaveSettings() error = %v", err) + } + + if !IsUserInstall() { + t.Error("IsUserInstall() with user install type should return true") + } + }) +} + +func TestSaveSettings_CreatesConfigDirectory(t *testing.T) { + // Create a temp directory for test + tmpDir := t.TempDir() + originalRoot := os.Getenv("DTVEM_ROOT") + defer func() { + if originalRoot != "" { + _ = os.Setenv("DTVEM_ROOT", originalRoot) + } else { + _ = os.Unsetenv("DTVEM_ROOT") + } + resetPathsForTesting() + }() + + _ = os.Setenv("DTVEM_ROOT", tmpDir) + resetPathsForTesting() + + // Config directory should not exist initially + configDir := filepath.Join(tmpDir, "config") + if _, err := os.Stat(configDir); err == nil { + t.Fatal("Config directory should not exist initially") + } + + // SaveSettings should create the config directory + settings := &Settings{InstallType: InstallTypeUser} + if err := SaveSettings(settings); err != nil { + t.Fatalf("SaveSettings() error = %v", err) + } + + // Config directory should now exist + if _, err := os.Stat(configDir); os.IsNotExist(err) { + t.Error("SaveSettings() should create config directory") + } + + // Settings file should exist + settingsPath := filepath.Join(configDir, SettingsFileName) + if _, err := os.Stat(settingsPath); os.IsNotExist(err) { + t.Error("SaveSettings() should create settings file") + } +} + +func TestInstallTypeConstants(t *testing.T) { + // Verify constant values + if InstallTypeSystem != "system" { + t.Errorf("InstallTypeSystem = %q, want %q", InstallTypeSystem, "system") + } + if InstallTypeUser != "user" { + t.Errorf("InstallTypeUser = %q, want %q", InstallTypeUser, "user") + } +} + +func TestSettingsFileName(t *testing.T) { + expected := "settings.json" + if SettingsFileName != expected { + t.Errorf("SettingsFileName = %q, want %q", SettingsFileName, expected) + } +} diff --git a/src/internal/path/path_unix.go b/src/internal/path/path_unix.go index 8c65ac3..1aa6c7a 100644 --- a/src/internal/path/path_unix.go +++ b/src/internal/path/path_unix.go @@ -54,7 +54,10 @@ func GetShellConfigFile(shell string) string { // AddToPath adds the shims directory to the user's PATH by modifying their shell config. // If skipConfirmation is true, the function will proceed without prompting the user. -func AddToPath(shimsDir string, skipConfirmation bool) error { +// The userInstall parameter is accepted for API consistency with Windows but is ignored +// since Unix installations always modify user-level shell config files. +func AddToPath(shimsDir string, skipConfirmation bool, userInstall bool) error { + // Note: userInstall is ignored on Unix - we always modify user shell config shell := DetectShell() if shell == "unknown" { return fmt.Errorf("could not detect shell - please add %s to your PATH manually", shimsDir) diff --git a/src/internal/path/path_windows.go b/src/internal/path/path_windows.go index 8695006..4e27657 100644 --- a/src/internal/path/path_windows.go +++ b/src/internal/path/path_windows.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" "syscall" "unsafe" @@ -29,12 +30,30 @@ const ( // pathActionMove is used to indicate the shims directory needs to be moved to the beginning of PATH pathActionMove = "move" + // pathActionAdd is used to indicate the shims directory needs to be added to PATH + pathActionAdd = "add" ) -// AddToPath adds the shims directory to the System PATH on Windows. +// RuntimeConflict represents a system-installed runtime that may conflict with dtvem +type RuntimeConflict struct { + Name string // Display name (e.g., "Node.js") + Path string // Full path to the executable +} + +// AddToPath adds the shims directory to the PATH on Windows. +// If userInstall is true, it modifies the User PATH (no admin required). +// If userInstall is false, it modifies the System PATH (requires admin). +func AddToPath(shimsDir string, skipConfirmation bool, userInstall bool) error { + if userInstall { + return addToUserPath(shimsDir, skipConfirmation) + } + return addToSystemPath(shimsDir, skipConfirmation) +} + +// addToSystemPath adds the shims directory to the System PATH on Windows. // This requires administrator privileges. If not elevated, it will prompt // the user to re-run with elevation (unless skipConfirmation is true). -func AddToPath(shimsDir string, skipConfirmation bool) error { +func addToSystemPath(shimsDir string, skipConfirmation bool) error { // Check current System PATH status needsUpdate, action, err := checkSystemPath(shimsDir) if err != nil { @@ -55,6 +74,36 @@ func AddToPath(shimsDir string, skipConfirmation bool) error { return modifySystemPath(shimsDir, action) } +// addToUserPath adds the shims directory to the User PATH on Windows. +// This does not require administrator privileges. +func addToUserPath(shimsDir string, skipConfirmation bool) error { + // Check for system runtime conflicts first + conflicts := detectSystemRuntimeConflicts() + if len(conflicts) > 0 { + continueInstall, err := warnAboutSystemConflicts(conflicts, skipConfirmation) + if err != nil { + return err + } + if !continueInstall { + return nil + } + } + + // Check current User PATH status + needsUpdate, action, err := checkUserPath(shimsDir) + if err != nil { + return err + } + + if !needsUpdate { + ui.Success("%s is already at the beginning of your User PATH", shimsDir) + return nil + } + + // Modify User PATH + return modifyUserPath(shimsDir, action) +} + // checkSystemPath checks if the shims directory needs to be added/moved in System PATH // Returns: needsUpdate, action ("add" or "move"), error func checkSystemPath(shimsDir string) (bool, string, error) { @@ -85,7 +134,45 @@ func checkSystemPath(shimsDir string) (bool, string, error) { } else if foundAt > 0 { return true, pathActionMove, nil // Exists but not at beginning } - return true, "add", nil // Not in PATH + return true, pathActionAdd, nil // Not in PATH +} + +// checkUserPath checks if the shims directory needs to be added/moved in User PATH +// Returns: needsUpdate, action ("add" or "move"), error +func checkUserPath(shimsDir string) (bool, string, error) { + key, err := registry.OpenKey(registry.CURRENT_USER, `Environment`, registry.QUERY_VALUE) + if err != nil { + return false, "", fmt.Errorf("failed to open User PATH registry key: %w", err) + } + defer func() { _ = key.Close() }() + + currentPath, _, err := key.GetStringValue("Path") + if err != nil && !errors.Is(err, registry.ErrNotExist) { + return false, "", fmt.Errorf("failed to read User PATH: %w", err) + } + + // If PATH doesn't exist yet, we need to add it + if errors.Is(err, registry.ErrNotExist) || currentPath == "" { + return true, pathActionAdd, nil + } + + paths := strings.Split(currentPath, ";") + foundAt := -1 + + for i, p := range paths { + trimmed := strings.TrimSpace(p) + if strings.EqualFold(trimmed, shimsDir) { + foundAt = i + break + } + } + + if foundAt == 0 { + return false, "", nil // Already at beginning + } else if foundAt > 0 { + return true, pathActionMove, nil // Exists but not at beginning + } + return true, pathActionAdd, nil // Not in PATH } // isAdmin checks if the current process has administrator privileges @@ -212,6 +299,61 @@ func modifySystemPath(shimsDir, action string) error { return nil } +// modifyUserPath modifies the User PATH (no admin privileges required) +func modifyUserPath(shimsDir, action string) error { + key, err := registry.OpenKey(registry.CURRENT_USER, `Environment`, registry.QUERY_VALUE|registry.SET_VALUE) + if err != nil { + return fmt.Errorf("failed to open User PATH registry key for writing: %w", err) + } + defer func() { _ = key.Close() }() + + currentPath, _, err := key.GetStringValue("Path") + if err != nil && !errors.Is(err, registry.ErrNotExist) { + return fmt.Errorf("failed to read User PATH: %w", err) + } + + // Parse and filter current PATH entries + var filteredPaths []string + if currentPath != "" { + paths := strings.Split(currentPath, ";") + for _, p := range paths { + trimmed := strings.TrimSpace(p) + if trimmed == "" { + continue + } + // Skip if it's the shims dir (we'll prepend it) + if strings.EqualFold(trimmed, shimsDir) { + continue + } + filteredPaths = append(filteredPaths, trimmed) + } + } + + // Build new PATH with shimsDir at the beginning + newPath := shimsDir + if len(filteredPaths) > 0 { + newPath += ";" + strings.Join(filteredPaths, ";") + } + + // Write back to registry + err = key.SetStringValue("Path", newPath) + if err != nil { + return fmt.Errorf("failed to update User PATH in registry: %w", err) + } + + // Broadcast WM_SETTINGCHANGE to notify running processes + broadcastSettingChange() + + if action == pathActionMove { + ui.Success("Moved %s to the beginning of your User PATH", shimsDir) + } else { + ui.Success("Added %s to your User PATH", shimsDir) + } + ui.Warning("Please restart your terminal for the changes to take effect") + + return nil +} + // broadcastSettingChange broadcasts WM_SETTINGCHANGE to notify the system of environment changes func broadcastSettingChange() { env := syscall.StringToUTF16Ptr("Environment") @@ -226,6 +368,115 @@ func broadcastSettingChange() { ) } +// detectSystemRuntimeConflicts checks if system-installed runtimes exist in the System PATH +// that would take priority over dtvem shims when using User PATH installation. +// It excludes the dtvem shims directory to avoid false positives. +func detectSystemRuntimeConflicts() []RuntimeConflict { + var conflicts []RuntimeConflict + + // Get System PATH only (excluding User PATH) + systemPath := getSystemPathOnly() + if systemPath == "" { + return conflicts + } + + // Get dtvem shims directory to exclude from conflict detection + shimsDir := ShimsDir() + + // Runtimes to check for + runtimeChecks := []struct { + execName string + displayName string + }{ + {"node", "Node.js"}, + {"python", "Python"}, + {"ruby", "Ruby"}, + } + + pathDirs := strings.Split(systemPath, ";") + + for _, runtime := range runtimeChecks { + for _, dir := range pathDirs { + dir = strings.TrimSpace(dir) + if dir == "" { + continue + } + + // Skip dtvem shims directory (case-insensitive on Windows) + if strings.EqualFold(dir, shimsDir) { + continue + } + + // Check for .exe extension on Windows + execPath := filepath.Join(dir, runtime.execName+".exe") + if info, err := os.Stat(execPath); err == nil && !info.IsDir() { + conflicts = append(conflicts, RuntimeConflict{ + Name: runtime.displayName, + Path: execPath, + }) + break // Found this runtime, move to next + } + } + } + + return conflicts +} + +// getSystemPathOnly reads the System PATH from registry (excludes User PATH) +func getSystemPathOnly() string { + key, err := registry.OpenKey(registry.LOCAL_MACHINE, `SYSTEM\CurrentControlSet\Control\Session Manager\Environment`, registry.QUERY_VALUE) + if err != nil { + return "" + } + defer func() { _ = key.Close() }() + + systemPath, _, err := key.GetStringValue("Path") + if err != nil { + return "" + } + + return systemPath +} + +// warnAboutSystemConflicts displays a warning about system-installed runtimes +// and prompts the user to continue or abort. +// Returns: (continueInstall, error) +func warnAboutSystemConflicts(conflicts []RuntimeConflict, skipConfirmation bool) (bool, error) { + ui.Warning("System-installed runtimes detected that will take priority over dtvem:") + for _, conflict := range conflicts { + ui.Info(" - %s: %s", conflict.Name, ui.Highlight(conflict.Path)) + } + + ui.Info("") + ui.Info("On Windows, System PATH is evaluated before User PATH.") + ui.Info("These system runtimes will be used instead of dtvem-managed versions.") + ui.Info("") + ui.Info("Options:") + ui.Info(" 1. Uninstall the system runtimes to use dtvem-managed versions") + ui.Info(" 2. Run 'dtvem init' as administrator for system-level PATH (recommended)") + ui.Info(" 3. Continue with user install (system runtimes will take priority)") + + if skipConfirmation { + // With -y flag, continue anyway but still show the warning + ui.Info("") + ui.Warning("Continuing with user install (--yes flag specified)") + return true, nil + } + + fmt.Printf("\nContinue with user install? [y/N]: ") + + var response string + _, _ = fmt.Scanln(&response) + response = strings.ToLower(strings.TrimSpace(response)) + + if response == constants.ResponseY || response == constants.ResponseYes { + return true, nil + } + + ui.Info("User install canceled. Run 'dtvem init' without --user for system-level PATH.") + return false, nil +} + // DetectShell returns "powershell" or "cmd" on Windows (not actually used, but for consistency) func DetectShell() string { // Check if running in PowerShell