From 7b4f8b8911f286d45d50c4d4ea43363c88b2b027 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Sun, 26 Apr 2026 21:49:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(cli):=20T8=20=E2=80=94=20global=20--projec?= =?UTF-8?q?t=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6.1 T8 of the Multi-Project model. CLI gains a global \`--project \` flag (also GROUNDS_PROJECT env var); when set, every forge call carries it as \`?projectId=...\` so multi-project users can target a specific project from the terminal. Empty value falls back to forge's default-project resolution (the user's auto-created Personal project), so single-project users see no behaviour change. - internal/api/client.go: Client gains a ProjectID field + WithProject clone helper. doRequest threads the id through scopedPath() which appends the query string (idempotent, URL-escapes, doesn't double- append if path already carries projectId). - ClusterDelete builds its request inline; updated to also call scopedPath. - cmd/grounds/commands/root.go: cobra persistent flag declared at the root so all subcommands inherit it. - cmd/grounds/commands/cluster + push: read --project / GROUNDS_PROJECT before constructing the API client and stamp it onto Client.ProjectID. Tests cover the path-mutation table cases + the wire-level propagation through doRequest + the WithProject clone semantics. --- cmd/grounds/commands/cluster/cluster.go | 29 ++++++++++++-- cmd/grounds/commands/cluster/delete.go | 2 +- cmd/grounds/commands/cluster/down.go | 2 +- cmd/grounds/commands/cluster/status.go | 2 +- cmd/grounds/commands/cluster/up.go | 2 +- cmd/grounds/commands/push/list.go | 1 + cmd/grounds/commands/push/push.go | 12 ++++++ cmd/grounds/commands/push/retry.go | 1 + cmd/grounds/commands/root.go | 1 + internal/api/client.go | 37 +++++++++++++++++- internal/api/client_test.go | 52 +++++++++++++++++++++++++ internal/api/cluster.go | 2 +- 12 files changed, 134 insertions(+), 9 deletions(-) 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 }