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	"golang.org/x/mod/semver"
 14)
 15
 16const (
 17	moduleProxyURL = "https://proxy.golang.org/git.secluded.site/crush/@latest"
 18	userAgent      = "crush/1.0"
 19)
 20
 21// Default is the default [Client].
 22var Default Client = &moduleProxy{}
 23
 24// Info contains information about an available update.
 25type Info struct {
 26	Current string
 27	Latest  string
 28	URL     string
 29}
 30
 31// Matches a version string like:
 32// v0.0.0-0.20251231235959-06c807842604
 33var goInstallRegexp = regexp.MustCompile(`^v?\d+\.\d+\.\d+-\d+\.\d{14}-[0-9a-f]{12}$`)
 34
 35func (i Info) IsDevelopment() bool {
 36	return i.Current == "devel" || i.Current == "unknown" || strings.Contains(i.Current, "dirty") || goInstallRegexp.MatchString(i.Current)
 37}
 38
 39// Available returns true if there's an update available.
 40//
 41// Fork prereleases are treated as latest for their base version. Other
 42// prereleases follow standard semver comparison.
 43func (i Info) Available() bool {
 44	vCurrent := "v" + i.Current
 45	vLatest := "v" + i.Latest
 46
 47	if semver.Compare(vLatest, vCurrent) <= 0 {
 48		return false
 49	}
 50
 51	// Fork vs stable override:
 52	// If current is a fork prerelease (contains "-fork") and latest is the
 53	// stable version of the same base, don't upgrade.
 54	if strings.Contains(i.Current, "-fork") && !strings.Contains(i.Latest, "-") {
 55		baseCurrent := strings.Split(vCurrent, "-")[0]
 56		if baseCurrent == vLatest {
 57			return false
 58		}
 59	}
 60
 61	return true
 62}
 63
 64// Check checks if a new version is available.
 65func Check(ctx context.Context, current string, client Client) (Info, error) {
 66	info := Info{
 67		Current: current,
 68		Latest:  current,
 69		URL:     "https://git.secluded.site/crush",
 70	}
 71
 72	version, err := client.Latest(ctx)
 73	if err != nil {
 74		return info, fmt.Errorf("failed to fetch latest version: %w", err)
 75	}
 76
 77	info.Latest = strings.TrimPrefix(version.Version, "v")
 78	info.Current = strings.TrimPrefix(info.Current, "v")
 79	return info, nil
 80}
 81
 82// ModuleVersion represents a version from the Go module proxy.
 83type ModuleVersion struct {
 84	Version string    `json:"Version"`
 85	Time    time.Time `json:"Time"`
 86}
 87
 88// Client is a client that can get the latest version.
 89type Client interface {
 90	Latest(ctx context.Context) (*ModuleVersion, error)
 91}
 92
 93type moduleProxy struct{}
 94
 95// Latest implements [Client].
 96func (c *moduleProxy) Latest(ctx context.Context) (*ModuleVersion, error) {
 97	client := &http.Client{
 98		Timeout: 30 * time.Second,
 99	}
100
101	req, err := http.NewRequestWithContext(ctx, "GET", moduleProxyURL, nil)
102	if err != nil {
103		return nil, err
104	}
105	req.Header.Set("User-Agent", userAgent)
106
107	resp, err := client.Do(req)
108	if err != nil {
109		return nil, err
110	}
111	defer resp.Body.Close()
112
113	if resp.StatusCode != http.StatusOK {
114		body, _ := io.ReadAll(resp.Body)
115		return nil, fmt.Errorf("module proxy returned status %d: %s", resp.StatusCode, string(body))
116	}
117
118	var version ModuleVersion
119	if err := json.NewDecoder(resp.Body).Decode(&version); err != nil {
120		return nil, err
121	}
122
123	return &version, nil
124}