1package update
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "regexp"
10 "strings"
11 "time"
12)
13
14const (
15 githubApiUrl = "https://api.github.com/repos/charmbracelet/crush/releases/latest"
16 userAgent = "crush/1.0"
17)
18
19// Default is the default [Client].
20var Default Client = &github{}
21
22// Info contains information about an available update.
23type Info struct {
24 Current string
25 Latest string
26 URL string
27}
28
29// Matches a version string like:
30// v0.0.0-0.20251231235959-06c807842604
31var goInstallRegexp = regexp.MustCompile(`^v?\d+\.\d+\.\d+-\d+\.\d{14}-[0-9a-f]{12}$`)
32
33func (i Info) IsDevelopment() bool {
34 return i.Current == "devel" || i.Current == "unknown" || strings.Contains(i.Current, "dirty") || goInstallRegexp.MatchString(i.Current)
35}
36
37// Available returns true if there's an update available.
38//
39// If both current and latest are stable versions, returns true if versions are
40// different.
41// If current is a pre-release and latest isn't, returns true.
42// If latest is a pre-release and current isn't, returns false.
43func (i Info) Available() bool {
44 cpr := strings.Contains(i.Current, "-")
45 lpr := strings.Contains(i.Latest, "-")
46 // current is pre release && latest isn't a prerelease
47 if cpr && !lpr {
48 return true
49 }
50 // latest is pre release && current isn't a prerelease
51 if lpr && !cpr {
52 return false
53 }
54 return i.Current != i.Latest
55}
56
57// Check checks if a new version is available.
58func Check(ctx context.Context, current string, client Client) (Info, error) {
59 info := Info{
60 Current: current,
61 Latest: current,
62 }
63
64 release, err := client.Latest(ctx)
65 if err != nil {
66 return info, fmt.Errorf("failed to fetch latest release: %w", err)
67 }
68
69 info.Latest = strings.TrimPrefix(release.TagName, "v")
70 info.Current = strings.TrimPrefix(info.Current, "v")
71 info.URL = release.HTMLURL
72 return info, nil
73}
74
75// Release represents a GitHub release.
76type Release struct {
77 TagName string `json:"tag_name"`
78 HTMLURL string `json:"html_url"`
79}
80
81// Client is a client that can get the latest release.
82type Client interface {
83 Latest(ctx context.Context) (*Release, error)
84}
85
86type github struct{}
87
88// Latest implements [Client].
89func (c *github) Latest(ctx context.Context) (*Release, error) {
90 client := &http.Client{
91 Timeout: 30 * time.Second,
92 }
93
94 req, err := http.NewRequestWithContext(ctx, "GET", githubApiUrl, nil)
95 if err != nil {
96 return nil, err
97 }
98 req.Header.Set("User-Agent", userAgent)
99 req.Header.Set("Accept", "application/vnd.github.v3+json")
100
101 resp, err := client.Do(req)
102 if err != nil {
103 return nil, err
104 }
105 defer resp.Body.Close()
106
107 if resp.StatusCode != http.StatusOK {
108 body, _ := io.ReadAll(resp.Body)
109 return nil, fmt.Errorf("github api returned status %d: %s", resp.StatusCode, string(body))
110 }
111
112 var release Release
113 if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
114 return nil, err
115 }
116
117 return &release, nil
118}