diff --git a/pkg/update/check.go b/pkg/update/check.go index e0b4b09..59f86ef 100644 --- a/pkg/update/check.go +++ b/pkg/update/check.go @@ -77,6 +77,38 @@ func IsNewerVersion(current, latest string) (bool, error) { return lv.GreaterThan(cv), nil } +// veryOldMinorGap is the number of minor versions behind (within the same +// major) at which the upgrade prompt escalates from informational to urgent. +const veryOldMinorGap = 5 + +// IsVeryOldVersion reports whether current is far enough behind latest to +// warrant an urgent upgrade warning: two or more majors behind, or at least +// veryOldMinorGap minor versions behind within the same major. A single +// major bump intentionally does not escalate so v0.x users are not all +// flagged "very old" the day v1.0 ships. +func IsVeryOldVersion(current, latest string) (bool, error) { + c := normalizeSemver(current) + l := normalizeSemver(latest) + if c == "" || l == "" { + return false, errors.New("non-semver version") + } + cv, err := semver.NewVersion(c) + if err != nil { + return false, err + } + lv, err := semver.NewVersion(l) + if err != nil { + return false, err + } + if lv.Major() >= cv.Major()+2 { + return true, nil + } + if lv.Major() == cv.Major() && lv.Minor() >= cv.Minor()+veryOldMinorGap { + return true, nil + } + return false, nil +} + // FetchLatest queries GitHub Releases and returns the latest stable tag and URL. // It expects that the GitHub API returns releases in descending chronological order // (newest first), which is standard behavior. @@ -158,31 +190,38 @@ func isOnOldBrewTap() bool { return false } -// printUpgradeMessage prints a concise upgrade banner. +// printUpgradeMessage prints a concise upgrade banner on stderr, escalating +// to an urgent warning when the local version is far behind latest. func printUpgradeMessage(current, latest, url string) { cur := strings.TrimPrefix(current, "v") lat := strings.TrimPrefix(latest, "v") - pterm.Println() - pterm.Info.Printf("A new release of kernel is available: %s → %s\n", cur, lat) + info := pterm.Info.WithWriter(os.Stderr) + warn := pterm.Warning.WithWriter(os.Stderr) + fmt.Fprintln(os.Stderr) + if veryOld, err := IsVeryOldVersion(current, latest); err == nil && veryOld { + warn.Printf("You are running a very old version of kernel (%s) and should upgrade as soon as possible. Latest: %s\n", cur, lat) + } else { + info.Printf("A new release of kernel is available: %s → %s\n", cur, lat) + } if url != "" { - pterm.Info.Printf("Release notes: %s\n", url) + info.Printf("Release notes: %s\n", url) } method, _ := DetectInstallMethod() if method == InstallMethodBrew && isOnOldBrewTap() { - pterm.Println() - pterm.Warning.Println("You have kernel installed from the old tap (onkernel/tap).") - pterm.Warning.Println("To upgrade, switch to the new tap:") - pterm.Println() - pterm.Println(" brew uninstall kernel") - pterm.Println(" brew install kernel/tap/kernel") + fmt.Fprintln(os.Stderr) + warn.Println("You have kernel installed from the old tap (onkernel/tap).") + warn.Println("To upgrade, switch to the new tap:") + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, " brew uninstall kernel") + fmt.Fprintln(os.Stderr, " brew install kernel/tap/kernel") return } if cmd := SuggestUpgradeCommand(); cmd != "" { - pterm.Info.Printf("To upgrade, run: %s\n", cmd) + info.Printf("To upgrade, run: %s\n", cmd) } else { - pterm.Info.Println("To upgrade, visit the release page above or use your package manager.") + info.Println("To upgrade, visit the release page above or use your package manager.") } } @@ -200,6 +239,9 @@ func MaybeShowMessage(ctx context.Context, currentVersion string, frequency time if invokedTrivialCommand() { return } + if !stdoutIsTerminal() { + return + } cachePath := filepath.Join(xdgCacheDir(), cacheRelPath) cache, _ := loadCache(cachePath) @@ -415,3 +457,13 @@ func invokedTrivialCommand() bool { } return false } + +// stdoutIsTerminal reports whether stdout is a TTY. Used to skip the upgrade +// banner when stdout is piped or redirected. +func stdoutIsTerminal() bool { + fi, err := os.Stdout.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) != 0 +} diff --git a/pkg/update/check_test.go b/pkg/update/check_test.go index b804e70..55ccb35 100644 --- a/pkg/update/check_test.go +++ b/pkg/update/check_test.go @@ -111,3 +111,36 @@ func TestInstallMethodRulesPathPrecedence(t *testing.T) { assert.Equal(t, InstallMethodPNPM, detect("/home/user/.local/share/pnpm/kernel")) assert.Equal(t, InstallMethodUnknown, detect("/usr/local/bin/kernel")) } + +func TestIsVeryOldVersion(t *testing.T) { + tests := []struct { + name string + current string + latest string + want bool + wantErr bool + }{ + {"same version", "v0.19.1", "v0.19.1", false, false}, + {"one minor behind", "v0.18.0", "v0.19.0", false, false}, + {"four minor behind", "v0.15.0", "v0.19.0", false, false}, + {"five minor behind escalates", "v0.14.0", "v0.19.0", true, false}, + {"many minor behind", "v0.5.0", "v0.19.1", true, false}, + {"single major bump does not escalate", "v1.2.3", "v2.0.0", false, false}, + {"single major bump from 0.x does not escalate", "v0.19.2", "v1.0.0", false, false}, + {"two majors behind escalates", "v1.2.3", "v3.0.0", true, false}, + {"patch behind only", "v0.19.0", "v0.19.5", false, false}, + {"v prefix tolerated", "0.10.0", "v0.19.0", true, false}, + {"non-semver returns error", "dev", "v0.19.0", false, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := IsVeryOldVersion(tt.current, tt.latest) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +}