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