update.go

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