Skip to content

Commit c15f215

Browse files
committed
Add interactive update notifications
1 parent fa5af9b commit c15f215

8 files changed

Lines changed: 390 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ Configuration priority (highest to lowest):
183183

184184
`FIZZY_ACCOUNT` is accepted as a deprecated alias for `FIZZY_PROFILE`.
185185

186+
`FIZZY_NO_UPDATE_NOTIFIER=1` runs commands without update notifications.
187+
186188
Inspect the effective config and precedence:
187189

188190
```bash

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/charmbracelet/huh v1.0.0
99
github.com/charmbracelet/lipgloss v1.1.0
1010
github.com/charmbracelet/x/term v0.2.2
11+
github.com/hashicorp/go-version v1.7.0
1112
github.com/itchyny/gojq v0.12.19
1213
github.com/mattn/go-isatty v0.0.22
1314
github.com/muesli/termenv v0.16.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
5757
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
5858
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
5959
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
60+
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
61+
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
6062
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
6163
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
6264
github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs=

internal/commands/root.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ var rootCmd = &cobra.Command{
171171
}
172172
}
173173

174+
startUpdateCheck()
174175
return nil
175176
},
176177
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
@@ -182,6 +183,7 @@ var rootCmd = &cobra.Command{
182183
if RefreshSkillsIfVersionChanged() && !IsMachineOutput() {
183184
fmt.Fprintf(os.Stderr, "Agent skill updated to match CLI %s\n", currentVersion())
184185
}
186+
finishUpdateCheck()
185187
return nil
186188
},
187189
SilenceUsage: true,
@@ -1384,6 +1386,11 @@ func ResetTestMode() {
13841386
cfgLimit = 0
13851387
cfgJQ = ""
13861388
cfgProfile = ""
1389+
if updateCancel != nil {
1390+
updateCancel()
1391+
}
1392+
updateCancel = nil
1393+
updateMessage = nil
13871394
}
13881395

13891396
// GetRootCmd returns the root command for testing.

internal/commands/update.go

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
package commands
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"os"
10+
"os/exec"
11+
"path/filepath"
12+
"regexp"
13+
"strconv"
14+
"strings"
15+
"time"
16+
17+
"github.com/basecamp/fizzy-cli/internal/config"
18+
version "github.com/hashicorp/go-version"
19+
"github.com/mattn/go-isatty"
20+
"gopkg.in/yaml.v3"
21+
)
22+
23+
const fizzyUpdateRepo = "basecamp/fizzy-cli"
24+
25+
var gitDescribeSuffixRE = regexp.MustCompile(`\d+-\d+-g[a-f0-9]{8}$`)
26+
27+
var (
28+
updateHTTPClient = &http.Client{Timeout: 5 * time.Second}
29+
updateCancel context.CancelFunc
30+
updateMessage chan *releaseInfo
31+
)
32+
33+
type releaseInfo struct {
34+
Version string `json:"tag_name" yaml:"version"`
35+
URL string `json:"html_url" yaml:"url"`
36+
PublishedAt time.Time `json:"published_at" yaml:"published_at"`
37+
}
38+
39+
type updateStateEntry struct {
40+
CheckedForUpdateAt time.Time `yaml:"checked_for_update_at"`
41+
LatestRelease releaseInfo `yaml:"latest_release"`
42+
}
43+
44+
func startUpdateCheck() {
45+
if updateCancel != nil {
46+
updateCancel()
47+
updateCancel = nil
48+
updateMessage = nil
49+
}
50+
51+
current := currentVersion()
52+
if !isUpdateableVersion(current) || !shouldCheckForUpdate() {
53+
return
54+
}
55+
56+
stateDir, err := config.StateDir()
57+
if err != nil {
58+
return
59+
}
60+
61+
ctx, cancel := context.WithCancel(context.Background())
62+
updateCancel = cancel
63+
updateMessage = make(chan *releaseInfo, 1)
64+
go func() {
65+
rel, err := checkForUpdate(ctx, updateHTTPClient, filepath.Join(stateDir, "state.yml"), fizzyUpdateRepo, current)
66+
if err != nil && cfgVerbose {
67+
fmt.Fprintf(os.Stderr, "warning: checking for update failed: %v\n", err)
68+
}
69+
updateMessage <- rel
70+
}()
71+
}
72+
73+
func finishUpdateCheck() {
74+
if updateCancel == nil || updateMessage == nil {
75+
return
76+
}
77+
78+
updateCancel()
79+
rel := <-updateMessage
80+
updateCancel = nil
81+
updateMessage = nil
82+
if rel == nil {
83+
return
84+
}
85+
86+
exe, _ := os.Executable()
87+
isHomebrew := isUnderHomebrew(exe)
88+
if isHomebrew && isRecentRelease(rel.PublishedAt) {
89+
return
90+
}
91+
92+
fmt.Fprintf(os.Stderr, "\n\nA new release of fizzy is available: %s → %s\n",
93+
strings.TrimPrefix(currentVersion(), "v"),
94+
strings.TrimPrefix(rel.Version, "v"))
95+
if isHomebrew {
96+
fmt.Fprintln(os.Stderr, "To upgrade, run: brew upgrade basecamp/tap/fizzy")
97+
} else {
98+
fmt.Fprintln(os.Stderr, "Upgrade with your package manager, or download it from:")
99+
}
100+
fmt.Fprintf(os.Stderr, "%s\n\n", rel.URL)
101+
}
102+
103+
func shouldCheckForUpdate() bool {
104+
if os.Getenv("FIZZY_NO_UPDATE_NOTIFIER") != "" {
105+
return false
106+
}
107+
if os.Getenv("CODESPACES") != "" {
108+
return false
109+
}
110+
if isCI() {
111+
return false
112+
}
113+
if cfgAgent || cfgJSON || cfgQuiet || cfgIDsOnly || cfgCount || cfgJQ != "" {
114+
return false
115+
}
116+
return isTerminal(os.Stdout) && isTerminal(os.Stderr)
117+
}
118+
119+
func checkForUpdate(ctx context.Context, client *http.Client, stateFilePath, repo, currentVersion string) (*releaseInfo, error) {
120+
stateEntry, _ := getUpdateStateEntry(stateFilePath)
121+
if stateEntry != nil && time.Since(stateEntry.CheckedForUpdateAt).Hours() < 24 {
122+
return nil, nil
123+
}
124+
125+
rel, err := getLatestReleaseInfo(ctx, client, repo)
126+
if err != nil {
127+
return nil, err
128+
}
129+
130+
if err := setUpdateStateEntry(stateFilePath, time.Now(), *rel); err != nil {
131+
return nil, err
132+
}
133+
134+
if versionGreaterThan(rel.Version, currentVersion) {
135+
return rel, nil
136+
}
137+
return nil, nil
138+
}
139+
140+
func getLatestReleaseInfo(ctx context.Context, client *http.Client, repo string) (*releaseInfo, error) {
141+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo), nil)
142+
if err != nil {
143+
return nil, err
144+
}
145+
req.Header.Set("Accept", "application/vnd.github+json")
146+
147+
res, err := client.Do(req)
148+
if err != nil {
149+
return nil, err
150+
}
151+
defer func() {
152+
_, _ = io.Copy(io.Discard, res.Body)
153+
res.Body.Close()
154+
}()
155+
if res.StatusCode != http.StatusOK {
156+
return nil, fmt.Errorf("unexpected HTTP %d", res.StatusCode)
157+
}
158+
159+
var rel releaseInfo
160+
if err := json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&rel); err != nil {
161+
return nil, err
162+
}
163+
return &rel, nil
164+
}
165+
166+
func getUpdateStateEntry(stateFilePath string) (*updateStateEntry, error) {
167+
content, err := os.ReadFile(stateFilePath)
168+
if err != nil {
169+
return nil, err
170+
}
171+
172+
var stateEntry updateStateEntry
173+
if err := yaml.Unmarshal(content, &stateEntry); err != nil {
174+
return nil, err
175+
}
176+
return &stateEntry, nil
177+
}
178+
179+
func setUpdateStateEntry(stateFilePath string, t time.Time, rel releaseInfo) error {
180+
content, err := yaml.Marshal(updateStateEntry{CheckedForUpdateAt: t, LatestRelease: rel})
181+
if err != nil {
182+
return err
183+
}
184+
if err := os.MkdirAll(filepath.Dir(stateFilePath), 0o755); err != nil {
185+
return err
186+
}
187+
return os.WriteFile(stateFilePath, content, 0o600)
188+
}
189+
190+
func versionGreaterThan(v, w string) bool {
191+
w = gitDescribeSuffixRE.ReplaceAllStringFunc(w, func(m string) string {
192+
idx := strings.IndexRune(m, '-')
193+
n, _ := strconv.Atoi(m[:idx])
194+
return fmt.Sprintf("%d-pre.0", n+1)
195+
})
196+
197+
vv, ve := version.NewVersion(v)
198+
vw, we := version.NewVersion(w)
199+
return ve == nil && we == nil && vv.GreaterThan(vw)
200+
}
201+
202+
func isUpdateableVersion(v string) bool {
203+
v = strings.TrimSpace(v)
204+
if v == "" || v == "dev" || strings.Contains(v, "dirty") || strings.Contains(v, "-g") {
205+
return false
206+
}
207+
_, err := version.NewVersion(v)
208+
return err == nil
209+
}
210+
211+
func isRecentRelease(publishedAt time.Time) bool {
212+
return !publishedAt.IsZero() && time.Since(publishedAt) < 24*time.Hour
213+
}
214+
215+
func isUnderHomebrew(exePath string) bool {
216+
if exePath == "" {
217+
return false
218+
}
219+
brewExe, err := exec.LookPath("brew")
220+
if err != nil {
221+
return false
222+
}
223+
prefix, err := exec.Command(brewExe, "--prefix").Output()
224+
if err != nil {
225+
return false
226+
}
227+
brewBinPrefix := filepath.Join(strings.TrimSpace(string(prefix)), "bin") + string(filepath.Separator)
228+
return strings.HasPrefix(exePath, brewBinPrefix)
229+
}
230+
231+
func isCI() bool {
232+
for _, name := range []string{"CI", "GITHUB_ACTIONS", "BUILDKITE", "CIRCLECI", "GITLAB_CI", "JENKINS_URL", "TEAMCITY_VERSION", "TF_BUILD"} {
233+
if os.Getenv(name) != "" {
234+
return true
235+
}
236+
}
237+
return false
238+
}
239+
240+
func isTerminal(f *os.File) bool {
241+
return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
242+
}

internal/commands/update_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package commands
2+
3+
import (
4+
"context"
5+
"io"
6+
"net/http"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
"time"
12+
)
13+
14+
type roundTripFunc func(*http.Request) (*http.Response, error)
15+
16+
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
17+
return f(req)
18+
}
19+
20+
func TestCheckForUpdateFindsNewRelease(t *testing.T) {
21+
stateFile := filepath.Join(t.TempDir(), "state.yml")
22+
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
23+
if req.URL.String() != "https://api.github.com/repos/basecamp/fizzy-cli/releases/latest" {
24+
t.Fatalf("unexpected URL %s", req.URL.String())
25+
}
26+
body := `{"tag_name":"v3.0.3","html_url":"https://github.com/basecamp/fizzy-cli/releases/tag/v3.0.3","published_at":"2026-03-02T21:19:42Z"}`
27+
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body))}, nil
28+
})}
29+
30+
rel, err := checkForUpdate(context.Background(), client, stateFile, "basecamp/fizzy-cli", "v3.0.2")
31+
if err != nil {
32+
t.Fatalf("unexpected error: %v", err)
33+
}
34+
if rel == nil || rel.Version != "v3.0.3" {
35+
t.Fatalf("release = %#v, want v3.0.3", rel)
36+
}
37+
if _, err := os.Stat(stateFile); err != nil {
38+
t.Fatalf("state file not written: %v", err)
39+
}
40+
}
41+
42+
func TestCheckForUpdateSkipsRecentState(t *testing.T) {
43+
stateFile := filepath.Join(t.TempDir(), "state.yml")
44+
err := setUpdateStateEntry(stateFile, time.Now().Add(-time.Hour), releaseInfo{Version: "v3.0.3"})
45+
if err != nil {
46+
t.Fatalf("failed to write state: %v", err)
47+
}
48+
49+
called := false
50+
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
51+
called = true
52+
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{}`))}, nil
53+
})}
54+
55+
rel, err := checkForUpdate(context.Background(), client, stateFile, "basecamp/fizzy-cli", "v3.0.2")
56+
if err != nil {
57+
t.Fatalf("unexpected error: %v", err)
58+
}
59+
if rel != nil {
60+
t.Fatalf("release = %#v, want nil", rel)
61+
}
62+
if called {
63+
t.Fatal("expected recent state to skip HTTP request")
64+
}
65+
}
66+
67+
func TestVersionGreaterThan(t *testing.T) {
68+
tests := []struct {
69+
latest string
70+
current string
71+
want bool
72+
}{
73+
{"v3.0.3", "v3.0.2", true},
74+
{"v3.0.3", "v3.0.3", false},
75+
{"v3.0.2", "v3.0.3", false},
76+
{"v3.1.0", "v3.1.0-2-gabcdef12", false},
77+
{"v3.1.1", "v3.1.0-2-gabcdef12", true},
78+
{"v3.0.3", "dev", false},
79+
}
80+
81+
for _, tt := range tests {
82+
if got := versionGreaterThan(tt.latest, tt.current); got != tt.want {
83+
t.Fatalf("versionGreaterThan(%q, %q) = %v, want %v", tt.latest, tt.current, got, tt.want)
84+
}
85+
}
86+
}
87+
88+
func TestShouldCheckForUpdateHonorsOptOut(t *testing.T) {
89+
t.Setenv("FIZZY_NO_UPDATE_NOTIFIER", "1")
90+
if shouldCheckForUpdate() {
91+
t.Fatal("expected FIZZY_NO_UPDATE_NOTIFIER to disable update checks")
92+
}
93+
}

0 commit comments

Comments
 (0)