From d053b4bc4d81f43e36dc9b53bbe09834d08d1db7 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 19:34:28 +0200 Subject: [PATCH] feat: implement update checking --- README.md | 24 +- cmd/grounds/commands/doctor.go | 278 ++++++++++++++++++++---- cmd/grounds/commands/doctor_test.go | 140 +++++++++++- cmd/grounds/commands/root.go | 26 ++- cmd/grounds/commands/root_test.go | 27 +++ cmd/grounds/commands/version.go | 71 +++++- cmd/grounds/commands/version_test.go | 50 +++++ cmd/grounds/main.go | 4 + go.mod | 40 ++-- go.sum | 81 ++++--- internal/render/color.go | 1 + internal/version/check.go | 145 ++++++++++++ internal/version/check_test.go | 136 ++++++++++++ internal/version/install_method.go | 63 ++++++ internal/version/install_method_test.go | 69 ++++++ 15 files changed, 1025 insertions(+), 130 deletions(-) create mode 100644 cmd/grounds/commands/root_test.go create mode 100644 internal/version/check.go create mode 100644 internal/version/check_test.go create mode 100644 internal/version/install_method.go create mode 100644 internal/version/install_method_test.go diff --git a/README.md b/README.md index e5a0fb3..ee24929 100644 --- a/README.md +++ b/README.md @@ -40,18 +40,18 @@ grounds logs --follow # tail logs ## Commands -| Command | What | -|---|---| -| `grounds login / logout` | Auth via Keycloak device flow | -| `grounds version` | Build info | -| `grounds completion ` | Shell completions | -| `grounds doctor` | Diagnose env (auth, API, gradle, java) | -| `grounds init` | Scaffold a grounds.yaml | -| `grounds cluster up/down/delete/status` | Workspace lifecycle | -| `grounds push [--target=dev]` | Build + deploy via Gradle plugin | -| `grounds push retry/list` | Re-run / list pushes | -| `grounds logs [--follow]` | Stream logs | -| `grounds logs deployment [--follow]` | Stream deployment logs | +| Command | What | +|---------------------------------------------|----------------------------------------------| +| `grounds login / logout` | Auth via Keycloak device flow | +| `grounds version [--check]` | Build info and optional release update check | +| `grounds completion ` | Shell completions | +| `grounds doctor` | Diagnose env and warn about CLI updates | +| `grounds init` | Scaffold a grounds.yaml | +| `grounds cluster up/down/delete/status` | Workspace lifecycle | +| `grounds push [--target=dev]` | Build + deploy via Gradle plugin | +| `grounds push retry/list` | Re-run / list pushes | +| `grounds logs [--follow]` | Stream logs | +| `grounds logs deployment [--follow]` | Stream deployment logs | ## Configuration diff --git a/cmd/grounds/commands/doctor.go b/cmd/grounds/commands/doctor.go index 19c68ac..5f4765e 100644 --- a/cmd/grounds/commands/doctor.go +++ b/cmd/grounds/commands/doctor.go @@ -2,81 +2,252 @@ package commands import ( "context" + "errors" "fmt" "io" "net/http" + "os" "os/exec" "runtime" "time" "github.com/spf13/cobra" + "golang.org/x/term" "github.com/groundsgg/grounds-cli/internal/auth" "github.com/groundsgg/grounds-cli/internal/config" "github.com/groundsgg/grounds-cli/internal/render" + "github.com/groundsgg/grounds-cli/internal/version" +) + +var ErrDoctorIssuesFound = errors.New("doctor issues found") + +type checkStatus string + +const ( + statusOK checkStatus = "ok" + statusWarn checkStatus = "warn" + statusError checkStatus = "error" ) type checkResult struct { + name string + status checkStatus + summary string + details []string +} + +type doctorCheck struct { name string - ok bool - msg string + run func(context.Context) checkResult } +var doctorCheckLatest = version.CheckLatest + func NewDoctorCommand() *cobra.Command { - return &cobra.Command{ - Use: "doctor", - Short: "Diagnose env, auth, API reachability", + var strict bool + cmd := &cobra.Command{ + Use: "doctor", + Short: "Diagnose env, auth, API reachability", + SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { out := cmd.OutOrStdout() - results := runChecks(context.Background()) - anyFail := false - for _, r := range results { - if r.ok { - fmt.Fprintln(out, render.Green("✔"), r.name, " ", r.msg) - } else { - anyFail = true - fmt.Fprintln(out, render.Red("✗"), r.name, " ", r.msg) - } - } - if anyFail { - return fmt.Errorf("doctor: 1 or more checks failed") - } - fmt.Fprintln(out, "Overall:", render.Green("OK")) - return nil + return runDoctorChecks(context.Background(), out, doctorChecks(), isTerminal(out), strict) }, } + cmd.Flags().BoolVar(&strict, "strict", false, "exit non-zero when doctor finds errors") + return cmd +} + +func doctorChecks() []doctorCheck { + return []doctorCheck{ + {name: "Version", run: checkVersion}, + {name: "Config", run: func(context.Context) checkResult { return checkConfig() }}, + {name: "Auth", run: checkAuth}, + {name: "API", run: checkAPI}, + {name: "Gradle", run: func(context.Context) checkResult { return checkGradle() }}, + {name: "Java", run: func(context.Context) checkResult { return checkJava() }}, + } +} + +func runDoctorChecks(ctx context.Context, out io.Writer, checks []doctorCheck, interactive bool, strict bool) error { + fmt.Fprintln(out, "Doctor summary:") + fmt.Fprintln(out) + + results := make([]checkResult, 0, len(checks)) + for _, check := range checks { + var result checkResult + if interactive { + result = runDoctorCheckWithSpinner(ctx, out, check) + } else { + result = check.run(ctx) + } + if result.name == "" { + result.name = check.name + } + results = append(results, result) + printCheckResult(out, result) + } + fmt.Fprintln(out) + + return printDoctorFooter(out, results, strict) +} + +func printCheckResult(out io.Writer, r checkResult) { + fmt.Fprintf(out, "%s %s - %s\n", statusBadge(r.status), r.name, r.summary) + for _, detail := range r.details { + fmt.Fprintf(out, " %s %s\n", detailIcon(r.status), detail) + } +} + +func printDoctorFooter(out io.Writer, results []checkResult, strict bool) error { + errorCount := 0 + warnCount := 0 + for _, r := range results { + switch r.status { + case statusError: + errorCount++ + case statusWarn: + warnCount++ + } + } + if errorCount > 0 { + fmt.Fprintf(out, "%s Doctor found issues in %d %s.\n", render.Red("✗"), errorCount, categoryWord(errorCount)) + if strict { + return ErrDoctorIssuesFound + } + return nil + } + if warnCount > 0 { + fmt.Fprintf(out, "%s Doctor found warnings in %d %s.\n", render.Yellow("!"), warnCount, categoryWord(warnCount)) + return nil + } + fmt.Fprintln(out, render.Green("✓"), "Doctor found no issues.") + return nil +} + +func statusBadge(status checkStatus) string { + switch status { + case statusWarn: + return render.Yellow("[!]") + case statusError: + return render.Red("[✗]") + default: + return render.Green("[✓]") + } +} + +func detailIcon(status checkStatus) string { + switch status { + case statusError: + return render.Red("✗") + case statusWarn: + return render.Yellow("!") + default: + return "•" + } +} + +func categoryWord(count int) string { + if count == 1 { + return "category" + } + return "categories" +} + +func isTerminal(out io.Writer) bool { + file, ok := out.(*os.File) + return ok && term.IsTerminal(int(file.Fd())) +} + +func runDoctorCheckWithSpinner(ctx context.Context, out io.Writer, check doctorCheck) checkResult { + done := make(chan checkResult, 1) + go func() { + done <- check.run(ctx) + }() + + frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + frame := 0 + printSpinnerLine(out, frames[frame], check.name) + for { + select { + case result := <-done: + clearSpinnerLine(out) + return result + case <-ticker.C: + frame = (frame + 1) % len(frames) + clearSpinnerLine(out) + printSpinnerLine(out, frames[frame], check.name) + } + } +} + +func spinnerBadge(frame string) string { + return render.Yellow("[" + frame + "]") } -func runChecks(ctx context.Context) []checkResult { - out := []checkResult{} - out = append(out, checkConfig()) - out = append(out, checkAuth(ctx)) - out = append(out, checkAPI(ctx)) - out = append(out, checkGradle()) - out = append(out, checkJava()) - return out +func printSpinnerLine(out io.Writer, frame, name string) { + fmt.Fprintf(out, "%s %s\n", spinnerBadge(frame), name) +} + +func clearSpinnerLine(out io.Writer) { + fmt.Fprint(out, "\033[1A\r\033[K") +} + +func checkVersion(ctx context.Context) checkResult { + ctx2, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + + report, err := doctorCheckLatest(ctx2, version.CheckOptions{ + Current: version.Version, + APIBaseURL: version.DefaultReleaseAPIBaseURL, + HTTPClient: versionCheckHTTPClient, + }) + if err != nil { + return checkResult{ + name: "Version", + status: statusWarn, + summary: "Could not check for CLI updates", + details: []string{"Run `grounds version --check` to try again. " + err.Error()}, + } + } + if report.UpdateAvailable { + return checkResult{ + name: "Version", + status: statusWarn, + summary: fmt.Sprintf("Grounds CLI %s is outdated; latest is %s", report.Current, report.Latest), + details: []string{"Run `grounds version --check` for update instructions."}, + } + } + if !report.Comparable { + return checkResult{name: "Version", status: statusOK, summary: fmt.Sprintf("Grounds CLI %s is a local build (latest release is %s)", report.Current, report.Latest)} + } + return checkResult{name: "Version", status: statusOK, summary: "Grounds CLI " + report.Current + " is up to date"} } func checkConfig() checkResult { cfg, err := config.Load("") if err != nil { - return checkResult{name: "config", ok: false, msg: err.Error()} + return checkResult{name: "Config", status: statusError, summary: "Could not load Grounds configuration", details: []string{err.Error()}} } - return checkResult{name: "config", ok: true, msg: cfg.Dir} + return checkResult{name: "Config", status: statusOK, summary: "Using configuration from " + cfg.Dir} } func checkAuth(ctx context.Context) checkResult { cfg, err := config.Load("") if err != nil { - return checkResult{name: "auth", ok: false, msg: err.Error()} + return checkResult{name: "Auth", status: statusError, summary: "Could not load authentication configuration", details: []string{err.Error()}} } if t := auth.EnvToken(); t != "" { - return checkResult{name: "auth", ok: true, msg: "GROUNDS_TOKEN set"} + return checkResult{name: "Auth", status: statusOK, summary: "Using GROUNDS_TOKEN from the environment"} } store := auth.NewStore(cfg.Dir) c, err := store.Load() if err != nil { - return checkResult{name: "auth", ok: false, msg: "not logged in"} + return checkResult{name: "Auth", status: statusError, summary: "You are not logged in", details: []string{"Run `grounds login` to authenticate."}} } // The access_token Keycloak issues is short-lived (≈5 min by default) // but the refresh_token lives much longer (≈30 d). Real commands go @@ -85,7 +256,7 @@ func checkAuth(ctx context.Context) checkResult { // works fine. Drop down to refresh + persist if needed. if time.Now().After(c.ExpiresAt.Add(-30 * time.Second)) { if time.Now().After(c.RefreshExpiresAt) { - return checkResult{name: "auth", ok: false, msg: "session expired (run 'grounds login')"} + return checkResult{name: "Auth", status: statusError, summary: "Your login session has expired", details: []string{"Run `grounds login` to authenticate again."}} } device := &auth.DeviceClient{ Issuer: defaultIssuer, @@ -94,38 +265,51 @@ func checkAuth(ctx context.Context) checkResult { } fresh, err := device.Refresh(ctx, c.RefreshToken) if err != nil { - return checkResult{name: "auth", ok: false, msg: "refresh failed: " + err.Error() + " (run 'grounds login')"} + return checkResult{name: "Auth", status: statusError, summary: "Could not refresh your login session", details: []string{err.Error(), "Run `grounds login` to authenticate again."}} } c.AccessToken = fresh.AccessToken c.RefreshToken = fresh.RefreshToken c.ExpiresAt = time.Now().Add(time.Duration(fresh.ExpiresIn) * time.Second) c.RefreshExpiresAt = time.Now().Add(time.Duration(fresh.RefreshExpiresIn) * time.Second) if err := store.Save(c); err != nil { - return checkResult{name: "auth", ok: false, msg: "refresh ok but persist failed: " + err.Error()} + return checkResult{name: "Auth", status: statusError, summary: "Refreshed your session but could not save it", details: []string{err.Error()}} } - return checkResult{name: "auth", ok: true, msg: c.PreferredUser + " (refreshed; valid " + time.Until(c.ExpiresAt).Round(time.Minute).String() + ")"} + return checkResult{name: "Auth", status: statusOK, summary: c.PreferredUser + " is logged in (session refreshed, valid for " + time.Until(c.ExpiresAt).Round(time.Minute).String() + ")"} } - return checkResult{name: "auth", ok: true, msg: c.PreferredUser + " (valid " + time.Until(c.ExpiresAt).Round(time.Minute).String() + ", refresh in " + time.Until(c.RefreshExpiresAt).Round(time.Hour).String() + ")"} + return checkResult{name: "Auth", status: statusOK, summary: c.PreferredUser + " is logged in (session valid for " + time.Until(c.ExpiresAt).Round(time.Minute).String() + ", refresh token valid for " + time.Until(c.RefreshExpiresAt).Round(time.Hour).String() + ")"} } func checkAPI(ctx context.Context) checkResult { cfg, err := config.Load("") if err != nil { - return checkResult{name: "api", ok: false, msg: err.Error()} + return checkResult{name: "API", status: statusError, summary: "Could not load API configuration", details: []string{err.Error()}} } ctx2, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() req, _ := http.NewRequestWithContext(ctx2, "GET", cfg.APIURL+"/healthz", nil) resp, err := (&http.Client{}).Do(req) if err != nil { - return checkResult{name: "api", ok: false, msg: err.Error()} + return checkResult{name: "API", status: statusError, summary: "Could not reach the Grounds API at " + cfg.APIURL, details: []string{err.Error()}} } defer resp.Body.Close() io.Copy(io.Discard, resp.Body) - if resp.StatusCode != 200 { - return checkResult{name: "api", ok: false, msg: fmt.Sprintf("status %d", resp.StatusCode)} + return checkAPIResult(cfg.APIURL, resp.StatusCode, nil) +} + +func checkAPIResult(apiURL string, statusCode int, details []string) checkResult { + if statusCode != http.StatusOK { + return checkResult{ + name: "API", + status: statusError, + summary: fmt.Sprintf("Grounds API at %s returned HTTP %d", apiURL, statusCode), + details: details, + } + } + return checkResult{ + name: "API", + status: statusOK, + summary: "Connected to the Grounds API at " + apiURL + " (/healthz returned HTTP 200)", } - return checkResult{name: "api", ok: true, msg: cfg.APIURL + " → 200 /healthz"} } func checkGradle() checkResult { @@ -134,17 +318,17 @@ func checkGradle() checkResult { name = "gradlew.bat" } if _, err := exec.LookPath("./" + name); err != nil { - return checkResult{name: "gradle", ok: false, msg: "./gradlew not in cwd (run from project root)"} + return checkResult{name: "Gradle", status: statusError, summary: "Gradle wrapper was not found in this directory", details: []string{"Run `grounds doctor` from your project root, where `./" + name + "` exists."}} } - return checkResult{name: "gradle", ok: true, msg: "./" + name} + return checkResult{name: "Gradle", status: statusOK, summary: "Found Gradle wrapper ./" + name} } func checkJava() checkResult { out, err := exec.Command("java", "-version").CombinedOutput() if err != nil { - return checkResult{name: "java", ok: false, msg: "java not in PATH"} + return checkResult{name: "Java", status: statusError, summary: "Java was not found on PATH", details: []string{"Install Java and make sure the java command is available."}} } - return checkResult{name: "java", ok: true, msg: extractJavaVersion(string(out))} + return checkResult{name: "Java", status: statusOK, summary: "Found Java runtime (" + extractJavaVersion(string(out)) + ")"} } func extractJavaVersion(s string) string { diff --git a/cmd/grounds/commands/doctor_test.go b/cmd/grounds/commands/doctor_test.go index 8ce8622..9c0ecfa 100644 --- a/cmd/grounds/commands/doctor_test.go +++ b/cmd/grounds/commands/doctor_test.go @@ -2,8 +2,12 @@ package commands import ( "bytes" + "context" + "net/http" "strings" "testing" + + "github.com/groundsgg/grounds-cli/internal/version" ) func TestDoctorRuns(t *testing.T) { @@ -14,9 +18,143 @@ func TestDoctorRuns(t *testing.T) { // test — we just verify it ran and produced output. _ = cmd.Execute() out := buf.String() - for _, want := range []string{"config", "auth", "api", "gradle", "java"} { + for _, want := range []string{"Config", "Auth", "API", "Gradle", "Java"} { if !strings.Contains(out, want) { t.Errorf("missing %q\n%s", want, out) } } } + +func TestRunDoctorChecksRendersFlutterStyleOutput(t *testing.T) { + checks := []doctorCheck{ + { + name: "Version", + run: func(context.Context) checkResult { + return checkResult{name: "Version", status: statusOK, summary: "Grounds CLI 0.1.13 is up to date"} + }, + }, + { + name: "Auth", + run: func(context.Context) checkResult { + return checkResult{ + name: "Auth", + status: statusWarn, + summary: "You are not logged in", + details: []string{"Run `grounds login` to authenticate."}, + } + }, + }, + } + + var buf bytes.Buffer + err := runDoctorChecks(context.Background(), &buf, checks, false, false) + if err != nil { + t.Fatalf("warning result should not fail doctor: %v", err) + } + + want := "Doctor summary:\n\n" + + "[✓] Version - Grounds CLI 0.1.13 is up to date\n" + + "[!] Auth - You are not logged in\n" + + " ! Run `grounds login` to authenticate.\n\n" + + "! Doctor found warnings in 1 category.\n" + if buf.String() != want { + t.Fatalf("unexpected output:\n%s\nwant:\n%s", buf.String(), want) + } +} + +func TestCheckVersionWarnsWhenUpdateAvailable(t *testing.T) { + oldCheckLatest := doctorCheckLatest + doctorCheckLatest = func(ctx context.Context, opts version.CheckOptions) (version.CheckReport, error) { + return version.CheckReport{ + Current: "0.1.12", + Latest: "0.1.13", + UpdateAvailable: true, + }, nil + } + defer func() { doctorCheckLatest = oldCheckLatest }() + + result := checkVersion(context.Background()) + if result.status != statusWarn { + t.Fatalf("expected version warning: %#v", result) + } + combined := result.summary + "\n" + strings.Join(result.details, "\n") + for _, want := range []string{"Grounds CLI 0.1.12 is outdated; latest is 0.1.13", "Run `grounds version --check` for update instructions."} { + if !strings.Contains(combined, want) { + t.Errorf("message missing %q: %s", want, combined) + } + } +} + +func TestCheckVersionReportsLocalBuildWithoutWarning(t *testing.T) { + oldCheckLatest := doctorCheckLatest + doctorCheckLatest = func(ctx context.Context, opts version.CheckOptions) (version.CheckReport, error) { + return version.CheckReport{ + Current: "dev", + Latest: "0.1.13", + Comparable: false, + }, nil + } + defer func() { doctorCheckLatest = oldCheckLatest }() + + result := checkVersion(context.Background()) + if result.status != statusOK { + t.Fatalf("expected local build to be OK, got %#v", result) + } + if result.summary != "Grounds CLI dev is a local build (latest release is 0.1.13)" { + t.Fatalf("unexpected summary: %s", result.summary) + } +} + +func TestRunDoctorChecksReportsErrorsWithoutCommandError(t *testing.T) { + checks := []doctorCheck{ + { + name: "Gradle", + run: func(context.Context) checkResult { + return checkResult{ + name: "Gradle", + status: statusError, + summary: "Gradle wrapper was not found in this directory", + details: []string{"Run `grounds doctor` from your project root, where `./gradlew` exists."}, + } + }, + }, + } + + var buf bytes.Buffer + err := runDoctorChecks(context.Background(), &buf, checks, false, false) + if err != nil { + t.Fatalf("doctor findings should not fail by default: %v", err) + } + for _, want := range []string{"[✗] Gradle - Gradle wrapper was not found in this directory", " ✗ Run `grounds doctor` from your project root, where `./gradlew` exists.", "✗ Doctor found issues in 1 category."} { + if !strings.Contains(buf.String(), want) { + t.Errorf("output missing %q:\n%s", want, buf.String()) + } + } +} + +func TestRunDoctorChecksStrictFailsForErrorStatus(t *testing.T) { + checks := []doctorCheck{ + { + name: "Gradle", + run: func(context.Context) checkResult { + return checkResult{name: "Gradle", status: statusError, summary: "Gradle wrapper was not found in this directory"} + }, + }, + } + + var buf bytes.Buffer + err := runDoctorChecks(context.Background(), &buf, checks, false, true) + if err != ErrDoctorIssuesFound { + t.Fatalf("expected strict doctor sentinel error, got %v", err) + } +} + +func TestCheckAPISummaryIsUserFriendly(t *testing.T) { + result := checkAPIResult("https://platform.grnds.io", http.StatusOK, nil) + if result.summary != "Connected to the Grounds API at https://platform.grnds.io (/healthz returned HTTP 200)" { + t.Fatalf("unexpected API summary: %s", result.summary) + } + if len(result.details) != 0 { + t.Fatalf("OK result should not include detail lines: %#v", result.details) + } +} diff --git a/cmd/grounds/commands/root.go b/cmd/grounds/commands/root.go index cc0e7fb..2b0bd91 100644 --- a/cmd/grounds/commands/root.go +++ b/cmd/grounds/commands/root.go @@ -1,13 +1,29 @@ package commands -import "github.com/spf13/cobra" +import ( + "github.com/spf13/cobra" + + "github.com/groundsgg/grounds-cli/internal/render" +) func NewRootCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "grounds", - Short: "Grounds Internal Developer Platform CLI", - Long: "Drives the Grounds platform from the terminal.", - SilenceUsage: true, + Use: "grounds", + Short: "Grounds Internal Developer Platform CLI", + Long: "Drives the Grounds platform from the terminal.", + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + disableColor, err := cmd.Flags().GetBool("no-color") + if err != nil { + return err + } + render.SetEnabled(disableColor) + if cmd != nil && cmd.HasParent() && cmd.Parent().PersistentPreRunE != nil && cmd.Parent() != cmd.Root() { + return cmd.Parent().PersistentPreRunE(cmd, args) + } + return nil + }, } cmd.PersistentFlags().String("api-url", "", "override API endpoint (also GROUNDS_API_URL)") cmd.PersistentFlags().String("output", "table", "output format: table | json | yaml") diff --git a/cmd/grounds/commands/root_test.go b/cmd/grounds/commands/root_test.go new file mode 100644 index 0000000..54e7f01 --- /dev/null +++ b/cmd/grounds/commands/root_test.go @@ -0,0 +1,27 @@ +package commands + +import ( + "testing" + + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +func TestRootCommandAppliesNoColorFlag(t *testing.T) { + color.NoColor = false + defer func() { color.NoColor = false }() + + root := NewRootCommand() + root.AddCommand(&cobra.Command{ + Use: "noop", + Run: func(*cobra.Command, []string) {}, + }) + root.SetArgs([]string{"--no-color", "noop"}) + + if err := root.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if !color.NoColor { + t.Fatal("expected --no-color to disable color output") + } +} diff --git a/cmd/grounds/commands/version.go b/cmd/grounds/commands/version.go index b32fa77..baa3611 100644 --- a/cmd/grounds/commands/version.go +++ b/cmd/grounds/commands/version.go @@ -1,22 +1,85 @@ package commands import ( + "context" "fmt" + "net/http" + "os" + "path/filepath" + "time" "github.com/spf13/cobra" "github.com/groundsgg/grounds-cli/internal/version" ) +var versionCheckHTTPClient = &http.Client{Timeout: 5 * time.Second} + func NewVersionCommand() *cobra.Command { - return &cobra.Command{ + var check bool + var releaseAPIURL string + + cmd := &cobra.Command{ Use: "version", Short: "Print version, commit, and build date", RunE: func(cmd *cobra.Command, _ []string) error { - _, err := fmt.Fprintf(cmd.OutOrStdout(), + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "grounds version %s\n commit: %s\n built: %s\n", - version.Version, version.Commit, version.BuildAt) - return err + version.Version, version.Commit, version.BuildAt); err != nil { + return err + } + if !check { + return nil + } + + report, err := version.CheckLatest(context.Background(), version.CheckOptions{ + Current: version.Version, + APIBaseURL: releaseAPIURL, + HTTPClient: versionCheckHTTPClient, + }) + if err != nil { + return err + } + + status := "up to date" + if !report.Comparable { + status = "local build" + } else if report.UpdateAvailable { + status = "update available" + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), + " latest: %s\n status: %s\n", + report.Latest, status); err != nil { + return err + } + + if report.UpdateAvailable { + printUpdateHint(cmd, report) + } + return nil }, } + cmd.Flags().BoolVar(&check, "check", false, "check whether a newer release is available") + cmd.Flags().StringVar(&releaseAPIURL, "release-api-url", version.DefaultReleaseAPIBaseURL, "GitHub API base URL for release checks") + _ = cmd.Flags().MarkHidden("release-api-url") + return cmd +} + +func printUpdateHint(cmd *cobra.Command, report version.CheckReport) { + executable, err := os.Executable() + if err == nil { + if resolved, resolveErr := filepath.EvalSymlinks(executable); resolveErr == nil { + executable = resolved + } + } + + install := version.DetectInstallMethod(executable, os.Getenv("HOME")) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " install method: %s\n", install.Method) + if install.UpdateCommand != "" { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " update command: `%s`\n", install.UpdateCommand) + return + } + if report.ReleaseURL != "" { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " release: %s\n", report.ReleaseURL) + } } diff --git a/cmd/grounds/commands/version_test.go b/cmd/grounds/commands/version_test.go index 38c59a3..e086582 100644 --- a/cmd/grounds/commands/version_test.go +++ b/cmd/grounds/commands/version_test.go @@ -2,6 +2,8 @@ package commands import ( "bytes" + "io" + "net/http" "strings" "testing" @@ -28,3 +30,51 @@ func TestVersionCommand(t *testing.T) { } } } + +func TestVersionCommandCheckReportsUpdate(t *testing.T) { + version.Version = "0.1.12" + version.Commit = "abc123" + version.BuildAt = "2026-04-25T00:00:00Z" + + oldClient := versionCheckHTTPClient + versionCheckHTTPClient = &http.Client{Transport: commandRoundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"tag_name":"v0.1.13","html_url":"https://github.com/groundsgg/grounds-cli/releases/tag/v0.1.13"}`)), + }, nil + })} + defer func() { versionCheckHTTPClient = oldClient }() + + cmd := NewVersionCommand() + cmd.SetArgs([]string{"--check"}) + cmd.SetOut(&bytes.Buffer{}) + if err := cmd.Flags().Set("release-api-url", "https://api.github.test"); err != nil { + t.Fatalf("set release api url: %v", err) + } + + buf := &bytes.Buffer{} + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + + out := buf.String() + for _, want := range []string{ + "grounds version 0.1.12", + "latest: 0.1.13", + "status: update available", + "https://github.com/groundsgg/grounds-cli/releases/tag/v0.1.13", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q\n%s", want, out) + } + } +} + +type commandRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f commandRoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} diff --git a/cmd/grounds/main.go b/cmd/grounds/main.go index 531b857..2c88b48 100644 --- a/cmd/grounds/main.go +++ b/cmd/grounds/main.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "os" @@ -43,6 +44,9 @@ func main() { // captured event as a tag so issues group by subcommand. executedCmd, err := root.ExecuteC() if err != nil { + if errors.Is(err, commands.ErrDoctorIssuesFound) { + os.Exit(1) + } commandPath := "" if executedCmd != nil { commandPath = executedCmd.CommandPath() diff --git a/go.mod b/go.mod index ec369c1..84f3f46 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,13 @@ go 1.25.0 require ( github.com/charmbracelet/huh v1.0.0 github.com/fatih/color v1.19.0 - github.com/getsentry/sentry-go v0.46.1 + github.com/getsentry/sentry-go v0.46.2 github.com/jedib0t/go-pretty/v6 v6.7.10 github.com/r3labs/sse/v2 v2.10.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/zalando/go-keyring v0.2.8 - golang.org/x/term v0.29.0 + golang.org/x/term v0.42.0 gopkg.in/cenkalti/backoff.v1 v1.1.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -20,42 +20,42 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect - github.com/charmbracelet/bubbletea v1.3.6 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/strings v0.1.0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.0.0-20191116160921-f9c825593386 // indirect - golang.org/x/sync v0.16.0 // indirect + golang.org/x/net v0.53.0 // indirect golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/text v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index 4a3379c..89653b2 100644 --- a/go.sum +++ b/go.sum @@ -8,34 +8,38 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= -github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= -github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= +github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= @@ -52,14 +56,14 @@ github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/getsentry/sentry-go v0.46.1 h1:mZyQFaQYkPxAdDG4HR8gDg6j4CnKYVWt4TF92N7i3XY= -github.com/getsentry/sentry-go v0.46.1/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns= +github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -72,16 +76,16 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -90,8 +94,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -100,16 +104,13 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0= github.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= -github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -140,20 +141,18 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/net v0.0.0-20191116160921-f9c825593386 h1:ktbWvQrW08Txdxno1PiDpSxPXG6ndGsfnJjRRtkM0LQ= golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= -golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/render/color.go b/internal/render/color.go index 1249175..c4f055b 100644 --- a/internal/render/color.go +++ b/internal/render/color.go @@ -15,6 +15,7 @@ func SetEnabled(disable bool) { } // Auto-detect from terminal capability (fatih/color does this by // default for color.NoColor=false). + color.NoColor = false } func Green(s string) string { return color.New(color.FgGreen).SprintFunc()(s) } diff --git a/internal/version/check.go b/internal/version/check.go new file mode 100644 index 0000000..4f2247e --- /dev/null +++ b/internal/version/check.go @@ -0,0 +1,145 @@ +package version + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +const ( + DefaultReleaseAPIBaseURL = "https://api.github.com" + releasePath = "/repos/groundsgg/grounds-cli/releases/latest" +) + +type CheckOptions struct { + Current string + APIBaseURL string + HTTPClient *http.Client +} + +type CheckReport struct { + Current string + Latest string + ReleaseURL string + Comparable bool + UpdateAvailable bool +} + +type latestReleaseResponse struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` +} + +func CheckLatest(ctx context.Context, opts CheckOptions) (CheckReport, error) { + current := Normalize(opts.Current) + if current == "" { + current = Normalize(Version) + } + + apiBaseURL := strings.TrimRight(opts.APIBaseURL, "/") + if apiBaseURL == "" { + apiBaseURL = DefaultReleaseAPIBaseURL + } + + client := opts.HTTPClient + if client == nil { + client = &http.Client{Timeout: 5 * time.Second} + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBaseURL+releasePath, nil) + if err != nil { + return CheckReport{}, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "grounds-cli/"+current) + + resp, err := client.Do(req) + if err != nil { + return CheckReport{}, fmt.Errorf("failed to fetch latest release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return CheckReport{}, fmt.Errorf("failed to fetch latest release: status=%d", resp.StatusCode) + } + + var latest latestReleaseResponse + if err := json.NewDecoder(resp.Body).Decode(&latest); err != nil { + return CheckReport{}, fmt.Errorf("failed to decode latest release: %w", err) + } + + latestVersion := Normalize(latest.TagName) + if latestVersion == "" { + return CheckReport{}, fmt.Errorf("invalid latest release response: missing tag_name") + } + + comparable := Comparable(current, latestVersion) + return CheckReport{ + Current: current, + Latest: latestVersion, + ReleaseURL: latest.HTMLURL, + Comparable: comparable, + UpdateAvailable: comparable && Compare(current, latestVersion) < 0, + }, nil +} + +func Normalize(v string) string { + v = strings.TrimSpace(v) + v = strings.TrimPrefix(v, "v") + return v +} + +func Compare(current, latest string) int { + current = Normalize(current) + latest = Normalize(latest) + + currentParts, currentOK := parseVersion(current) + latestParts, latestOK := parseVersion(latest) + if !currentOK && !latestOK { + return strings.Compare(current, latest) + } + if !currentOK || !latestOK { + return 0 + } + + for i := range currentParts { + if currentParts[i] < latestParts[i] { + return -1 + } + if currentParts[i] > latestParts[i] { + return 1 + } + } + return 0 +} + +func Comparable(current, latest string) bool { + current = Normalize(current) + latest = Normalize(latest) + _, currentOK := parseVersion(current) + _, latestOK := parseVersion(latest) + return currentOK == latestOK +} + +func parseVersion(v string) ([3]int, bool) { + var parts [3]int + fields := strings.Split(v, ".") + if len(fields) != 3 { + return parts, false + } + for i, field := range fields { + if field == "" { + return parts, false + } + for _, r := range field { + if r < '0' || r > '9' { + return parts, false + } + parts[i] = parts[i]*10 + int(r-'0') + } + } + return parts, true +} diff --git a/internal/version/check_test.go b/internal/version/check_test.go new file mode 100644 index 0000000..ac74038 --- /dev/null +++ b/internal/version/check_test.go @@ -0,0 +1,136 @@ +package version + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +func TestCheckLatestReportsUpdateAvailable(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/repos/groundsgg/grounds-cli/releases/latest" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + return jsonResponse(200, `{"tag_name":"v0.1.13","html_url":"https://github.com/groundsgg/grounds-cli/releases/tag/v0.1.13"}`), nil + })} + + report, err := CheckLatest(context.Background(), CheckOptions{ + Current: "0.1.12", + APIBaseURL: "https://api.github.test", + HTTPClient: client, + }) + if err != nil { + t.Fatalf("check latest: %v", err) + } + + if !report.UpdateAvailable { + t.Fatalf("expected update to be available: %#v", report) + } + if !report.Comparable { + t.Fatalf("expected release versions to be comparable: %#v", report) + } + if report.Current != "0.1.12" || report.Latest != "0.1.13" { + t.Fatalf("unexpected versions: %#v", report) + } + if report.ReleaseURL != "https://github.com/groundsgg/grounds-cli/releases/tag/v0.1.13" { + t.Fatalf("unexpected release url: %s", report.ReleaseURL) + } +} + +func TestCheckLatestReportsLocalBuildAsIncomparable(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return jsonResponse(200, `{"tag_name":"v0.1.13"}`), nil + })} + + report, err := CheckLatest(context.Background(), CheckOptions{ + Current: "dev", + APIBaseURL: "https://api.github.test", + HTTPClient: client, + }) + if err != nil { + t.Fatalf("check latest: %v", err) + } + if report.Comparable { + t.Fatalf("expected local build to be incomparable: %#v", report) + } + if report.UpdateAvailable { + t.Fatalf("incomparable local build should not report update available: %#v", report) + } +} + +func TestCheckLatestReportsUpToDate(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return jsonResponse(200, `{"tag_name":"v0.1.13"}`), nil + })} + + report, err := CheckLatest(context.Background(), CheckOptions{ + Current: "0.1.13", + APIBaseURL: "https://api.github.test", + HTTPClient: client, + }) + if err != nil { + t.Fatalf("check latest: %v", err) + } + + if report.UpdateAvailable { + t.Fatalf("expected current version to be up to date: %#v", report) + } +} + +func TestCheckLatestRejectsInvalidReleaseResponse(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return jsonResponse(200, `{"tag_name":""}`), nil + })} + + _, err := CheckLatest(context.Background(), CheckOptions{ + Current: "0.1.13", + APIBaseURL: "https://api.github.test", + HTTPClient: client, + }) + if err == nil { + t.Fatal("expected invalid release response error") + } + if !strings.Contains(err.Error(), "missing tag_name") { + t.Fatalf("unexpected error: %v", err) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +func jsonResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func TestCompareVersions(t *testing.T) { + tests := []struct { + name string + current string + latest string + want int + }{ + {name: "latest patch newer", current: "0.1.12", latest: "0.1.13", want: -1}, + {name: "same version", current: "v0.1.13", latest: "0.1.13", want: 0}, + {name: "current newer", current: "0.2.0", latest: "0.1.13", want: 1}, + {name: "dev current is not comparable", current: "dev", latest: "0.1.13", want: 0}, + {name: "git describe current is not comparable", current: "284e1b8-dirty", latest: "0.1.13", want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Compare(tt.current, tt.latest) + if got != tt.want { + t.Fatalf("Compare(%q, %q) = %d, want %d", tt.current, tt.latest, got, tt.want) + } + }) + } +} diff --git a/internal/version/install_method.go b/internal/version/install_method.go new file mode 100644 index 0000000..e0e57ab --- /dev/null +++ b/internal/version/install_method.go @@ -0,0 +1,63 @@ +package version + +import ( + "path/filepath" + "strings" +) + +type InstallMethod string + +const ( + InstallHomebrew InstallMethod = "homebrew" + InstallScoop InstallMethod = "scoop" + InstallRaw InstallMethod = "raw" + InstallSystem InstallMethod = "system-package" + InstallUnknown InstallMethod = "unknown" +) + +type InstallInfo struct { + Method InstallMethod + UpdateCommand string +} + +const rawInstallCommand = "curl -sSL https://github.com/groundsgg/grounds-cli/releases/latest/download/install.sh | bash" + +func DetectInstallMethod(executablePath, homeDir string) InstallInfo { + normalized := normalizePath(executablePath) + home := normalizePath(homeDir) + + if strings.Contains(normalized, "/homebrew/cellar/") || strings.Contains(normalized, "/cellar/grounds/") { + return InstallInfo{Method: InstallHomebrew, UpdateCommand: "brew upgrade groundsgg/tap/grounds"} + } + + if strings.Contains(normalized, "/scoop/apps/grounds/") { + return InstallInfo{Method: InstallScoop, UpdateCommand: "scoop update grounds"} + } + + if home != "" && normalized == home+"/.local/bin/grounds" { + return InstallInfo{ + Method: InstallRaw, + UpdateCommand: rawInstallCommand, + } + } + + if normalized == "/usr/bin/grounds" || normalized == "/usr/local/bin/grounds" { + return InstallInfo{Method: InstallSystem} + } + + if home != "" && strings.HasPrefix(normalized, home+"/") && strings.HasSuffix(normalized, "/grounds") { + return InstallInfo{ + Method: InstallRaw, + UpdateCommand: "INSTALL_DIR=" + strings.TrimSuffix(normalized, "/grounds") + " " + rawInstallCommand, + } + } + + return InstallInfo{Method: InstallUnknown} +} + +func normalizePath(path string) string { + path = filepath.ToSlash(path) + path = strings.ReplaceAll(path, "\\", "/") + path = strings.ToLower(path) + return strings.TrimRight(path, "/") +} diff --git a/internal/version/install_method_test.go b/internal/version/install_method_test.go new file mode 100644 index 0000000..16306a2 --- /dev/null +++ b/internal/version/install_method_test.go @@ -0,0 +1,69 @@ +package version + +import "testing" + +func TestDetectInstallMethod(t *testing.T) { + tests := []struct { + name string + path string + home string + wantMethod InstallMethod + wantCommand string + }{ + { + name: "homebrew arm", + path: "/opt/homebrew/Cellar/grounds/0.1.13/bin/grounds", + wantMethod: InstallHomebrew, + wantCommand: "brew upgrade groundsgg/tap/grounds", + }, + { + name: "scoop", + path: `C:\Users\Lukas\scoop\apps\grounds\current\grounds.exe`, + wantMethod: InstallScoop, + wantCommand: "scoop update grounds", + }, + { + name: "raw local bin", + path: "/home/lukas/.local/bin/grounds", + home: "/home/lukas", + wantMethod: InstallRaw, + wantCommand: `curl -sSL https://github.com/groundsgg/grounds-cli/releases/latest/download/install.sh | bash`, + }, + { + name: "raw custom install dir under home", + path: "/home/lukas/tools/grounds/bin/grounds", + home: "/home/lukas", + wantMethod: InstallRaw, + wantCommand: `INSTALL_DIR=/home/lukas/tools/grounds/bin curl -sSL https://github.com/groundsgg/grounds-cli/releases/latest/download/install.sh | bash`, + }, + { + name: "usr local system path remains package managed", + path: "/usr/local/bin/grounds", + home: "/home/lukas", + wantMethod: InstallSystem, + }, + { + name: "usr system path remains package managed", + path: "/usr/bin/grounds", + home: "/home/lukas", + wantMethod: InstallSystem, + }, + { + name: "unknown", + path: "/tmp/grounds", + wantMethod: InstallUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectInstallMethod(tt.path, tt.home) + if got.Method != tt.wantMethod { + t.Fatalf("method = %q, want %q", got.Method, tt.wantMethod) + } + if got.UpdateCommand != tt.wantCommand { + t.Fatalf("update command = %q, want %q", got.UpdateCommand, tt.wantCommand) + } + }) + } +}