diff --git a/cmd/grounds/commands/bundle/bundle.go b/cmd/grounds/commands/bundle/bundle.go index 71f3121..b5b4f3c 100644 --- a/cmd/grounds/commands/bundle/bundle.go +++ b/cmd/grounds/commands/bundle/bundle.go @@ -15,8 +15,9 @@ import ( func NewBundleCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "bundle", - Short: "Inspect available platform-test bundles", + Use: "bundle", + Short: "Inspect available platform-test bundles", + Example: " grounds bundle list\n grounds bundle show main\n grounds bundle show 0.4.0", } cmd.AddCommand(newList(), newShow()) return cmd diff --git a/cmd/grounds/commands/bundle/list.go b/cmd/grounds/commands/bundle/list.go index ac3cc04..67cad4d 100644 --- a/cmd/grounds/commands/bundle/list.go +++ b/cmd/grounds/commands/bundle/list.go @@ -6,6 +6,8 @@ import ( "text/tabwriter" "github.com/spf13/cobra" + + "github.com/groundsgg/grounds-cli/internal/render" ) func newList() *cobra.Command { @@ -14,7 +16,7 @@ func newList() *cobra.Command { Short: "List released platform-bundle versions", Long: `Lists released library-platform-bundle versions, newest first. Drafts and prereleases are filtered out. The version with (latest) is -the same one 'grounds cluster up --bundle main' would track today.`, +the same one ` + "`grounds cluster up --bundle main`" + ` would track today.`, RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() c, err := buildClient(ctx, cmd) @@ -26,7 +28,8 @@ the same one 'grounds cluster up --bundle main' would track today.`, return err } if len(releases) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "no released bundles found") + render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Bundle", "No released bundles found") + render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Try "+render.Command("grounds bundle show main")+" to inspect the current bundle.") return nil } w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) diff --git a/cmd/grounds/commands/bundle/show.go b/cmd/grounds/commands/bundle/show.go index f36aaac..18800e0 100644 --- a/cmd/grounds/commands/bundle/show.go +++ b/cmd/grounds/commands/bundle/show.go @@ -14,7 +14,7 @@ func newShow() *cobra.Command { Use: "show ", Short: "Show the components in a bundle", Long: `Fetches the parsed bundle.yaml at the given ref and prints the -component table. accepts the same shapes as 'cluster up --bundle': +component table. accepts the same shapes as ` + "`grounds cluster up --bundle`" + `: semver, "v…", the full release tag, or "main" for the latest commit. Examples: diff --git a/cmd/grounds/commands/cluster/cluster.go b/cmd/grounds/commands/cluster/cluster.go index 34cd4bf..f2753c9 100644 --- a/cmd/grounds/commands/cluster/cluster.go +++ b/cmd/grounds/commands/cluster/cluster.go @@ -14,7 +14,11 @@ import ( ) func NewClusterCommand() *cobra.Command { - cmd := &cobra.Command{Use: "cluster", Short: "Manage your dev workspace lifecycle"} + cmd := &cobra.Command{ + Use: "cluster", + Short: "Manage your dev workspace lifecycle", + Example: " grounds cluster status\n grounds cluster up\n grounds cluster down\n grounds cluster delete", + } cmd.AddCommand(newUp(), newDown(), newDelete(), newStatus()) return cmd } diff --git a/cmd/grounds/commands/cluster/delete.go b/cmd/grounds/commands/cluster/delete.go index 9bbb932..9e1b30e 100644 --- a/cmd/grounds/commands/cluster/delete.go +++ b/cmd/grounds/commands/cluster/delete.go @@ -3,12 +3,12 @@ package cluster import ( "context" "errors" - "fmt" "os" "github.com/spf13/cobra" "golang.org/x/term" + "github.com/groundsgg/grounds-cli/internal/render" "github.com/groundsgg/grounds-cli/internal/ui" ) @@ -36,7 +36,7 @@ func newDelete() *cobra.Command { return errors.New("non-interactive delete requires --yes ") } } else { - fmt.Fprintln(cmd.OutOrStdout(), "⚠ This will permanently delete", s.Namespace, "and all its data.") + render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Workspace", "This will permanently delete "+s.Namespace+" and all its data") if err := ui.AskTypeName(os.Stdin, cmd.OutOrStdout(), s.Namespace, s.Namespace); err != nil { return err } @@ -48,9 +48,10 @@ func newDelete() *cobra.Command { } switch res.State { case "deleted": - fmt.Fprintln(cmd.OutOrStdout(), "✔ Deleted.") + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Workspace", "Deleted "+s.Namespace) case "deleting": - fmt.Fprintln(cmd.OutOrStdout(), "→ Stuck Terminating; will be cleaned up by the janitor on next run.") + render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Workspace", "Delete is still in progress") + render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Cleanup will continue automatically.") } return nil }, diff --git a/cmd/grounds/commands/cluster/down.go b/cmd/grounds/commands/cluster/down.go index 316fa41..f49621b 100644 --- a/cmd/grounds/commands/cluster/down.go +++ b/cmd/grounds/commands/cluster/down.go @@ -2,7 +2,6 @@ package cluster import ( "context" - "fmt" "github.com/spf13/cobra" @@ -23,7 +22,6 @@ func newDown() *cobra.Command { if err != nil { return err } - fmt.Fprintln(cmd.OutOrStdout(), "✔ Paused.") render.Status(cmd.OutOrStdout(), s) return nil }, diff --git a/cmd/grounds/commands/cluster/status.go b/cmd/grounds/commands/cluster/status.go index 946b804..3d4fd13 100644 --- a/cmd/grounds/commands/cluster/status.go +++ b/cmd/grounds/commands/cluster/status.go @@ -2,7 +2,6 @@ package cluster import ( "context" - "fmt" "github.com/spf13/cobra" @@ -23,7 +22,8 @@ func newStatus() *cobra.Command { s, err := c.GetCluster(ctx) if err != nil { if apiErr, ok := err.(*api.Error); ok && apiErr.StatusCode == 404 { - fmt.Fprintln(cmd.OutOrStdout(), "→ no workspace yet. Run 'grounds push' to create one.") + render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Workspace", "No workspace found") + render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Run "+render.Command("grounds push")+" to create one.") return nil } return err diff --git a/cmd/grounds/commands/cluster/up.go b/cmd/grounds/commands/cluster/up.go index 52b40e9..91f213e 100644 --- a/cmd/grounds/commands/cluster/up.go +++ b/cmd/grounds/commands/cluster/up.go @@ -17,8 +17,9 @@ func newUp() *cobra.Command { var bundleRef string var overridePath string cmd := &cobra.Command{ - Use: "up [--profile=minigame|platform] [--bundle= [--override=]]", - Short: "Spawn or resume the workspace", + Use: "up [--profile=minigame|platform] [--bundle= [--override=]]", + Short: "Spawn or resume the workspace", + Example: " grounds cluster up\n grounds cluster up --profile=platform\n grounds cluster up --bundle=0.4.0 --override=./overrides/me.yaml", Long: `Create the workspace if it doesn't exist, or resume it from a paused state. Profiles: @@ -69,7 +70,7 @@ Profile is locked once a workspace exists. To switch, ` + "`grounds cluster dele if err != nil { return err } - fmt.Fprintln(cmd.OutOrStdout(), "✔ Active.") + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Workspace", "Active") render.Status(cmd.OutOrStdout(), s) return nil }, @@ -112,13 +113,15 @@ func loadBundleRequest(bundleRef, overridePath string) (*api.BundleUpRequest, er func renderBundleResult(w interface { Write(p []byte) (int, error) }, res *api.BundleUpResult) { - fmt.Fprintf(w, "✔ %s — bundle %s — %s\n", res.State, res.BundleVersion, res.Namespace) - fmt.Fprintf(w, " components: %d resolved, %d succeeded, %d failed\n", - res.Components.Resolved, len(res.Components.Succeeded), len(res.Components.Failed)) + status := render.StatusOK + summary := fmt.Sprintf("%s with bundle %s in namespace %s", res.State, res.BundleVersion, res.Namespace) if len(res.Components.Failed) > 0 { - fmt.Fprintln(w, " failed:") - for _, f := range res.Components.Failed { - fmt.Fprintf(w, " - %s: %s\n", f.Name, f.Error) - } + status = render.StatusWarn + } + render.StatusLine(w, status, "Workspace", summary) + render.DetailLine(w, status, fmt.Sprintf("Components: %d resolved, %d succeeded, %d failed", + res.Components.Resolved, len(res.Components.Succeeded), len(res.Components.Failed))) + for _, f := range res.Components.Failed { + render.DetailLine(w, render.StatusError, fmt.Sprintf("%s: %s", f.Name, f.Error)) } } diff --git a/cmd/grounds/commands/cluster/up_test.go b/cmd/grounds/commands/cluster/up_test.go index 418bcfd..012f33e 100644 --- a/cmd/grounds/commands/cluster/up_test.go +++ b/cmd/grounds/commands/cluster/up_test.go @@ -1,9 +1,14 @@ package cluster import ( + "bytes" "os" "path/filepath" "testing" + + "github.com/fatih/color" + + "github.com/groundsgg/grounds-cli/internal/api" ) func TestLoadBundleRequest(t *testing.T) { @@ -89,3 +94,30 @@ func writeTempYAML(t *testing.T, content string) string { } return path } + +func TestRenderBundleResult(t *testing.T) { + color.NoColor = true + defer func() { color.NoColor = false }() + + res := &api.BundleUpResult{ + State: "active", + BundleVersion: "0.4.0", + Namespace: "dev-lukas", + } + res.Components.Resolved = 2 + res.Components.Succeeded = []string{"api"} + res.Components.Failed = append(res.Components.Failed, struct { + Name string `json:"name"` + Error string `json:"error"` + }{Name: "worker", Error: "image pull failed"}) + + var buf bytes.Buffer + renderBundleResult(&buf, res) + + want := "[!] Workspace - active with bundle 0.4.0 in namespace dev-lukas\n" + + " ! Components: 2 resolved, 1 succeeded, 1 failed\n" + + " ✗ worker: image pull failed\n" + if got := buf.String(); got != want { + t.Fatalf("renderBundleResult output = %q, want %q", got, want) + } +} diff --git a/cmd/grounds/commands/devspace/devspace.go b/cmd/grounds/commands/devspace/devspace.go index 0e91cb2..8472836 100644 --- a/cmd/grounds/commands/devspace/devspace.go +++ b/cmd/grounds/commands/devspace/devspace.go @@ -15,8 +15,9 @@ import ( func NewDevspaceCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "devspace", - Short: "DevSpace integration helpers", + Use: "devspace", + Short: "DevSpace integration helpers", + Example: " grounds devspace generate plugin-social --bundle main\n grounds devspace generate plugin-social --override ./me.yaml", } cmd.AddCommand(newGenerate()) return cmd diff --git a/cmd/grounds/commands/devspace/generate.go b/cmd/grounds/commands/devspace/generate.go index 6d7d3f2..81b87db 100644 --- a/cmd/grounds/commands/devspace/generate.go +++ b/cmd/grounds/commands/devspace/generate.go @@ -9,6 +9,7 @@ import ( "gopkg.in/yaml.v3" "github.com/groundsgg/grounds-cli/internal/api" + "github.com/groundsgg/grounds-cli/internal/render" ) func newGenerate() *cobra.Command { @@ -67,7 +68,7 @@ Examples: if err := os.WriteFile(outputPath, yaml, 0o644); err != nil { return fmt.Errorf("writing %s: %w", outputPath, err) } - fmt.Fprintf(cmd.OutOrStdout(), "✔ Wrote %d bytes to %s\n", len(yaml), outputPath) + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "DevSpace", generateSuccessSummary(outputPath)) return nil }, } @@ -77,6 +78,10 @@ Examples: return cmd } +func generateSuccessSummary(outputPath string) string { + return "Wrote " + outputPath +} + // loadGenerateInputs picks the bundle-ref + per-component override // out of the override file (when present), with --bundle on the // command line winning over the file's `bundle:` field. diff --git a/cmd/grounds/commands/devspace/generate_test.go b/cmd/grounds/commands/devspace/generate_test.go index 28e63eb..67777db 100644 --- a/cmd/grounds/commands/devspace/generate_test.go +++ b/cmd/grounds/commands/devspace/generate_test.go @@ -6,6 +6,12 @@ import ( "testing" ) +func TestGenerateSuccessSummary(t *testing.T) { + if got := generateSuccessSummary("./devspace.yaml"); got != "Wrote ./devspace.yaml" { + t.Fatalf("generateSuccessSummary = %q", got) + } +} + func TestLoadGenerateInputs(t *testing.T) { t.Run("bundle flag only, no override", func(t *testing.T) { bundle, override, err := loadGenerateInputs("0.4.0", "", "plugin-social") @@ -71,7 +77,7 @@ overrides: {} } }) - t.Run("component not in override file → override is nil", func(t *testing.T) { + t.Run("component missing from override file uses nil override", func(t *testing.T) { path := writeTempYAML(t, ` bundle: 0.4.0 overrides: diff --git a/cmd/grounds/commands/doctor.go b/cmd/grounds/commands/doctor.go index 5f4765e..ad84e13 100644 --- a/cmd/grounds/commands/doctor.go +++ b/cmd/grounds/commands/doctor.go @@ -94,9 +94,20 @@ func runDoctorChecks(ctx context.Context, out io.Writer, checks []doctorCheck, i } func printCheckResult(out io.Writer, r checkResult) { - fmt.Fprintf(out, "%s %s - %s\n", statusBadge(r.status), r.name, r.summary) + render.StatusLine(out, renderStatus(r.status), r.name, r.summary) for _, detail := range r.details { - fmt.Fprintf(out, " %s %s\n", detailIcon(r.status), detail) + render.DetailLine(out, renderStatus(r.status), detail) + } +} + +func renderStatus(status checkStatus) render.StatusKind { + switch status { + case statusWarn: + return render.StatusWarn + case statusError: + return render.StatusError + default: + return render.StatusOK } } @@ -126,28 +137,6 @@ func printDoctorFooter(out io.Writer, results []checkResult, strict bool) error 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" diff --git a/cmd/grounds/commands/init.go b/cmd/grounds/commands/init.go index 73699e8..9b96755 100644 --- a/cmd/grounds/commands/init.go +++ b/cmd/grounds/commands/init.go @@ -8,6 +8,8 @@ import ( "github.com/charmbracelet/huh" "github.com/spf13/cobra" + + "github.com/groundsgg/grounds-cli/internal/render" ) type initFlags struct { @@ -73,7 +75,7 @@ func writeGroundsYaml(out io.Writer, f *initFlags) error { if err := os.WriteFile(path, []byte(body), 0644); err != nil { return err } - fmt.Fprintln(out, "→ Wrote grounds.yaml") - fmt.Fprintln(out, "Next: grounds push") + render.StatusLine(out, render.StatusOK, "Init", "Wrote grounds.yaml") + render.DetailLine(out, render.StatusOK, "Next: run "+render.Command("grounds push")+".") return nil } diff --git a/cmd/grounds/commands/init_test.go b/cmd/grounds/commands/init_test.go index 8b99ba6..c40d25b 100644 --- a/cmd/grounds/commands/init_test.go +++ b/cmd/grounds/commands/init_test.go @@ -26,4 +26,7 @@ func TestInit_NonInteractive(t *testing.T) { if !bytes.Contains(body, []byte("name: my-arena")) { t.Errorf("body = %s", body) } + if got := buf.String(); got != "[✓] Init - Wrote grounds.yaml\n • Next: run `grounds push`.\n" { + t.Fatalf("output = %q", got) + } } diff --git a/cmd/grounds/commands/login.go b/cmd/grounds/commands/login.go index 5f69be3..a684641 100644 --- a/cmd/grounds/commands/login.go +++ b/cmd/grounds/commands/login.go @@ -4,7 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" - "fmt" + "io" "net/http" "strings" "time" @@ -14,6 +14,7 @@ import ( "github.com/groundsgg/grounds-cli/internal/auth" "github.com/groundsgg/grounds-cli/internal/browser" "github.com/groundsgg/grounds-cli/internal/config" + "github.com/groundsgg/grounds-cli/internal/render" ) const ( @@ -41,9 +42,7 @@ func NewLoginCommand() *cobra.Command { return err } - fmt.Fprintln(cmd.OutOrStdout(), "→ Opening browser to", dc.VerificationURI) - fmt.Fprintln(cmd.OutOrStdout(), " Verification code:", dc.UserCode) - _ = browser.OpenURL(dc.VerificationURIComplete) + printDeviceLoginInstructions(cmd.OutOrStdout(), dc, browser.OpenURL(dc.VerificationURIComplete)) tok, err := device.PollToken(ctx, dc.DeviceCode, dc.CodeVerifier, dc.Interval, dc.ExpiresIn) if err != nil { @@ -59,12 +58,33 @@ func NewLoginCommand() *cobra.Command { return err } - fmt.Fprintln(cmd.OutOrStdout(), "✔ Authenticated as", preferred) + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Auth", "Logged in as "+loginSubject(preferred, email)) return nil }, } } +func printDeviceLoginInstructions(out io.Writer, dc *auth.DeviceCodeResponse, openErr error) { + if openErr != nil { + render.StatusLine(out, render.StatusWarn, "Browser", "Could not open device login page automatically") + render.DetailLine(out, render.StatusWarn, "URL: "+dc.VerificationURI) + render.DetailLine(out, render.StatusWarn, "Code: "+dc.UserCode) + return + } + render.StatusLine(out, render.StatusOK, "Browser", "Opened device login page") + render.DetailLine(out, render.StatusOK, "Code: "+dc.UserCode) +} + +func loginSubject(preferred, email string) string { + if preferred != "" { + return preferred + } + if email != "" { + return email + } + return "current user" +} + func decodeIDToken(idToken string) (email, preferred string) { parts := strings.Split(idToken, ".") if len(parts) != 3 { diff --git a/cmd/grounds/commands/login_test.go b/cmd/grounds/commands/login_test.go new file mode 100644 index 0000000..c9cea07 --- /dev/null +++ b/cmd/grounds/commands/login_test.go @@ -0,0 +1,79 @@ +package commands + +import ( + "bytes" + "errors" + "testing" + + "github.com/fatih/color" + + "github.com/groundsgg/grounds-cli/internal/auth" +) + +func TestLoginSubject(t *testing.T) { + tests := []struct { + name string + preferred string + email string + want string + }{ + { + name: "preferred username", + preferred: "player-one", + email: "player@example.com", + want: "player-one", + }, + { + name: "email", + email: "player@example.com", + want: "player@example.com", + }, + { + name: "current user", + want: "current user", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := loginSubject(tt.preferred, tt.email); got != tt.want { + t.Fatalf("loginSubject(%q, %q) = %q, want %q", tt.preferred, tt.email, got, tt.want) + } + }) + } +} + +func TestPrintDeviceLoginInstructionsOpenedBrowser(t *testing.T) { + color.NoColor = true + t.Cleanup(func() { color.NoColor = false }) + + var buf bytes.Buffer + printDeviceLoginInstructions(&buf, &auth.DeviceCodeResponse{ + UserCode: "ABCD-EFGH", + VerificationURI: "https://example.test/device", + }, nil) + + want := "[✓] Browser - Opened device login page\n" + + " • Code: ABCD-EFGH\n" + if got := buf.String(); got != want { + t.Fatalf("output = %q, want %q", got, want) + } +} + +func TestPrintDeviceLoginInstructionsBrowserOpenFailed(t *testing.T) { + color.NoColor = true + t.Cleanup(func() { color.NoColor = false }) + + var buf bytes.Buffer + printDeviceLoginInstructions(&buf, &auth.DeviceCodeResponse{ + UserCode: "ABCD-EFGH", + VerificationURI: "https://example.test/device", + }, errors.New("no opener")) + + want := "[!] Browser - Could not open device login page automatically\n" + + " ! URL: https://example.test/device\n" + + " ! Code: ABCD-EFGH\n" + if got := buf.String(); got != want { + t.Fatalf("output = %q, want %q", got, want) + } +} diff --git a/cmd/grounds/commands/logout.go b/cmd/grounds/commands/logout.go index 34a55e2..be51aa9 100644 --- a/cmd/grounds/commands/logout.go +++ b/cmd/grounds/commands/logout.go @@ -1,12 +1,11 @@ package commands import ( - "fmt" - "github.com/spf13/cobra" "github.com/groundsgg/grounds-cli/internal/auth" "github.com/groundsgg/grounds-cli/internal/config" + "github.com/groundsgg/grounds-cli/internal/render" ) func NewLogoutCommand() *cobra.Command { @@ -21,7 +20,7 @@ func NewLogoutCommand() *cobra.Command { if err := auth.NewStore(cfg.Dir).Delete(); err != nil { return err } - fmt.Fprintln(cmd.OutOrStdout(), "✔ Logged out.") + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Auth", "Logged out") return nil }, } diff --git a/cmd/grounds/commands/logout_test.go b/cmd/grounds/commands/logout_test.go new file mode 100644 index 0000000..a1d988f --- /dev/null +++ b/cmd/grounds/commands/logout_test.go @@ -0,0 +1,27 @@ +package commands + +import ( + "bytes" + "testing" + + "github.com/fatih/color" +) + +func TestLogoutOutput(t *testing.T) { + previous := color.NoColor + color.NoColor = true + t.Cleanup(func() { color.NoColor = previous }) + + t.Setenv("GROUNDS_CONFIG_DIR", t.TempDir()) + + cmd := NewLogoutCommand() + buf := &bytes.Buffer{} + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if got := buf.String(); got != "[✓] Auth - Logged out\n" { + t.Fatalf("output = %q", got) + } +} diff --git a/cmd/grounds/commands/logs/logs.go b/cmd/grounds/commands/logs/logs.go index bf014b9..9526479 100644 --- a/cmd/grounds/commands/logs/logs.go +++ b/cmd/grounds/commands/logs/logs.go @@ -18,9 +18,10 @@ import ( func NewLogsCommand() *cobra.Command { var follow bool cmd := &cobra.Command{ - Use: "logs ", - Short: "Stream push logs (or deployment logs via 'grounds logs deployment ')", - Args: cobra.ExactArgs(1), + Use: "logs ", + Short: "Stream push logs, or deployment logs with `grounds logs deployment `", + Example: " grounds logs \n grounds logs --follow\n grounds logs deployment ", + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return streamLogs(cmd.Context(), args[0], "push", follow) }, diff --git a/cmd/grounds/commands/logs/logs_test.go b/cmd/grounds/commands/logs/logs_test.go new file mode 100644 index 0000000..fa89ea8 --- /dev/null +++ b/cmd/grounds/commands/logs/logs_test.go @@ -0,0 +1,24 @@ +package logs + +import ( + "strings" + "testing" +) + +func TestLogsExamplesIncludeRequiredPushID(t *testing.T) { + cmd := NewLogsCommand() + + for _, example := range []string{ + "grounds logs ", + "grounds logs --follow", + "grounds logs deployment ", + } { + if !strings.Contains(cmd.Example, example) { + t.Fatalf("logs examples = %q, want %q", cmd.Example, example) + } + } + + if strings.Contains(cmd.Example, "grounds logs\n") || strings.Contains(cmd.Example, "grounds logs --follow") { + t.Fatalf("logs examples = %q, should include required ", cmd.Example) + } +} diff --git a/cmd/grounds/commands/preview/preview.go b/cmd/grounds/commands/preview/preview.go index 3893e3e..ab90eef 100644 --- a/cmd/grounds/commands/preview/preview.go +++ b/cmd/grounds/commands/preview/preview.go @@ -24,8 +24,9 @@ import ( // grounds preview unpin — re-enable TTL sweep func NewPreviewCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "preview", - Short: "Manage preview environments (target=staging deploys)", + Use: "preview", + Short: "Manage staging preview environments", + Example: " grounds preview list\n grounds preview show \n grounds preview pin \n grounds preview unpin ", } cmd.AddCommand(newList(), newShow(), newPin(true), newPin(false)) return cmd @@ -87,7 +88,8 @@ func newList() *cobra.Command { return err } if len(res.Items) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "no preview environments") + render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Preview", "No preview environments found") + render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Run "+render.Command("grounds push --target=staging")+" to create one.") return nil } header := []string{"ID", "PUSH", "NAME", "TYPE", "STATUS", "PINNED", "EXPIRES", "URL"} @@ -145,11 +147,14 @@ func newShow() *cobra.Command { enc.SetIndent("", " ") return enc.Encode(p) } - fmt.Fprintf(cmd.OutOrStdout(), "ID: %s\nPushID: %s\nNamespace: %s\nName: %s (%s)\nStatus: %s\nPinned: %t\nExpires: %s\nURL: %s\n", - p.ID, p.PushID, p.Namespace, - p.Push.ManifestName, p.Push.ManifestType, - p.Push.Status, p.Pinned, - formatTime(p.ExpiresAt), p.PublicURL) + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Preview", p.Push.ManifestName+" ("+p.Push.Status+")") + render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "ID: "+p.ID) + render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Push: "+p.PushID) + render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Namespace: "+p.Namespace) + render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Type: "+p.Push.ManifestType) + render.DetailLine(cmd.OutOrStdout(), render.StatusOK, fmt.Sprintf("Pinned: %t", p.Pinned)) + render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Expires: "+formatTime(p.ExpiresAt)) + render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "URL: "+p.PublicURL) return nil }, } @@ -182,11 +187,7 @@ func newPin(pin bool) *cobra.Command { if err != nil { return err } - verb := "pinned" - if !pin { - verb = "unpinned" - } - fmt.Fprintf(cmd.OutOrStdout(), "%s %s (%s)\n", verb, shortID(p.ID), p.Push.ManifestName) + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Preview", previewPinSummary(pin, p.ID, p.Push.ManifestName)) return nil }, } @@ -201,6 +202,14 @@ func shortID(s string) string { return s[:8] } +func previewPinSummary(pin bool, id, manifestName string) string { + verb := "Pinned" + if !pin { + verb = "Unpinned" + } + return fmt.Sprintf("%s %s (%s)", verb, shortID(id), manifestName) +} + func formatTime(t *time.Time) string { if t == nil { return "—" diff --git a/cmd/grounds/commands/preview/preview_test.go b/cmd/grounds/commands/preview/preview_test.go index 579d7dc..778fc98 100644 --- a/cmd/grounds/commands/preview/preview_test.go +++ b/cmd/grounds/commands/preview/preview_test.go @@ -40,6 +40,15 @@ func TestPinUseLineDiffersFromUnpin(t *testing.T) { } } +func TestPreviewPinSummary(t *testing.T) { + if got := previewPinSummary(true, "abcdef1234", "plugin-social"); got != "Pinned abcdef12 (plugin-social)" { + t.Fatalf("previewPinSummary(pin) = %q", got) + } + if got := previewPinSummary(false, "abcdef1234", "plugin-social"); got != "Unpinned abcdef12 (plugin-social)" { + t.Fatalf("previewPinSummary(unpin) = %q", got) + } +} + func TestShortIDTruncatesAt8Chars(t *testing.T) { cases := map[string]string{ "abc": "abc", diff --git a/cmd/grounds/commands/push/list.go b/cmd/grounds/commands/push/list.go index 0b797a3..84d7973 100644 --- a/cmd/grounds/commands/push/list.go +++ b/cmd/grounds/commands/push/list.go @@ -2,7 +2,7 @@ package push import ( "context" - "fmt" + "io" "github.com/spf13/cobra" @@ -18,6 +18,7 @@ func newList() *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List pushes", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() cfg, err := config.Load("") @@ -41,7 +42,7 @@ func newList() *cobra.Command { } render.Table(cmd.OutOrStdout(), header, rows) if list.NextCursor != "" { - fmt.Fprintln(cmd.OutOrStdout(), "(more available; pagination flag TBD)") + renderPushPaginationNote(cmd.OutOrStdout()) } return nil }, @@ -50,3 +51,8 @@ func newList() *cobra.Command { cmd.Flags().IntVar(&limit, "limit", 20, "page size") return cmd } + +func renderPushPaginationNote(out io.Writer) { + render.StatusLine(out, render.StatusWarn, "Push", "More results are available") + render.DetailLine(out, render.StatusWarn, "Pagination is not available in this CLI version.") +} diff --git a/cmd/grounds/commands/push/push.go b/cmd/grounds/commands/push/push.go index 48b924c..9d31ba3 100644 --- a/cmd/grounds/commands/push/push.go +++ b/cmd/grounds/commands/push/push.go @@ -12,25 +12,29 @@ import ( "github.com/groundsgg/grounds-cli/internal/auth" "github.com/groundsgg/grounds-cli/internal/config" "github.com/groundsgg/grounds-cli/internal/gradle" + "github.com/groundsgg/grounds-cli/internal/render" ) func NewPushCommand() *cobra.Command { - cmd := &cobra.Command{Use: "push", Short: "Build and deploy the current project"} - cmd.AddCommand(newPush(), newRetry(), newList()) + cmd := newPush() + cmd.Example = " grounds push\n grounds push --target=staging\n grounds push list --mine" + cmd.AddCommand(newRetry(), newList()) return cmd } func newPush() *cobra.Command { var target string cmd := &cobra.Command{ - Use: "push [--target=dev|staging]", - Short: "Build via Gradle plugin and deploy to a target", + Use: "push [--target=dev|staging]", + Short: "Build via Gradle plugin and deploy to a target", + Example: " grounds push\n grounds push --target=staging", Long: `Build the current project with the grounds-push Gradle plugin and deploy it. Targets: dev — long-lived, lands in your personal namespace (user-). staging — ephemeral preview env, fresh namespace per push, auto-deleted after 7 days. Public URL pattern: -pr.dev.grnds.io.`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { if target != "dev" && target != "staging" { return fmt.Errorf("invalid --target %q: must be \"dev\" or \"staging\"", target) @@ -41,7 +45,7 @@ Targets: } wrapper, err := gradle.FindWrapper(cwd) if err != nil { - return fmt.Errorf("%w\n → not a Gradle project? Run 'grounds init' to scaffold, or cd to your project root", err) + return fmt.Errorf("%w\n ! Not a Gradle project? Run %s to scaffold, or cd to your project root.", err, render.Command("grounds init")) } ctx := context.Background() @@ -61,7 +65,7 @@ Targets: Device: defaultDevice(), } if _, err := src.Token(ctx); err != nil { - return fmt.Errorf("auth refresh failed: %w\n → run 'grounds login' to re-authenticate", err) + return authRefreshError(err) } } @@ -70,9 +74,16 @@ Targets: }, } cmd.Flags().StringVar(&target, "target", "dev", "deploy target: dev (persistent personal ns) or staging (ephemeral preview env, 7d TTL)") + _ = cmd.RegisterFlagCompletionFunc("target", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return []string{"dev", "staging"}, cobra.ShellCompDirectiveNoFileComp + }) return cmd } +func authRefreshError(err error) error { + return fmt.Errorf("auth refresh failed: %w\n ! Run %s to re-authenticate.", err, render.Command("grounds login")) +} + // projectIDFrom resolves the global --project flag, falling back to // the GROUNDS_PROJECT env var. Empty string when neither is set — // forge then uses the caller's default project. diff --git a/cmd/grounds/commands/push/push_test.go b/cmd/grounds/commands/push/push_test.go index a7747ba..79e985e 100644 --- a/cmd/grounds/commands/push/push_test.go +++ b/cmd/grounds/commands/push/push_test.go @@ -2,8 +2,16 @@ package push import ( "bytes" + "errors" + "os" + "reflect" "strings" "testing" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/groundsgg/grounds-cli/internal/api" ) // Validates the --target flag's allow-list before grounds-push gets @@ -41,3 +49,185 @@ func TestPushDefaultTargetIsDev(t *testing.T) { t.Errorf("expected default --target=dev, got %q", flag.DefValue) } } + +func TestPushTargetCompletion(t *testing.T) { + cmd := newPush() + completion, ok := cmd.GetFlagCompletionFunc("target") + if !ok { + t.Fatal("expected --target completion function") + } + + got, directive := completion(cmd, nil, "") + want := []string{"dev", "staging"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("target completions = %v, want %v", got, want) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("completion directive = %v, want %v", directive, cobra.ShellCompDirectiveNoFileComp) + } +} + +func TestPushRootOwnsDeployFlagsAndSubcommands(t *testing.T) { + cmd := NewPushCommand() + + if flag := cmd.Flag("target"); flag == nil { + t.Fatal("expected root push command to define --target") + } else if flag.DefValue != "dev" { + t.Fatalf("default --target = %q, want %q", flag.DefValue, "dev") + } + + for _, name := range []string{"list", "retry"} { + if sub, _, err := cmd.Find([]string{name}); err != nil { + t.Fatalf("Find(%q) error = %v", name, err) + } else if sub.Name() != name { + t.Fatalf("Find(%q) = %q, want %q", name, sub.Name(), name) + } + } + + if sub, _, err := cmd.Find([]string{"push"}); err == nil && sub.Name() == "push" && sub != cmd { + t.Fatalf("unexpected nested push subcommand found") + } +} + +func TestPushRootRejectsUnexpectedArgsBeforeDeployWork(t *testing.T) { + for _, args := range [][]string{ + {"definitely-not-a-command"}, + {"push"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + cmd := NewPushCommand() + cmd.SetArgs(args) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + err := cmd.Execute() + if err == nil { + t.Fatal("expected unexpected argument error") + } + got := err.Error() + if !strings.Contains(got, "unknown command") { + t.Fatalf("error = %q, want argument validation error", got) + } + if strings.Contains(got, "Run `grounds init`") || strings.Contains(got, "Not a Gradle project") { + t.Fatalf("error = %q, should not enter deploy path", got) + } + }) + } +} + +func TestPushListRejectsUnexpectedArgsBeforeAPIWork(t *testing.T) { + cmd := NewPushCommand() + cmd.SetArgs([]string{"list", "unexpected"}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + err := cmd.Execute() + if err == nil { + t.Fatal("expected unexpected argument error") + } + got := err.Error() + if !strings.Contains(got, "unknown command") && !strings.Contains(got, "arg(s)") { + t.Fatalf("error = %q, want argument validation error", got) + } + if strings.Contains(got, "credentials") || strings.Contains(got, "GROUNDS_TOKEN") { + t.Fatalf("error = %q, should not enter auth/API path", got) + } +} + +func TestPushDeployCommandRejectsUnexpectedArgsBeforeDeployWork(t *testing.T) { + cmd := newPush() + cmd.SetArgs([]string{"definitely-not-a-command"}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + err := cmd.Execute() + if err == nil { + t.Fatal("expected unexpected argument error") + } + got := err.Error() + if !strings.Contains(got, "unknown command") && !strings.Contains(got, "arg(s)") { + t.Fatalf("error = %q, want argument validation error", got) + } + if strings.Contains(got, "Run `grounds init`") || strings.Contains(got, "Not a Gradle project") { + t.Fatalf("error = %q, should not enter deploy path", got) + } +} + +func TestPushMissingGradleWrapperSuggestsCommand(t *testing.T) { + dir := t.TempDir() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(cwd); err != nil { + t.Fatalf("Chdir(%q) error = %v", cwd, err) + } + }) + if err := os.Chdir(dir); err != nil { + t.Fatalf("Chdir(%q) error = %v", dir, err) + } + + cmd := newPush() + cmd.SetArgs([]string{}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + err = cmd.Execute() + if err == nil { + t.Fatal("expected missing Gradle wrapper error") + } + got := err.Error() + if !strings.Contains(got, "Run `grounds init`") { + t.Fatalf("error = %q, want command suggestion", got) + } + if strings.Contains(got, oldArrow()) || strings.Contains(got, singleQuotedCommand("grounds init")) { + t.Fatalf("error = %q, should not use arrows or single-quoted commands", got) + } +} + +func TestPushAuthRefreshErrorSuggestsLoginCommand(t *testing.T) { + err := authRefreshError(errors.New("token expired")) + + got := err.Error() + if !strings.Contains(got, "Run `grounds login`") { + t.Fatalf("error = %q, want login command suggestion", got) + } + if strings.Contains(got, oldArrow()) || strings.Contains(got, singleQuotedCommand("grounds login")) { + t.Fatalf("error = %q, should not use arrows or single-quoted commands", got) + } +} + +func oldArrow() string { + return string(rune(0x2192)) +} + +func singleQuotedCommand(command string) string { + return "'" + command + "'" +} + +func TestRenderRetryTriggered(t *testing.T) { + color.NoColor = true + t.Cleanup(func() { color.NoColor = false }) + + var buf bytes.Buffer + renderRetryTriggered(&buf, &api.Push{ID: "push-123", Status: "queued"}) + + want := "[✓] Push - Retry triggered for push-123\n • Status: queued\n" + if got := buf.String(); got != want { + t.Fatalf("retry output = %q, want %q", got, want) + } +} + +func TestRenderPushPaginationNote(t *testing.T) { + color.NoColor = true + t.Cleanup(func() { color.NoColor = false }) + + var buf bytes.Buffer + renderPushPaginationNote(&buf) + + want := "[!] Push - More results are available\n ! Pagination is not available in this CLI version.\n" + if got := buf.String(); got != want { + t.Fatalf("pagination output = %q, want %q", got, want) + } +} diff --git a/cmd/grounds/commands/push/retry.go b/cmd/grounds/commands/push/retry.go index 3910229..12ca206 100644 --- a/cmd/grounds/commands/push/retry.go +++ b/cmd/grounds/commands/push/retry.go @@ -2,7 +2,6 @@ package push import ( "context" - "fmt" "io" "os" @@ -11,6 +10,7 @@ import ( "github.com/groundsgg/grounds-cli/internal/api" "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/sse" ) @@ -36,7 +36,7 @@ func newRetry() *cobra.Command { if err != nil { return err } - fmt.Fprintln(cmd.OutOrStdout(), "→ Retry triggered for", p.ID, "status:", p.Status) + renderRetryTriggered(cmd.OutOrStdout(), p) if !follow { return nil } @@ -60,3 +60,8 @@ func newRetry() *cobra.Command { cmd.Flags().BoolVar(&follow, "follow", true, "stream logs after retry") return cmd } + +func renderRetryTriggered(out io.Writer, p *api.Push) { + render.StatusLine(out, render.StatusOK, "Push", "Retry triggered for "+p.ID) + render.DetailLine(out, render.StatusOK, "Status: "+p.Status) +} diff --git a/cmd/grounds/commands/root.go b/cmd/grounds/commands/root.go index 2b0bd91..651a3da 100644 --- a/cmd/grounds/commands/root.go +++ b/cmd/grounds/commands/root.go @@ -9,8 +9,8 @@ import ( func NewRootCommand() *cobra.Command { cmd := &cobra.Command{ Use: "grounds", - Short: "Grounds Internal Developer Platform CLI", - Long: "Drives the Grounds platform from the terminal.", + Short: "Grounds developer platform CLI", + Long: "Build, deploy, inspect, and troubleshoot Grounds projects from the terminal.", SilenceUsage: true, SilenceErrors: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { @@ -26,7 +26,6 @@ func NewRootCommand() *cobra.Command { }, } cmd.PersistentFlags().String("api-url", "", "override API endpoint (also GROUNDS_API_URL)") - cmd.PersistentFlags().String("output", "table", "output format: table | json | yaml") cmd.PersistentFlags().BoolP("verbose", "v", false, "debug logging to stderr") cmd.PersistentFlags().Bool("no-color", false, "disable colour output") cmd.PersistentFlags().String("config", "", "alternative config directory (also GROUNDS_CONFIG_DIR)") diff --git a/cmd/grounds/commands/root_test.go b/cmd/grounds/commands/root_test.go index 54e7f01..82f9085 100644 --- a/cmd/grounds/commands/root_test.go +++ b/cmd/grounds/commands/root_test.go @@ -25,3 +25,10 @@ func TestRootCommandAppliesNoColorFlag(t *testing.T) { t.Fatal("expected --no-color to disable color output") } } + +func TestRootCommandDoesNotAdvertiseUnusedOutputFlag(t *testing.T) { + root := NewRootCommand() + if flag := root.PersistentFlags().Lookup("output"); flag != nil { + t.Fatalf("unexpected unused output flag: %q", flag.Usage) + } +} diff --git a/cmd/grounds/commands/version.go b/cmd/grounds/commands/version.go index baa3611..5dfa475 100644 --- a/cmd/grounds/commands/version.go +++ b/cmd/grounds/commands/version.go @@ -20,8 +20,9 @@ func NewVersionCommand() *cobra.Command { var releaseAPIURL string cmd := &cobra.Command{ - Use: "version", - Short: "Print version, commit, and build date", + Use: "version", + Short: "Print version information and check for updates", + Example: " grounds version\n grounds version --check", RunE: func(cmd *cobra.Command, _ []string) error { if _, err := fmt.Fprintf(cmd.OutOrStdout(), "grounds version %s\n commit: %s\n built: %s\n", diff --git a/internal/render/message.go b/internal/render/message.go new file mode 100644 index 0000000..55ca4fb --- /dev/null +++ b/internal/render/message.go @@ -0,0 +1,48 @@ +package render + +import ( + "fmt" + "io" +) + +type StatusKind string + +const ( + StatusOK StatusKind = "ok" + StatusWarn StatusKind = "warn" + StatusError StatusKind = "error" +) + +func StatusBadge(status StatusKind) string { + switch status { + case StatusWarn: + return Yellow("[!]") + case StatusError: + return Red("[✗]") + default: + return Green("[✓]") + } +} + +func DetailIcon(status StatusKind) string { + switch status { + case StatusError: + return Red("✗") + case StatusWarn: + return Yellow("!") + default: + return "•" + } +} + +func StatusLine(w io.Writer, status StatusKind, subject, summary string) { + fmt.Fprintf(w, "%s %s - %s\n", StatusBadge(status), subject, summary) +} + +func DetailLine(w io.Writer, status StatusKind, detail string) { + fmt.Fprintf(w, " %s %s\n", DetailIcon(status), detail) +} + +func Command(command string) string { + return "`" + command + "`" +} diff --git a/internal/render/message_test.go b/internal/render/message_test.go new file mode 100644 index 0000000..a666816 --- /dev/null +++ b/internal/render/message_test.go @@ -0,0 +1,55 @@ +package render + +import ( + "bytes" + "testing" + + "github.com/fatih/color" +) + +func TestStatusBadgeNoColor(t *testing.T) { + color.NoColor = true + defer func() { color.NoColor = false }() + + if got := StatusBadge(StatusOK); got != "[✓]" { + t.Fatalf("StatusBadge(StatusOK) = %q", got) + } + if got := StatusBadge(StatusWarn); got != "[!]" { + t.Fatalf("StatusBadge(StatusWarn) = %q", got) + } + if got := StatusBadge(StatusError); got != "[✗]" { + t.Fatalf("StatusBadge(StatusError) = %q", got) + } +} + +func TestStatusLine(t *testing.T) { + color.NoColor = true + defer func() { color.NoColor = false }() + + var buf bytes.Buffer + StatusLine(&buf, StatusOK, "Init", "Wrote grounds.yaml") + + want := "[✓] Init - Wrote grounds.yaml\n" + if got := buf.String(); got != want { + t.Fatalf("StatusLine output = %q, want %q", got, want) + } +} + +func TestDetailLine(t *testing.T) { + color.NoColor = true + defer func() { color.NoColor = false }() + + var buf bytes.Buffer + DetailLine(&buf, StatusWarn, "Run "+Command("grounds push")+" to create one.") + + want := " ! Run `grounds push` to create one.\n" + if got := buf.String(); got != want { + t.Fatalf("DetailLine output = %q, want %q", got, want) + } +} + +func TestCommand(t *testing.T) { + if got := Command("grounds version --check"); got != "`grounds version --check`" { + t.Fatalf("Command() = %q", got) + } +} diff --git a/internal/render/status.go b/internal/render/status.go index 8b27375..be40fa0 100644 --- a/internal/render/status.go +++ b/internal/render/status.go @@ -51,7 +51,8 @@ func Status(w io.Writer, s *api.ClusterStatus) { if s.State == "paused" { fmt.Fprintln(w) - fmt.Fprintln(w, Yellow("⚠ paused. Next push or 'grounds cluster up' resumes.")) + StatusLine(w, StatusWarn, "Workspace", "Paused") + DetailLine(w, StatusWarn, "Next push or "+Command("grounds cluster up")+" resumes it.") } } diff --git a/internal/render/status_test.go b/internal/render/status_test.go index cce30c8..f8b1ef8 100644 --- a/internal/render/status_test.go +++ b/internal/render/status_test.go @@ -34,16 +34,19 @@ func TestStatus_PausedShowsWarning(t *testing.T) { buf := &bytes.Buffer{} in := time.Now().Add(48 * time.Hour) Status(buf, &api.ClusterStatus{ - Namespace: "user-x", - State: "paused", - Profile: "minigame", - AutoDeleteAt: &in, + Namespace: "user-x", + State: "paused", + Profile: "minigame", + AutoDeleteAt: &in, }) out := buf.String() if !strings.Contains(out, "auto-delete at") { t.Errorf("no auto-delete row\n%s", out) } - if !strings.Contains(out, "paused. Next push") { + if !strings.Contains(out, "Workspace - Paused") { t.Errorf("no warning line\n%s", out) } + if !strings.Contains(out, "Next push or `grounds cluster up` resumes it.") { + t.Errorf("no warning detail\n%s", out) + } }