From 89ccae7093d4a333b56ed09ca4041bbb645b27f7 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Wed, 1 Oct 2025 15:01:39 -0700 Subject: [PATCH 1/5] Fix Homebrew formula style issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update description to remove leading article - Use bin/"devx" syntax instead of "#{bin}/devx" in test These changes address brew audit warnings for better formula style compliance. šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .goreleaser.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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" From 4751e6ca992aac5579f9a8eca361290e685ae56b Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Wed, 1 Oct 2025 15:35:45 -0700 Subject: [PATCH 2/5] Add self-update functionality with GitHub release integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements automatic update checking and self-update capabilities: - Background update checks on startup (configurable interval, default 24h) - CLI commands for checking and installing updates - TUI integration with update banner and 'u' key binding - Installation method detection (Homebrew vs manual vs go install) - Rate limiting with cached state to avoid excessive API calls - Graceful handling of dev versions and private repositories New features: - `devx update` - Download and install latest version - `devx version --check-updates` - Check for available updates - Config options: auto_check_updates, update_check_interval - Update notifications in TUI with visual banner - Smart detection to skip self-update for Homebrew installations šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 4 +- cmd/root.go | 39 +++++++++++- cmd/update.go | 113 +++++++++++++++++++++++++++++++++ cmd/version.go | 32 ++++++++++ config/updatecheck.go | 72 +++++++++++++++++++++ config/updatecheck_test.go | 124 +++++++++++++++++++++++++++++++++++++ go.mod | 9 +++ go.sum | 51 +++++++++++++++ tui/model.go | 88 +++++++++++++++++++++++++- update/checker.go | 114 ++++++++++++++++++++++++++++++++++ update/checker_test.go | 97 +++++++++++++++++++++++++++++ update/detector.go | 91 +++++++++++++++++++++++++++ update/detector_test.go | 49 +++++++++++++++ update/installer.go | 61 ++++++++++++++++++ 14 files changed, 941 insertions(+), 3 deletions(-) create mode 100644 cmd/update.go create mode 100644 config/updatecheck.go create mode 100644 config/updatecheck_test.go create mode 100644 update/checker.go create mode 100644 update/checker_test.go create mode 100644 update/detector.go create mode 100644 update/detector_test.go create mode 100644 update/installer.go 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/cmd/root.go b/cmd/root.go index 4cfdfdc..34e5380 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,46 @@ 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) + } + } + }() +} + // 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..462e386 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,33 @@ 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.") } 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..9d51475 --- /dev/null +++ b/config/updatecheck_test.go @@ -0,0 +1,124 @@ +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() + os.Chdir(tempDir) + defer os.Chdir(originalWd) + + // 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() + os.Chdir(tempDir) + defer os.Chdir(originalWd) + + // 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..da58e6e 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,10 +46,14 @@ 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.32.0 // indirect golang.org/x/net v0.33.0 // indirect + golang.org/x/oauth2 v0.25.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 diff --git a/go.sum b/go.sum index 902831e..8d140c7 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.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 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.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= +golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +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/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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 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..6b7ebfd --- /dev/null +++ b/update/checker.go @@ -0,0 +1,114 @@ +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 + state.LastCheck = time.Now() + if info.Available { + state.LastNotifiedVersion = info.LatestVersion + } + 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 +} diff --git a/update/checker_test.go b/update/checker_test.go new file mode 100644 index 0000000..72f75b9 --- /dev/null +++ b/update/checker_test.go @@ -0,0 +1,97 @@ +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() + os.Chdir(tempDir) + defer os.Chdir(originalWd) + + // 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..4cc7d60 --- /dev/null +++ b/update/installer.go @@ -0,0 +1,61 @@ +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 + currentVersion, err := semver.Parse(strings.TrimPrefix(version.Version, "v")) + if err != nil { + return fmt.Errorf("parsing current version: %w", err) + } + + // 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)", currentVersion) + } + + // 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 +} From 1a8227c6f8ca353dda0e25058683c901a1969b7c Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Wed, 1 Oct 2025 16:42:35 -0700 Subject: [PATCH 3/5] Fix lint errors: add error checking for os.Chdir calls in tests errcheck was complaining about unchecked os.Chdir error returns. Added proper error handling with t.Fatalf for initial chdir and t.Errorf for deferred directory restoration. --- config/updatecheck_test.go | 20 ++++++++++++++++---- update/checker_test.go | 10 ++++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/config/updatecheck_test.go b/config/updatecheck_test.go index 9d51475..9d341e3 100644 --- a/config/updatecheck_test.go +++ b/config/updatecheck_test.go @@ -19,8 +19,14 @@ func TestUpdateCheckStatePersistence(t *testing.T) { // Change working directory to temp dir so FindProjectConfigDir doesn't find the real .devx originalWd, _ := os.Getwd() - os.Chdir(tempDir) - defer os.Chdir(originalWd) + 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{ @@ -105,8 +111,14 @@ func TestLoadUpdateCheckStateNonExistent(t *testing.T) { // Change working directory to temp dir so FindProjectConfigDir doesn't find the real .devx originalWd, _ := os.Getwd() - os.Chdir(tempDir) - defer os.Chdir(originalWd) + 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() diff --git a/update/checker_test.go b/update/checker_test.go index 72f75b9..df159be 100644 --- a/update/checker_test.go +++ b/update/checker_test.go @@ -71,8 +71,14 @@ func TestShouldNotifyUser(t *testing.T) { defer os.Setenv("HOME", originalHome) originalWd, _ := os.Getwd() - os.Chdir(tempDir) - defer os.Chdir(originalWd) + 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{ From ae398180d53abca9aba157110c11914dc228a763 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Wed, 1 Oct 2025 17:03:36 -0700 Subject: [PATCH 4/5] Fix critical update system issues and security vulnerabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review feedback: 1. Fix notification flow bug (CRITICAL) - Moved LastNotifiedVersion update AFTER showing notification - Added MarkUpdateNotified() function for proper state management - Users now correctly see update notifications on first check 2. Fix dev version handling (CRITICAL) - Non-semver versions (like "dev") now treated as 0.0.0 - Allows self-update to work from development builds - Mirrors the logic already in CheckForUpdates() 3. Update vulnerable dependencies (CRITICAL) - golang.org/x/crypto: v0.32.0 → v0.35.0 (fixes DoS vulnerability) - golang.org/x/oauth2: v0.25.0 → v0.27.0 (fixes memory exhaustion) - golang.org/x/text: v0.21.0 → v0.22.0 (transitive update) All tests passing. Security vulnerabilities resolved. --- cmd/root.go | 6 ++++++ go.mod | 6 +++--- go.sum | 12 ++++++------ update/checker.go | 22 ++++++++++++++++++---- update/installer.go | 15 ++++++++++----- 5 files changed, 43 insertions(+), 18 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 34e5380..2692011 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -134,6 +134,12 @@ func checkForUpdatesBackground() { // 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) + } } } }() diff --git a/go.mod b/go.mod index da58e6e..1feabca 100644 --- a/go.mod +++ b/go.mod @@ -51,10 +51,10 @@ require ( 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.32.0 // indirect + golang.org/x/crypto v0.35.0 // indirect golang.org/x/net v0.33.0 // indirect - golang.org/x/oauth2 v0.25.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 8d140c7..34a1242 100644 --- a/go.sum +++ b/go.sum @@ -120,8 +120,8 @@ 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.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +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= @@ -132,8 +132,8 @@ 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.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= -golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +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= @@ -147,8 +147,8 @@ golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +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= diff --git a/update/checker.go b/update/checker.go index 6b7ebfd..cd3fe84 100644 --- a/update/checker.go +++ b/update/checker.go @@ -82,11 +82,8 @@ func CheckForUpdatesWithCache(interval time.Duration) (*UpdateInfo, bool, error) return nil, false, err } - // Save the check time + // Save the check time (but don't update LastNotifiedVersion yet - that happens after we actually notify) state.LastCheck = time.Now() - if info.Available { - state.LastNotifiedVersion = info.LatestVersion - } 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) @@ -112,3 +109,20 @@ func ShouldNotifyUser(info *UpdateInfo) (bool, error) { // 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/installer.go b/update/installer.go index 4cc7d60..1ab09ea 100644 --- a/update/installer.go +++ b/update/installer.go @@ -11,10 +11,15 @@ import ( // PerformUpdate downloads and installs the latest version func PerformUpdate(force bool) error { - // Parse current version - currentVersion, err := semver.Parse(strings.TrimPrefix(version.Version, "v")) - if err != nil { - return fmt.Errorf("parsing current version: %w", err) + // 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 @@ -29,7 +34,7 @@ func PerformUpdate(force bool) error { // Check if update is needed if latest.Version.LTE(currentVersion) && !force { - return fmt.Errorf("you are already running the latest version (%s)", currentVersion) + return fmt.Errorf("you are already running the latest version (%s)", version.Version) } // Perform the update From d14ed9c025de65680b10358ebb3033b54972740c Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Wed, 1 Oct 2025 17:25:39 -0700 Subject: [PATCH 5/5] Add MarkUpdateNotified call in version command Ensure notification state is saved after showing update message in 'devx version --check-updates' to prevent repeated notifications. Addresses CodeRabbit review feedback. --- cmd/version.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/version.go b/cmd/version.go index 462e386..0b30003 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -76,4 +76,10 @@ func checkForVersionUpdates() { 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 + } }