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
29 changes: 26 additions & 3 deletions cmd/grounds/commands/cluster/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cluster
import (
"context"
"net/http"
"os"
"time"

"github.com/spf13/cobra"
Expand All @@ -19,8 +20,9 @@ func NewClusterCommand() *cobra.Command {
}

// buildClient is the shared helper every cluster subcommand uses to
// resolve config + auth + API client.
func buildClient(ctx context.Context) (*api.Client, *config.Config, error) {
// resolve config + auth + API client. The cobra command is passed in
// so we can read the global --project flag.
func buildClient(_ context.Context, cmd *cobra.Command) (*api.Client, *config.Config, error) {
cfg, err := config.Load("")
if err != nil {
return nil, nil, err
Expand All @@ -32,7 +34,28 @@ func buildClient(ctx context.Context) (*api.Client, *config.Config, error) {
Device: defaultDeviceClient(),
}
}
return api.New(cfg.APIURL, ts), cfg, nil
c := api.New(cfg.APIURL, ts)
c.ProjectID = resolveProjectID(cmd)
return c, cfg, nil
}

// resolveProjectID picks --project, falling back to the GROUNDS_PROJECT
// env var. Empty string when neither is set, in which case forge falls
// back to the caller's default project.
func resolveProjectID(cmd *cobra.Command) string {
if cmd != nil {
if p, _ := cmd.Flags().GetString("project"); p != "" {
return p
}
}
return envOr("GROUNDS_PROJECT", "")
}

func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

// defaultDeviceClient mirrors login.go (same issuer, same client ID).
Expand Down
2 changes: 1 addition & 1 deletion cmd/grounds/commands/cluster/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func newDelete() *cobra.Command {
Short: "Permanently delete the workspace and all its data",
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := context.Background()
c, _, err := buildClient(ctx)
c, _, err := buildClient(ctx, cmd)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/grounds/commands/cluster/down.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func newDown() *cobra.Command {
Short: "Pause the workspace immediately",
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := context.Background()
c, _, err := buildClient(ctx)
c, _, err := buildClient(ctx, cmd)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/grounds/commands/cluster/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func newStatus() *cobra.Command {
Short: "Show workspace state, deployments, quota",
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := context.Background()
c, _, err := buildClient(ctx)
c, _, err := buildClient(ctx, cmd)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/grounds/commands/cluster/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func newUp() *cobra.Command {
Short: "Resume the paused workspace",
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := context.Background()
c, _, err := buildClient(ctx)
c, _, err := buildClient(ctx, cmd)
if err != nil {
return err
}
Expand Down
1 change: 1 addition & 0 deletions cmd/grounds/commands/push/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func newList() *cobra.Command {
ts = &auth.FileTokenSource{Store: auth.NewStore(cfg.Dir), Device: defaultDevice()}
}
c := api.New(cfg.APIURL, ts)
c.ProjectID = projectIDFrom(cmd)
list, err := c.ListPushes(ctx, mine, limit)
if err != nil {
return err
Expand Down
12 changes: 12 additions & 0 deletions cmd/grounds/commands/push/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ func newPush() *cobra.Command {
return cmd
}

// 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.
func projectIDFrom(cmd *cobra.Command) string {
if cmd != nil {
if p, _ := cmd.Flags().GetString("project"); p != "" {
return p
}
}
return os.Getenv("GROUNDS_PROJECT")
}

// defaultDevice mirrors login.go (same issuer, same client ID).
// Lifted here so subcommands don't depend on the commands package.
func defaultDevice() *auth.DeviceClient {
Expand Down
1 change: 1 addition & 0 deletions cmd/grounds/commands/push/retry.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func newRetry() *cobra.Command {
ts = &auth.FileTokenSource{Store: auth.NewStore(cfg.Dir), Device: defaultDevice()}
}
c := api.New(cfg.APIURL, ts)
c.ProjectID = projectIDFrom(cmd)
p, err := c.RetryPush(ctx, args[0])
if err != nil {
return err
Expand Down
1 change: 1 addition & 0 deletions cmd/grounds/commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ func NewRootCommand() *cobra.Command {
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)")
cmd.PersistentFlags().String("project", "", "project id to scope this call (also GROUNDS_PROJECT)")
return cmd
}
37 changes: 36 additions & 1 deletion internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"

"github.com/groundsgg/grounds-cli/internal/auth"
)
Expand All @@ -15,6 +17,20 @@ type Client struct {
BaseURL string
HTTP *http.Client
Tokens TokenSource
// ProjectID, when set, is appended as `?projectId=...` to every
// project-scoped request. Driven by the global `--project` flag /
// GROUNDS_PROJECT env var. Empty string → forge falls back to the
// caller's default project (their auto-created Personal one).
ProjectID string
}

// WithProject returns a copy of the Client scoped to the given project id.
// Used by command handlers that resolve --project before each call so the
// underlying base client can be shared across goroutines.
func (c *Client) WithProject(id string) *Client {
clone := *c
clone.ProjectID = id
return &clone
}

// TokenSource produces a fresh bearer token, refreshing on demand. The
Expand All @@ -36,7 +52,7 @@ func (c *Client) doRequest(ctx context.Context, method, path string, body any, o
}
rdr = bytes.NewReader(blob)
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rdr)
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+c.scopedPath(path), rdr)
if err != nil {
return err
}
Expand Down Expand Up @@ -67,6 +83,25 @@ func (c *Client) doRequest(ctx context.Context, method, path string, body any, o
return nil
}

// scopedPath appends `?projectId=...` to the path when the client carries
// a project id. Idempotent — does nothing when ProjectID is empty (forge
// falls back to the caller's default project) or when the path already
// contains a `projectId=` query.
func (c *Client) scopedPath(path string) string {
if c.ProjectID == "" {
return path
}
// already scoped
if strings.Contains(path, "projectId=") {
return path
}
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
return path + sep + "projectId=" + url.QueryEscape(c.ProjectID)
}

// envTokenSource uses GROUNDS_TOKEN verbatim, no refresh.
type envTokenSource struct{ token string }

Expand Down
52 changes: 52 additions & 0 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,58 @@ func TestDoRequest_AuthHeader(t *testing.T) {
}
}

func TestScopedPath(t *testing.T) {
cases := []struct {
name string
projectID string
in string
want string
}{
{name: "empty project leaves path untouched", projectID: "", in: "/v1/cluster", want: "/v1/cluster"},
{name: "appends as first query param", projectID: "p1", in: "/v1/cluster", want: "/v1/cluster?projectId=p1"},
{name: "appends with & when path already has a query", projectID: "p1", in: "/v1/pushes?cursor=x", want: "/v1/pushes?cursor=x&projectId=p1"},
{name: "url-escapes the project id", projectID: "with spaces", in: "/v1/cluster", want: "/v1/cluster?projectId=with+spaces"},
{name: "no double-append when projectId already present", projectID: "p1", in: "/v1/cluster?projectId=p2", want: "/v1/cluster?projectId=p2"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := &Client{ProjectID: tc.projectID}
got := c.scopedPath(tc.in)
if got != tc.want {
t.Errorf("got %q want %q", got, tc.want)
}
})
}
}

func TestProjectIDPropagatedThroughDoRequest(t *testing.T) {
var seenURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seenURL = r.URL.String()
w.Write([]byte(`{}`))
}))
defer srv.Close()
c := New(srv.URL, nil)
c.ProjectID = "p-test"
if _, err := c.GetCluster(context.Background()); err != nil {
t.Fatalf("GetCluster: %v", err)
}
if seenURL != "/v1/cluster?projectId=p-test" {
t.Errorf("server saw %q", seenURL)
}
}

func TestWithProjectClonesIndependently(t *testing.T) {
base := &Client{BaseURL: "x", ProjectID: ""}
scoped := base.WithProject("p1")
if base.ProjectID != "" {
t.Errorf("WithProject mutated base.ProjectID = %q", base.ProjectID)
}
if scoped.ProjectID != "p1" {
t.Errorf("scoped.ProjectID = %q", scoped.ProjectID)
}
}

func TestDoRequest_ErrorMapping(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
Expand Down
2 changes: 1 addition & 1 deletion internal/api/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ type ClusterDeleteResult struct {

func (c *Client) ClusterDelete(ctx context.Context, namespace string) (*ClusterDeleteResult, error) {
// We can't reuse doRequest because we need a custom header. Inline.
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+"/v1/cluster", nil)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+c.scopedPath("/v1/cluster"), nil)
if err != nil {
return nil, err
}
Expand Down
Loading