Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
devx
.envrc
.tmuxp.yaml
.tmuxp.yaml
dist/
.devx/
4 changes: 2 additions & 2 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"

Expand Down
45 changes: 44 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
113 changes: 113 additions & 0 deletions cmd/update.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
38 changes: 38 additions & 0 deletions cmd/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import (
"encoding/json"
"fmt"

"github.com/jfox85/devx/update"
"github.com/jfox85/devx/version"
"github.com/spf13/cobra"
)

var (
versionOutput string
detailedFlag bool
checkUpdates bool
)

var versionCmd = &cobra.Command{
Expand All @@ -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) {
Expand All @@ -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
}
}
72 changes: 72 additions & 0 deletions config/updatecheck.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading