Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions cmd/grounds/commands/preview/preview.go
Original file line number Diff line number Diff line change
@@ -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 <id>",
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 <id>"
short := "Pin a preview env so the TTL janitor skips it"
if !pin {
use = "unpin <id>"
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)
}
66 changes: 66 additions & 0 deletions cmd/grounds/commands/preview/preview_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 2 additions & 0 deletions cmd/grounds/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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)
Expand Down
67 changes: 67 additions & 0 deletions internal/api/preview.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading