From d2a5879a8bd549aea416655bb7a557c915251162 Mon Sep 17 00:00:00 2001 From: Piyush-Goenka Date: Tue, 15 Sep 2026 16:13:21 +0530 Subject: [PATCH 1/2] added the flexprice upgrade functionality --- README.md | 8 +- docs/flexprice.md | 1 + docs/flexprice_update.md | 41 +++++ internal/cmd/AGENTS.md | 1 + internal/cmd/groups.go | 2 +- internal/cmd/root.go | 6 + internal/cmd/testdata/root_help.golden | 1 + internal/cmd/update.go | 139 ++++++++++++++++ internal/cmd/update_test.go | 210 +++++++++++++++++++++++++ internal/update/update.go | 157 ++++++++++++++++++ internal/update/update_test.go | 160 +++++++++++++++++++ 11 files changed, 722 insertions(+), 4 deletions(-) create mode 100644 docs/flexprice_update.md create mode 100644 internal/cmd/update.go create mode 100644 internal/cmd/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 5a1def9..ece07ef 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,11 @@ and fix it with: echo 'export PATH="$(go env GOPATH)/bin:$PATH"' >> ~/.zshrc # or ~/.bashrc source ~/.zshrc -**Upgrading:** re-run the install script, download the new release and -replace the binary, or run `go install github.com/flexprice/cli/cmd/flexprice@latest` -again. Whichever you pick, your config and stored keys are untouched. +**Upgrading:** the CLI checks GitHub once a day and prints a notice when a +newer release exists. Run `flexprice update` to replace the binary in place +(`--check` only reports). Set `FLEXPRICE_NO_UPDATE=1` to silence the notice. +On Windows, download the release by hand or re-run `go install`. Your config +and stored keys are untouched either way. ## Quickstart diff --git a/docs/flexprice.md b/docs/flexprice.md index 2f71afc..e77b1ca 100644 --- a/docs/flexprice.md +++ b/docs/flexprice.md @@ -73,6 +73,7 @@ flexprice [flags] * [flexprice tax-associations](flexprice_tax-associations.md) - Which tax rates apply to which entities * [flexprice tax-rates](flexprice_tax-rates.md) - Tax rates available to apply * [flexprice tenants](flexprice_tenants.md) - Your top-level account +* [flexprice update](flexprice_update.md) - Update the CLI to the latest release * [flexprice users](flexprice_users.md) - People with access to your tenant * [flexprice version](flexprice_version.md) - Print the CLI version and embedded spec build * [flexprice wallets](flexprice_wallets.md) - Prepaid credit balances held by a customer diff --git a/docs/flexprice_update.md b/docs/flexprice_update.md new file mode 100644 index 0000000..21381de --- /dev/null +++ b/docs/flexprice_update.md @@ -0,0 +1,41 @@ +## flexprice update + +Update the CLI to the latest release + +### Synopsis + +Check GitHub for a newer release and, if there is one, run the install script (https://cli.flexprice.io) to replace this binary in place. + +Every other command checks once a day and prints a notice when a newer release exists; set FLEXPRICE_NO_UPDATE=1 to turn that off. + +``` +flexprice update [flags] +``` + +### Options + +``` + --check report whether a newer release exists without installing it + -h, --help help for update +``` + +### Options inherited from parent commands + +``` + --all page through every record (prints the last page; use --output json with --limit for bulk export) + --api-key string API key (CI use; prefer flexprice login) + --base-url string override the API base URL + --columns strings columns to show in table output + --debug dump requests and responses, secrets redacted + --limit int maximum records to return (default 20) + --no-color disable coloured output + --no-input never prompt; fail instead of asking + --output string output format: table, json, yaml (default "table") + -p, --profile string profile to use for this command + --quiet suppress progress output + --region string region key, e.g. us or in +``` + +### SEE ALSO + +* [flexprice](flexprice.md) - Flexprice CLI — usage-based billing from your terminal diff --git a/internal/cmd/AGENTS.md b/internal/cmd/AGENTS.md index 52d3bda..49e886a 100644 --- a/internal/cmd/AGENTS.md +++ b/internal/cmd/AGENTS.md @@ -26,6 +26,7 @@ hand-written (`login`, `whoami`, `open`, ...) and spec-dispatched | `config.go` | `config list`/`config use` | | `init.go` | `init` — guided first run | | `misc.go` | `open`, `version` | +| `update.go` | `update`, `wantsUpdateNotice`, `printUpdateNotice` | ## Startup wiring order diff --git a/internal/cmd/groups.go b/internal/cmd/groups.go index 2893927..2399c86 100644 --- a/internal/cmd/groups.go +++ b/internal/cmd/groups.go @@ -88,7 +88,7 @@ var resourceGroups = map[string]resourceEntry{ var builtinGroups = map[string]string{ "init": groupSetup, "login": groupSetup, "logout": groupSetup, "whoami": groupSetup, "env": groupSetup, "config": groupSetup, - "open": groupSetup, "version": groupSetup, + "open": groupSetup, "version": groupSetup, "update": groupSetup, "get": groupAdvanced, "post": groupAdvanced, "delete": groupAdvanced, "resources": groupAdvanced, diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 7c7f57d..ff06a30 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -15,6 +15,7 @@ import ( "github.com/flexprice/cli/internal/spec" "github.com/flexprice/cli/internal/style" "github.com/flexprice/cli/internal/ui" + "github.com/flexprice/cli/internal/update" ) // Created per root: pflag writes flag defaults into the bound pointer at @@ -53,6 +54,7 @@ func NewRootCommand(version string) *cobra.Command { } bindGlobals(root.PersistentFlags(), g) + checker := update.NewChecker() // Flags are not populated until Execute() parses them, so this hook is the // first point where g's fields are real. @@ -61,6 +63,9 @@ func NewRootCommand(version string) *cobra.Command { style.Disable() } g.UI = ui.FromEnv(g.Quiet, g.NoInput, !g.NoColor) + if wantsUpdateNotice(cmd, g, version, term.IsTerminal(int(os.Stderr.Fd()))) { + printUpdateNotice(cmd.Context(), g, version, checker) + } return nil } @@ -86,6 +91,7 @@ func NewRootCommand(version string) *cobra.Command { newConfigCommand(g), newOpenCommand(g, version), newVersionCommand(g, version), + newUpdateCommand(g, version, checker, installScriptURL), ) // Must run before any command carrying a GroupID reaches Execute: cobra diff --git a/internal/cmd/testdata/root_help.golden b/internal/cmd/testdata/root_help.golden index ccd4a3e..26faa31 100644 --- a/internal/cmd/testdata/root_help.golden +++ b/internal/cmd/testdata/root_help.golden @@ -13,6 +13,7 @@ Setup login Store credentials for a region and environment logout Remove a stored profile and its key open Open Flexprice in your browser + update Update the CLI to the latest release version Print the CLI version and embedded spec build whoami Show the active profile, environment and key backend diff --git a/internal/cmd/update.go b/internal/cmd/update.go new file mode 100644 index 0000000..eb685ec --- /dev/null +++ b/internal/cmd/update.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "github.com/spf13/cobra" + "github.com/flexprice/cli/internal/update" +) + +const installScriptURL = "https://cli.flexprice.io" + +var noUpdateNotice = map[string]bool{ + "version": true, + "update": true, + "help": true, + "completion": true, + cobra.ShellCompRequestCmd: true, + cobra.ShellCompNoDescRequestCmd: true, +} + +func wantsUpdateNotice(cmd *cobra.Command, g *Globals, version string, stderrTTY bool) bool { + if !stderrTTY || g.Quiet || version == "dev" { + return false + } + if os.Getenv("FLEXPRICE_NO_UPDATE") != "" { + return false + } + for c := cmd; c != nil; c = c.Parent() { + if noUpdateNotice[c.Name()] { + return false + } + } + return true +} + +func printUpdateNotice(ctx context.Context, g *Globals, version string, c *update.Checker) { + if ctx == nil { + ctx = context.Background() + } + latest, err := c.Latest(ctx) + if err != nil && g.Debug { + g.UI.Info("update check skipped: %v", err) + } + if !update.IsNewer(version, latest) { + return + } + g.UI.Info("A new version of flexprice is available: %s → %s", withV(version), latest) + g.UI.Info("Run `flexprice update` to upgrade.") +} + +// Builds stamp "1.0.1"; release tags read "v1.0.1". Shown the tag way. +func withV(v string) string { + if v == "" || v[0] == 'v' { + return v + } + return "v" + v +} + +func newUpdateCommand(g *Globals, version string, c *update.Checker, scriptURL string) *cobra.Command { + var check bool + cmd := &cobra.Command{ + Use: "update", + Short: "Update the CLI to the latest release", + Long: "Check GitHub for a newer release and, if there is one, run the install " + + "script (" + installScriptURL + ") to replace this binary in place.\n\n" + + "Every other command checks once a day and prints a notice when a newer " + + "release exists; set FLEXPRICE_NO_UPDATE=1 to turn that off.", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + latest, err := c.Fetch(ctx) + if err != nil { + return err + } + if !update.IsNewer(version, latest) { + g.UI.Data("flexprice %s is up to date (latest release: %s)", withV(version), latest) + return nil + } + if check { + g.UI.Data("flexprice %s is available (installed: %s)", latest, withV(version)) + g.UI.Info("Run `flexprice update` to upgrade.") + return nil + } + if runtime.GOOS == "windows" { + return fmt.Errorf("the install script does not support Windows; download %s from %s/latest", latest, update.ReleasesURL) + } + g.UI.Info("Updating flexprice %s to %s...", withV(version), latest) + return runInstallScript(ctx, scriptURL, latest, cmd.OutOrStdout(), cmd.ErrOrStderr()) + }, + } + cmd.Flags().BoolVar(&check, "check", false, "report whether a newer release exists without installing it") + return cmd +} + +func runInstallScript(ctx context.Context, scriptURL, version string, stdout, stderr io.Writer) error { + exe, err := os.Executable() + if err != nil { + return err + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, scriptURL, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("download install script: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download install script: unexpected %s from %s", resp.Status, scriptURL) + } + script, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return fmt.Errorf("download install script: %w", err) + } + + sh := exec.CommandContext(ctx, "sh", "-s") + sh.Stdin = bytes.NewReader(script) + sh.Stdout = stdout + sh.Stderr = stderr + sh.Env = append(os.Environ(), + "FLEXPRICE_VERSION="+version, + "FLEXPRICE_INSTALL_DIR="+filepath.Dir(exe), + ) + return sh.Run() +} diff --git a/internal/cmd/update_test.go b/internal/cmd/update_test.go new file mode 100644 index 0000000..7f1e4eb --- /dev/null +++ b/internal/cmd/update_test.go @@ -0,0 +1,210 @@ +package cmd + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/flexprice/cli/internal/ui" + "github.com/flexprice/cli/internal/update" +) + +func TestWantsUpdateNotice(t *testing.T) { + cases := []struct { + name string + command string + version string + quiet bool + env string + tty bool + want bool + }{ + {name: "resource command on a tty", command: "customers", version: "1.0.1", tty: true, want: true}, + {name: "bare root", command: "flexprice", version: "1.0.1", tty: true, want: true}, + {name: "version", command: "version", version: "1.0.1", tty: true, want: false}, + {name: "update", command: "update", version: "1.0.1", tty: true, want: false}, + {name: "help", command: "help", version: "1.0.1", tty: true, want: false}, + {name: "completion", command: "completion", version: "1.0.1", tty: true, want: false}, + {name: "completion internals", command: cobra.ShellCompRequestCmd, version: "1.0.1", tty: true, want: false}, + {name: "dev build", command: "customers", version: "dev", tty: true, want: false}, + {name: "quiet", command: "customers", version: "1.0.1", quiet: true, tty: true, want: false}, + {name: "not a terminal", command: "customers", version: "1.0.1", tty: false, want: false}, + {name: "opted out", command: "customers", version: "1.0.1", tty: true, env: "1", want: false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Setenv("FLEXPRICE_NO_UPDATE", c.env) + got := wantsUpdateNotice(&cobra.Command{Use: c.command}, &Globals{Quiet: c.quiet}, c.version, c.tty) + if got != c.want { + t.Errorf("wantsUpdateNotice = %v, want %v", got, c.want) + } + }) + } +} + +func TestWantsUpdateNotice_NestedCompletion(t *testing.T) { + parent := &cobra.Command{Use: "completion"} + child := &cobra.Command{Use: "zsh"} + parent.AddCommand(child) + if wantsUpdateNotice(child, &Globals{}, "1.0.1", true) { + t.Error("completion subcommands must not trigger a notice") + } +} + +// Serves the /releases/latest redirect and, at "/", an install script that +// records the environment it was given. +func fakeReleases(t *testing.T, tag string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/releases/latest": + http.Redirect(w, r, "/releases/tag/"+tag, http.StatusFound) + case "/": + _, _ = w.Write([]byte("#!/bin/sh\nprintf '%s %s\\n' \"$FLEXPRICE_VERSION\" \"$FLEXPRICE_INSTALL_DIR\" > \"$INSTALL_LOG\"\n")) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func testChecker(t *testing.T, srv *httptest.Server) *update.Checker { + t.Helper() + return &update.Checker{ + ReleasesURL: srv.URL + "/releases", + CachePath: filepath.Join(t.TempDir(), "update-check.json"), + } +} + +func testUI(out, errOut *bytes.Buffer) *ui.UI { + return ui.New(ui.Options{Out: out, Err: errOut, StderrTTY: true, Term: "dumb"}) +} + +// The two lines are a contract: exactly this text, stderr only. +func TestPrintUpdateNotice_ExactText(t *testing.T) { + srv := fakeReleases(t, "v2.0.1") + var out, errOut bytes.Buffer + g := &Globals{UI: testUI(&out, &errOut)} + + printUpdateNotice(context.Background(), g, "1.0.1", testChecker(t, srv)) + + want := "A new version of flexprice is available: v1.0.1 → v2.0.1\n" + + "Run `flexprice update` to upgrade.\n" + if errOut.String() != want { + t.Errorf("stderr:\n%q\nwant:\n%q", errOut.String(), want) + } + if out.Len() != 0 { + t.Errorf("the notice must never touch stdout, got:\n%s", out.String()) + } +} + +func TestPrintUpdateNotice_SilentWhenCurrent(t *testing.T) { + srv := fakeReleases(t, "v1.0.1") + var out, errOut bytes.Buffer + g := &Globals{UI: testUI(&out, &errOut)} + + printUpdateNotice(context.Background(), g, "1.0.1", testChecker(t, srv)) + if errOut.Len() != 0 { + t.Errorf("no notice expected when current, got:\n%s", errOut.String()) + } +} + +// The hook runs before every command and must never get in its way. +func TestPrintUpdateNotice_SilentOnFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "down", http.StatusServiceUnavailable) + })) + t.Cleanup(srv.Close) + var out, errOut bytes.Buffer + g := &Globals{UI: testUI(&out, &errOut)} + + printUpdateNotice(context.Background(), g, "1.0.1", testChecker(t, srv)) + if errOut.Len() != 0 { + t.Errorf("a failed check must be silent outside --debug, got:\n%s", errOut.String()) + } +} + +func runUpdate(t *testing.T, srv *httptest.Server, version string, args ...string) (string, string, error) { + t.Helper() + var out, errOut bytes.Buffer + cmd := newUpdateCommand(&Globals{UI: testUI(&out, &errOut)}, version, testChecker(t, srv), srv.URL+"/") + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), errOut.String(), err +} + +func TestUpdateCommand_UpToDate(t *testing.T) { + srv := fakeReleases(t, "v1.0.1") + out, _, err := runUpdate(t, srv, "1.0.1") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "up to date") { + t.Errorf("expected an up-to-date message, got:\n%s", out) + } +} + +func TestUpdateCommand_CheckOnly(t *testing.T) { + srv := fakeReleases(t, "v2.0.1") + t.Setenv("INSTALL_LOG", filepath.Join(t.TempDir(), "never-written")) + out, _, err := runUpdate(t, srv, "1.0.1", "--check") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "v2.0.1") { + t.Errorf("stdout should name the newer version, got:\n%s", out) + } + if _, err := os.Stat(os.Getenv("INSTALL_LOG")); err == nil { + t.Error("--check must not run the install script") + } +} + +func TestUpdateCommand_RunsInstallScriptPinnedToBinaryDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the install script is not run on Windows") + } + srv := fakeReleases(t, "v2.0.1") + log := filepath.Join(t.TempDir(), "install.log") + t.Setenv("INSTALL_LOG", log) + + if _, _, err := runUpdate(t, srv, "1.0.1"); err != nil { + t.Fatalf("update: %v", err) + } + + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + got, err := os.ReadFile(log) + if err != nil { + t.Fatalf("install script did not run: %v", err) + } + want := "v2.0.1 " + filepath.Dir(exe) + "\n" + if string(got) != want { + t.Errorf("script env = %q, want %q", got, want) + } +} + +func TestUpdateCommand_FailsWhenUnreachable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "down", http.StatusServiceUnavailable) + })) + t.Cleanup(srv.Close) + if _, _, err := runUpdate(t, srv, "1.0.1"); err == nil { + t.Fatal("an explicit update that cannot reach the release server must fail loudly") + } +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..7d5acd6 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,157 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + ReleasesURL = "https://github.com/flexprice/cli/releases" + CheckInterval = 24 * time.Hour + fetchTimeout = 3 * time.Second +) + +type Checker struct { + ReleasesURL string + CachePath string + Client *http.Client + Now func() time.Time +} + +func NewChecker() *Checker { + c := &Checker{} + if home, err := os.UserHomeDir(); err == nil { + c.CachePath = filepath.Join(home, ".flexprice", "update-check.json") + } + return c +} + +type cache struct { + CheckedAt time.Time `json:"checked_at"` + Latest string `json:"latest"` +} + +func (c *Checker) Latest(ctx context.Context) (string, error) { + cached := c.read() + if c.now().Sub(cached.CheckedAt) < CheckInterval { + return cached.Latest, nil + } + tag, err := c.Fetch(ctx) + if err != nil { + c.write(cache{CheckedAt: c.now(), Latest: cached.Latest}) + return cached.Latest, err + } + return tag, nil +} + +func (c *Checker) Fetch(ctx context.Context) (string, error) { + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + + base := c.ReleasesURL + if base == "" { + base = ReleasesURL + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/latest", nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "flexprice-cli") + + client := http.Client{} + if c.Client != nil { + client = *c.Client + } + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + loc := resp.Header.Get("Location") + i := strings.LastIndex(loc, "/tag/") + if resp.StatusCode < 300 || resp.StatusCode >= 400 || i < 0 { + return "", fmt.Errorf("resolve latest release: unexpected %s from %s/latest", resp.Status, base) + } + tag := strings.TrimSuffix(loc[i+len("/tag/"):], "/") + if _, ok := parse(tag); !ok { + return "", fmt.Errorf("resolve latest release: %q is not a release tag", tag) + } + c.write(cache{CheckedAt: c.now(), Latest: tag}) + return tag, nil +} + +func IsNewer(current, latest string) bool { + cur, ok := parse(current) + if !ok { + return false + } + lat, ok := parse(latest) + if !ok { + return false + } + for i := range cur { + if lat[i] != cur[i] { + return lat[i] > cur[i] + } + } + return false +} + +func parse(v string) ([3]int, bool) { + var out [3]int + parts := strings.Split(strings.TrimPrefix(strings.TrimSpace(v), "v"), ".") + if len(parts) != 3 { + return out, false + } + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return out, false + } + out[i] = n + } + return out, true +} + +func (c *Checker) now() time.Time { + if c.Now != nil { + return c.Now() + } + return time.Now() +} + +func (c *Checker) read() cache { + var s cache + if c.CachePath == "" { + return s + } + b, err := os.ReadFile(c.CachePath) + if err != nil || json.Unmarshal(b, &s) != nil { + return cache{} + } + return s +} + +func (c *Checker) write(s cache) { + if c.CachePath == "" { + return + } + if err := os.MkdirAll(filepath.Dir(c.CachePath), 0o700); err != nil { + return + } + b, err := json.Marshal(s) + if err != nil { + return + } + _ = os.WriteFile(c.CachePath, b, 0o600) +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..aa64bf0 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,160 @@ +package update + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func TestIsNewer(t *testing.T) { + cases := []struct { + current, latest string + want bool + }{ + {"1.0.1", "v2.0.1", true}, + {"v1.0.1", "v2.0.1", true}, + {"1.2.3", "v1.10.0", true}, + {"2.0.1", "v2.0.1", false}, + {"2.0.1", "v1.9.9", false}, + {"dev", "v9.9.9", false}, + {"1.0.0", "v1.0.1-rc1", false}, + {"1.0.0", "", false}, + {"1.0.0", "nightly", false}, + } + for _, c := range cases { + if got := IsNewer(c.current, c.latest); got != c.want { + t.Errorf("IsNewer(%q, %q) = %v, want %v", c.current, c.latest, got, c.want) + } + } +} + +// A release server that only answers the /releases/latest redirect. The tag +// can be swapped and the server can be told to fail, both mid-test. +type fakeReleases struct { + *httptest.Server + hits atomic.Int32 + tag atomic.Value + fail atomic.Bool +} + +func newFakeReleases(t *testing.T, tag string) *fakeReleases { + t.Helper() + f := &fakeReleases{} + f.tag.Store(tag) + f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/releases/latest" { + http.NotFound(w, r) + return + } + f.hits.Add(1) + if f.fail.Load() { + http.Error(w, "down", http.StatusServiceUnavailable) + return + } + http.Redirect(w, r, "/releases/tag/"+f.tag.Load().(string), http.StatusFound) + })) + t.Cleanup(f.Close) + return f +} + +func newChecker(t *testing.T, f *fakeReleases, now *time.Time) *Checker { + t.Helper() + return &Checker{ + ReleasesURL: f.URL + "/releases", + CachePath: filepath.Join(t.TempDir(), "update-check.json"), + Now: func() time.Time { return *now }, + } +} + +func TestLatest_FetchesOnceThenCaches(t *testing.T) { + f := newFakeReleases(t, "v2.0.1") + now := time.Date(2026, 9, 12, 9, 0, 0, 0, time.UTC) + c := newChecker(t, f, &now) + + for i := 0; i < 3; i++ { + tag, err := c.Latest(context.Background()) + if err != nil || tag != "v2.0.1" { + t.Fatalf("Latest = %q, %v; want v2.0.1", tag, err) + } + } + if got := f.hits.Load(); got != 1 { + t.Errorf("server hit %d times, want 1", got) + } +} + +func TestLatest_RefetchesAfterInterval(t *testing.T) { + f := newFakeReleases(t, "v2.0.1") + now := time.Date(2026, 9, 12, 9, 0, 0, 0, time.UTC) + c := newChecker(t, f, &now) + + if _, err := c.Latest(context.Background()); err != nil { + t.Fatal(err) + } + f.tag.Store("v2.1.0") + now = now.Add(CheckInterval + time.Minute) + + tag, err := c.Latest(context.Background()) + if err != nil || tag != "v2.1.0" { + t.Fatalf("Latest after interval = %q, %v; want v2.1.0", tag, err) + } + if got := f.hits.Load(); got != 2 { + t.Errorf("server hit %d times, want 2", got) + } +} + +// Offline users must not pay the timeout on every command, and must not lose +// the tag they already knew about. +func TestLatest_FailureIsCachedAndKeepsLastTag(t *testing.T) { + f := newFakeReleases(t, "v2.0.1") + now := time.Date(2026, 9, 12, 9, 0, 0, 0, time.UTC) + c := newChecker(t, f, &now) + + if _, err := c.Latest(context.Background()); err != nil { + t.Fatal(err) + } + f.fail.Store(true) + now = now.Add(CheckInterval + time.Minute) + + tag, err := c.Latest(context.Background()) + if err == nil { + t.Fatal("expected an error from a failing server") + } + if tag != "v2.0.1" { + t.Errorf("failed fetch should return the last known tag, got %q", tag) + } + + tag, err = c.Latest(context.Background()) + if err != nil || tag != "v2.0.1" { + t.Errorf("within the interval the failure must be cached: got %q, %v", tag, err) + } + if got := f.hits.Load(); got != 2 { + t.Errorf("server hit %d times, want 2", got) + } +} + +func TestFetch_RejectsNonReleaseRedirect(t *testing.T) { + f := newFakeReleases(t, "nightly") + now := time.Now() + c := newChecker(t, f, &now) + if _, err := c.Fetch(context.Background()); err == nil { + t.Error("a redirect to a non-version tag must be an error, not a version") + } +} + +func TestLatest_CorruptCacheIsIgnored(t *testing.T) { + f := newFakeReleases(t, "v2.0.1") + now := time.Now() + c := newChecker(t, f, &now) + if err := os.WriteFile(c.CachePath, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + tag, err := c.Latest(context.Background()) + if err != nil || tag != "v2.0.1" { + t.Fatalf("Latest with corrupt cache = %q, %v; want v2.0.1", tag, err) + } +} From 75bf3ee54563155b6c9f6029c76184b0c6b3620e Mon Sep 17 00:00:00 2001 From: Piyush-Goenka Date: Tue, 15 Sep 2026 16:27:36 +0530 Subject: [PATCH 2/2] fix lint --- internal/cmd/update.go | 4 +++- internal/update/update.go | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/cmd/update.go b/internal/cmd/update.go index eb685ec..4e59721 100644 --- a/internal/cmd/update.go +++ b/internal/cmd/update.go @@ -10,7 +10,9 @@ import ( "os/exec" "path/filepath" "runtime" + "github.com/spf13/cobra" + "github.com/flexprice/cli/internal/update" ) @@ -66,7 +68,7 @@ func withV(v string) string { func newUpdateCommand(g *Globals, version string, c *update.Checker, scriptURL string) *cobra.Command { var check bool cmd := &cobra.Command{ - Use: "update", + Use: "update", Short: "Update the CLI to the latest release", Long: "Check GitHub for a newer release and, if there is one, run the install " + "script (" + installScriptURL + ") to replace this binary in place.\n\n" + diff --git a/internal/update/update.go b/internal/update/update.go index 7d5acd6..f5d1e0a 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -13,9 +13,9 @@ import ( ) const ( - ReleasesURL = "https://github.com/flexprice/cli/releases" + ReleasesURL = "https://github.com/flexprice/cli/releases" CheckInterval = 24 * time.Hour - fetchTimeout = 3 * time.Second + fetchTimeout = 3 * time.Second ) type Checker struct {