1package update
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"io"
  8	"net/http"
  9	"os"
 10	"strings"
 11	"time"
 12
 13	"github.com/charmbracelet/crush/internal/version"
 14)
 15
 16const (
 17	githubAPIURL = "https://api.github.com/repos/charmbracelet/crush/releases/latest"
 18	userAgent    = "crush/1.0"
 19)
 20
 21// Release represents a GitHub release.
 22type Release struct {
 23	TagName string `json:"tag_name"`
 24	HTMLURL string `json:"html_url"`
 25}
 26
 27// UpdateInfo contains information about an available update.
 28type UpdateInfo struct {
 29	CurrentVersion string
 30	LatestVersion  string
 31	ReleaseURL     string
 32	Available      bool
 33}
 34
 35// CheckForUpdate checks if a new version is available.
 36func CheckForUpdate(ctx context.Context) (*UpdateInfo, error) {
 37	info := &UpdateInfo{
 38		CurrentVersion: version.Version,
 39	}
 40
 41	// Skip update check for development versions.
 42	if strings.Contains(version.Version, "unknown") {
 43		return info, nil
 44	}
 45
 46	release, err := fetchLatestRelease(ctx)
 47	if err != nil {
 48		return nil, fmt.Errorf("failed to fetch latest release: %w", err)
 49	}
 50
 51	info.LatestVersion = strings.TrimPrefix(release.TagName, "v")
 52	info.ReleaseURL = release.HTMLURL
 53
 54	// Compare versions.
 55	if compareVersions(info.CurrentVersion, info.LatestVersion) < 0 {
 56		info.Available = true
 57	}
 58
 59	return info, nil
 60}
 61
 62// fetchLatestRelease fetches the latest release information from GitHub.
 63func fetchLatestRelease(ctx context.Context) (*Release, error) {
 64	client := &http.Client{
 65		Timeout: 30 * time.Second,
 66	}
 67
 68	req, err := http.NewRequestWithContext(ctx, "GET", githubAPIURL, nil)
 69	if err != nil {
 70		return nil, err
 71	}
 72	req.Header.Set("User-Agent", userAgent)
 73	req.Header.Set("Accept", "application/vnd.github.v3+json")
 74
 75	resp, err := client.Do(req)
 76	if err != nil {
 77		return nil, err
 78	}
 79	defer resp.Body.Close()
 80
 81	if resp.StatusCode != http.StatusOK {
 82		body, _ := io.ReadAll(resp.Body)
 83		return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))
 84	}
 85
 86	var release Release
 87	if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
 88		return nil, err
 89	}
 90
 91	return &release, nil
 92}
 93
 94// compareVersions compares two semantic versions.
 95// Returns -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2.
 96func compareVersions(v1, v2 string) int {
 97	// Remove 'v' prefix if present.
 98	v1 = strings.TrimPrefix(v1, "v")
 99	v2 = strings.TrimPrefix(v2, "v")
100
101	// Split versions into parts.
102	parts1 := strings.Split(v1, ".")
103	parts2 := strings.Split(v2, ".")
104
105	// Compare each part.
106	for i := 0; i < len(parts1) && i < len(parts2); i++ {
107		var n1, n2 int
108		fmt.Sscanf(parts1[i], "%d", &n1)
109		fmt.Sscanf(parts2[i], "%d", &n2)
110
111		if n1 < n2 {
112			return -1
113		} else if n1 > n2 {
114			return 1
115		}
116	}
117
118	// If all parts are equal, compare lengths.
119	if len(parts1) < len(parts2) {
120		return -1
121	} else if len(parts1) > len(parts2) {
122		return 1
123	}
124
125	return 0
126}
127
128// CheckForUpdateAsync performs an update check in the background and returns immediately.
129// If an update is available, it returns the update info through the channel.
130func CheckForUpdateAsync(ctx context.Context, dataDir string) <-chan *UpdateInfo {
131	ch := make(chan *UpdateInfo, 1)
132
133	go func() {
134		defer close(ch)
135
136		// Perform the check.
137		info, err := CheckForUpdate(ctx)
138		if err != nil {
139			// Log error but don't fail.
140			fmt.Fprintf(os.Stderr, "Failed to check for updates: %v\n", err)
141			return
142		}
143
144		// Send update info if available.
145		if info.Available {
146			ch <- info
147		}
148	}()
149
150	return ch
151}