diff --git a/cmd/grounds/commands/preview/preview.go b/cmd/grounds/commands/preview/preview.go new file mode 100644 index 0000000..3893e3e --- /dev/null +++ b/cmd/grounds/commands/preview/preview.go @@ -0,0 +1,209 @@ +package preview + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "time" + + "github.com/spf13/cobra" + + "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" +) + +// NewPreviewCommand returns the `grounds preview` subtree: +// +// grounds preview list — show active preview envs in this project +// grounds preview show — print one preview env in detail +// grounds preview pin — keep an env alive past its 7d TTL +// grounds preview unpin — re-enable TTL sweep +func NewPreviewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "preview", + Short: "Manage preview environments (target=staging deploys)", + } + cmd.AddCommand(newList(), newShow(), newPin(true), newPin(false)) + return cmd +} + +// ---------------------------------------------------------------------------- +// helpers — duplicated from push package (small enough not to warrant a +// shared internal/cmd helper yet; lift if a third subtree needs it). +// ---------------------------------------------------------------------------- + +func projectIDFrom(cmd *cobra.Command) string { + if cmd != nil { + if p, _ := cmd.Flags().GetString("project"); p != "" { + return p + } + } + return os.Getenv("GROUNDS_PROJECT") +} + +func defaultDevice() *auth.DeviceClient { + return &auth.DeviceClient{ + Issuer: "https://account.grounds.gg/realms/grounds", + ClientID: "grounds-cli", + HTTP: &http.Client{Timeout: 30 * time.Second}, + } +} + +func makeClient(cmd *cobra.Command) (*api.Client, error) { + cfg, err := config.Load("") + if err != nil { + return nil, err + } + ts := api.NewEnvTokenSource() + if ts == nil { + ts = &auth.FileTokenSource{Store: auth.NewStore(cfg.Dir), Device: defaultDevice()} + } + c := api.New(cfg.APIURL, ts) + c.ProjectID = projectIDFrom(cmd) + return c, nil +} + +// ---------------------------------------------------------------------------- +// list +// ---------------------------------------------------------------------------- + +func newList() *cobra.Command { + var includeDeleted bool + cmd := &cobra.Command{ + Use: "list", + Short: "List preview environments in the current project", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + c, err := makeClient(cmd) + if err != nil { + return err + } + res, err := c.ListPreviewEnvs(ctx, includeDeleted) + if err != nil { + return err + } + if len(res.Items) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no preview environments") + return nil + } + header := []string{"ID", "PUSH", "NAME", "TYPE", "STATUS", "PINNED", "EXPIRES", "URL"} + rows := make([][]any, 0, len(res.Items)) + for _, p := range res.Items { + exp := "—" + if p.ExpiresAt != nil { + exp = p.ExpiresAt.Format("2006-01-02") + } + pin := "no" + if p.Pinned { + pin = "yes" + } + rows = append(rows, []any{ + shortID(p.ID), + shortID(p.PushID), + p.Push.ManifestName, + p.Push.ManifestType, + p.Push.Status, + pin, + exp, + p.PublicURL, + }) + } + render.Table(cmd.OutOrStdout(), header, rows) + return nil + }, + } + cmd.Flags().BoolVar(&includeDeleted, "include-deleted", false, "show envs already swept by the janitor") + return cmd +} + +// ---------------------------------------------------------------------------- +// show +// ---------------------------------------------------------------------------- + +func newShow() *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "show ", + Short: "Show one preview environment in detail", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + c, err := makeClient(cmd) + if err != nil { + return err + } + p, err := c.GetPreviewEnv(ctx, args[0]) + if err != nil { + return err + } + if asJSON { + enc := json.NewEncoder(cmd.OutOrStdout()) + 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) + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "output JSON instead of human-readable text") + return cmd +} + +// ---------------------------------------------------------------------------- +// pin / unpin (single factory; flag boolean differs) +// ---------------------------------------------------------------------------- + +func newPin(pin bool) *cobra.Command { + use := "pin " + short := "Pin a preview env so the TTL janitor skips it" + if !pin { + use = "unpin " + short = "Un-pin a preview env so the TTL janitor can sweep it" + } + return &cobra.Command{ + Use: use, + Short: short, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + c, err := makeClient(cmd) + if err != nil { + return err + } + p, err := c.SetPreviewPin(ctx, args[0], pin) + 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) + return nil + }, + } +} + +// ---------------------------------------------------------------------------- + +func shortID(s string) string { + if len(s) <= 8 { + return s + } + return s[:8] +} + +func formatTime(t *time.Time) string { + if t == nil { + return "—" + } + return t.Format(time.RFC3339) +} diff --git a/cmd/grounds/commands/preview/preview_test.go b/cmd/grounds/commands/preview/preview_test.go new file mode 100644 index 0000000..579d7dc --- /dev/null +++ b/cmd/grounds/commands/preview/preview_test.go @@ -0,0 +1,66 @@ +package preview + +import ( + "strings" + "testing" + "time" +) + +func TestNewPreviewCommandHasFourSubcommands(t *testing.T) { + cmd := NewPreviewCommand() + got := map[string]bool{} + for _, c := range cmd.Commands() { + got[c.Name()] = true + } + for _, name := range []string{"list", "show", "pin", "unpin"} { + if !got[name] { + t.Errorf("missing subcommand %q", name) + } + } +} + +func TestShowRequiresExactlyOneArg(t *testing.T) { + cmd := newShow() + cmd.SetArgs([]string{}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + if err := cmd.Execute(); err == nil { + t.Error("expected error for 0 args, got nil") + } +} + +func TestPinUseLineDiffersFromUnpin(t *testing.T) { + pin := newPin(true) + unpin := newPin(false) + if !strings.HasPrefix(pin.Use, "pin") { + t.Errorf("expected pin Use to start with 'pin', got %q", pin.Use) + } + if !strings.HasPrefix(unpin.Use, "unpin") { + t.Errorf("expected unpin Use to start with 'unpin', got %q", unpin.Use) + } +} + +func TestShortIDTruncatesAt8Chars(t *testing.T) { + cases := map[string]string{ + "abc": "abc", + "abcdefgh": "abcdefgh", + "abcdefghijkl": "abcdefgh", + "1456b204-569c-4403-a648-b1b1401f": "1456b204", + } + for in, want := range cases { + if got := shortID(in); got != want { + t.Errorf("shortID(%q) = %q, want %q", in, got, want) + } + } +} + +func TestFormatTimeNilGivesDash(t *testing.T) { + if got := formatTime(nil); got != "—" { + t.Errorf("formatTime(nil) = %q, want '—'", got) + } + now := time.Now() + got := formatTime(&now) + if got == "—" || got == "" { + t.Errorf("formatTime(now) returned %q, expected RFC3339 timestamp", got) + } +} diff --git a/cmd/grounds/main.go b/cmd/grounds/main.go index 94dd588..ed9c1f4 100644 --- a/cmd/grounds/main.go +++ b/cmd/grounds/main.go @@ -7,6 +7,7 @@ import ( "github.com/groundsgg/grounds-cli/cmd/grounds/commands" "github.com/groundsgg/grounds-cli/cmd/grounds/commands/cluster" "github.com/groundsgg/grounds-cli/cmd/grounds/commands/logs" + "github.com/groundsgg/grounds-cli/cmd/grounds/commands/preview" "github.com/groundsgg/grounds-cli/cmd/grounds/commands/push" ) @@ -21,6 +22,7 @@ func main() { root.AddCommand(cluster.NewClusterCommand()) root.AddCommand(logs.NewLogsCommand()) root.AddCommand(push.NewPushCommand()) + root.AddCommand(preview.NewPreviewCommand()) if err := root.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/internal/api/preview.go b/internal/api/preview.go new file mode 100644 index 0000000..127e7f9 --- /dev/null +++ b/internal/api/preview.go @@ -0,0 +1,67 @@ +package api + +import ( + "context" + "net/http" + "net/url" + "time" +) + +// Preview env metadata as returned by forge /v1/preview-envs. +type PreviewEnv struct { + ID string `json:"id"` + PushID string `json:"pushId"` + Namespace string `json:"namespace"` + Hostname string `json:"hostname"` + PublicURL string `json:"publicUrl"` + Pinned bool `json:"pinned"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + DeletedAt *time.Time `json:"deletedAt,omitempty"` + Push PreviewPushSummary `json:"push"` +} + +type PreviewPushSummary struct { + ID string `json:"id"` + Status string `json:"status"` + ManifestName string `json:"manifestName"` + ManifestType string `json:"manifestType"` +} + +type PreviewEnvList struct { + Items []PreviewEnv `json:"items"` +} + +func (c *Client) ListPreviewEnvs(ctx context.Context, includeDeleted bool) (*PreviewEnvList, error) { + q := url.Values{} + if includeDeleted { + q.Set("includeDeleted", "true") + } + path := "/v1/preview-envs" + if e := q.Encode(); e != "" { + path += "?" + e + } + out := &PreviewEnvList{} + if err := c.doRequest(ctx, http.MethodGet, path, nil, out); err != nil { + return nil, err + } + return out, nil +} + +func (c *Client) GetPreviewEnv(ctx context.Context, id string) (*PreviewEnv, error) { + out := &PreviewEnv{} + if err := c.doRequest(ctx, http.MethodGet, "/v1/preview-envs/"+url.PathEscape(id), nil, out); err != nil { + return nil, err + } + return out, nil +} + +// SetPreviewPin flips the pinned flag — pinned envs are skipped by the +// PreviewJanitor TTL sweep and stay alive past expiresAt. +func (c *Client) SetPreviewPin(ctx context.Context, id string, pinned bool) (*PreviewEnv, error) { + body := map[string]bool{"pinned": pinned} + out := &PreviewEnv{} + if err := c.doRequest(ctx, http.MethodPatch, "/v1/preview-envs/"+url.PathEscape(id), body, out); err != nil { + return nil, err + } + return out, nil +}