Skip to content

Commit e0a67ee

Browse files
authored
Merge pull request #6 from onkernel/phani/cli-version-update
cli display update message functionality
2 parents 404b4a0 + abc411d commit e0a67ee

File tree

4 files changed

+333
-0
lines changed

4 files changed

+333
-0
lines changed

cmd/root.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import (
55
"fmt"
66
"os"
77
"runtime"
8+
"time"
89

910
"github.com/charmbracelet/fang"
1011
"github.com/onkernel/cli/pkg/auth"
12+
"github.com/onkernel/cli/pkg/update"
1113
"github.com/onkernel/kernel-go-sdk"
1214
"github.com/onkernel/kernel-go-sdk/option"
1315
"github.com/pterm/pterm"
@@ -123,6 +125,12 @@ func init() {
123125
rootCmd.AddCommand(invokeCmd)
124126
rootCmd.AddCommand(browsersCmd)
125127
rootCmd.AddCommand(appCmd)
128+
129+
rootCmd.PersistentPostRunE = func(cmd *cobra.Command, args []string) error {
130+
// running synchronously so we never slow the command
131+
update.MaybeShowMessage(cmd.Context(), metadata.Version, 24*time.Hour)
132+
return nil
133+
}
126134
}
127135

128136
func initConfig() {

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/onkernel/cli
33
go 1.25.0
44

55
require (
6+
github.com/Masterminds/semver/v3 v3.4.0
67
github.com/boyter/gocodewalker v1.4.0
78
github.com/charmbracelet/fang v0.2.0
89
github.com/golang-jwt/jwt/v5 v5.2.2

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/
1717
github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE=
1818
github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4=
1919
github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY=
20+
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
21+
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
2022
github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
2123
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
2224
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=

pkg/update/check.go

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
package update
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"net/http"
9+
"os"
10+
"os/exec"
11+
"path/filepath"
12+
"strings"
13+
"time"
14+
15+
"github.com/pterm/pterm"
16+
17+
"github.com/Masterminds/semver/v3"
18+
)
19+
20+
const (
21+
defaultReleasesAPI = "https://api.github.com/repos/onkernel/cli/releases"
22+
userAgent = "kernel-cli/update-check"
23+
cacheRelPath = "kernel/update-check.json"
24+
requestTimeout = 3 * time.Second
25+
)
26+
27+
// Cache stores update-check metadata to throttle frequency and avoid
28+
// repeating the same banner too often.
29+
type Cache struct {
30+
LastChecked time.Time `json:"last_checked"`
31+
LastShownVersion string `json:"last_shown_version"`
32+
}
33+
34+
// shouldCheck returns true if we should perform a network check now.
35+
func shouldCheck(lastChecked, now time.Time, frequency time.Duration) bool {
36+
if lastChecked.IsZero() {
37+
return true
38+
}
39+
return now.Sub(lastChecked) >= frequency
40+
}
41+
42+
func normalizeSemver(v string) string {
43+
v = strings.TrimSpace(v)
44+
if v == "" {
45+
return ""
46+
}
47+
if strings.HasPrefix(v, "v") || strings.HasPrefix(v, "V") {
48+
v = v[1:]
49+
}
50+
return v
51+
}
52+
53+
func isSemverLike(v string) bool {
54+
v = normalizeSemver(v)
55+
if v == "" {
56+
return false
57+
}
58+
_, err := semver.NewVersion(v)
59+
return err == nil
60+
}
61+
62+
// isNewerVersion reports whether latest > current using semver rules.
63+
func isNewerVersion(current, latest string) (bool, error) {
64+
c := normalizeSemver(current)
65+
l := normalizeSemver(latest)
66+
if c == "" || l == "" {
67+
return false, errors.New("non-semver version")
68+
}
69+
cv, err := semver.NewVersion(c)
70+
if err != nil {
71+
return false, err
72+
}
73+
lv, err := semver.NewVersion(l)
74+
if err != nil {
75+
return false, err
76+
}
77+
return lv.GreaterThan(cv), nil
78+
}
79+
80+
// fetchLatest queries GitHub Releases and returns the latest stable tag and URL.
81+
// It expects that the GitHub API returns releases in descending chronological order
82+
// (newest first), which is standard behavior.
83+
func fetchLatest(ctx context.Context) (tag string, url string, err error) {
84+
apiURL := os.Getenv("KERNEL_RELEASES_URL")
85+
if apiURL == "" {
86+
apiURL = defaultReleasesAPI
87+
}
88+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
89+
if err != nil {
90+
return "", "", err
91+
}
92+
req.Header.Set("Accept", "application/vnd.github+json")
93+
req.Header.Set("User-Agent", userAgent)
94+
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
95+
req.Header.Set("Authorization", "Bearer "+token)
96+
}
97+
98+
resp, err := http.DefaultClient.Do(req)
99+
if err != nil {
100+
return "", "", err
101+
}
102+
defer resp.Body.Close()
103+
if resp.StatusCode != http.StatusOK {
104+
return "", "", fmt.Errorf("unexpected status: %s", resp.Status)
105+
}
106+
107+
var releases []struct {
108+
TagName string `json:"tag_name"`
109+
HTMLURL string `json:"html_url"`
110+
Draft bool `json:"draft"`
111+
Prerelease bool `json:"prerelease"`
112+
}
113+
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
114+
return "", "", err
115+
}
116+
for _, r := range releases {
117+
if r.Draft || r.Prerelease {
118+
continue
119+
}
120+
if r.TagName == "" {
121+
continue
122+
}
123+
return r.TagName, r.HTMLURL, nil
124+
}
125+
return "", "", errors.New("no stable releases found")
126+
}
127+
128+
// printUpgradeMessage prints a concise upgrade banner.
129+
func printUpgradeMessage(current, latest, url string) {
130+
cur := strings.TrimPrefix(current, "v")
131+
lat := strings.TrimPrefix(latest, "v")
132+
pterm.Println()
133+
pterm.Info.Printf("A new release of kernel is available: %s → %s\n", cur, lat)
134+
if url != "" {
135+
pterm.Info.Printf("Release notes: %s\n", url)
136+
}
137+
if cmd := suggestUpgradeCommand(); cmd != "" {
138+
pterm.Info.Printf("To upgrade, run: %s\n", cmd)
139+
} else {
140+
pterm.Info.Println("To upgrade, visit the release page above or use your package manager.")
141+
}
142+
}
143+
144+
// MaybeShowMessage orchestrates cache, fetch, compare, and printing.
145+
// It is designed to be non-fatal and fast; errors are swallowed.
146+
func MaybeShowMessage(ctx context.Context, currentVersion string, frequency time.Duration) {
147+
defer func() { _ = recover() }()
148+
149+
if os.Getenv("KERNEL_NO_UPDATE_CHECK") == "1" {
150+
return
151+
}
152+
if !isSemverLike(currentVersion) {
153+
return
154+
}
155+
if invokedTrivialCommand() {
156+
return
157+
}
158+
159+
cachePath := filepath.Join(xdgCacheDir(), cacheRelPath)
160+
cache, _ := loadCache(cachePath)
161+
162+
// Allow env override for frequency in tests (e.g., "1h", "24h").
163+
effectiveFreq := frequency
164+
if envFreq := os.Getenv("KERNEL_UPDATE_CHECK_FREQUENCY"); envFreq != "" {
165+
if d, err := time.ParseDuration(envFreq); err == nil && d > 0 {
166+
effectiveFreq = d
167+
}
168+
}
169+
if !shouldCheck(cache.LastChecked, time.Now().UTC(), effectiveFreq) {
170+
return
171+
}
172+
173+
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
174+
defer cancel()
175+
latestTag, releaseURL, err := fetchLatest(ctx)
176+
if err != nil {
177+
cache.LastChecked = time.Now().UTC()
178+
_ = saveCache(cachePath, cache)
179+
return
180+
}
181+
isNewer, err := isNewerVersion(currentVersion, latestTag)
182+
if err != nil || !isNewer {
183+
cache.LastChecked = time.Now().UTC()
184+
_ = saveCache(cachePath, cache)
185+
return
186+
}
187+
188+
// Note: We intentionally do not suppress by LastShownVersion so that
189+
// the banner reappears each frequency window until the user upgrades.
190+
printUpgradeMessage(currentVersion, latestTag, releaseURL)
191+
cache.LastChecked = time.Now().UTC()
192+
cache.LastShownVersion = latestTag
193+
_ = saveCache(cachePath, cache)
194+
}
195+
196+
// xdgCacheDir returns a best-effort per-user cache directory.
197+
func xdgCacheDir() string {
198+
if d := os.Getenv("XDG_CACHE_HOME"); d != "" {
199+
return d
200+
}
201+
if h, err := os.UserHomeDir(); err == nil {
202+
return filepath.Join(h, ".cache")
203+
}
204+
return "."
205+
}
206+
207+
// loadCache reads the cache file from path. If the file doesn't exist,
208+
// returns an empty cache and no error.
209+
func loadCache(path string) (Cache, error) {
210+
b, err := os.ReadFile(path)
211+
if err != nil {
212+
if os.IsNotExist(err) {
213+
return Cache{}, nil
214+
}
215+
return Cache{}, err
216+
}
217+
var c Cache
218+
if err := json.Unmarshal(b, &c); err != nil {
219+
return Cache{}, err
220+
}
221+
return c, nil
222+
}
223+
224+
// saveCache writes the cache to disk, creating parent directories as needed.
225+
func saveCache(path string, c Cache) error {
226+
dir := filepath.Dir(path)
227+
if err := os.MkdirAll(dir, 0o755); err != nil {
228+
return err
229+
}
230+
b, err := json.MarshalIndent(c, "", " ")
231+
if err != nil {
232+
return err
233+
}
234+
return os.WriteFile(path, b, 0o600)
235+
}
236+
237+
// suggestUpgradeCommand attempts to infer how the user installed kernel and
238+
// returns a tailored upgrade command. Falls back to empty string on unknown.
239+
func suggestUpgradeCommand() string {
240+
// Collect candidate paths: current executable and shell-resolved binary
241+
candidates := []string{}
242+
if exe, err := os.Executable(); err == nil && exe != "" {
243+
if real, err2 := filepath.EvalSymlinks(exe); err2 == nil && real != "" {
244+
exe = real
245+
}
246+
candidates = append(candidates, exe)
247+
}
248+
if which, err := exec.LookPath("kernel"); err == nil && which != "" {
249+
candidates = append(candidates, which)
250+
}
251+
252+
// Helpers
253+
norm := func(p string) string { return strings.ToLower(filepath.ToSlash(p)) }
254+
hasHomebrew := func(p string) bool {
255+
p = norm(p)
256+
return strings.Contains(p, "homebrew") || strings.Contains(p, "/cellar/")
257+
}
258+
hasBun := func(p string) bool { p = norm(p); return strings.Contains(p, "/.bun/") }
259+
hasPNPM := func(p string) bool {
260+
p = norm(p)
261+
return strings.Contains(p, "/pnpm/") || strings.Contains(p, "/.pnpm/")
262+
}
263+
hasNPM := func(p string) bool {
264+
p = norm(p)
265+
return strings.Contains(p, "/npm/") || strings.Contains(p, "/node_modules/.bin/")
266+
}
267+
268+
type rule struct {
269+
check func(string) bool
270+
envKeys []string
271+
cmd string
272+
}
273+
274+
rules := []rule{
275+
{hasHomebrew, nil, "brew upgrade onkernel/tap/kernel"},
276+
{hasBun, []string{"BUN_INSTALL"}, "bun add -g @onkernel/cli@latest"},
277+
{hasPNPM, []string{"PNPM_HOME"}, "pnpm add -g @onkernel/cli@latest"},
278+
{hasNPM, []string{"NPM_CONFIG_PREFIX", "npm_config_prefix", "VOLTA_HOME"}, "npm i -g @onkernel/cli@latest"},
279+
}
280+
281+
// Path-based detection first
282+
for _, c := range candidates {
283+
for _, r := range rules {
284+
if r.check != nil && r.check(c) {
285+
return r.cmd
286+
}
287+
}
288+
}
289+
290+
// Env-only fallbacks
291+
envSet := func(keys []string) bool {
292+
for _, k := range keys {
293+
if k == "" {
294+
continue
295+
}
296+
if os.Getenv(k) != "" {
297+
return true
298+
}
299+
}
300+
return false
301+
}
302+
for _, r := range rules {
303+
if len(r.envKeys) > 0 && envSet(r.envKeys) {
304+
return r.cmd
305+
}
306+
}
307+
308+
// Default suggestion when unknown
309+
return "brew upgrade onkernel/tap/kernel"
310+
}
311+
312+
// invokedTrivialCommand returns true if the argv suggests a trivial invocation
313+
// like help/completion/version-only where we can skip the update check.
314+
func invokedTrivialCommand() bool {
315+
args := os.Args[1:]
316+
for _, a := range args {
317+
if a == "--version" || a == "-v" || a == "help" || a == "completion" {
318+
return true
319+
}
320+
}
321+
return false
322+
}

0 commit comments

Comments
 (0)