Skip to content

Commit 1d45a15

Browse files
committed
feat: add update notifications (gh CLI pattern)
Background goroutine checks GitHub Releases API on every run, cached for 24h. Shows notice on stderr after command output. Homebrew-aware: suppresses if release is <24h old, shows `brew upgrade cnap` for Homebrew users. Skips in CI/non-TTY.
1 parent 8549045 commit 1d45a15

4 files changed

Lines changed: 279 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ Config is stored at `~/.cnap/config.yaml`. Environment variables take priority:
9797
| `CNAP_API_URL` | API base URL (overrides config) |
9898
| `CNAP_AUTH_URL` | Auth base URL (overrides config) |
9999
| `CNAP_DEBUG` | Enable debug logging (set to any value) |
100+
| `CNAP_NO_UPDATE_NOTIFIER` | Disable update notifications (set to any value) |
100101

101102
## Global Flags
102103

internal/cmd/root.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package cmd
33
import (
44
"context"
55
"fmt"
6+
"os"
7+
"strings"
8+
"time"
69

710
authcmd "github.com/cnap-tech/cli/internal/cmd/auth"
811
clusterscmd "github.com/cnap-tech/cli/internal/cmd/clusters"
@@ -14,6 +17,7 @@ import (
1417
workspacescmd "github.com/cnap-tech/cli/internal/cmd/workspaces"
1518
"github.com/cnap-tech/cli/internal/cmdutil"
1619
"github.com/cnap-tech/cli/internal/debug"
20+
"github.com/cnap-tech/cli/internal/update"
1721
"github.com/cnap-tech/cli/internal/useragent"
1822
"github.com/spf13/cobra"
1923
)
@@ -24,7 +28,38 @@ var (
2428
)
2529

2630
func Execute(ctx context.Context) error {
27-
return rootCmd().ExecuteContext(ctx)
31+
root := rootCmd()
32+
33+
// Background update check (gh CLI pattern)
34+
updateCh := make(chan *update.ReleaseInfo)
35+
go func() {
36+
if version == "dev" || !update.ShouldCheckForUpdate() {
37+
updateCh <- nil
38+
return
39+
}
40+
checkCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
41+
defer cancel()
42+
rel, _ := update.CheckForUpdate(checkCtx, version)
43+
updateCh <- rel
44+
}()
45+
46+
err := root.ExecuteContext(ctx)
47+
48+
// Print update notice after command output
49+
if newRelease := <-updateCh; newRelease != nil {
50+
isHomebrew := update.IsUnderHomebrew()
51+
if !(isHomebrew && update.IsRecentRelease(newRelease.PublishedAt)) {
52+
fmt.Fprintf(os.Stderr, "\nA new release of cnap is available: %s → %s\n",
53+
strings.TrimPrefix(version, "v"),
54+
strings.TrimPrefix(newRelease.Version, "v"))
55+
if isHomebrew {
56+
fmt.Fprintf(os.Stderr, "To upgrade, run: brew upgrade cnap\n")
57+
}
58+
fmt.Fprintf(os.Stderr, "%s\n", newRelease.URL)
59+
}
60+
}
61+
62+
return err
2863
}
2964

3065
func rootCmd() *cobra.Command {

internal/update/update.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
// Package update provides version update checking for the CNAP CLI.
2+
// It checks the GitHub Releases API in the background and caches results
3+
// for 24 hours to avoid excessive API calls.
4+
package update
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"os"
13+
"os/exec"
14+
"path/filepath"
15+
"strconv"
16+
"strings"
17+
"time"
18+
19+
"github.com/cnap-tech/cli/internal/config"
20+
"golang.org/x/term"
21+
"gopkg.in/yaml.v3"
22+
)
23+
24+
const (
25+
repo = "cnap-tech/cli"
26+
stateFile = "state.yaml"
27+
)
28+
29+
// ReleaseInfo stores information about a GitHub release.
30+
type ReleaseInfo struct {
31+
Version string `json:"tag_name"`
32+
URL string `json:"html_url"`
33+
PublishedAt time.Time `json:"published_at"`
34+
}
35+
36+
type stateEntry struct {
37+
CheckedForUpdateAt time.Time `yaml:"checked_for_update_at"`
38+
LatestRelease ReleaseInfo `yaml:"latest_release"`
39+
}
40+
41+
// ShouldCheckForUpdate returns true if the environment is suitable for update checks.
42+
func ShouldCheckForUpdate() bool {
43+
if os.Getenv("CNAP_NO_UPDATE_NOTIFIER") != "" {
44+
return false
45+
}
46+
if os.Getenv("CODESPACES") != "" {
47+
return false
48+
}
49+
if isCI() {
50+
return false
51+
}
52+
return term.IsTerminal(int(os.Stderr.Fd()))
53+
}
54+
55+
// CheckForUpdate checks whether a newer version of the CLI is available.
56+
// Returns nil if the check was performed recently (within 24h) or if the
57+
// current version is up to date.
58+
func CheckForUpdate(ctx context.Context, currentVersion string) (*ReleaseInfo, error) {
59+
stateFilePath, err := statePath()
60+
if err != nil {
61+
return nil, err
62+
}
63+
64+
// Return early if checked recently
65+
state, _ := getState(stateFilePath)
66+
if state != nil && time.Since(state.CheckedForUpdateAt).Hours() < 24 {
67+
return nil, nil
68+
}
69+
70+
// Fetch latest release from GitHub
71+
release, err := fetchLatestRelease(ctx)
72+
if err != nil {
73+
return nil, err
74+
}
75+
76+
// Cache the result
77+
_ = setState(stateFilePath, time.Now(), *release)
78+
79+
if versionGreaterThan(release.Version, currentVersion) {
80+
return release, nil
81+
}
82+
83+
return nil, nil
84+
}
85+
86+
// IsUnderHomebrew returns true if the CLI binary is managed by Homebrew.
87+
func IsUnderHomebrew() bool {
88+
exe, err := os.Executable()
89+
if err != nil {
90+
return false
91+
}
92+
93+
brewExe, err := exec.LookPath("brew")
94+
if err != nil {
95+
return false
96+
}
97+
98+
brewPrefixBytes, err := exec.Command(brewExe, "--prefix").Output()
99+
if err != nil {
100+
return false
101+
}
102+
103+
brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator)
104+
return strings.HasPrefix(exe, brewBinPrefix)
105+
}
106+
107+
// IsRecentRelease returns true if the release was published less than 24 hours ago.
108+
func IsRecentRelease(publishedAt time.Time) bool {
109+
return !publishedAt.IsZero() && time.Since(publishedAt) < 24*time.Hour
110+
}
111+
112+
func statePath() (string, error) {
113+
dir, err := config.ConfigDir()
114+
if err != nil {
115+
return "", err
116+
}
117+
return filepath.Join(dir, stateFile), nil
118+
}
119+
120+
func getState(path string) (*stateEntry, error) {
121+
data, err := os.ReadFile(path)
122+
if err != nil {
123+
return nil, err
124+
}
125+
var s stateEntry
126+
if err := yaml.Unmarshal(data, &s); err != nil {
127+
return nil, err
128+
}
129+
return &s, nil
130+
}
131+
132+
func setState(path string, t time.Time, r ReleaseInfo) error {
133+
data, err := yaml.Marshal(stateEntry{CheckedForUpdateAt: t, LatestRelease: r})
134+
if err != nil {
135+
return err
136+
}
137+
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
138+
return err
139+
}
140+
return os.WriteFile(path, data, 0o600)
141+
}
142+
143+
func fetchLatestRelease(ctx context.Context) (*ReleaseInfo, error) {
144+
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
145+
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
146+
if err != nil {
147+
return nil, err
148+
}
149+
150+
resp, err := http.DefaultClient.Do(req)
151+
if err != nil {
152+
return nil, err
153+
}
154+
defer func() {
155+
_, _ = io.Copy(io.Discard, resp.Body)
156+
resp.Body.Close()
157+
}()
158+
159+
if resp.StatusCode != 200 {
160+
return nil, fmt.Errorf("unexpected HTTP %d", resp.StatusCode)
161+
}
162+
163+
var release ReleaseInfo
164+
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
165+
return nil, err
166+
}
167+
return &release, nil
168+
}
169+
170+
// versionGreaterThan returns true if v is a newer version than w.
171+
// Versions are expected as semver strings with optional "v" prefix (e.g. "v0.5.1" or "0.5.1").
172+
func versionGreaterThan(v, w string) bool {
173+
vParts := parseVersion(v)
174+
wParts := parseVersion(w)
175+
if vParts == nil || wParts == nil {
176+
return false
177+
}
178+
for i := 0; i < 3; i++ {
179+
if vParts[i] > wParts[i] {
180+
return true
181+
}
182+
if vParts[i] < wParts[i] {
183+
return false
184+
}
185+
}
186+
return false
187+
}
188+
189+
func parseVersion(s string) []int {
190+
s = strings.TrimPrefix(s, "v")
191+
parts := strings.SplitN(s, ".", 3)
192+
if len(parts) != 3 {
193+
return nil
194+
}
195+
nums := make([]int, 3)
196+
for i, p := range parts {
197+
n, err := strconv.Atoi(p)
198+
if err != nil {
199+
return nil
200+
}
201+
nums[i] = n
202+
}
203+
return nums
204+
}
205+
206+
// isCI returns true if running in a known CI environment.
207+
func isCI() bool {
208+
return os.Getenv("CI") != "" ||
209+
os.Getenv("BUILD_NUMBER") != "" ||
210+
os.Getenv("RUN_ID") != ""
211+
}

internal/update/update_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package update
2+
3+
import "testing"
4+
5+
func TestVersionGreaterThan(t *testing.T) {
6+
tests := []struct {
7+
v, w string
8+
want bool
9+
}{
10+
{"v0.5.1", "v0.5.0", true},
11+
{"0.5.1", "0.5.0", true},
12+
{"v0.5.0", "v0.5.0", false},
13+
{"v0.4.0", "v0.5.0", false},
14+
{"v1.0.0", "v0.9.9", true},
15+
{"v0.10.0", "v0.9.0", true},
16+
{"v2.0.0", "v1.99.99", true},
17+
{"v0.5.1", "dev", false},
18+
{"dev", "v0.5.0", false},
19+
{"invalid", "v0.5.0", false},
20+
{"v0.5.0", "invalid", false},
21+
}
22+
23+
for _, tt := range tests {
24+
t.Run(tt.v+"_vs_"+tt.w, func(t *testing.T) {
25+
got := versionGreaterThan(tt.v, tt.w)
26+
if got != tt.want {
27+
t.Errorf("versionGreaterThan(%q, %q) = %v, want %v", tt.v, tt.w, got, tt.want)
28+
}
29+
})
30+
}
31+
}

0 commit comments

Comments
 (0)