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/Masterminds/semver/v3"
14 "github.com/charmbracelet/crush/internal/version"
15)
16
17const (
18 githubAPIURL = "https://api.github.com/repos/charmbracelet/crush/releases/latest"
19 userAgent = "crush/1.0"
20)
21
22// Release represents a GitHub release.
23type Release struct {
24 TagName string `json:"tag_name"`
25 HTMLURL string `json:"html_url"`
26}
27
28// UpdateInfo contains information about an available update.
29type UpdateInfo struct {
30 CurrentVersion string
31 LatestVersion string
32 ReleaseURL string
33 Available bool
34}
35
36// CheckForUpdate checks if a new version is available.
37func CheckForUpdate(ctx context.Context) (*UpdateInfo, error) {
38 info := &UpdateInfo{
39 CurrentVersion: version.Version,
40 }
41
42 cv, err := semver.NewVersion(version.Version)
43 if err != nil {
44 // its devel, unknown, etc
45 return info, nil
46 }
47
48 release, err := fetchLatestRelease(ctx)
49 if err != nil {
50 return nil, fmt.Errorf("failed to fetch latest release: %w", err)
51 }
52
53 lv, err := semver.NewVersion(release.TagName)
54 if err != nil {
55 return nil, fmt.Errorf("failed to parse latest version: %w", err)
56 }
57
58 info.LatestVersion = strings.TrimPrefix(release.TagName, "v")
59 info.ReleaseURL = release.HTMLURL
60 info.Available = lv.GreaterThan(cv)
61
62 return info, nil
63}
64
65// fetchLatestRelease fetches the latest release information from GitHub.
66func fetchLatestRelease(ctx context.Context) (*Release, error) {
67 client := &http.Client{
68 Timeout: 30 * time.Second,
69 }
70
71 req, err := http.NewRequestWithContext(ctx, "GET", githubAPIURL, nil)
72 if err != nil {
73 return nil, err
74 }
75 req.Header.Set("User-Agent", userAgent)
76 req.Header.Set("Accept", "application/vnd.github.v3+json")
77
78 resp, err := client.Do(req)
79 if err != nil {
80 return nil, err
81 }
82 defer resp.Body.Close()
83
84 if resp.StatusCode != http.StatusOK {
85 body, _ := io.ReadAll(resp.Body)
86 return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))
87 }
88
89 var release Release
90 if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
91 return nil, err
92 }
93
94 return &release, nil
95}
96
97// CheckForUpdateAsync performs an update check in the background and returns immediately.
98// If an update is available, it returns the update info through the channel.
99func CheckForUpdateAsync(ctx context.Context, dataDir string) <-chan *UpdateInfo {
100 ch := make(chan *UpdateInfo, 1)
101
102 go func() {
103 defer close(ch)
104
105 // Perform the check.
106 info, err := CheckForUpdate(ctx)
107 if err != nil {
108 // Log error but don't fail.
109 fmt.Fprintf(os.Stderr, "Failed to check for updates: %v\n", err)
110 return
111 }
112
113 // Send update info if available.
114 if info.Available {
115 ch <- info
116 }
117 }()
118
119 return ch
120}