@@ -0,0 +1,118 @@
1+package update
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "regexp"
10+ "strings"
11+ "time"
12+)
13+
14+const (
15+ githubApiUrl = "https://api.github.com/repos/charmbracelet/crush/releases/latest"
16+ userAgent = "crush/1.0"
17+)
18+
19+// Default is the default [Client].
20+var Default Client = &github{}
21+
22+// Info contains information about an available update.
23+type 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
31+var goInstallRegexp = regexp.MustCompile(`^v?\d+\.\d+\.\d+-\d+\.\d{14}-[0-9a-f]{12}$`)
32+
33+func (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.
43+func (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.
58+func 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.
76+type 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.
82+type Client interface {
83+ Latest(ctx context.Context) (*Release, error)
84+}
85+
86+type github struct{}
87+
88+// Latest implements [Client].
89+func (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+}