Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ Configuration priority (highest to lowest):

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

`FIZZY_NO_UPDATE_NOTIFIER=1` runs commands without update notifications.

Inspect the effective config and precedence:

```bash
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/term v0.2.2
github.com/hashicorp/go-version v1.7.0
github.com/itchyny/gojq v0.12.19
github.com/mattn/go-isatty v0.0.22
github.com/muesli/termenv v0.16.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs=
Expand Down
7 changes: 7 additions & 0 deletions internal/commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ var rootCmd = &cobra.Command{
}
}

startUpdateCheck()
return nil
},
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -182,6 +183,7 @@ var rootCmd = &cobra.Command{
if RefreshSkillsIfVersionChanged() && !IsMachineOutput() {
fmt.Fprintf(os.Stderr, "Agent skill updated to match CLI %s\n", currentVersion())
}
finishUpdateCheck()
return nil
},
SilenceUsage: true,
Expand Down Expand Up @@ -1384,6 +1386,11 @@ func ResetTestMode() {
cfgLimit = 0
cfgJQ = ""
cfgProfile = ""
if updateCancel != nil {
updateCancel()
}
updateCancel = nil
updateMessage = nil
}

// GetRootCmd returns the root command for testing.
Expand Down
244 changes: 244 additions & 0 deletions internal/commands/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
package commands

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"

"github.com/basecamp/fizzy-cli/internal/config"
version "github.com/hashicorp/go-version"
"github.com/mattn/go-isatty"
"gopkg.in/yaml.v3"
)

const fizzyUpdateRepo = "basecamp/fizzy-cli"

var gitDescribeSuffixRE = regexp.MustCompile(`\d+-\d+-g[a-f0-9]{8}$`)

var (
updateHTTPClient = &http.Client{Timeout: 5 * time.Second}
updateCancel context.CancelFunc
updateMessage chan *releaseInfo
)

type releaseInfo struct {
Version string `json:"tag_name" yaml:"version"`
URL string `json:"html_url" yaml:"url"`
PublishedAt time.Time `json:"published_at" yaml:"published_at"`
}

type updateStateEntry struct {
CheckedForUpdateAt time.Time `yaml:"checked_for_update_at"`
LatestRelease releaseInfo `yaml:"latest_release"`
}

func startUpdateCheck() {
if updateCancel != nil {
updateCancel()
Comment thread
robzolkos marked this conversation as resolved.
updateCancel = nil
updateMessage = nil
}

current := currentVersion()
if !isUpdateableVersion(current) || !shouldCheckForUpdate() {
return
}

stateDir, err := config.StateDir()
if err != nil {
return
}

ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel is retained and called after command execution
updateCancel = cancel
updateMessage = make(chan *releaseInfo, 1)
go func() {
rel, err := checkForUpdate(ctx, updateHTTPClient, filepath.Join(stateDir, "state.yml"), fizzyUpdateRepo, current)
if err != nil && cfgVerbose {
fmt.Fprintf(os.Stderr, "warning: checking for update failed: %v\n", err)
}
updateMessage <- rel
}()
Comment thread
robzolkos marked this conversation as resolved.
}

func finishUpdateCheck() {
if updateCancel == nil || updateMessage == nil {
return
}

updateCancel()
rel := <-updateMessage
updateCancel = nil
updateMessage = nil
Comment thread
robzolkos marked this conversation as resolved.
Outdated
if rel == nil {
return
}

exe, _ := os.Executable()
isHomebrew := isUnderHomebrew(exe)
if isHomebrew && isRecentRelease(rel.PublishedAt) {
return
}

fmt.Fprintf(os.Stderr, "\n\nA new release of fizzy is available: %s → %s\n",
strings.TrimPrefix(currentVersion(), "v"),
strings.TrimPrefix(rel.Version, "v"))
if isHomebrew {
fmt.Fprintln(os.Stderr, "To upgrade, run: brew upgrade basecamp/tap/fizzy")
Comment thread
robzolkos marked this conversation as resolved.
Outdated
} else {
fmt.Fprintln(os.Stderr, "Upgrade with your package manager, or download it from:")
}
fmt.Fprintf(os.Stderr, "%s\n\n", rel.URL)
}

func shouldCheckForUpdate() bool {
if os.Getenv("FIZZY_NO_UPDATE_NOTIFIER") != "" {
return false
}
if os.Getenv("CODESPACES") != "" {
return false
}
if isCI() {
return false
}
if cfgAgent || cfgJSON || cfgQuiet || cfgIDsOnly || cfgCount || cfgJQ != "" {
return false
}
return isTerminal(os.Stdout) && isTerminal(os.Stderr)
Comment thread
robzolkos marked this conversation as resolved.
Outdated
}
Comment thread
robzolkos marked this conversation as resolved.
Outdated

func checkForUpdate(ctx context.Context, client *http.Client, stateFilePath, repo, currentVersion string) (*releaseInfo, error) {
stateEntry, _ := getUpdateStateEntry(stateFilePath)
Comment thread
robzolkos marked this conversation as resolved.
Outdated
if stateEntry != nil && time.Since(stateEntry.CheckedForUpdateAt).Hours() < 24 {
return nil, nil
}
Comment thread
robzolkos marked this conversation as resolved.
Outdated
Comment thread
robzolkos marked this conversation as resolved.

rel, err := getLatestReleaseInfo(ctx, client, repo)
if err != nil {
return nil, err
}

if err := setUpdateStateEntry(stateFilePath, time.Now(), *rel); err != nil {
return nil, err
}

if versionGreaterThan(rel.Version, currentVersion) {
return rel, nil
}
return nil, nil
}

func getLatestReleaseInfo(ctx context.Context, client *http.Client, repo string) (*releaseInfo, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo), nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")

Comment thread
robzolkos marked this conversation as resolved.
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer func() {
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
}()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected HTTP %d", res.StatusCode)
}

var rel releaseInfo
if err := json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&rel); err != nil {
return nil, err
}
return &rel, nil
}

func getUpdateStateEntry(stateFilePath string) (*updateStateEntry, error) {
content, err := os.ReadFile(stateFilePath)
if err != nil {
return nil, err
}

var stateEntry updateStateEntry
if err := yaml.Unmarshal(content, &stateEntry); err != nil {
return nil, err
}
return &stateEntry, nil
}

func setUpdateStateEntry(stateFilePath string, t time.Time, rel releaseInfo) error {
content, err := yaml.Marshal(updateStateEntry{CheckedForUpdateAt: t, LatestRelease: rel})
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(stateFilePath), 0o700); err != nil {
return err
}
return os.WriteFile(stateFilePath, content, 0o600)
}

func versionGreaterThan(v, w string) bool {
w = gitDescribeSuffixRE.ReplaceAllStringFunc(w, func(m string) string {
idx := strings.IndexRune(m, '-')
n, _ := strconv.Atoi(m[:idx])
return fmt.Sprintf("%d-pre.0", n+1)
})

vv, ve := version.NewVersion(v)
vw, we := version.NewVersion(w)
return ve == nil && we == nil && vv.GreaterThan(vw)
}

func isUpdateableVersion(v string) bool {
v = strings.TrimSpace(v)
if v == "" || v == "dev" || strings.Contains(v, "dirty") || strings.Contains(v, "-g") {
return false
}
_, err := version.NewVersion(v)
return err == nil
}

func isRecentRelease(publishedAt time.Time) bool {
return !publishedAt.IsZero() && time.Since(publishedAt) < 24*time.Hour
}

func isUnderHomebrew(exePath string) bool {
if exePath == "" {
return false
}
brewExe, err := exec.LookPath("brew")
if err != nil {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
prefix, err := exec.CommandContext(ctx, brewExe, "--prefix").Output() //nolint:gosec // G204: brewExe comes from exec.LookPath
if err != nil {
return false
}
brewBinPrefix := filepath.Join(strings.TrimSpace(string(prefix)), "bin") + string(filepath.Separator)
return strings.HasPrefix(exePath, brewBinPrefix)
}

func isCI() bool {
for _, name := range []string{"CI", "GITHUB_ACTIONS", "BUILDKITE", "CIRCLECI", "GITLAB_CI", "JENKINS_URL", "TEAMCITY_VERSION", "TF_BUILD"} {
if os.Getenv(name) != "" {
return true
}
}
return false
}

func isTerminal(f *os.File) bool {
return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
}
93 changes: 93 additions & 0 deletions internal/commands/update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package commands

import (
"context"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}

func TestCheckForUpdateFindsNewRelease(t *testing.T) {
stateFile := filepath.Join(t.TempDir(), "state.yml")
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.String() != "https://api.github.com/repos/basecamp/fizzy-cli/releases/latest" {
t.Fatalf("unexpected URL %s", req.URL.String())
}
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"}`
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body))}, nil
})}

