From 60cf13195a363c3f5bf9aa195c2a6a983b28cf96 Mon Sep 17 00:00:00 2001 From: RogerCecilpot Date: Sun, 25 Oct 2026 12:28:35 +0000 Subject: [PATCH] feat: daily update notifier At most once a day, check the latest GitHub release in the background while the command runs and, if it is newer, print a notice to stderr after a successful command (with the brew upgrade command for Homebrew installs). The finished command waits at most 250ms for the check, which itself times out after 2s; failures are silent. Skipped when CI is set, stderr is not a terminal, DEVLYFT_NO_UPDATE_NOTIFIER is set, for dev builds, and for `version` and `completion`. The last check time is cached in the user cache directory (0600). The README documents that there is no telemetry in v1. Refs devlyft/devlyft#259 Co-Authored-By: Claude Opus 5.5 --- README.md | 14 ++ go.mod | 6 +- go.sum | 16 +- internal/cli/root.go | 5 +- internal/cli/update.go | 102 +++++++++++++ internal/cli/update_test.go | 80 ++++++++++ internal/update/update.go | 219 +++++++++++++++++++++++++++ internal/update/update_test.go | 264 +++++++++++++++++++++++++++++++++ 8 files changed, 694 insertions(+), 12 deletions(-) create mode 100644 internal/cli/update.go create mode 100644 internal/cli/update_test.go create mode 100644 internal/update/update.go create mode 100644 internal/update/update_test.go diff --git a/README.md b/README.md index 701eb97..4008072 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,20 @@ go install github.com/devlyft/cli/cmd/devlyft@latest Or download an archive or package from [Releases](https://github.com/devlyft/cli/releases). Every release file is listed in a cosign-signed checksum file; see [docs/releasing.md](docs/releasing.md#verifying-a-download) to verify a download. +## Privacy and update checks + +devlyft has no telemetry in v1: it does not collect or send usage data. + +The only network call it makes besides your own API requests is a check +for a newer release, at most once a day: an unauthenticated request to +`https://api.github.com/repos/devlyft/cli/releases/latest`. If a newer +version exists, a one-line notice is printed to stderr after the command +finishes. The check runs alongside the command and never delays it. + +It is skipped when `CI` is set, when stderr is not a terminal, for +development builds, and for `devlyft version` and `devlyft completion`. +To turn it off, set `DEVLYFT_NO_UPDATE_NOTIFIER=1`. + ## Development Requires Go 1.26 or later. diff --git a/go.mod b/go.mod index 61699bd..993f8b4 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/spf13/cobra v1.10.2 go.uber.org/mock v0.6.0 go.yaml.in/yaml/v3 v3.0.5 + golang.org/x/mod v0.42.0 golang.org/x/term v0.47.0 sigs.k8s.io/yaml v1.6.0 ) @@ -37,11 +38,10 @@ require ( github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/mod v0.30.0 // indirect - golang.org/x/sync v0.19.0 // indirect + golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.32.0 // indirect - golang.org/x/tools v0.39.0 // indirect + golang.org/x/tools v0.51.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index d0eaa58..bba2dd5 100644 --- a/go.sum +++ b/go.sum @@ -134,8 +134,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -143,13 +143,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -179,8 +179,8 @@ golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/cli/root.go b/internal/cli/root.go index 796f862..7d6cd22 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -95,7 +95,10 @@ func setup(f *cmdutil.Factory, flags *globalFlags) error { // the only place errors are printed and mapped to exit codes. func Execute(ctx context.Context, ios *iostreams.IOStreams, args []string) int { f := &cmdutil.Factory{IO: ios} - return execute(ctx, f, NewRootCmd(f), args) + notifier := startUpdateCheck(ctx, ios, args) + code := execute(ctx, f, NewRootCmd(f), args) + notifier.finish(code) + return code } func execute(ctx context.Context, f *cmdutil.Factory, root *cobra.Command, args []string) int { diff --git a/internal/cli/update.go b/internal/cli/update.go new file mode 100644 index 0000000..f8e9c4f --- /dev/null +++ b/internal/cli/update.go @@ -0,0 +1,102 @@ +package cli + +import ( + "context" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/devlyft/cli/internal/exitcode" + "github.com/devlyft/cli/internal/iostreams" + updatecheck "github.com/devlyft/cli/internal/update" + "github.com/devlyft/cli/internal/version" +) + +const ( + // updateTimeout bounds the release lookup. + updateTimeout = 2 * time.Second + // updateGrace is how long a finished command waits for the lookup + // before giving up on the notice for this run. + updateGrace = 250 * time.Millisecond +) + +// skipUpdateCommands never show the notice: their output is often captured +// or sourced by a shell. +var skipUpdateCommands = map[string]bool{ + "version": true, + "completion": true, + "__complete": true, + "__completeNoDesc": true, +} + +// updateNotifier runs the update check alongside a command and prints the +// notice to stderr afterwards if it is ready. +type updateNotifier struct { + result chan *updatecheck.Release + current string + errOut io.Writer + brew bool +} + +// startUpdateCheck starts the daily update check in the background, or +// returns nil when the notifier is disabled for this invocation. +func startUpdateCheck(ctx context.Context, ios *iostreams.IOStreams, args []string) *updateNotifier { + current := version.Get().Version + if !updatecheck.Enabled(os.Getenv, ios.IsStderrTTY(), current) || skipUpdateCommands[firstArg(args)] { + return nil + } + statePath, err := updatecheck.DefaultStatePath() + if err != nil { + return nil + } + exe, _ := os.Executable() + n := &updateNotifier{ + result: make(chan *updatecheck.Release, 1), + current: current, + errOut: ios.ErrOut, + brew: exe != "" && updatecheck.IsHomebrew(exe), + } + checker := &updatecheck.Checker{ + Current: current, + StatePath: statePath, + Client: &http.Client{Timeout: updateTimeout}, + } + go func() { + ctx, cancel := context.WithTimeout(ctx, updateTimeout) + defer cancel() + rel, _ := checker.Check(ctx) // failures are silent by design + n.result <- rel + }() + return n +} + +// finish prints the notice after a successful command. It waits at most +// updateGrace for the check to complete. +func (n *updateNotifier) finish(code int) { + if n == nil || code != exitcode.OK { + return + } + select { + case rel := <-n.result: + if rel != nil { + _, _ = io.WriteString(n.errOut, updatecheck.Notice(n.current, rel, n.brew)) + } + case <-time.After(updateGrace): + } +} + +// firstArg returns the first argument that is not a flag: the top-level +// command name. +func firstArg(args []string) string { + for _, a := range args { + if a == "--" { + return "" + } + if !strings.HasPrefix(a, "-") { + return a + } + } + return "" +} diff --git a/internal/cli/update_test.go b/internal/cli/update_test.go new file mode 100644 index 0000000..54988f4 --- /dev/null +++ b/internal/cli/update_test.go @@ -0,0 +1,80 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/devlyft/cli/internal/exitcode" + "github.com/devlyft/cli/internal/iostreams" + updatecheck "github.com/devlyft/cli/internal/update" +) + +func TestStartUpdateCheckDisabled(t *testing.T) { + t.Parallel() + + // Test streams are not terminals and test builds are "dev", so the + // notifier must not start. + ios, _, _, _ := iostreams.Test() + if n := startUpdateCheck(t.Context(), ios, []string{"widgets"}); n != nil { + t.Error("update check started for a non-terminal dev build") + } +} + +func TestUpdateNotifierFinish(t *testing.T) { + t.Parallel() + + rel := &updatecheck.Release{Version: "0.2.0", URL: "https://example.com/v0.2.0"} + tests := []struct { + name string + result *updatecheck.Release + ready bool + code int + want string + }{ + {name: "newer release after success", result: rel, ready: true, code: exitcode.OK, want: "0.1.0 → 0.2.0"}, + {name: "no newer release", result: nil, ready: true, code: exitcode.OK}, + {name: "command failed", result: rel, ready: true, code: exitcode.NotFound}, + {name: "check not ready within grace", ready: false, code: exitcode.OK}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var errOut bytes.Buffer + n := &updateNotifier{result: make(chan *updatecheck.Release, 1), current: "0.1.0", errOut: &errOut} + if tt.ready { + n.result <- tt.result + } + n.finish(tt.code) + if tt.want == "" && errOut.Len() != 0 { + t.Errorf("stderr = %q, want empty", errOut.String()) + } + if tt.want != "" && !strings.Contains(errOut.String(), tt.want) { + t.Errorf("stderr = %q, want %q", errOut.String(), tt.want) + } + }) + } + + // A nil notifier (disabled) is a no-op. + var n *updateNotifier + n.finish(exitcode.OK) +} + +func TestFirstArg(t *testing.T) { + t.Parallel() + + for args, want := range map[string]string{ + "version": "version", + "--no-color version": "version", + "completion bash": "completion", + "-- version": "", + "": "", + } { + if got := firstArg(strings.Fields(args)); got != want { + t.Errorf("firstArg(%q) = %q, want %q", args, got, want) + } + } + if !skipUpdateCommands["version"] || !skipUpdateCommands["completion"] || skipUpdateCommands["deploys"] { + t.Error("unexpected skip list") + } +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..750f1b6 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,219 @@ +// Package update checks, at most once a day, whether a newer release of the +// CLI exists. It is the CLI's only network call that is not an API request: +// an unauthenticated GET of the latest GitHub release. Nothing about the +// user or their usage is sent. There is no telemetry in v1. +package update + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/mod/semver" +) + +// LatestReleaseURL is the GitHub API endpoint for the latest release. +const LatestReleaseURL = "https://api.github.com/repos/devlyft/cli/releases/latest" + +// Interval is the minimum time between checks. +const Interval = 24 * time.Hour + +// DisableEnv disables the update notifier when set to any non-empty value. +const DisableEnv = "DEVLYFT_NO_UPDATE_NOTIFIER" + +// Release is a newer release than the running one. +type Release struct { + Version string `json:"version"` + URL string `json:"url"` +} + +// state is persisted between runs. +type state struct { + CheckedAt time.Time `json:"checked_at"` + Latest Release `json:"latest"` +} + +// Enabled reports whether the notifier may run: not in CI, not when the +// user turned it off, only when stderr is a terminal and only for release +// builds with a semver version. +func Enabled(getenv func(string) string, stderrTTY bool, current string) bool { + switch { + case getenv("CI") != "", getenv(DisableEnv) != "": + return false + case !stderrTTY: + return false + } + return semver.IsValid(canonical(current)) +} + +// Checker looks up the latest release. +type Checker struct { + // Current is the running version, with or without a leading "v". + Current string + // StatePath is where the time of the last check is recorded. + StatePath string + // URL is the latest-release endpoint; LatestReleaseURL by default. + URL string + Client *http.Client + Now func() time.Time +} + +// DefaultStatePath returns the state file location in the user cache +// directory. +func DefaultStatePath() (string, error) { + dir, err := os.UserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "devlyft", "update-state.json"), nil +} + +// Check returns the latest release when it is newer than the running +// version. It returns nil when it is not, or when a check already ran +// within Interval, in which case it makes no request. Errors are for +// debug logging only; callers stay silent. +func (c *Checker) Check(ctx context.Context) (*Release, error) { + now := c.now() + if st, err := c.readState(); err == nil && now.Sub(st.CheckedAt) < Interval && !st.CheckedAt.After(now) { + return nil, nil + } + + latest, err := c.fetch(ctx) + if err != nil { + return nil, err + } + if err := c.writeState(state{CheckedAt: now, Latest: *latest}); err != nil { + return nil, err + } + if semver.Compare(canonical(latest.Version), canonical(c.Current)) <= 0 { + return nil, nil + } + return latest, nil +} + +func (c *Checker) fetch(ctx context.Context) (*Release, error) { + url := c.URL + if url == "" { + url = LatestReleaseURL + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "devlyft-cli/"+strings.TrimPrefix(c.Current, "v")) + + client := c.Client + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("latest release: %s", resp.Status) + } + + var body struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { + return nil, fmt.Errorf("latest release: %w", err) + } + if !semver.IsValid(canonical(body.TagName)) { + return nil, fmt.Errorf("latest release: invalid tag %q", body.TagName) + } + return &Release{Version: strings.TrimPrefix(body.TagName, "v"), URL: body.HTMLURL}, nil +} + +func (c *Checker) readState() (state, error) { + var st state + b, err := os.ReadFile(c.StatePath) + if err != nil { + return st, err + } + err = json.Unmarshal(b, &st) + return st, err +} + +func (c *Checker) writeState(st state) error { + if c.StatePath == "" { + return errors.New("no state path") + } + dir := filepath.Dir(c.StatePath) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + b, err := json.Marshal(st) + if err != nil { + return err + } + // CreateTemp creates the file with mode 0600; renaming keeps it. + tmp, err := os.CreateTemp(dir, ".update-state-*") + if err != nil { + return err + } + defer func() { _ = os.Remove(tmp.Name()) }() + if _, err := tmp.Write(b); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), c.StatePath) +} + +func (c *Checker) now() time.Time { + if c.Now != nil { + return c.Now() + } + return time.Now() +} + +// Notice formats the message printed to stderr. homebrew adds the upgrade +// command for Homebrew installs. +func Notice(current string, r *Release, homebrew bool) string { + var b strings.Builder + fmt.Fprintf(&b, "\nA new release of devlyft is available: %s → %s\n", + strings.TrimPrefix(current, "v"), r.Version) + if homebrew { + b.WriteString("To upgrade, run: brew upgrade devlyft/tap/devlyft\n") + } + if r.URL != "" { + b.WriteString(r.URL + "\n") + } + return b.String() +} + +// IsHomebrew reports whether the executable at path was installed by +// Homebrew. +func IsHomebrew(path string) bool { + if resolved, err := filepath.EvalSymlinks(path); err == nil { + path = resolved + } + path = filepath.ToSlash(path) + for _, prefix := range []string{"/opt/homebrew/", "/home/linuxbrew/.linuxbrew/", "/usr/local/Cellar/"} { + if strings.HasPrefix(path, prefix) { + return true + } + } + return strings.Contains(path, "/Cellar/") +} + +func canonical(v string) string { + if v != "" && !strings.HasPrefix(v, "v") { + v = "v" + v + } + return v +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..01b8932 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,264 @@ +package update + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + "time" +) + +func env(vars map[string]string) func(string) string { + return func(k string) string { return vars[k] } +} + +func TestEnabled(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + env map[string]string + tty bool + version string + want bool + }{ + {name: "release build at a terminal", tty: true, version: "0.1.0", want: true}, + {name: "v-prefixed version", tty: true, version: "v0.1.0", want: true}, + {name: "CI set", env: map[string]string{"CI": "true"}, tty: true, version: "0.1.0"}, + {name: "CI set to anything", env: map[string]string{"CI": "1"}, tty: true, version: "0.1.0"}, + {name: "disabled by env", env: map[string]string{DisableEnv: "1"}, tty: true, version: "0.1.0"}, + {name: "stderr not a terminal", tty: false, version: "0.1.0"}, + {name: "dev build", tty: true, version: "dev"}, + {name: "empty version", tty: true, version: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := Enabled(env(tt.env), tt.tty, tt.version); got != tt.want { + t.Errorf("Enabled() = %v, want %v", got, tt.want) + } + }) + } +} + +// releaseServer serves tag as the latest release and counts requests. +func releaseServer(t *testing.T, status int, tag string) (url string, hits *atomic.Int32) { + t.Helper() + hits = &atomic.Int32{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + if r.Header.Get("Authorization") != "" { + t.Error("update check must be unauthenticated") + } + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{ + "tag_name": tag, + "html_url": "https://github.com/devlyft/cli/releases/tag/" + tag, + }) + })) + t.Cleanup(srv.Close) + return srv.URL, hits +} + +var now = time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC) + +func newChecker(t *testing.T, url, current string) *Checker { + t.Helper() + return &Checker{ + Current: current, + StatePath: filepath.Join(t.TempDir(), "devlyft", "update-state.json"), + URL: url, + Now: func() time.Time { return now }, + } +} + +func TestCheckVersions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + current string + tag string + newer bool + }{ + {name: "newer minor", current: "0.1.0", tag: "v0.2.0", newer: true}, + {name: "newer patch", current: "v0.1.0", tag: "v0.1.1", newer: true}, + {name: "equal", current: "0.2.0", tag: "v0.2.0"}, + {name: "older", current: "0.3.0", tag: "v0.2.0"}, + {name: "local prerelease of the latest", current: "0.2.0-rc.1", tag: "v0.2.0", newer: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + url, _ := releaseServer(t, http.StatusOK, tt.tag) + c := newChecker(t, url, tt.current) + + rel, err := c.Check(t.Context()) + if err != nil { + t.Fatal(err) + } + if (rel != nil) != tt.newer { + t.Fatalf("Check() = %+v, want newer=%v", rel, tt.newer) + } + if rel != nil && rel.Version != strings.TrimPrefix(tt.tag, "v") { + t.Errorf("Version = %q", rel.Version) + } + }) + } +} + +func TestCheckAtMostOnceADay(t *testing.T) { + t.Parallel() + + url, hits := releaseServer(t, http.StatusOK, "v0.2.0") + c := newChecker(t, url, "0.1.0") + + if rel, err := c.Check(t.Context()); err != nil || rel == nil { + t.Fatalf("first check = %+v, %v; want a release", rel, err) + } + + // Within 24 hours: no request and no notice. + c.Now = func() time.Time { return now.Add(23 * time.Hour) } + if rel, err := c.Check(t.Context()); err != nil || rel != nil { + t.Fatalf("second check = %+v, %v; want nothing", rel, err) + } + if hits.Load() != 1 { + t.Fatalf("requests = %d, want 1", hits.Load()) + } + + // After 24 hours: checks again. + c.Now = func() time.Time { return now.Add(25 * time.Hour) } + if rel, err := c.Check(t.Context()); err != nil || rel == nil { + t.Fatalf("third check = %+v, %v; want a release", rel, err) + } + if hits.Load() != 2 { + t.Fatalf("requests = %d, want 2", hits.Load()) + } +} + +func TestCheckClockMovedBackwards(t *testing.T) { + t.Parallel() + + url, hits := releaseServer(t, http.StatusOK, "v0.2.0") + c := newChecker(t, url, "0.1.0") + if _, err := c.Check(t.Context()); err != nil { + t.Fatal(err) + } + c.Now = func() time.Time { return now.Add(-48 * time.Hour) } + if _, err := c.Check(t.Context()); err != nil { + t.Fatal(err) + } + if hits.Load() != 2 { + t.Errorf("requests = %d, want 2: a check time in the future must not suppress checks forever", hits.Load()) + } +} + +func TestCheckStateFile(t *testing.T) { + t.Parallel() + + url, _ := releaseServer(t, http.StatusOK, "v0.2.0") + c := newChecker(t, url, "0.1.0") + if _, err := c.Check(t.Context()); err != nil { + t.Fatal(err) + } + + fi, err := os.Stat(c.StatePath) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" && fi.Mode().Perm() != 0o600 { + t.Errorf("state file mode = %v, want 0600", fi.Mode().Perm()) + } + b, err := os.ReadFile(c.StatePath) + if err != nil { + t.Fatal(err) + } + var st state + if err := json.Unmarshal(b, &st); err != nil { + t.Fatal(err) + } + if !st.CheckedAt.Equal(now) || st.Latest.Version != "0.2.0" { + t.Errorf("state = %+v", st) + } +} + +func TestCheckFailuresAreSilentAndRetried(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status int + tag string + }{ + {name: "server error", status: http.StatusInternalServerError, tag: "v0.2.0"}, + {name: "rate limited", status: http.StatusForbidden, tag: "v0.2.0"}, + {name: "invalid tag", status: http.StatusOK, tag: "latest"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + url, hits := releaseServer(t, tt.status, tt.tag) + c := newChecker(t, url, "0.1.0") + + for range 2 { + if rel, err := c.Check(t.Context()); err == nil || rel != nil { + t.Fatalf("Check() = %+v, %v; want an error and no release", rel, err) + } + } + // A failed check is not recorded, so the next run tries again. + if hits.Load() != 2 { + t.Errorf("requests = %d, want 2", hits.Load()) + } + }) + } +} + +func TestCheckNetworkFailure(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.NotFoundHandler()) + url := srv.URL + srv.Close() + + c := newChecker(t, url, "0.1.0") + if rel, err := c.Check(t.Context()); err == nil || rel != nil { + t.Fatalf("Check() = %+v, %v; want an error and no release", rel, err) + } +} + +func TestNotice(t *testing.T) { + t.Parallel() + + rel := &Release{Version: "0.2.0", URL: "https://github.com/devlyft/cli/releases/tag/v0.2.0"} + + got := Notice("v0.1.0", rel, false) + want := "\nA new release of devlyft is available: 0.1.0 → 0.2.0\nhttps://github.com/devlyft/cli/releases/tag/v0.2.0\n" + if got != want { + t.Errorf("Notice() = %q, want %q", got, want) + } + if got := Notice("0.1.0", rel, true); !strings.Contains(got, "brew upgrade devlyft/tap/devlyft") { + t.Errorf("Homebrew notice missing upgrade command: %q", got) + } +} + +func TestIsHomebrew(t *testing.T) { + t.Parallel() + + for path, want := range map[string]bool{ + "/opt/homebrew/bin/devlyft": true, + "/usr/local/Cellar/devlyft/0.1.0/bin/devlyft": true, + "/home/linuxbrew/.linuxbrew/bin/devlyft": true, + "/usr/local/bin/devlyft": false, + "/home/me/go/bin/devlyft": false, + "/home/linuxbrew/.linuxbrew/Cellar/devlyft/0.1.0/bin/": true, + } { + if got := IsHomebrew(path); got != want { + t.Errorf("IsHomebrew(%q) = %v, want %v", path, got, want) + } + } +}