1package agent
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "os"
9 "path/filepath"
10 "strings"
11 "time"
12
13 "golang.org/x/mod/semver"
14)
15
16// updateCheckTTL is how often we hit the GitHub API to look for a new
17// release. Half a day is frequent enough to notice the same day a
18// release ships without spamming the API on every zut launch.
19const updateCheckTTL = 12 * time.Hour
20
21// updateCheckFile is the on-disk cache, keyed to the current binary
22// version. Writes a JSON blob with the last observed latest tag and
23// when we checked, so we can skip the network call on most launches.
24const updateCheckFile = "update-check.json"
25
26// githubReleasesAPI is the REST endpoint we query. Using the API (not
27// the HTML redirect) because the JSON response is stable and small.
28const githubReleasesAPI = "https://api.github.com/repos/bnema/zut/releases/latest"
29
30const devVersionNotice = "dev version detected; skipping update check"
31
32// UpdateInfo describes the result of an update check. Zero-value means
33// "no update available, no error, don't show anything".
34type UpdateInfo struct {
35 Current string // e.g. "0.0.4"
36 Latest string // e.g. "0.0.5"
37 Available bool // true when latest > current
38 URL string // release page url for the changelog link
39}
40
41func isDevVersion(version string) bool {
42 version = strings.TrimPrefix(strings.TrimSpace(version), "v")
43 if i := strings.IndexAny(version, " ("); i > 0 {
44 version = version[:i]
45 }
46 return version == "" || version == "dev" || version == "0.0.0" ||
47 version == "0.0.0-dev" || strings.HasPrefix(version, "0.0.0-dev+")
48}
49
50// updateCache is the on-disk structure written to $ZUT_HOME.
51type updateCache struct {
52 CheckedAt time.Time `json:"checked_at"`
53 // The version that was current when we last checked. Invalidates
54 // the cache if the binary itself has been updated since.
55 CurrentAt string `json:"current_at"`
56 Latest string `json:"latest"`
57 URL string `json:"url"`
58}
59
60// CheckForUpdate returns info about a newer release, using a cached
61// result when one is fresh enough. Designed to be called at tui
62// startup and rendered as a dismissible banner.
63//
64// Always returns a usable UpdateInfo (zero-value on error). The
65// banner renderer skips the display when Available is false, so a
66// network failure silently no-ops; we never block startup on this.
67func CheckForUpdate(ctx context.Context, zutHome, currentVersion string) UpdateInfo {
68 // Development builds never have an update to offer. Skip without
69 // reading the cache or touching the network.
70 if isDevVersion(currentVersion) {
71 return UpdateInfo{}
72 }
73
74 cachePath := filepath.Join(zutHome, updateCheckFile)
75 if c, ok := readUpdateCache(cachePath); ok {
76 // Cache is fresh and tracks the same binary version.
77 // Additional guard: only trust the cache when it already
78 // reports an available update. If the cache says "up to
79 // date" (Latest <= Current) we re-check anyway so a
80 // release published after the last launch is picked up
81 // without waiting out the full TTL. The API call is a
82 // single 4s request; cheap to do on every up-to-date
83 // launch, and skips entirely for the common "installed a
84 // new version, cache reports update available" path.
85 if time.Since(c.CheckedAt) < updateCheckTTL && c.CurrentAt == currentVersion {
86 info := buildInfo(currentVersion, c.Latest, c.URL)
87 if info.Available {
88 return info
89 }
90 // fall through to re-check
91 }
92 }
93
94 latest, url, err := fetchLatestRelease(ctx)
95 if err != nil {
96 // Network or auth failure (common while the repo is private
97 // and no GITHUB_TOKEN is set). Silent no-op; we'll try again
98 // after the TTL on the next launch.
99 return UpdateInfo{}
100 }
101
102 _ = writeUpdateCache(cachePath, updateCache{
103 CheckedAt: time.Now().UTC(),
104 CurrentAt: currentVersion,
105 Latest: latest,
106 URL: url,
107 })
108
109 return buildInfo(currentVersion, latest, url)
110}
111
112// CheckForUpdateAsync runs CheckForUpdate in a goroutine, delivers the
113// result to the returned channel, and never blocks startup. The
114// channel is always closed; receivers should `ok`-check.
115func CheckForUpdateAsync(zutHome, currentVersion string) <-chan UpdateInfo {
116 ch := make(chan UpdateInfo, 1)
117 go func() {
118 defer close(ch)
119 ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
120 defer cancel()
121 ch <- CheckForUpdate(ctx, zutHome, currentVersion)
122 }()
123 return ch
124}
125
126func buildInfo(current, latest, url string) UpdateInfo {
127 info := UpdateInfo{
128 Current: current,
129 Latest: strings.TrimPrefix(latest, "v"),
130 URL: url,
131 }
132 info.Available = versionLess(info.Current, info.Latest)
133 return info
134}
135
136// versionLess reports whether a is an older semantic version than b.
137// Invalid versions return false so update checks never offer a downgrade.
138func versionLess(a, b string) bool {
139 comparison, err := compareVersions(a, b)
140 return err == nil && comparison < 0
141}
142
143func compareVersions(a, b string) (int, error) {
144 a = normalizedVersion(a)
145 b = normalizedVersion(b)
146 if !semver.IsValid(a) {
147 return 0, fmt.Errorf("invalid semantic version %q", strings.TrimPrefix(a, "v"))
148 }
149 if !semver.IsValid(b) {
150 return 0, fmt.Errorf("invalid semantic version %q", strings.TrimPrefix(b, "v"))
151 }
152 return semver.Compare(a, b), nil
153}
154
155func normalizedVersion(v string) string {
156 v = versionOnly(v)
157 if !strings.HasPrefix(v, "v") {
158 v = "v" + v
159 }
160 return v
161}
162
163// fetchLatestRelease queries the GitHub API for the latest published
164// release. Honours $GITHUB_TOKEN for private repos.
165func fetchLatestRelease(ctx context.Context) (tag, url string, err error) {
166 req, err := http.NewRequestWithContext(ctx, "GET", githubReleasesAPI, nil)
167 if err != nil {
168 return "", "", err
169 }
170 req.Header.Set("accept", "application/vnd.github+json")
171 req.Header.Set("x-github-api-version", "2022-11-28")
172 if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
173 req.Header.Set("authorization", "Bearer "+tok)
174 }
175
176 client := &http.Client{Timeout: 4 * time.Second}
177 resp, err := client.Do(req)
178 if err != nil {
179 return "", "", err
180 }
181 defer resp.Body.Close()
182 if resp.StatusCode != 200 {
183 return "", "", fmt.Errorf("github api %d", resp.StatusCode)
184 }
185
186 var body struct {
187 TagName string `json:"tag_name"`
188 HTMLURL string `json:"html_url"`
189 }
190 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
191 return "", "", err
192 }
193 return body.TagName, body.HTMLURL, nil
194}
195
196// readUpdateCache loads the last check result. Returns ok=false on
197// any error (missing file, bad json) so callers just treat it as a
198// cache miss and re-fetch.
199func readUpdateCache(path string) (updateCache, bool) {
200 b, err := os.ReadFile(path)
201 if err != nil {
202 return updateCache{}, false
203 }
204 var c updateCache
205 if err := json.Unmarshal(b, &c); err != nil {
206 return updateCache{}, false
207 }
208 return c, true
209}
210
211func writeUpdateCache(path string, c updateCache) error {
212 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
213 return err
214 }
215 b, err := json.MarshalIndent(c, "", " ")
216 if err != nil {
217 return err
218 }
219 return os.WriteFile(path, b, 0o600)
220}