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}