update.go

  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" {
 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.URL = release.HTMLURL
 68	return info, nil
 69}
 70
 71// Release represents a GitHub release.
 72type Release struct {
 73	TagName string `json:"tag_name"`
 74	HTMLURL string `json:"html_url"`
 75}
 76
 77// Client is a client that can get the latest release.
 78type Client interface {
 79	Latest(ctx context.Context) (*Release, error)
 80}
 81
 82type github struct{}
 83
 84// Latest implements [Client].
 85func (c *github) Latest(ctx context.Context) (*Release, error) {
 86	client := &http.Client{
 87		Timeout: 30 * time.Second,
 88	}
 89
 90	req, err := http.NewRequestWithContext(ctx, "GET", githubAPIURL, nil)
 91	if err != nil {
 92		return nil, err
 93	}
 94	req.Header.Set("User-Agent", userAgent)
 95	req.Header.Set("Accept", "application/vnd.github.v3+json")
 96
 97	resp, err := client.Do(req)
 98	if err != nil {
 99		return nil, err
100	}
101	defer resp.Body.Close()
102
103	if resp.StatusCode != http.StatusOK {
104		body, _ := io.ReadAll(resp.Body)
105		return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))
106	}
107
108	var release Release
109	if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
110		return nil, err
111	}
112
113	return &release, nil
114}