diff --git a/cmd/grounds/commands/cluster/cluster.go b/cmd/grounds/commands/cluster/cluster.go index 36df37c..34cd4bf 100644 --- a/cmd/grounds/commands/cluster/cluster.go +++ b/cmd/grounds/commands/cluster/cluster.go @@ -3,6 +3,7 @@ package cluster import ( "context" "net/http" + "os" "time" "github.com/spf13/cobra" @@ -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 @@ -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). diff --git a/cmd/grounds/commands/cluster/delete.go b/cmd/grounds/commands/cluster/delete.go index 4858afc..9bbb932 100644 --- a/cmd/grounds/commands/cluster/delete.go +++ b/cmd/grounds/commands/cluster/delete.go @@ -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 } diff --git a/cmd/grounds/commands/cluster/down.go b/cmd/grounds/commands/cluster/down.go index f20663d..316fa41 100644 --- a/cmd/grounds/commands/cluster/down.go +++ b/cmd/grounds/commands/cluster/down.go @@ -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 } diff --git a/cmd/grounds/commands/cluster/status.go b/cmd/grounds/commands/cluster/status.go index 6b25ebf..946b804 100644 --- a/cmd/grounds/commands/cluster/status.go +++ b/cmd/grounds/commands/cluster/status.go @@ -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 } diff --git a/cmd/grounds/commands/cluster/up.go b/cmd/grounds/commands/cluster/up.go index 4b25156..2ae255d 100644 --- a/cmd/grounds/commands/cluster/up.go +++ b/cmd/grounds/commands/cluster/up.go @@ -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 } diff --git a/cmd/grounds/commands/push/list.go b/cmd/grounds/commands/push/list.go index d3e20ca..0b797a3 100644 --- a/cmd/grounds/commands/push/list.go +++ b/cmd/grounds/commands/push/list.go @@ -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 diff --git a/cmd/grounds/commands/push/push.go b/cmd/grounds/commands/push/push.go index 6e435ba..eccd65b 100644 --- a/cmd/grounds/commands/push/push.go +++ b/cmd/grounds/commands/push/push.go @@ -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 { diff --git a/cmd/grounds/commands/push/retry.go b/cmd/grounds/commands/push/retry.go index 45410a3..3910229 100644 --- a/cmd/grounds/commands/push/retry.go +++ b/cmd/grounds/commands/push/retry.go @@ -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 diff --git a/cmd/grounds/commands/root.go b/cmd/grounds/commands/root.go index 2471fec..cc0e7fb 100644 --- a/cmd/grounds/commands/root.go +++ b/cmd/grounds/commands/root.go @@ -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 } diff --git a/internal/api/client.go b/internal/api/client.go index 3b34378..e88e09f 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -7,6 +7,8 @@ import ( "fmt" "io" "net/http" + "net/url" + "strings" "github.com/groundsgg/grounds-cli/internal/auth" ) @@ -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 @@ -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 } @@ -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 } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 8dfd17a..eaa4f72 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -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) diff --git a/internal/api/cluster.go b/internal/api/cluster.go index a0b790b..d0e989b 100644 --- a/internal/api/cluster.go +++ b/internal/api/cluster.go @@ -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 }