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