Skip to content

Commit f21b7f8

Browse files
Merge pull request #188 from hdresearch/fix/update-nag-not-printing
fix(update): make "update available" nag actually print
2 parents f7af8e8 + ff9efbb commit f21b7f8

5 files changed

Lines changed: 649 additions & 29 deletions

File tree

cmd/root.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -179,17 +179,17 @@ interaction capabilities, and more.`,
179179
cmd.CalledAs() == "help" ||
180180
cmd.Name() == "upgrade"
181181

182-
if !skipUpdateCheck && update.ShouldCheckForUpdate() {
183-
// Update the check time regardless of result
184-
update.UpdateCheckTime()
185-
// Check for updates in background
186-
go func() {
187-
DebugPrint("Checking for updates...\n")
188-
hasUpdate, latestVersion, err := update.CheckForUpdates(Version, Repository, verbose)
189-
if err == nil && hasUpdate {
190-
fmt.Printf("💡 Update available: %s -> %s (run 'vers upgrade')\n\n", Version, latestVersion)
191-
}
192-
}()
182+
if !skipUpdateCheck {
183+
// Synchronous update check with a tight timeout. Uses a
184+
// disk-cached "latest known release" so the nag prints
185+
// instantly on subsequent runs without any network I/O,
186+
// and only refreshes once per check interval.
187+
//
188+
// Done synchronously (not in a goroutine) because short
189+
// commands like `vers ps` would exit before a detached
190+
// goroutine could finish its HTTP call, suppressing the
191+
// nag indefinitely.
192+
update.MaybeNotifyUpdate(cmd.Context(), Version, Repository, 800*time.Millisecond, verbose)
193193
}
194194

195195
if cmd.Name() == "login" || cmd.Name() == "signup" || cmd.Name() == "help" || cmd.CalledAs() == "help" {

internal/config/config_json.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ type UpdateCheckConfig struct {
1818
LastCheck time.Time `json:"last_check"`
1919
NextCheck time.Time `json:"next_check"`
2020
CheckInterval int64 `json:"check_interval"` // in seconds, default 3600 (1 hour)
21+
// LatestVersion is the most recently observed upstream release tag
22+
// (e.g. "v0.10.0"). Cached so the "update available" nag can be
23+
// printed synchronously on subsequent runs without hitting the network.
24+
LatestVersion string `json:"latest_version,omitempty"`
2125
}
2226

2327
// GetCLIConfigPath returns the path to the CLI config file

internal/update/update.go

Lines changed: 227 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package update
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
67
"net/http"
8+
"os"
79
"strings"
810
"time"
911

@@ -25,39 +27,83 @@ type GitHubRelease struct {
2527
PublishedAt time.Time `json:"published_at"`
2628
}
2729

28-
// CheckForUpdates checks if there's a new version available
30+
// IsDevVersion reports whether a version string represents a local/dev build
31+
// for which we should skip update checks entirely.
32+
//
33+
// This catches:
34+
// - the literal sentinels "dev" / "unknown" / "" set when ldflags weren't applied
35+
// - "dev-<sha>" / "*-dirty" produced by our init() fallback against `git describe`
36+
// - Go module *pseudo-versions* of the form "v0.0.0-<timestamp>-<commit>",
37+
// which are what `go install` and `go run` produce against an untagged
38+
// commit. Without this, integration test runs that build via the module
39+
// graph end up with a perfectly valid-looking semver and try to "upgrade"
40+
// to whatever real release is currently latest, contaminating test output.
41+
func IsDevVersion(v string) bool {
42+
stripped := strings.TrimPrefix(v, "v")
43+
if stripped == "" || stripped == "dev" || stripped == "unknown" {
44+
return true
45+
}
46+
if strings.HasPrefix(stripped, "dev-") || strings.Contains(stripped, "-dirty") {
47+
return true
48+
}
49+
// Go pseudo-versions all start with "0.0.0-" (untagged repo) or
50+
// "X.Y.Z-0.<timestamp>-<commit>" (post-tag). The first form is the
51+
// only one we hit in practice (the release pipeline sets a real
52+
// version via ldflags), so checking that prefix is sufficient.
53+
if strings.HasPrefix(stripped, "0.0.0-") {
54+
return true
55+
}
56+
return false
57+
}
58+
59+
// CheckForUpdates checks if there's a new version available.
60+
// This is a thin wrapper around CheckForUpdatesContext that uses a default
61+
// 5s timeout for backward compatibility with callers that don't manage
62+
// their own context (e.g. `vers upgrade`).
2963
func CheckForUpdates(currentVersion, repository string, verbose bool) (bool, string, error) {
30-
// Skip check for dev versions
31-
currentVersion = strings.TrimPrefix(currentVersion, "v")
32-
if currentVersion == "dev" || currentVersion == "unknown" {
64+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
65+
defer cancel()
66+
return CheckForUpdatesContext(ctx, currentVersion, repository, verbose)
67+
}
68+
69+
// CheckForUpdatesContext checks if there's a new version available, honoring
70+
// the supplied context's deadline/cancellation.
71+
func CheckForUpdatesContext(ctx context.Context, currentVersion, repository string, verbose bool) (bool, string, error) {
72+
if IsDevVersion(currentVersion) {
3373
if verbose {
34-
fmt.Printf("[DEBUG] Skipping update check for development version\n")
74+
fmt.Printf("[DEBUG] Skipping update check for development version %q\n", currentVersion)
3575
}
3676
return false, "", nil
3777
}
3878

39-
// Get latest release
40-
latest, err := GetLatestRelease(repository, false, verbose)
79+
latest, err := GetLatestReleaseContext(ctx, repository, false, verbose)
4180
if err != nil {
4281
if verbose {
4382
fmt.Printf("[DEBUG] Failed to check for updates: %v\n", err)
4483
}
45-
return false, "", nil // Don't error out - just skip the check
84+
return false, "", err
4685
}
4786

87+
current := strings.TrimPrefix(currentVersion, "v")
4888
latestVersion := strings.TrimPrefix(latest.TagName, "v")
4989
if verbose {
50-
fmt.Printf("[DEBUG] Current: %s, Latest: %s\n", currentVersion, latestVersion)
90+
fmt.Printf("[DEBUG] Current: %s, Latest: %s\n", current, latestVersion)
5191
}
5292

53-
// Check if there's an update available
54-
hasUpdate := currentVersion != latestVersion
55-
return hasUpdate, latest.TagName, nil
93+
return current != latestVersion, latest.TagName, nil
5694
}
5795

58-
// GetLatestRelease fetches the latest release from GitHub
59-
// If includePrerelease is true, it will return the latest release including prereleases
96+
// GetLatestRelease fetches the latest release from GitHub.
97+
// If includePrerelease is true, it will return the latest release including
98+
// prereleases. Uses a default 5s timeout.
6099
func GetLatestRelease(repository string, includePrerelease bool, verbose bool) (*GitHubRelease, error) {
100+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
101+
defer cancel()
102+
return GetLatestReleaseContext(ctx, repository, includePrerelease, verbose)
103+
}
104+
105+
// GetLatestReleaseContext is like GetLatestRelease but honors the supplied context.
106+
func GetLatestReleaseContext(ctx context.Context, repository string, includePrerelease bool, verbose bool) (*GitHubRelease, error) {
61107
// Extract owner/repo from Repository constant
62108
repoURL := strings.TrimPrefix(repository, "https://github.com/")
63109

@@ -70,7 +116,13 @@ func GetLatestRelease(repository string, includePrerelease bool, verbose bool) (
70116
fmt.Printf("[DEBUG] Fetching release info from: %s\n", apiURL)
71117
}
72118

73-
resp, err := http.Get(apiURL)
119+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
120+
if err != nil {
121+
return nil, fmt.Errorf("failed to build release request: %w", err)
122+
}
123+
req.Header.Set("Accept", "application/vnd.github+json")
124+
125+
resp, err := http.DefaultClient.Do(req)
74126
if err != nil {
75127
return nil, fmt.Errorf("failed to fetch release info: %w", err)
76128
}
@@ -123,3 +175,163 @@ func UpdateCheckTime() {
123175
cliConfig.SetNextCheckTime()
124176
config.SaveCLIConfig(cliConfig)
125177
}
178+
179+
// isNewerSemver returns true if `latest` is a strictly higher version than
180+
// `current` using a tolerant lexical comparison after stripping a leading
181+
// "v". Falls back to plain string inequality if either side fails to parse
182+
// as dotted integers, which preserves the old behavior.
183+
func isNewerSemver(current, latest string) bool {
184+
c := strings.TrimPrefix(current, "v")
185+
l := strings.TrimPrefix(latest, "v")
186+
if c == "" || l == "" || c == l {
187+
return false
188+
}
189+
190+
// Strip pre-release/build metadata for the numeric comparison.
191+
stripMeta := func(s string) string {
192+
if i := strings.IndexAny(s, "-+"); i >= 0 {
193+
return s[:i]
194+
}
195+
return s
196+
}
197+
198+
cp := strings.Split(stripMeta(c), ".")
199+
lp := strings.Split(stripMeta(l), ".")
200+
parseInt := func(s string) (int, bool) {
201+
n := 0
202+
if s == "" {
203+
return 0, false
204+
}
205+
for _, r := range s {
206+
if r < '0' || r > '9' {
207+
return 0, false
208+
}
209+
n = n*10 + int(r-'0')
210+
}
211+
return n, true
212+
}
213+
214+
for i := 0; i < len(cp) || i < len(lp); i++ {
215+
var ci, li int
216+
var ok bool
217+
if i < len(cp) {
218+
if ci, ok = parseInt(cp[i]); !ok {
219+
return c != l // unparseable -> fall back
220+
}
221+
}
222+
if i < len(lp) {
223+
if li, ok = parseInt(lp[i]); !ok {
224+
return c != l
225+
}
226+
}
227+
if li > ci {
228+
return true
229+
}
230+
if li < ci {
231+
return false
232+
}
233+
}
234+
return false
235+
}
236+
237+
// MaybeNotifyUpdate prints an "update available" message to the given writer
238+
// when a newer release is known. It is designed to be called once during CLI
239+
// startup and is cheap on the hot path:
240+
//
241+
// - If a previously-cached LatestVersion is newer than `current`, the
242+
// message prints synchronously with no network I/O.
243+
// - Otherwise, if it's been longer than the configured check interval since
244+
// the last successful check, it performs a single bounded HTTP request
245+
// (capped at `timeout`) to refresh the cache. The cache (and NextCheck
246+
// timestamp) are only advanced on a successful response, so transient
247+
// network failures don't suppress the nag for an hour.
248+
//
249+
// Errors are intentionally swallowed — the update check must never break a
250+
// real command. When verbose is true, debug output is written to stderr.
251+
func MaybeNotifyUpdate(ctx context.Context, current, repository string, timeout time.Duration, verbose bool) {
252+
// Escape hatches for CI / scripted use. Either of these silences the
253+
// nag and skips all network I/O. Mirrors NO_UPDATE_NOTIFIER (npm) and
254+
// HOMEBREW_NO_AUTO_UPDATE (brew), which are well-known patterns.
255+
if envFlagSet("VERS_NO_UPDATE_CHECK") || envFlagSet("NO_UPDATE_NOTIFIER") {
256+
if verbose {
257+
fmt.Fprintf(os.Stderr, "[DEBUG] update: skipped (VERS_NO_UPDATE_CHECK / NO_UPDATE_NOTIFIER set)\n")
258+
}
259+
return
260+
}
261+
if IsDevVersion(current) {
262+
if verbose {
263+
fmt.Fprintf(os.Stderr, "[DEBUG] update: skipped (dev/pseudo version %q)\n", current)
264+
}
265+
return
266+
}
267+
268+
cliConfig, err := config.LoadCLIConfig()
269+
if err != nil {
270+
if verbose {
271+
fmt.Fprintf(os.Stderr, "[DEBUG] update: failed to load CLI config: %v\n", err)
272+
}
273+
return
274+
}
275+
276+
// 1. Fast path: print from cache if we already know about a newer release.
277+
cached := cliConfig.UpdateCheck.LatestVersion
278+
printedCached := false
279+
if cached != "" && isNewerSemver(current, cached) {
280+
printUpdateBanner(current, cached)
281+
printedCached = true
282+
}
283+
284+
if !cliConfig.ShouldCheckForUpdate() {
285+
return
286+
}
287+
288+
// 2. Slow path: refresh from GitHub with a tight timeout.
289+
fetchCtx, cancel := context.WithTimeout(ctx, timeout)
290+
defer cancel()
291+
292+
latest, err := GetLatestReleaseContext(fetchCtx, repository, false, verbose)
293+
if err != nil {
294+
if verbose {
295+
fmt.Fprintf(os.Stderr, "[DEBUG] update: refresh failed: %v\n", err)
296+
}
297+
// Don't fully bump NextCheck on failure — try again soon (5min backoff)
298+
// so a flaky network at the moment of first launch doesn't suppress
299+
// the nag for the full check interval.
300+
cliConfig.UpdateCheck.NextCheck = time.Now().Add(5 * time.Minute)
301+
_ = config.SaveCLIConfig(cliConfig)
302+
return
303+
}
304+
305+
cliConfig.UpdateCheck.LatestVersion = latest.TagName
306+
cliConfig.SetNextCheckTime()
307+
if err := config.SaveCLIConfig(cliConfig); err != nil && verbose {
308+
fmt.Fprintf(os.Stderr, "[DEBUG] update: failed to save CLI config: %v\n", err)
309+
}
310+
311+
// If the refresh revealed a newer version that the fast path didn't
312+
// already print, print now.
313+
if !printedCached && isNewerSemver(current, latest.TagName) {
314+
printUpdateBanner(current, latest.TagName)
315+
}
316+
}
317+
318+
func printUpdateBanner(current, latest string) {
319+
fmt.Fprintf(os.Stderr, "💡 vers update available: %s -> %s (run 'vers upgrade')\n\n", current, latest)
320+
}
321+
322+
// envFlagSet returns true if the env var is set to a "truthy" value.
323+
// Treats unset, empty string, "0", "false", "no", "off" (case-insensitive)
324+
// as false and anything else as true. This matches the convention used by
325+
// most CLI tools and avoids surprises like VERS_NO_UPDATE_CHECK=0 being
326+
// interpreted as "yes, suppress".
327+
func envFlagSet(name string) bool {
328+
v := strings.TrimSpace(os.Getenv(name))
329+
if v == "" {
330+
return false
331+
}
332+
switch strings.ToLower(v) {
333+
case "0", "false", "no", "off":
334+
return false
335+
}
336+
return true
337+
}

0 commit comments

Comments
 (0)