diff --git a/.gitignore b/.gitignore index 0c79ab9..c3064c2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ devx .envrc -.tmuxp.yaml \ No newline at end of file +.tmuxp.yaml +dist/ +.devx/ \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7936d49..e3fde30 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -35,7 +35,7 @@ changelog: brews: - name: devx homepage: https://github.com/jfox85/devx - description: "A macOS development environment manager" + description: "macOS development environment manager with Git worktrees and tmux" license: "MIT" commit_author: name: devx-bot @@ -45,7 +45,7 @@ brews: name: homebrew-devx token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" test: | - system "#{bin}/devx", "--version" + system bin/"devx", "--version" install: | bin.install "devx" diff --git a/cmd/root.go b/cmd/root.go index 4cfdfdc..2692011 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,10 +7,12 @@ import ( "fmt" "os" "strings" + "time" "github.com/jfox85/devx/config" "github.com/jfox85/devx/deps" "github.com/jfox85/devx/tui" + "github.com/jfox85/devx/update" "github.com/jfox85/devx/version" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -36,7 +38,7 @@ func Execute() { } func init() { - cobra.OnInitialize(initConfig) + cobra.OnInitialize(initConfig, checkForUpdatesBackground) // Here you will define your flags and configuration settings. // Cobra supports persistent flags, which, if defined here, @@ -97,11 +99,52 @@ func initConfig() { viper.SetDefault("editor", "") viper.SetDefault("bootstrap_files", []string{}) viper.SetDefault("cleanup_command", "") + viper.SetDefault("auto_check_updates", true) + viper.SetDefault("update_check_interval", "24h") // Read in config file if found _ = viper.ReadInConfig() } +// checkForUpdatesBackground performs a background update check +func checkForUpdatesBackground() { + // Skip if auto-update checking is disabled + if !viper.GetBool("auto_check_updates") { + return + } + + // Parse the update check interval + intervalStr := viper.GetString("update_check_interval") + interval, err := time.ParseDuration(intervalStr) + if err != nil { + interval = 24 * time.Hour // Default to 24 hours + } + + // Run in background to avoid blocking startup + go func() { + info, checked, err := update.CheckForUpdatesWithCache(interval) + if err != nil || !checked || info == nil { + return + } + + // Only show notification if update is available + if info.Available { + shouldNotify, _ := update.ShouldNotifyUser(info) + if shouldNotify { + // Print notification message + fmt.Printf("\nšŸ’” devx %s is available (currently %s). Run 'devx update' to upgrade.\n\n", + info.LatestVersion, info.CurrentVersion) + + // Mark as notified so we don't spam on every command + if err := update.MarkUpdateNotified(info.LatestVersion); err != nil { + // Log but don't fail - this is not critical + fmt.Printf("Warning: failed to save notification state: %v\n", err) + } + } + } + }() +} + // runTUI launches the terminal user interface func runTUI() error { return tui.Run() diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000..e000812 --- /dev/null +++ b/cmd/update.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/jfox85/devx/update" + "github.com/spf13/cobra" +) + +var ( + checkOnly bool + forceUpdate bool +) + +// updateCmd represents the update command +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Update devx to the latest version", + Long: `Update devx to the latest version from GitHub releases. + +This command will: +- Check for the latest release on GitHub +- Download and replace the current binary if a newer version is available +- Preserve the installation method when possible +- Show progress during download + +Note: If devx was installed via Homebrew, you'll be directed to use 'brew upgrade' instead. + +Examples: + devx update # Update to latest version + devx update --check # Only check for updates + devx update --force # Force update even if same version`, + Run: func(cmd *cobra.Command, args []string) { + if checkOnly { + checkForUpdates() + return + } + + performUpdate() + }, +} + +func init() { + rootCmd.AddCommand(updateCmd) + + updateCmd.Flags().BoolVar(&checkOnly, "check", false, "Only check for updates without downloading") + updateCmd.Flags().BoolVar(&forceUpdate, "force", false, "Force update even if current version is latest") +} + +// checkForUpdates checks if a newer version is available +func checkForUpdates() { + fmt.Println("Checking for updates...") + + info, err := update.CheckForUpdates() + if err != nil { + fmt.Printf("Error checking for updates: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Current version: %s\n", info.CurrentVersion) + fmt.Printf("Latest version: %s\n", info.LatestVersion) + + if !info.Available { + fmt.Println("āœ… You are running the latest version!") + return + } + + fmt.Printf("šŸ†™ A newer version is available: %s → %s\n", info.CurrentVersion, info.LatestVersion) + fmt.Printf("Release URL: %s\n", info.ReleaseURL) + fmt.Println("\nRun 'devx update' to upgrade.") +} + +// performUpdate downloads and installs the latest version +func performUpdate() { + // Check if we can self-update + if !update.CanSelfUpdate() { + fmt.Println(update.GetUpdateInstructions()) + return + } + + fmt.Println("Checking for updates...") + + // Check for updates first + info, err := update.CheckForUpdates() + if err != nil { + fmt.Printf("Error checking for updates: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Current version: %s\n", info.CurrentVersion) + fmt.Printf("Latest version: %s\n", info.LatestVersion) + + // Check if update is needed + if !info.Available && !forceUpdate { + fmt.Println("āœ… You are already running the latest version!") + return + } + + if forceUpdate && !info.Available { + fmt.Println("Forcing update due to --force flag...") + } else { + fmt.Printf("šŸ”„ Updating from %s to %s...\n", info.CurrentVersion, info.LatestVersion) + } + + fmt.Println("šŸ“„ Downloading update...") + + // Perform the update + if err := update.PerformUpdate(forceUpdate); err != nil { + fmt.Printf("āŒ Update failed: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/version.go b/cmd/version.go index 5d9183e..0b30003 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" + "github.com/jfox85/devx/update" "github.com/jfox85/devx/version" "github.com/spf13/cobra" ) @@ -11,6 +12,7 @@ import ( var ( versionOutput string detailedFlag bool + checkUpdates bool ) var versionCmd = &cobra.Command{ @@ -24,6 +26,7 @@ func init() { rootCmd.AddCommand(versionCmd) versionCmd.Flags().StringVarP(&versionOutput, "output", "o", "", "Output format: json") versionCmd.Flags().BoolVar(&detailedFlag, "detailed", false, "Show detailed version information") + versionCmd.Flags().BoolVar(&checkUpdates, "check-updates", false, "Check for available updates") } func runVersion(cmd *cobra.Command, args []string) { @@ -44,4 +47,39 @@ func runVersion(cmd *cobra.Command, args []string) { fmt.Println(info.String()) } } + + // Check for updates if requested + if checkUpdates { + fmt.Println() // Add blank line + checkForVersionUpdates() + } +} + +// checkForVersionUpdates checks if a newer version is available +func checkForVersionUpdates() { + fmt.Println("Checking for updates...") + + updateInfo, err := update.CheckForUpdates() + if err != nil { + fmt.Printf("Error checking for updates: %v\n", err) + return + } + + fmt.Printf("Current version: %s\n", updateInfo.CurrentVersion) + fmt.Printf("Latest version: %s\n", updateInfo.LatestVersion) + + if !updateInfo.Available { + fmt.Println("āœ… You are running the latest version!") + return + } + + fmt.Printf("šŸ†™ A newer version is available: %s → %s\n", updateInfo.CurrentVersion, updateInfo.LatestVersion) + fmt.Printf("Release URL: %s\n", updateInfo.ReleaseURL) + fmt.Println("\nRun 'devx update' to upgrade.") + + // Mark as notified so we don't spam on every command + if err := update.MarkUpdateNotified(updateInfo.LatestVersion); err != nil { + // Silently ignore - notification state is not critical + _ = err + } } diff --git a/config/updatecheck.go b/config/updatecheck.go new file mode 100644 index 0000000..aa9aa6c --- /dev/null +++ b/config/updatecheck.go @@ -0,0 +1,72 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +// UpdateCheckState stores the last update check information +type UpdateCheckState struct { + LastCheck time.Time `json:"last_check"` + LastNotifiedVersion string `json:"last_notified_version,omitempty"` +} + +// GetUpdateCheckPath returns the path to the update check state file +func GetUpdateCheckPath() (string, error) { + configDir := GetConfigDir() + return filepath.Join(configDir, "updatecheck.json"), nil +} + +// LoadUpdateCheckState loads the update check state from disk +func LoadUpdateCheckState() (*UpdateCheckState, error) { + path, err := GetUpdateCheckPath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + // Return empty state if file doesn't exist + return &UpdateCheckState{}, nil + } + return nil, err + } + + var state UpdateCheckState + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + + return &state, nil +} + +// SaveUpdateCheckState saves the update check state to disk +func SaveUpdateCheckState(state *UpdateCheckState) error { + path, err := GetUpdateCheckPath() + if err != nil { + return err + } + + // Ensure config directory exists + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) +} + +// ShouldCheckForUpdates determines if we should check for updates based on interval +func ShouldCheckForUpdates(lastCheck time.Time, interval time.Duration) bool { + if lastCheck.IsZero() { + return true + } + return time.Since(lastCheck) >= interval +} diff --git a/config/updatecheck_test.go b/config/updatecheck_test.go new file mode 100644 index 0000000..9d341e3 --- /dev/null +++ b/config/updatecheck_test.go @@ -0,0 +1,136 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestUpdateCheckStatePersistence(t *testing.T) { + // Use a temporary directory for testing + tempDir := t.TempDir() + originalHome := os.Getenv("HOME") + + // Create a mock config directory + _ = filepath.Join(tempDir, ".config", "devx") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + // Change working directory to temp dir so FindProjectConfigDir doesn't find the real .devx + originalWd, _ := os.Getwd() + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + defer func() { + if err := os.Chdir(originalWd); err != nil { + t.Errorf("Failed to restore directory: %v", err) + } + }() + + // Test saving and loading + state := &UpdateCheckState{ + LastCheck: time.Now().Truncate(time.Second), // Truncate for comparison + LastNotifiedVersion: "0.2.0", + } + + err := SaveUpdateCheckState(state) + if err != nil { + t.Fatalf("Failed to save update check state: %v", err) + } + + loaded, err := LoadUpdateCheckState() + if err != nil { + t.Fatalf("Failed to load update check state: %v", err) + } + + if !loaded.LastCheck.Equal(state.LastCheck) { + t.Errorf("LastCheck mismatch: got %v, want %v", loaded.LastCheck, state.LastCheck) + } + + if loaded.LastNotifiedVersion != state.LastNotifiedVersion { + t.Errorf("LastNotifiedVersion mismatch: got %v, want %v", loaded.LastNotifiedVersion, state.LastNotifiedVersion) + } +} + +func TestShouldCheckForUpdates(t *testing.T) { + tests := []struct { + name string + lastCheck time.Time + interval time.Duration + expected bool + }{ + { + name: "never checked before (zero time)", + lastCheck: time.Time{}, + interval: 24 * time.Hour, + expected: true, + }, + { + name: "checked 1 hour ago, interval 24 hours", + lastCheck: time.Now().Add(-1 * time.Hour), + interval: 24 * time.Hour, + expected: false, + }, + { + name: "checked 25 hours ago, interval 24 hours", + lastCheck: time.Now().Add(-25 * time.Hour), + interval: 24 * time.Hour, + expected: true, + }, + { + name: "checked just now, interval 1 hour", + lastCheck: time.Now(), + interval: 1 * time.Hour, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ShouldCheckForUpdates(tt.lastCheck, tt.interval) + if result != tt.expected { + t.Errorf("ShouldCheckForUpdates(%v, %v) = %v, want %v", + tt.lastCheck, tt.interval, result, tt.expected) + } + }) + } +} + +func TestLoadUpdateCheckStateNonExistent(t *testing.T) { + // Use a completely isolated temporary directory + tempDir := t.TempDir() + + // Create a unique test directory to avoid conflicts with other tests + testConfigDir := filepath.Join(tempDir, "test-config-nonexistent") + + // Override the GetConfigDir function for this test by using environment + originalHome := os.Getenv("HOME") + os.Setenv("HOME", testConfigDir) + defer os.Setenv("HOME", originalHome) + + // Change working directory to temp dir so FindProjectConfigDir doesn't find the real .devx + originalWd, _ := os.Getwd() + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + defer func() { + if err := os.Chdir(originalWd); err != nil { + t.Errorf("Failed to restore directory: %v", err) + } + }() + + // Should return empty state for non-existent file + state, err := LoadUpdateCheckState() + if err != nil { + t.Fatalf("Expected no error for non-existent file, got: %v", err) + } + + if !state.LastCheck.IsZero() { + t.Errorf("Expected zero LastCheck for new state, got: %v", state.LastCheck) + } + + if state.LastNotifiedVersion != "" { + t.Errorf("Expected empty LastNotifiedVersion for new state, got: %v", state.LastNotifiedVersion) + } +} diff --git a/go.mod b/go.mod index 3ba9020..1feabca 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,13 @@ go 1.23.0 toolchain go1.23.10 require ( + github.com/blang/semver v3.5.1+incompatible github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.5 github.com/charmbracelet/lipgloss v1.1.0 github.com/go-resty/resty/v2 v2.16.5 github.com/jsumners/go-getport v1.0.0 + github.com/rhysd/go-github-selfupdate v1.2.3 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 gopkg.in/yaml.v3 v3.0.1 @@ -25,6 +27,9 @@ require ( github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/google/go-github/v30 v30.1.0 // indirect + github.com/google/go-querystring v1.0.0 // indirect + github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -41,11 +46,15 @@ require ( github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tcnksm/go-gitconfig v0.1.2 // indirect + github.com/ulikunitz/xz v0.5.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect + golang.org/x/crypto v0.35.0 // indirect golang.org/x/net v0.33.0 // indirect + golang.org/x/oauth2 v0.27.0 // indirect golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/text v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index 902831e..34a1242 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= @@ -28,20 +30,33 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo= +github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8= +github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jsumners/go-getport v1.0.0 h1:d11eDaP25dKKoJRAFeBrchCayceft735pDSTFCEdkb4= github.com/jsumners/go-getport v1.0.0/go.mod h1:KpeJgwNSkpuXuoGhJ2Hgl5QJqWbLG1m0jY2rQsYUTIE= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -58,10 +73,15 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.2 h1:3mYCb7aPxS/RU7TI1y4rkEn1oKmPRjNJLNEXgw7MH2I= +github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag= +github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -88,28 +108,59 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw= +github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE= +github.com/ulikunitz/xz v0.5.9 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I= +github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tui/model.go b/tui/model.go index 755daf8..788aa9a 100644 --- a/tui/model.go +++ b/tui/model.go @@ -23,6 +23,8 @@ import ( "github.com/jfox85/devx/claude" "github.com/jfox85/devx/config" "github.com/jfox85/devx/session" + "github.com/jfox85/devx/update" + "github.com/jfox85/devx/version" ) type sessionItem struct { @@ -80,6 +82,10 @@ type model struct { projectCursor int selectedProject string caddyWarning string + // Update availability + updateAvailable bool + updateVersion string + currentVersion string // Performance monitoring debugMode bool debugLevel int // 1=basic, 2=verbose @@ -123,6 +129,7 @@ type keyMap struct { Projects key.Binding Preview key.Binding ClaudeHooks key.Binding + Update key.Binding Quit key.Binding Help key.Binding Back key.Binding @@ -173,6 +180,10 @@ var keys = keyMap{ key.WithKeys("C"), key.WithHelp("C", "init Claude hooks"), ), + Update: key.NewBinding( + key.WithKeys("u"), + key.WithHelp("u", "update devx"), + ), Quit: key.NewBinding( key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit"), @@ -240,6 +251,9 @@ func InitialModel() *model { showPreview: true, // Enable preview by default width: 80, // Default width height: 24, // Default height + updateAvailable: false, + updateVersion: "", + currentVersion: "", debugMode: debugMode, debugLogger: debugLogger, ansiRegex: ansiRegex, @@ -265,7 +279,7 @@ func InitialModel() *model { } func (m *model) Init() tea.Cmd { - return tea.Batch(m.loadSessions, m.refreshPreview(), m.refreshSessions(), m.checkCaddyHealth()) + return tea.Batch(m.loadSessions, m.refreshPreview(), m.refreshSessions(), m.checkCaddyHealth(), m.checkForUpdates) } func (m *model) loadSessions() tea.Msg { @@ -387,6 +401,12 @@ type hostnamesLoadedMsg struct { type errMsg struct{ err error } +type updateAvailableMsg struct { + available bool + currentVersion string + latestVersion string +} + type sessionCreatedMsg struct { sessionName string } @@ -684,6 +704,25 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.ClaudeHooks): // Initialize Claude hooks for current working directory return m, m.initClaudeHooks() + + case key.Matches(msg, m.keys.Update): + // Only allow update if one is available and we can self-update + if m.updateAvailable && update.CanSelfUpdate() { + m.confirmMsg = fmt.Sprintf("Update devx from %s to %s?", m.currentVersion, m.updateVersion) + m.confirmFunc = func() { + // Perform update + if err := update.PerformUpdate(false); err != nil { + m.statusMsg = fmt.Sprintf("Update failed: %v", err) + } else { + m.statusMsg = "Update successful! Please restart devx." + } + } + m.state = stateConfirm + return m, nil + } else if !update.CanSelfUpdate() { + m.statusMsg = "Cannot self-update. " + update.GetUpdateInstructions() + return m, nil + } } case stateProjectAdd: @@ -853,6 +892,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case caddyHealthMsg: m.caddyWarning = msg.warning + case updateAvailableMsg: + m.updateAvailable = msg.available + m.currentVersion = msg.currentVersion + m.updateVersion = msg.latestVersion + case claudeHooksMsg: if msg.success { m.statusMsg = msg.message @@ -968,6 +1012,15 @@ func (m *model) listView() string { var b strings.Builder b.WriteString(logo + "\n" + headerStyle.Render("Sessions") + "\n\n") + // Show update banner if available + if m.updateAvailable { + updateBanner := lipgloss.NewStyle(). + Foreground(lipgloss.Color("42")). // Green + Bold(true). + Render(fmt.Sprintf("šŸ†™ Update available: %s → %s (Press 'u' to update)", m.currentVersion, m.updateVersion)) + b.WriteString(updateBanner + "\n\n") + } + // Show Caddy warning if present if m.caddyWarning != "" { b.WriteString(warningStyle.Render(m.caddyWarning) + "\n\n") @@ -1043,6 +1096,21 @@ func (m *model) listView() string { var sessionList strings.Builder sessionList.WriteString(headerStyle.Render("Sessions") + "\n\n") + // Show update banner if available + if m.updateAvailable { + updateBanner := lipgloss.NewStyle(). + Foreground(lipgloss.Color("42")). // Green + Bold(true). + Render(fmt.Sprintf("šŸ†™ %s → %s (u)", m.currentVersion, m.updateVersion)) + sessionList.WriteString(updateBanner + "\n\n") + } + + // Show Caddy warning if present (condensed for preview) + if m.caddyWarning != "" { + condensedWarning := strings.Split(m.caddyWarning, "\n")[0] // First line only + sessionList.WriteString(warningStyle.Render("āš ļø "+condensedWarning) + "\n\n") + } + // Group sessions by project for display var currentProject string for i, sess := range m.sessions { @@ -1562,6 +1630,24 @@ func (m *model) checkCaddyHealth() tea.Cmd { } } +func (m *model) checkForUpdates() tea.Msg { + info, err := update.CheckForUpdates() + if err != nil { + // Silently fail - update checks shouldn't interrupt the UI + return updateAvailableMsg{ + available: false, + currentVersion: version.Version, + latestVersion: "", + } + } + + return updateAvailableMsg{ + available: info.Available, + currentVersion: info.CurrentVersion, + latestVersion: info.LatestVersion, + } +} + func (m *model) createView() string { return headerStyle.Render("Create New Session") + "\n\n" + " Session name: " + m.textInput.View() + "\n\n" + diff --git a/update/checker.go b/update/checker.go new file mode 100644 index 0000000..cd3fe84 --- /dev/null +++ b/update/checker.go @@ -0,0 +1,128 @@ +package update + +import ( + "fmt" + "strings" + "time" + + "github.com/blang/semver" + "github.com/jfox85/devx/config" + "github.com/jfox85/devx/version" + "github.com/rhysd/go-github-selfupdate/selfupdate" +) + +const ( + // DefaultCheckInterval is the default time between update checks + DefaultCheckInterval = 24 * time.Hour + + // GitHubRepo is the repository to check for updates + GitHubRepo = "jfox85/devx" +) + +// UpdateInfo contains information about an available update +type UpdateInfo struct { + CurrentVersion string + LatestVersion string + ReleaseNotes string + ReleaseURL string + Available bool +} + +// CheckForUpdates checks GitHub for a newer version +func CheckForUpdates() (*UpdateInfo, error) { + // Parse current version - handle dev versions gracefully + var currentVersion semver.Version + cleanVersion := strings.TrimPrefix(version.Version, "v") + + // Check if this is a dev version (git commit hash, etc.) + if parsedVersion, err := semver.Parse(cleanVersion); err != nil { + // For dev versions, use 0.0.0 so any release is considered newer + currentVersion = semver.Version{Major: 0, Minor: 0, Patch: 0} + } else { + currentVersion = parsedVersion + } + + // Check GitHub for latest release + latest, found, err := selfupdate.DetectLatest(GitHubRepo) + if err != nil { + return nil, fmt.Errorf("checking for updates: %w", err) + } + + if !found { + return nil, fmt.Errorf("no release information found") + } + + info := &UpdateInfo{ + CurrentVersion: version.Version, // Use actual version string for display + LatestVersion: latest.Version.String(), + ReleaseNotes: latest.ReleaseNotes, + ReleaseURL: latest.URL, + Available: latest.Version.GT(currentVersion), + } + + return info, nil +} + +// CheckForUpdatesWithCache checks for updates if the check interval has passed +func CheckForUpdatesWithCache(interval time.Duration) (*UpdateInfo, bool, error) { + // Load last check state + state, err := config.LoadUpdateCheckState() + if err != nil { + return nil, false, fmt.Errorf("loading update check state: %w", err) + } + + // Check if we should perform the update check + if !config.ShouldCheckForUpdates(state.LastCheck, interval) { + return nil, false, nil // Not time to check yet + } + + // Perform the check + info, err := CheckForUpdates() + if err != nil { + return nil, false, err + } + + // Save the check time (but don't update LastNotifiedVersion yet - that happens after we actually notify) + state.LastCheck = time.Now() + if err := config.SaveUpdateCheckState(state); err != nil { + // Log but don't fail - this is not critical + fmt.Printf("Warning: failed to save update check state: %v\n", err) + } + + return info, true, nil +} + +// ShouldNotifyUser determines if we should notify the user about an update +// Returns true if: +// - An update is available +// - We haven't already notified about this version +func ShouldNotifyUser(info *UpdateInfo) (bool, error) { + if !info.Available { + return false, nil + } + + state, err := config.LoadUpdateCheckState() + if err != nil { + return true, nil // On error, default to notifying + } + + // If we haven't notified about this version yet, notify + return state.LastNotifiedVersion != info.LatestVersion, nil +} + +// MarkUpdateNotified marks an update version as having been shown to the user +// This should be called after successfully displaying an update notification +func MarkUpdateNotified(latestVersion string) error { + state, err := config.LoadUpdateCheckState() + if err != nil { + // If we can't load state, create a new one + state = &config.UpdateCheckState{} + } + + state.LastNotifiedVersion = latestVersion + if err := config.SaveUpdateCheckState(state); err != nil { + return fmt.Errorf("saving notification state: %w", err) + } + + return nil +} diff --git a/update/checker_test.go b/update/checker_test.go new file mode 100644 index 0000000..df159be --- /dev/null +++ b/update/checker_test.go @@ -0,0 +1,103 @@ +package update + +import ( + "os" + "testing" + "time" + + "github.com/jfox85/devx/config" +) + +func TestShouldNotifyUser(t *testing.T) { + tests := []struct { + name string + info *UpdateInfo + lastNotified string + expected bool + description string + }{ + { + name: "new update available, never notified", + info: &UpdateInfo{ + CurrentVersion: "0.1.0", + LatestVersion: "0.2.0", + Available: true, + }, + lastNotified: "", + expected: true, + description: "Should notify when update is available and never notified", + }, + { + name: "same update already notified", + info: &UpdateInfo{ + CurrentVersion: "0.1.0", + LatestVersion: "0.2.0", + Available: true, + }, + lastNotified: "0.2.0", + expected: false, + description: "Should not notify if already notified about this version", + }, + { + name: "newer update available than last notification", + info: &UpdateInfo{ + CurrentVersion: "0.1.0", + LatestVersion: "0.3.0", + Available: true, + }, + lastNotified: "0.2.0", + expected: true, + description: "Should notify if a newer version is available than last notified", + }, + { + name: "no update available", + info: &UpdateInfo{ + CurrentVersion: "0.2.0", + LatestVersion: "0.2.0", + Available: false, + }, + lastNotified: "", + expected: false, + description: "Should not notify when no update is available", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Use temp directory to isolate tests + tempDir := t.TempDir() + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + originalWd, _ := os.Getwd() + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + defer func() { + if err := os.Chdir(originalWd); err != nil { + t.Errorf("Failed to restore directory: %v", err) + } + }() + + // Setup: Save a test state + state := &config.UpdateCheckState{ + LastCheck: time.Now(), + LastNotifiedVersion: tt.lastNotified, + } + if err := config.SaveUpdateCheckState(state); err != nil { + t.Fatalf("Failed to save test state: %v", err) + } + + // Test + result, err := ShouldNotifyUser(tt.info) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if result != tt.expected { + t.Errorf("%s: got %v, want %v", tt.description, result, tt.expected) + } + }) + } +} diff --git a/update/detector.go b/update/detector.go new file mode 100644 index 0000000..ee73276 --- /dev/null +++ b/update/detector.go @@ -0,0 +1,91 @@ +package update + +import ( + "os" + "path/filepath" + "strings" +) + +// InstallMethod represents how devx was installed +type InstallMethod string + +const ( + InstallMethodHomebrew InstallMethod = "homebrew" + InstallMethodGoInstall InstallMethod = "go-install" + InstallMethodManual InstallMethod = "manual" + InstallMethodUnknown InstallMethod = "unknown" +) + +// DetectInstallMethod tries to determine how devx was installed +func DetectInstallMethod() InstallMethod { + exe, err := os.Executable() + if err != nil { + return InstallMethodUnknown + } + + exePath, err := filepath.EvalSymlinks(exe) + if err != nil { + exePath = exe + } + + // Check for Homebrew installation + if isHomebrewManaged(exePath) { + return InstallMethodHomebrew + } + + // Check for go install + if strings.Contains(exePath, "/go/bin/") { + return InstallMethodGoInstall + } + + // Default to manual installation + return InstallMethodManual +} + +// isHomebrewManaged checks if a binary is managed by Homebrew +func isHomebrewManaged(path string) bool { + // Check for Cellar paths (direct installation) + if strings.Contains(path, "/usr/local/Cellar/devx") || + strings.Contains(path, "/opt/homebrew/Cellar/devx") { + return true + } + + // Check if it's a symlink to a Homebrew Cellar location + if link, err := os.Readlink(path); err == nil { + return strings.Contains(link, "/Cellar/devx") + } + + // Check common Homebrew bin locations + if strings.Contains(path, "/usr/local/bin/devx") || + strings.Contains(path, "/opt/homebrew/bin/devx") { + // Verify it's actually managed by Homebrew via symlink check + if link, err := os.Readlink(path); err == nil { + return strings.Contains(link, "/Cellar/") + } + } + + return false +} + +// CanSelfUpdate returns true if the installation method supports self-update +func CanSelfUpdate() bool { + method := DetectInstallMethod() + // Homebrew installations should use `brew upgrade` + return method != InstallMethodHomebrew +} + +// GetUpdateInstructions returns platform-specific update instructions +func GetUpdateInstructions() string { + method := DetectInstallMethod() + + switch method { + case InstallMethodHomebrew: + return "Please update using Homebrew:\n brew upgrade devx\n\nOr:\n brew update && brew upgrade devx" + case InstallMethodGoInstall: + return "Please update using go install:\n go install github.com/jfox85/devx@latest" + case InstallMethodManual: + return "Run 'devx update' to update to the latest version" + default: + return "Unable to determine installation method. Please reinstall devx." + } +} diff --git a/update/detector_test.go b/update/detector_test.go new file mode 100644 index 0000000..09a4830 --- /dev/null +++ b/update/detector_test.go @@ -0,0 +1,49 @@ +package update + +import ( + "testing" +) + +func TestDetectInstallMethod(t *testing.T) { + // Note: This test is limited as it depends on the actual installation + // In a real test environment, we'd mock os.Executable and os.Readlink + method := DetectInstallMethod() + + // Should return a valid method + validMethods := map[InstallMethod]bool{ + InstallMethodHomebrew: true, + InstallMethodGoInstall: true, + InstallMethodManual: true, + InstallMethodUnknown: true, + } + + if !validMethods[method] { + t.Errorf("DetectInstallMethod returned invalid method: %v", method) + } +} + +func TestCanSelfUpdate(t *testing.T) { + // This test depends on the installation method + // Just verify it returns a boolean + result := CanSelfUpdate() + + // Should be a boolean (no panic) + if result { + t.Log("Self-update is supported") + } else { + t.Log("Self-update is not supported") + } +} + +func TestGetUpdateInstructions(t *testing.T) { + instructions := GetUpdateInstructions() + + if instructions == "" { + t.Error("GetUpdateInstructions returned empty string") + } + + // Should contain some helpful text + if len(instructions) < 10 { + t.Errorf("GetUpdateInstructions returned too short message: %s", instructions) + } +} diff --git a/update/installer.go b/update/installer.go new file mode 100644 index 0000000..1ab09ea --- /dev/null +++ b/update/installer.go @@ -0,0 +1,66 @@ +package update + +import ( + "fmt" + "strings" + + "github.com/blang/semver" + "github.com/jfox85/devx/version" + "github.com/rhysd/go-github-selfupdate/selfupdate" +) + +// PerformUpdate downloads and installs the latest version +func PerformUpdate(force bool) error { + // Parse current version - handle dev versions gracefully + var currentVersion semver.Version + rawVersion := strings.TrimPrefix(version.Version, "v") + + if parsedVersion, err := semver.Parse(rawVersion); err != nil { + // For dev versions, use 0.0.0 so any release is considered newer + currentVersion = semver.Version{Major: 0, Minor: 0, Patch: 0} + } else { + currentVersion = parsedVersion + } + + // Check for latest version + latest, found, err := selfupdate.DetectLatest(GitHubRepo) + if err != nil { + return fmt.Errorf("checking for updates: %w", err) + } + + if !found { + return fmt.Errorf("no release information found") + } + + // Check if update is needed + if latest.Version.LTE(currentVersion) && !force { + return fmt.Errorf("you are already running the latest version (%s)", version.Version) + } + + // Perform the update + release, err := selfupdate.UpdateSelf(currentVersion, GitHubRepo) + if err != nil { + return fmt.Errorf("update failed: %w", err) + } + + fmt.Printf("āœ… Successfully updated to %s!\n", release.Version) + fmt.Println("šŸŽ‰ devx has been updated. Restart any running instances to use the new version.") + + // Show release notes if available + if release.ReleaseNotes != "" { + fmt.Println("\nšŸ“‹ Release Notes:") + fmt.Println(release.ReleaseNotes) + } + + return nil +} + +// UpdateAvailable checks if an update is available without downloading +func UpdateAvailable() (bool, string, error) { + info, err := CheckForUpdates() + if err != nil { + return false, "", err + } + + return info.Available, info.LatestVersion, nil +}