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