update.go

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