rel, err := checkForUpdate(context.Background(), client, stateFile, "basecamp/fizzy-cli", "v3.0.2")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rel == nil || rel.Version != "v3.0.3" {
t.Fatalf("release = %#v, want v3.0.3", rel)
}
if _, err := os.Stat(stateFile); err != nil {
t.Fatalf("state file not written: %v", err)
}
}

func TestCheckForUpdateSkipsRecentState(t *testing.T) {
stateFile := filepath.Join(t.TempDir(), "state.yml")
err := setUpdateStateEntry(stateFile, time.Now().Add(-time.Hour), releaseInfo{Version: "v3.0.3"})
if err != nil {
t.Fatalf("failed to write state: %v", err)
}

called := false
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
called = true
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{}`))}, nil
})}

rel, err := checkForUpdate(context.Background(), client, stateFile, "basecamp/fizzy-cli", "v3.0.2")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rel != nil {
t.Fatalf("release = %#v, want nil", rel)
}
if called {
t.Fatal("expected recent state to skip HTTP request")
}
}

func TestVersionGreaterThan(t *testing.T) {
tests := []struct {
latest string
current string
want bool
}{
{"v3.0.3", "v3.0.2", true},
{"v3.0.3", "v3.0.3", false},
{"v3.0.2", "v3.0.3", false},
{"v3.1.0", "v3.1.0-2-gabcdef12", false},
{"v3.1.1", "v3.1.0-2-gabcdef12", true},
{"v3.0.3", "dev", false},
}

for _, tt := range tests {
if got := versionGreaterThan(tt.latest, tt.current); got != tt.want {
t.Fatalf("versionGreaterThan(%q, %q) = %v, want %v", tt.latest, tt.current, got, tt.want)
}
}
}

func TestShouldCheckForUpdateHonorsOptOut(t *testing.T) {
Comment thread
robzolkos marked this conversation as resolved.
t.Setenv("FIZZY_NO_UPDATE_NOTIFIER", "1")
if shouldCheckForUpdate() {
t.Fatal("expected FIZZY_NO_UPDATE_NOTIFIER to disable update checks")
}
Comment thread
robzolkos marked this conversation as resolved.
}
Loading