Skip to content

Commit f21a15a

Browse files
authored
Add interactive update notifications (#175)
* Add interactive update notifications * Address update notifier review feedback * Recover from corrupt update state * Use cached release for update notifications
1 parent fa5af9b commit f21a15a

8 files changed

Lines changed: 481 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: 9 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,13 @@ func ResetTestMode() {
13841386
cfgLimit = 0
13851387
cfgJQ = ""
13861388
cfgProfile = ""
1389+
if updateCancel != nil {
1390+
updateCancel()
1391+
}
1392+
updateCancel = nil
1393+
updateMessage = nil
1394+
machineOutputChecker = IsMachineOutput
1395+
terminalChecker = isTerminal
13871396
}
13881397

13891398
// GetRootCmd returns the root command for testing.

internal/commands/update.go

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

0 commit comments

Comments
 (0)