From e656ee6ca97ed5f6fcbeab8569d2f2b7253ee9dd Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Sun, 3 May 2026 21:48:33 +0200 Subject: [PATCH] feat(bundle): grounds bundle list / show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI wrapper for the new GET /v1/bundle/releases + GET /v1/bundle/:ref endpoints (forge#110). Lets engineers inspect available bundles before picking one for `grounds cluster up --bundle `: $ grounds bundle list VERSION PUBLISHED URL 0.4.0 (latest) 2026-05-03 https://... 0.3.0 2026-04-15 https://... $ grounds bundle show 0.4.0 Bundle: 0.4.0 About: Grounds-Platform-Composition... Components: NAME TYPE IMAGE CHART DEVSPACE plugin-social plugin-velocity .../plugin-social:edge 0.1.0 jar-sync-pod-restart nats-recorder (optional) utility .../nats-recorder:edge 0.1.0 - service-config grpc-service .../service-config:edge 0.1.0 quarkus-dev velocity plugin-velocity-base .../velocity:edge 0.1.0 jar-sync-pod-restart The `optional` marker calls out components that are off by default (currently nats-recorder) so engineers know to flip enabled:true in their override file if they want them. Pairs with grounds-forge#110 — without that PR these commands return 404. With it, no auth-token shaping is needed (the same Keycloak bearer the CLI already holds works for the bundle GETs). Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/grounds/commands/bundle/bundle.go | 60 ++++++++++++++++++++++ cmd/grounds/commands/bundle/list.go | 49 ++++++++++++++++++ cmd/grounds/commands/bundle/show.go | 68 +++++++++++++++++++++++++ cmd/grounds/main.go | 2 + internal/api/bundle.go | 73 +++++++++++++++++++++++++++ 5 files changed, 252 insertions(+) create mode 100644 cmd/grounds/commands/bundle/bundle.go create mode 100644 cmd/grounds/commands/bundle/list.go create mode 100644 cmd/grounds/commands/bundle/show.go create mode 100644 internal/api/bundle.go diff --git a/cmd/grounds/commands/bundle/bundle.go b/cmd/grounds/commands/bundle/bundle.go new file mode 100644 index 0000000..71f3121 --- /dev/null +++ b/cmd/grounds/commands/bundle/bundle.go @@ -0,0 +1,60 @@ +package bundle + +import ( + "context" + "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" +) + +func NewBundleCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "bundle", + Short: "Inspect available platform-test bundles", + } + cmd.AddCommand(newList(), newShow()) + return cmd +} + +// Mirrors cluster/cluster.go's buildClient. +func buildClient(_ context.Context, 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: defaultDeviceClient(), + } + } + c := api.New(cfg.APIURL, ts) + if p := projectIDFlag(cmd); p != "" { + c.ProjectID = p + } + return c, nil +} + +func projectIDFlag(cmd *cobra.Command) string { + if cmd != nil { + if p, _ := cmd.Flags().GetString("project"); p != "" { + return p + } + } + return os.Getenv("GROUNDS_PROJECT") +} + +func defaultDeviceClient() *auth.DeviceClient { + return &auth.DeviceClient{ + Issuer: "https://account.grounds.gg/realms/grounds", + ClientID: "grounds-cli", + HTTP: &http.Client{Timeout: 30 * time.Second}, + } +} diff --git a/cmd/grounds/commands/bundle/list.go b/cmd/grounds/commands/bundle/list.go new file mode 100644 index 0000000..ac3cc04 --- /dev/null +++ b/cmd/grounds/commands/bundle/list.go @@ -0,0 +1,49 @@ +package bundle + +import ( + "context" + "fmt" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +func newList() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + 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.`, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + c, err := buildClient(ctx, cmd) + if err != nil { + return err + } + releases, err := c.ListBundleReleases(ctx) + if err != nil { + return err + } + if len(releases) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no released bundles found") + return nil + } + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "VERSION\tPUBLISHED\tURL") + for _, r := range releases { + marker := "" + if r.IsLatest { + marker = " (latest)" + } + fmt.Fprintf(w, "%s%s\t%s\t%s\n", + r.Version, marker, + r.PublishedAt.Format("2006-01-02"), + r.HtmlURL, + ) + } + return w.Flush() + }, + } + return cmd +} diff --git a/cmd/grounds/commands/bundle/show.go b/cmd/grounds/commands/bundle/show.go new file mode 100644 index 0000000..f36aaac --- /dev/null +++ b/cmd/grounds/commands/bundle/show.go @@ -0,0 +1,68 @@ +package bundle + +import ( + "context" + "fmt" + "sort" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +func newShow() *cobra.Command { + cmd := &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': +semver, "v…", the full release tag, or "main" for the latest commit. + +Examples: + + grounds bundle show 0.4.0 + grounds bundle show main`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + c, err := buildClient(ctx, cmd) + if err != nil { + return err + } + b, err := c.GetBundle(ctx, args[0]) + if err != nil { + return err + } + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Bundle: %s\n", b.Metadata.Version) + if b.Metadata.Description != "" { + fmt.Fprintf(out, "About: %s\n", b.Metadata.Description) + } + fmt.Fprintln(out) + fmt.Fprintln(out, "Components:") + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, " NAME\tTYPE\tIMAGE\tCHART\tDEVSPACE") + + names := make([]string, 0, len(b.Components)) + for k := range b.Components { + names = append(names, k) + } + sort.Strings(names) + for _, name := range names { + comp := b.Components[name] + devspace := "-" + if comp.Devspace != nil && comp.Devspace.Workflow != "" { + devspace = comp.Devspace.Workflow + } + marker := name + if comp.Optional { + marker = name + " (optional)" + } + fmt.Fprintf(w, " %s\t%s\t%s:%s\t%s\t%s\n", + marker, comp.Type, comp.Image, comp.Version, comp.Chart.Version, devspace, + ) + } + return w.Flush() + }, + } + return cmd +} diff --git a/cmd/grounds/main.go b/cmd/grounds/main.go index f8ce4d6..b97822c 100644 --- a/cmd/grounds/main.go +++ b/cmd/grounds/main.go @@ -5,6 +5,7 @@ import ( "os" "github.com/groundsgg/grounds-cli/cmd/grounds/commands" + "github.com/groundsgg/grounds-cli/cmd/grounds/commands/bundle" "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" @@ -29,6 +30,7 @@ func main() { root.AddCommand(commands.NewLogoutCommand()) root.AddCommand(commands.NewDoctorCommand()) root.AddCommand(commands.NewInitCommand()) + root.AddCommand(bundle.NewBundleCommand()) root.AddCommand(cluster.NewClusterCommand()) root.AddCommand(logs.NewLogsCommand()) root.AddCommand(push.NewPushCommand()) diff --git a/internal/api/bundle.go b/internal/api/bundle.go new file mode 100644 index 0000000..ed3ff61 --- /dev/null +++ b/internal/api/bundle.go @@ -0,0 +1,73 @@ +package api + +import ( + "context" + "net/http" + "net/url" + "time" +) + +// BundleRelease mirrors one entry of GET /v1/bundle/releases. +type BundleRelease struct { + Version string `json:"version"` + PublishedAt time.Time `json:"publishedAt"` + HtmlURL string `json:"htmlUrl"` + IsLatest bool `json:"isLatest"` +} + +type bundleReleasesResponse struct { + Releases []BundleRelease `json:"releases"` +} + +// ListBundleReleases returns released library-platform-bundle versions +// (drafts + prereleases filtered out, newest-first). +func (c *Client) ListBundleReleases(ctx context.Context) ([]BundleRelease, error) { + out := &bundleReleasesResponse{} + if err := c.doRequest(ctx, http.MethodGet, "/v1/bundle/releases", nil, out); err != nil { + return nil, err + } + return out.Releases, nil +} + +// BundleSummary is the parsed bundle.yaml as JSON. The CLI doesn't +// need every field — Components is the most useful surface; the rest +// is opaque-passthrough so we don't have to keep this in sync with +// every bundle-schema bump. +type BundleSummary struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata BundleSummaryMetadata `json:"metadata"` + Shared map[string]any `json:"shared"` + Components map[string]BundleSummaryComponent `json:"components"` +} + +type BundleSummaryMetadata struct { + Version string `json:"version"` + Description string `json:"description,omitempty"` +} + +type BundleSummaryComponent struct { + Type string `json:"type"` + Image string `json:"image"` + Version string `json:"version"` + Optional bool `json:"optional,omitempty"` + Chart struct { + URL string `json:"url"` + Version string `json:"version"` + } `json:"chart"` + Devspace *struct { + Workflow string `json:"workflow,omitempty"` + } `json:"devspace,omitempty"` +} + +// GetBundle returns the parsed bundle.yaml at the given ref. `ref` +// accepts the same shapes loadBundle does — semver, "v…", the full +// tag, or "main". +func (c *Client) GetBundle(ctx context.Context, ref string) (*BundleSummary, error) { + out := &BundleSummary{} + path := "/v1/bundle/" + url.PathEscape(ref) + if err := c.doRequest(ctx, http.MethodGet, path, nil, out); err != nil { + return nil, err + } + return out, nil +}