From ab18e95132f517e5d04c15ef671a2877d43e17e4 Mon Sep 17 00:00:00 2001 From: Sayan- <1415138+Sayan-@users.noreply.github.com> Date: Thu, 14 May 2026 17:41:38 +0000 Subject: [PATCH 1/3] add urgent upgrade warning when CLI is many versions behind When the installed CLI is a full major version or 5+ minor versions behind the latest release, escalate the periodic update banner from "A new release is available" to "You are running a very old version and should upgrade as soon as possible". --- pkg/update/check.go | 40 ++++++++++++++++++++++++++++++++++++++-- pkg/update/check_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/pkg/update/check.go b/pkg/update/check.go index e0b4b09..2287588 100644 --- a/pkg/update/check.go +++ b/pkg/update/check.go @@ -77,6 +77,37 @@ 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 as soon as possible" warning. Any major-version +// gap qualifies; otherwise the user must be at least veryOldMinorGap minor +// versions behind within the same major. +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() { + 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,12 +189,17 @@ func isOnOldBrewTap() bool { return false } -// printUpgradeMessage prints a concise upgrade banner. +// printUpgradeMessage prints a concise upgrade banner. When the local version +// is far behind the latest release, the banner escalates to an urgent warning. 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) + if veryOld, err := IsVeryOldVersion(current, latest); err == nil && veryOld { + pterm.Warning.Printf("You are running a very old version of kernel (%s) and should upgrade as soon as possible. Latest: %s\n", cur, lat) + } else { + pterm.Info.Printf("A new release of kernel is available: %s → %s\n", cur, lat) + } if url != "" { pterm.Info.Printf("Release notes: %s\n", url) } diff --git a/pkg/update/check_test.go b/pkg/update/check_test.go index b804e70..f483fc2 100644 --- a/pkg/update/check_test.go +++ b/pkg/update/check_test.go @@ -111,3 +111,34 @@ 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}, + {"major behind escalates", "v1.2.3", "v2.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) + }) + } +} From de34c2e8adeef7b6fe1d22745691fe5395038cfa Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Mon, 18 May 2026 14:31:33 -0700 Subject: [PATCH 2/3] update: gate urgent banner behind TTY + stderr; require 2+ major gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes on top of the urgent-upgrade-warning banner: 1. IsVeryOldVersion now requires a 2+ major gap (not any major gap). This avoids telling every up-to-date v0.x user "you are running a very old version" the moment v1.0.0 ships. The 5-minor rule inside the same major is unchanged. 2. MaybeShowMessage skips when stdout is not a terminal. Pipes, redirects, and CI logs no longer get banner text mixed into machine-readable output (e.g. `kernel browsers list -o json | jq`). 3. printUpgradeMessage now writes to stderr instead of stdout, so the banner stays out of stdout in any edge case the TTY check misses. Tests updated: dropped the single-major-bump escalation case and added coverage for the v0.x → v1.0 cusp plus a true 2-major gap. Co-authored-by: Cursor --- pkg/update/check.go | 57 ++++++++++++++++++++++++++++------------ pkg/update/check_test.go | 4 ++- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/pkg/update/check.go b/pkg/update/check.go index 2287588..a6c8f08 100644 --- a/pkg/update/check.go +++ b/pkg/update/check.go @@ -82,9 +82,11 @@ func IsNewerVersion(current, latest string) (bool, error) { const veryOldMinorGap = 5 // IsVeryOldVersion reports whether current is far enough behind latest to -// warrant an urgent "upgrade as soon as possible" warning. Any major-version -// gap qualifies; otherwise the user must be at least veryOldMinorGap minor -// versions behind within the same major. +// warrant an urgent "upgrade as soon as possible" warning. The user qualifies +// when they are two or more majors behind, or at least veryOldMinorGap minor +// versions behind within the same major. A single major-version bump is +// intentionally not escalated: at the v0.x → v1.0 cusp, every up-to-date +// v0.x user would otherwise be told they are "very old" the day v1.0 ships. func IsVeryOldVersion(current, latest string) (bool, error) { c := normalizeSemver(current) l := normalizeSemver(latest) @@ -99,10 +101,11 @@ func IsVeryOldVersion(current, latest string) (bool, error) { if err != nil { return false, err } - if lv.Major() > cv.Major() { + majorGap := int64(lv.Major()) - int64(cv.Major()) + if majorGap >= 2 { return true, nil } - if lv.Major() == cv.Major() && lv.Minor() >= cv.Minor()+veryOldMinorGap { + if majorGap == 0 && lv.Minor() >= cv.Minor()+veryOldMinorGap { return true, nil } return false, nil @@ -191,34 +194,39 @@ func isOnOldBrewTap() bool { // printUpgradeMessage prints a concise upgrade banner. When the local version // is far behind the latest release, the banner escalates to an urgent warning. +// All output is routed to stderr so that callers piping or redirecting stdout +// (for example, `-o json` consumers) do not get banner text mixed into their +// machine-readable output. func printUpgradeMessage(current, latest, url string) { cur := strings.TrimPrefix(current, "v") lat := strings.TrimPrefix(latest, "v") - pterm.Println() + 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 { - pterm.Warning.Printf("You are running a very old version of kernel (%s) and should upgrade as soon as possible. Latest: %s\n", cur, lat) + 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 { - pterm.Info.Printf("A new release of kernel is available: %s → %s\n", cur, lat) + 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.") } } @@ -236,6 +244,9 @@ func MaybeShowMessage(ctx context.Context, currentVersion string, frequency time if invokedTrivialCommand() { return } + if !stdoutIsTerminal() { + return + } cachePath := filepath.Join(xdgCacheDir(), cacheRelPath) cache, _ := loadCache(cachePath) @@ -451,3 +462,15 @@ func invokedTrivialCommand() bool { } return false } + +// stdoutIsTerminal reports whether stdout is connected to a terminal. When +// stdout is a pipe, file, or other non-TTY (CI logs, `| jq`, `> file.json`, +// `-o json` consumers, etc.) we skip the upgrade banner so we never corrupt +// machine-readable output. +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 f483fc2..55ccb35 100644 --- a/pkg/update/check_test.go +++ b/pkg/update/check_test.go @@ -125,7 +125,9 @@ func TestIsVeryOldVersion(t *testing.T) { {"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}, - {"major behind escalates", "v1.2.3", "v2.0.0", 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}, From 58412028d68ea24f6ada71787e2f6aaa4a1d6aaa Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Mon, 18 May 2026 14:33:54 -0700 Subject: [PATCH 3/3] deslop: tighten comments and drop unnecessary int64 casts Co-authored-by: Cursor --- pkg/update/check.go | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/pkg/update/check.go b/pkg/update/check.go index a6c8f08..59f86ef 100644 --- a/pkg/update/check.go +++ b/pkg/update/check.go @@ -82,11 +82,10 @@ func IsNewerVersion(current, latest string) (bool, error) { const veryOldMinorGap = 5 // IsVeryOldVersion reports whether current is far enough behind latest to -// warrant an urgent "upgrade as soon as possible" warning. The user qualifies -// when they are two or more majors behind, or at least veryOldMinorGap minor -// versions behind within the same major. A single major-version bump is -// intentionally not escalated: at the v0.x → v1.0 cusp, every up-to-date -// v0.x user would otherwise be told they are "very old" the day v1.0 ships. +// 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) @@ -101,11 +100,10 @@ func IsVeryOldVersion(current, latest string) (bool, error) { if err != nil { return false, err } - majorGap := int64(lv.Major()) - int64(cv.Major()) - if majorGap >= 2 { + if lv.Major() >= cv.Major()+2 { return true, nil } - if majorGap == 0 && lv.Minor() >= cv.Minor()+veryOldMinorGap { + if lv.Major() == cv.Major() && lv.Minor() >= cv.Minor()+veryOldMinorGap { return true, nil } return false, nil @@ -192,11 +190,8 @@ func isOnOldBrewTap() bool { return false } -// printUpgradeMessage prints a concise upgrade banner. When the local version -// is far behind the latest release, the banner escalates to an urgent warning. -// All output is routed to stderr so that callers piping or redirecting stdout -// (for example, `-o json` consumers) do not get banner text mixed into their -// machine-readable output. +// 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") @@ -463,10 +458,8 @@ func invokedTrivialCommand() bool { return false } -// stdoutIsTerminal reports whether stdout is connected to a terminal. When -// stdout is a pipe, file, or other non-TTY (CI logs, `| jq`, `> file.json`, -// `-o json` consumers, etc.) we skip the upgrade banner so we never corrupt -// machine-readable output. +// 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 {