diff --git a/cmd/lib/installer/installer.go b/cmd/lib/installer/installer.go new file mode 100644 index 0000000..2a10548 --- /dev/null +++ b/cmd/lib/installer/installer.go @@ -0,0 +1,117 @@ +package installer + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// InstallMethod represents how stacktodate was installed +type InstallMethod int + +const ( + Unknown InstallMethod = iota + Homebrew + Binary +) + +// String returns a string representation of the install method +func (m InstallMethod) String() string { + switch m { + case Homebrew: + return "homebrew" + case Binary: + return "binary" + default: + return "unknown" + } +} + +// DetectInstallMethod attempts to determine how stacktodate was installed +func DetectInstallMethod() InstallMethod { + // Try to detect Homebrew installation first + if IsHomebrew() { + return Homebrew + } + + // Default to binary download + return Binary +} + +// IsHomebrew checks if stacktodate was installed via Homebrew +func IsHomebrew() bool { + // Method 1: Check executable path for Homebrew-specific directories + executable, err := os.Executable() + if err == nil { + if isHomebrewPath(executable) { + return true + } + } + + // Method 2: Verify with brew command (silent check) + if isBrewInstalled() { + return true + } + + return false +} + +// isHomebrewPath checks if the executable path looks like a Homebrew installation +func isHomebrewPath(execPath string) bool { + // Common Homebrew paths + homebrewPatterns := []string{ + "/Cellar/stacktodate/", // Intel Macs, Linux + "/opt/homebrew/Cellar/stacktodate", // Apple Silicon Macs + "/opt/homebrew/bin/stacktodate", + "/usr/local/bin/stacktodate", + "/usr/local/Cellar/stacktodate/", + } + + for _, pattern := range homebrewPatterns { + if strings.Contains(execPath, pattern) { + return true + } + } + + return false +} + +// isBrewInstalled checks if the brew command recognizes stacktodate +func isBrewInstalled() bool { + // Run: brew list stacktodate + // This will succeed (exit code 0) if stacktodate is installed via Homebrew + cmd := exec.Command("brew", "list", "stacktodate") + + // Redirect output to /dev/null (we don't need the output) + cmd.Stdout = nil + cmd.Stderr = nil + + // Silent execution - we only care about the exit code + return cmd.Run() == nil +} + +// GetUpgradeInstructions returns the appropriate upgrade instructions based on install method +func GetUpgradeInstructions(method InstallMethod, version string) string { + switch method { + case Homebrew: + return "Upgrade: brew upgrade stacktodate" + + case Binary: + return fmt.Sprintf("Download: https://github.com/stacktodate/stacktodate-cli/releases/tag/%s", version) + + default: + return fmt.Sprintf("Visit: https://github.com/stacktodate/stacktodate-cli/releases/tag/%s", version) + } +} + +// GetInstallerDownloadURL returns the download URL appropriate for the install method +func GetInstallerDownloadURL(method InstallMethod) string { + switch method { + case Homebrew: + return "https://github.com/stacktodate/homebrew-stacktodate" + + default: + return "https://github.com/stacktodate/stacktodate-cli/releases/latest" + } +} diff --git a/cmd/lib/installer/installer_test.go b/cmd/lib/installer/installer_test.go new file mode 100644 index 0000000..6824e46 --- /dev/null +++ b/cmd/lib/installer/installer_test.go @@ -0,0 +1,101 @@ +package installer + +import ( + "testing" +) + +func TestInstallMethodString(t *testing.T) { + tests := []struct { + method InstallMethod + expected string + }{ + {Homebrew, "homebrew"}, + {Binary, "binary"}, + {Unknown, "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + if got := tt.method.String(); got != tt.expected { + t.Fatalf("expected %s, got %s", tt.expected, got) + } + }) + } +} + +func TestIsHomebrewPath(t *testing.T) { + tests := []struct { + name string + path string + expected bool + }{ + {"Intel Mac Cellar", "/usr/local/Cellar/stacktodate/0.2.0/bin/stacktodate", true}, + {"Apple Silicon", "/opt/homebrew/Cellar/stacktodate/0.2.0/bin/stacktodate", true}, + {"Apple Silicon bin", "/opt/homebrew/bin/stacktodate", true}, + {"Standard usr local bin", "/usr/local/bin/stacktodate", true}, + {"Binary download", "/Users/username/Downloads/stacktodate", false}, + {"Build from source", "/Users/username/projects/stacktodate-cli/stacktodate", false}, + {"Go workspace", "/home/user/go/bin/stacktodate", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isHomebrewPath(tt.path); got != tt.expected { + t.Fatalf("expected %v, got %v for path %s", tt.expected, got, tt.path) + } + }) + } +} + +func TestGetUpgradeInstructions(t *testing.T) { + tests := []struct { + name string + method InstallMethod + version string + expected string + }{ + {"Homebrew", Homebrew, "v0.3.0", "Upgrade: brew upgrade stacktodate"}, + {"Homebrew without v", Homebrew, "0.3.0", "Upgrade: brew upgrade stacktodate"}, + {"Binary with v", Binary, "v0.3.0", "Download: https://github.com/stacktodate/stacktodate-cli/releases/tag/v0.3.0"}, + {"Binary without v", Binary, "0.3.0", "Download: https://github.com/stacktodate/stacktodate-cli/releases/tag/0.3.0"}, + {"Unknown", Unknown, "v0.3.0", "Visit: https://github.com/stacktodate/stacktodate-cli/releases/tag/v0.3.0"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := GetUpgradeInstructions(tt.method, tt.version); got != tt.expected { + t.Fatalf("expected %q, got %q", tt.expected, got) + } + }) + } +} + +func TestGetInstallerDownloadURL(t *testing.T) { + tests := []struct { + name string + method InstallMethod + expected string + }{ + {"Homebrew", Homebrew, "https://github.com/stacktodate/homebrew-stacktodate"}, + {"Binary", Binary, "https://github.com/stacktodate/stacktodate-cli/releases/latest"}, + {"Unknown", Unknown, "https://github.com/stacktodate/stacktodate-cli/releases/latest"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := GetInstallerDownloadURL(tt.method); got != tt.expected { + t.Fatalf("expected %q, got %q", tt.expected, got) + } + }) + } +} + +func TestDetectInstallMethod(t *testing.T) { + // This test just verifies the function runs without panic + // Actual detection result depends on environment + method := DetectInstallMethod() + + if method != Homebrew && method != Binary && method != Unknown { + t.Fatalf("unexpected install method: %v", method) + } +} diff --git a/cmd/lib/versioncheck/versioncheck.go b/cmd/lib/versioncheck/versioncheck.go new file mode 100644 index 0000000..970fb3e --- /dev/null +++ b/cmd/lib/versioncheck/versioncheck.go @@ -0,0 +1,240 @@ +package versioncheck + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + cacheDirName = ".stacktodate" + cacheFileName = "version-cache.json" + cacheTTL = 24 * time.Hour + githubAPIURL = "https://api.github.com/repos/stacktodate/stacktodate-cli/releases/latest" + httpTimeout = 10 * time.Second +) + +// getUserHomeDir returns the user's home directory (can be overridden for testing) +var getUserHomeDir = os.UserHomeDir + +// VersionCache represents the cached version information +type VersionCache struct { + Timestamp time.Time `json:"timestamp"` + LatestVersion string `json:"latestVersion"` + ReleaseURL string `json:"releaseUrl"` +} + +// GitHubRelease represents the GitHub API response for a release +type GitHubRelease struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + PublishedAt string `json:"published_at"` +} + +// GetCachePath returns the full path to the version cache file +func GetCachePath() (string, error) { + home, err := getUserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + cacheDir := filepath.Join(home, cacheDirName) + cachePath := filepath.Join(cacheDir, cacheFileName) + return cachePath, nil +} + +// IsCacheValid checks if a valid cache file exists and is not expired +func IsCacheValid() bool { + cachePath, err := GetCachePath() + if err != nil { + return false + } + + info, err := os.Stat(cachePath) + if err != nil { + return false + } + + return time.Since(info.ModTime()) < cacheTTL +} + +// LoadCache loads the version cache from disk +func LoadCache() (*VersionCache, error) { + cachePath, err := GetCachePath() + if err != nil { + return nil, err + } + + content, err := os.ReadFile(cachePath) + if err != nil { + return nil, fmt.Errorf("failed to read cache file: %w", err) + } + + var cache VersionCache + if err := json.Unmarshal(content, &cache); err != nil { + return nil, fmt.Errorf("failed to parse cache file: %w", err) + } + + return &cache, nil +} + +// SaveCache saves the version information to cache +func SaveCache(latestVersion, releaseURL string) error { + cachePath, err := GetCachePath() + if err != nil { + return err + } + + // Ensure cache directory exists + cacheDir := filepath.Dir(cachePath) + if err := os.MkdirAll(cacheDir, 0755); err != nil { + return fmt.Errorf("failed to create cache directory: %w", err) + } + + cache := VersionCache{ + Timestamp: time.Now(), + LatestVersion: latestVersion, + ReleaseURL: releaseURL, + } + + data, err := json.Marshal(cache) + if err != nil { + return fmt.Errorf("failed to marshal cache: %w", err) + } + + if err := os.WriteFile(cachePath, data, 0644); err != nil { + return fmt.Errorf("failed to write cache file: %w", err) + } + + return nil +} + +// FetchLatestFromGitHub fetches the latest release information from GitHub API +func FetchLatestFromGitHub() (*GitHubRelease, error) { + client := &http.Client{ + Timeout: httpTimeout, + } + + req, err := http.NewRequest("GET", githubAPIURL, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + // GitHub API requires User-Agent header + req.Header.Set("User-Agent", "stacktodate-cli") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching from GitHub: %w", err) + } + defer resp.Body.Close() + + // Handle rate limiting + if resp.StatusCode == http.StatusForbidden { + return nil, fmt.Errorf("rate limit exceeded") + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GitHub API error (status %d): %s", resp.StatusCode, string(body)) + } + + var release GitHubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + + return &release, nil +} + +// CompareVersions compares two semantic versions and returns true if latest is newer +// Handles 'dev' versions (always considered older) and different formats +func CompareVersions(current, latest string) (bool, error) { + // Special case: dev version is always older + if current == "dev" { + return true, nil + } + + // Strip 'v' prefix if present + current = strings.TrimPrefix(current, "v") + latest = strings.TrimPrefix(latest, "v") + + // Split versions by '.' + currentParts := strings.Split(current, ".") + latestParts := strings.Split(latest, ".") + + // Compare each numeric part + maxLen := len(currentParts) + if len(latestParts) > maxLen { + maxLen = len(latestParts) + } + + for i := 0; i < maxLen; i++ { + currPart := 0 + latPart := 0 + + if i < len(currentParts) { + val, err := strconv.Atoi(strings.TrimSpace(currentParts[i])) + if err != nil { + return false, fmt.Errorf("invalid current version format: %s", current) + } + currPart = val + } + + if i < len(latestParts) { + val, err := strconv.Atoi(strings.TrimSpace(latestParts[i])) + if err != nil { + return false, fmt.Errorf("invalid latest version format: %s", latest) + } + latPart = val + } + + if latPart > currPart { + return true, nil // Newer version available + } else if currPart > latPart { + return false, nil // Current is newer + } + } + + // All parts are equal + return false, nil +} + +// GetLatestVersion retrieves the latest version, checking cache first and fetching from GitHub if needed +// Implements graceful degradation: uses stale cache if network fails +func GetLatestVersion() (string, string, error) { + // Check if cache is still valid + if IsCacheValid() { + cache, err := LoadCache() + if err == nil && cache != nil { + return cache.LatestVersion, cache.ReleaseURL, nil + } + } + + // Cache is invalid or missing, fetch new data from GitHub + release, err := FetchLatestFromGitHub() + if err != nil { + // If fetch fails, try to use stale cache as fallback + cache, cacheErr := LoadCache() + if cacheErr == nil && cache != nil { + // Stale cache is better than nothing + return cache.LatestVersion, cache.ReleaseURL, nil + } + // Both fetch and fallback failed + return "", "", fmt.Errorf("failed to fetch version: %w", err) + } + + // Successfully fetched, save to cache for future use + if saveErr := SaveCache(release.TagName, release.HTMLURL); saveErr != nil { + // Cache save failure is not fatal - we still have the fetched data + // Silently ignore and continue + } + + return release.TagName, release.HTMLURL, nil +} diff --git a/cmd/lib/versioncheck/versioncheck_test.go b/cmd/lib/versioncheck/versioncheck_test.go new file mode 100644 index 0000000..78ba487 --- /dev/null +++ b/cmd/lib/versioncheck/versioncheck_test.go @@ -0,0 +1,255 @@ +package versioncheck + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestCompareVersions(t *testing.T) { + tests := []struct { + name string + current string + latest string + isNewer bool + shouldErr bool + }{ + // Normal cases + {"newer patch version", "0.2.2", "0.2.3", true, false}, + {"newer minor version", "0.2.2", "0.3.0", true, false}, + {"newer major version", "0.2.2", "1.0.0", true, false}, + {"same version", "0.2.2", "0.2.2", false, false}, + {"current is newer", "0.3.0", "0.2.2", false, false}, + + // Dev version + {"dev is always older", "dev", "0.2.2", true, false}, + {"dev current with newer", "dev", "1.0.0", true, false}, + + // With v prefix + {"with v prefix - newer", "v0.2.2", "v0.3.0", true, false}, + {"with v prefix - same", "v0.2.2", "v0.2.2", false, false}, + {"mixed v prefix", "v0.2.2", "0.3.0", true, false}, + + // Different length versions + {"shorter current vs longer latest", "0.2", "0.2.1", true, false}, + {"longer current vs shorter latest", "0.2.1", "0.2", false, false}, + + // Invalid versions + {"invalid current", "invalid", "0.2.2", false, true}, + {"invalid latest", "0.2.2", "invalid", false, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isNewer, err := CompareVersions(tt.current, tt.latest) + + if (err != nil) != tt.shouldErr { + t.Fatalf("expected error: %v, got: %v", tt.shouldErr, err != nil) + } + + if isNewer != tt.isNewer { + t.Fatalf("expected isNewer: %v, got: %v", tt.isNewer, isNewer) + } + }) + } +} + +func TestCachePath(t *testing.T) { + cachePath, err := GetCachePath() + if err != nil { + t.Fatalf("GetCachePath failed: %v", err) + } + + // Should contain .stacktodate and version-cache.json + if !filepath.IsAbs(cachePath) { + t.Fatalf("cache path should be absolute, got: %s", cachePath) + } + + if !strings.Contains(cachePath, ".stacktodate") { + t.Fatalf("cache path should contain .stacktodate, got: %s", cachePath) + } + + if !strings.Contains(cachePath, "version-cache.json") { + t.Fatalf("cache path should contain version-cache.json, got: %s", cachePath) + } +} + +func TestSaveAndLoadCache(t *testing.T) { + // Create a temporary directory for testing + tmpDir := t.TempDir() + + // Override home directory for testing + originalGetHomeDir := getUserHomeDir + getUserHomeDir = func() (string, error) { + return tmpDir, nil + } + defer func() { + getUserHomeDir = originalGetHomeDir + }() + + // Test save + testVersion := "v0.3.0" + testURL := "https://github.com/stacktodate/stacktodate-cli/releases/tag/v0.3.0" + + if err := SaveCache(testVersion, testURL); err != nil { + t.Fatalf("SaveCache failed: %v", err) + } + + // Verify cache file exists + cachePath, _ := GetCachePath() + if _, err := os.Stat(cachePath); err != nil { + t.Fatalf("cache file should exist, got error: %v", err) + } + + // Test load + cache, err := LoadCache() + if err != nil { + t.Fatalf("LoadCache failed: %v", err) + } + + if cache.LatestVersion != testVersion { + t.Fatalf("expected version %s, got %s", testVersion, cache.LatestVersion) + } + + if cache.ReleaseURL != testURL { + t.Fatalf("expected URL %s, got %s", testURL, cache.ReleaseURL) + } +} + +func TestIsCacheValid(t *testing.T) { + tmpDir := t.TempDir() + + originalGetHomeDir := getUserHomeDir + getUserHomeDir = func() (string, error) { + return tmpDir, nil + } + defer func() { + getUserHomeDir = originalGetHomeDir + }() + + // Test: cache doesn't exist + if IsCacheValid() { + t.Fatalf("empty cache should be invalid") + } + + // Create a valid cache + if err := SaveCache("v0.3.0", "https://example.com"); err != nil { + t.Fatalf("SaveCache failed: %v", err) + } + + // Test: cache exists and is fresh + if !IsCacheValid() { + t.Fatalf("fresh cache should be valid") + } + + // Modify file timestamp to be older than TTL + cachePath, _ := GetCachePath() + oldTime := time.Now().Add(-25 * time.Hour) + if err := os.Chtimes(cachePath, oldTime, oldTime); err != nil { + t.Fatalf("failed to modify file times: %v", err) + } + + // Test: cache exists but is expired + if IsCacheValid() { + t.Fatalf("expired cache should be invalid") + } +} + +func TestFetchLatestFromGitHub(t *testing.T) { + // Create a test server that mimics GitHub API + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify User-Agent header + if ua := r.Header.Get("User-Agent"); ua != "stacktodate-cli" { + w.WriteHeader(http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + response := GitHubRelease{ + TagName: "v0.3.0", + HTMLURL: "https://github.com/stacktodate/stacktodate-cli/releases/tag/v0.3.0", + PublishedAt: "2024-01-01T00:00:00Z", + } + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // TODO: This test would need to mock the HTTP client to test properly + // For now, we'll test the parsing logic independently + t.Run("parse release response", func(t *testing.T) { + jsonData := `{ + "tag_name": "v0.3.0", + "html_url": "https://github.com/stacktodate/stacktodate-cli/releases/tag/v0.3.0", + "published_at": "2024-01-01T00:00:00Z" + }` + + var release GitHubRelease + if err := json.Unmarshal([]byte(jsonData), &release); err != nil { + t.Fatalf("failed to parse release: %v", err) + } + + if release.TagName != "v0.3.0" { + t.Fatalf("expected tag v0.3.0, got %s", release.TagName) + } + + if release.HTMLURL != "https://github.com/stacktodate/stacktodate-cli/releases/tag/v0.3.0" { + t.Fatalf("unexpected URL") + } + }) +} + +func TestGetLatestVersionWithCache(t *testing.T) { + tmpDir := t.TempDir() + + originalGetHomeDir := getUserHomeDir + getUserHomeDir = func() (string, error) { + return tmpDir, nil + } + defer func() { + getUserHomeDir = originalGetHomeDir + }() + + // Create a valid cache + testVersion := "v0.3.0" + testURL := "https://github.com/stacktodate/stacktodate-cli/releases/tag/v0.3.0" + if err := SaveCache(testVersion, testURL); err != nil { + t.Fatalf("SaveCache failed: %v", err) + } + + // Get latest version should return from cache + version, url, err := GetLatestVersion() + if err != nil { + t.Fatalf("GetLatestVersion failed: %v", err) + } + + if version != testVersion { + t.Fatalf("expected version %s, got %s", testVersion, version) + } + + if url != testURL { + t.Fatalf("expected URL %s, got %s", testURL, url) + } +} + +func TestLoadCacheNonExistent(t *testing.T) { + tmpDir := t.TempDir() + + originalGetHomeDir := getUserHomeDir + getUserHomeDir = func() (string, error) { + return tmpDir, nil + } + defer func() { + getUserHomeDir = originalGetHomeDir + }() + + // Try to load non-existent cache + _, err := LoadCache() + if err == nil { + t.Fatalf("LoadCache should fail for non-existent cache") + } +} diff --git a/cmd/root.go b/cmd/root.go index 3529480..381850c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,8 +2,10 @@ package cmd import ( "fmt" + "os" "github.com/stacktodate/stacktodate-cli/cmd/helpers" + "github.com/stacktodate/stacktodate-cli/cmd/lib/versioncheck" "github.com/stacktodate/stacktodate-cli/internal/version" "github.com/spf13/cobra" ) @@ -12,6 +14,13 @@ var rootCmd = &cobra.Command{ Use: "stacktodate", Short: "Official CLI for Stack To Date", Long: `stacktodate - Track technology lifecycle statuses and plan for end-of-life upgrades`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // Only check on specific commands that should trigger automatic checks + cmdName := cmd.Name() + if shouldAutoCheck(cmdName) { + showCachedUpdateNotification() + } + }, Run: func(cmd *cobra.Command, args []string) { ver, _ := cmd.Flags().GetBool("version") if ver { @@ -34,3 +43,48 @@ func init() { rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(autodetectCmd) } + +// shouldAutoCheck determines if a command should trigger automatic version checks +func shouldAutoCheck(cmdName string) bool { + // Commands that should trigger automatic version checks + autoCheckCommands := map[string]bool{ + "init": true, + "update": true, + "check": true, + "push": true, + "autodetect": true, + } + + return autoCheckCommands[cmdName] +} + +// showCachedUpdateNotification shows an update notification if cache indicates a new version is available +// This only checks the cache (no network calls) to avoid any performance impact +func showCachedUpdateNotification() { + // Skip if update checking is disabled + if os.Getenv("STD_DISABLE_VERSION_CHECK") == "1" { + return + } + + // Only check if cache is valid (don't fetch from network) + if !versioncheck.IsCacheValid() { + return + } + + // Load cache + cache, err := versioncheck.LoadCache() + if err != nil || cache == nil { + return + } + + // Compare versions + current := version.GetVersion() + isNewer, err := versioncheck.CompareVersions(current, cache.LatestVersion) + if err != nil || !isNewer { + return + } + + // Show simple notification (don't disrupt command output) + fmt.Fprintf(os.Stderr, "\nA new version of stacktodate is available: %s → %s\n", current, cache.LatestVersion) + fmt.Fprintf(os.Stderr, "Run 'stacktodate version --check-updates' for upgrade instructions.\n\n") +} diff --git a/cmd/version.go b/cmd/version.go index f070b93..51aacb1 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -2,16 +2,80 @@ package cmd import ( "fmt" + "os" + "github.com/stacktodate/stacktodate-cli/cmd/lib/installer" + "github.com/stacktodate/stacktodate-cli/cmd/lib/versioncheck" "github.com/stacktodate/stacktodate-cli/internal/version" "github.com/spf13/cobra" ) +var checkUpdates bool + var versionCmd = &cobra.Command{ Use: "version", Short: "Print the version number", Long: `Display the current version of stacktodate`, Run: func(cmd *cobra.Command, args []string) { fmt.Println(version.GetFullVersion()) + + if checkUpdates { + checkForUpdates(true) + } }, } + +func init() { + rootCmd.AddCommand(versionCmd) + versionCmd.Flags().BoolVar(&checkUpdates, "check-updates", false, "Check for newer versions available") +} + +// checkForUpdates checks for a newer version and displays update information +func checkForUpdates(verbose bool) { + latest, releaseURL, err := versioncheck.GetLatestVersion() + if err != nil { + if verbose { + fmt.Fprintf(os.Stderr, "Unable to check for updates: %v\n", err) + } + return + } + + current := version.GetVersion() + isNewer, err := versioncheck.CompareVersions(current, latest) + if err != nil { + if verbose { + fmt.Fprintf(os.Stderr, "Unable to compare versions: %v\n", err) + } + return + } + + if isNewer { + installMethod := installer.DetectInstallMethod() + instructions := installer.GetUpgradeInstructions(installMethod, latest) + + if verbose { + fmt.Printf("\n%s\n", formatUpdateMessage(current, latest, releaseURL, instructions)) + } else { + // Silent notification for automatic checks + fmt.Fprintf(os.Stderr, "\nA new version of stacktodate is available: %s → %s\n", current, latest) + fmt.Fprintf(os.Stderr, "Run 'stacktodate version --check-updates' for upgrade instructions.\n\n") + } + } else if verbose { + fmt.Println("\nYou are using the latest version.") + } +} + +// formatUpdateMessage creates a formatted update notification message +func formatUpdateMessage(current, latest, releaseURL, instructions string) string { + return fmt.Sprintf(` +Update Available +================ + +Current version: %s +Latest version: %s + +%s + +Release notes: %s +`, current, latest, instructions, releaseURL) +}