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}