diff --git a/README.md b/README.md index c44ce7e..e4f94c3 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ curl -sSL https://github.com/groundsgg/grounds-cli/releases/latest/download/inst grounds login # OAuth device flow grounds init # scaffold grounds.yaml grounds init --app-name=plugin-config --type=plugin-paper --base-image=paper --flavor=paper +grounds project use main # set your default project grounds workspace scan ../ --yes # discover sibling plugin repos grounds push # first push grounds push --flavor=paper # push one app flavor from grounds.yaml @@ -53,6 +54,7 @@ Use `grounds init --flavor=` to scaffold a flavor manifest while legacy top | `grounds completion ` | Shell completions | | `grounds doctor` | Diagnose env and warn about CLI updates | | `grounds init` | Scaffold a grounds.yaml | +| `grounds project list/current/use/clear` | Select and inspect the default project | | `grounds workspace scan/add/list/enable/doctor` | Manage local plugin workspace overrides | | `grounds cluster up/down/delete/status` | Workspace lifecycle | | `grounds push [--target=dev] [--flavor=]` | Build + deploy via Gradle plugin | @@ -64,6 +66,12 @@ Use `grounds init --flavor=` to scaffold a flavor manifest while legacy top `~/.config/grounds/config.yaml` (XDG-aware). Overridable via flags or env vars (`GROUNDS_API_URL`, `GROUNDS_TOKEN`, `GROUNDS_CONFIG_DIR`). +Project-scoped commands use this precedence: +`--project` > `GROUNDS_PROJECT` > local default > global default > the only available project. +Use `grounds project use ` to save a global default, or `grounds project use --local` +to save a default for the current repository. `grounds project clear` removes the saved global default; +`grounds project clear --local` removes the local default only. + Local plugin workspace overrides are stored in `~/.config/grounds/workspace.yaml`. Default `grounds push` still uses the plugin sources pinned in committed `grounds.yaml`. Use local plugin artifacts only when requested: diff --git a/cmd/grounds/commands/bundle/bundle.go b/cmd/grounds/commands/bundle/bundle.go index b5b4f3c..7f0485a 100644 --- a/cmd/grounds/commands/bundle/bundle.go +++ b/cmd/grounds/commands/bundle/bundle.go @@ -2,15 +2,11 @@ package bundle import ( "context" - "net/http" - "os" - "time" "github.com/spf13/cobra" + "github.com/groundsgg/grounds-cli/cmd/grounds/commands/internal/projectscope" "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 { @@ -23,39 +19,7 @@ func NewBundleCommand() *cobra.Command { 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}, - } +func buildClient(ctx context.Context, cmd *cobra.Command) (*api.Client, error) { + c, _, _, err := projectscope.BuildClient(ctx, cmd) + return c, err } diff --git a/cmd/grounds/commands/cluster/cluster.go b/cmd/grounds/commands/cluster/cluster.go index 042dd7f..363d355 100644 --- a/cmd/grounds/commands/cluster/cluster.go +++ b/cmd/grounds/commands/cluster/cluster.go @@ -2,15 +2,13 @@ package cluster import ( "context" - "net/http" - "os" - "time" "github.com/spf13/cobra" + "github.com/groundsgg/grounds-cli/cmd/grounds/commands/internal/projectscope" "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/project" ) func NewClusterCommand() *cobra.Command { @@ -26,53 +24,6 @@ func NewClusterCommand() *cobra.Command { // buildClient is the shared helper every cluster subcommand uses to // 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 - } - ts := api.NewEnvTokenSource() - if ts == nil { - ts = &auth.FileTokenSource{ - Store: auth.NewStore(cfg.Dir), - Device: defaultDeviceClient(), - } - } - 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). -// Lifted here so subcommands don't depend on the commands package. -func defaultDeviceClient() *auth.DeviceClient { - // Avoid pkg-level constants from sibling pkg; hardcode same values - return &auth.DeviceClient{ - Issuer: "https://account.grounds.gg/realms/grounds", - ClientID: "grounds-cli", - HTTP: defaultHTTP(), - } -} - -func defaultHTTP() *http.Client { - return &http.Client{Timeout: 30 * time.Second} +func buildClient(ctx context.Context, cmd *cobra.Command) (*api.Client, *config.Config, project.Selection, error) { + return projectscope.BuildClient(ctx, cmd) } diff --git a/cmd/grounds/commands/cluster/delete.go b/cmd/grounds/commands/cluster/delete.go index 9e1b30e..9232a2c 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, cmd) + 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 f49621b..fa24e66 100644 --- a/cmd/grounds/commands/cluster/down.go +++ b/cmd/grounds/commands/cluster/down.go @@ -14,7 +14,7 @@ func newDown() *cobra.Command { Short: "Pause the workspace immediately", RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() - c, _, err := buildClient(ctx, cmd) + c, _, _, err := buildClient(ctx, cmd) if err != nil { return err } diff --git a/cmd/grounds/commands/cluster/reset.go b/cmd/grounds/commands/cluster/reset.go index 2f12412..2eaa7d0 100644 --- a/cmd/grounds/commands/cluster/reset.go +++ b/cmd/grounds/commands/cluster/reset.go @@ -28,7 +28,7 @@ ref (--bundle, default main) with no engineer overrides. The workspace keeps its namespace and profile — only the contents are reset to zero.`, RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() - c, _, err := buildClient(ctx, cmd) + 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 3d4fd13..faa8354 100644 --- a/cmd/grounds/commands/cluster/status.go +++ b/cmd/grounds/commands/cluster/status.go @@ -15,7 +15,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, cmd) + 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 0a105dd..f181ca8 100644 --- a/cmd/grounds/commands/cluster/up.go +++ b/cmd/grounds/commands/cluster/up.go @@ -46,7 +46,7 @@ re-up. To wipe a platform-bundle workspace back to a clean bundle base without changing profile, use ` + "`grounds cluster reset`" + `.`, RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() - c, _, err := buildClient(ctx, cmd) + c, _, _, err := buildClient(ctx, cmd) if err != nil { return err } diff --git a/cmd/grounds/commands/devspace/devspace.go b/cmd/grounds/commands/devspace/devspace.go index 8472836..58b20ba 100644 --- a/cmd/grounds/commands/devspace/devspace.go +++ b/cmd/grounds/commands/devspace/devspace.go @@ -2,15 +2,13 @@ package devspace import ( "context" - "net/http" - "os" - "time" "github.com/spf13/cobra" + "github.com/groundsgg/grounds-cli/cmd/grounds/commands/internal/projectscope" "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/project" ) func NewDevspaceCommand() *cobra.Command { @@ -25,47 +23,6 @@ func NewDevspaceCommand() *cobra.Command { // Mirrors cluster/cluster.go — same auth + config resolution, same // shape so callers don't need to know which command-group they're in. -func buildClient(_ context.Context, cmd *cobra.Command) (*api.Client, *config.Config, error) { - cfg, err := config.Load("") - if err != nil { - return nil, nil, err - } - ts := api.NewEnvTokenSource() - if ts == nil { - ts = &auth.FileTokenSource{ - Store: auth.NewStore(cfg.Dir), - Device: defaultDeviceClient(), - } - } - c := api.New(cfg.APIURL, ts) - c.ProjectID = resolveProjectID(cmd) - return c, cfg, nil -} - -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 -} - -func defaultDeviceClient() *auth.DeviceClient { - return &auth.DeviceClient{ - Issuer: "https://account.grounds.gg/realms/grounds", - ClientID: "grounds-cli", - HTTP: defaultHTTP(), - } -} - -func defaultHTTP() *http.Client { - return &http.Client{Timeout: 30 * time.Second} +func buildClient(ctx context.Context, cmd *cobra.Command) (*api.Client, *config.Config, project.Selection, error) { + return projectscope.BuildClient(ctx, cmd) } diff --git a/cmd/grounds/commands/devspace/generate.go b/cmd/grounds/commands/devspace/generate.go index 81b87db..e4eb4a9 100644 --- a/cmd/grounds/commands/devspace/generate.go +++ b/cmd/grounds/commands/devspace/generate.go @@ -47,7 +47,7 @@ Examples: } ctx := context.Background() - c, _, err := buildClient(ctx, cmd) + c, _, _, err := buildClient(ctx, cmd) if err != nil { return err } diff --git a/cmd/grounds/commands/internal/projectscope/client.go b/cmd/grounds/commands/internal/projectscope/client.go new file mode 100644 index 0000000..bde6d50 --- /dev/null +++ b/cmd/grounds/commands/internal/projectscope/client.go @@ -0,0 +1,85 @@ +package projectscope + +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" + "github.com/groundsgg/grounds-cli/internal/project" + "github.com/groundsgg/grounds-cli/internal/workspace" +) + +func BuildClient(ctx context.Context, cmd *cobra.Command) (*api.Client, *config.Config, project.Selection, error) { + cfg, err := config.Load("") + if err != nil { + return nil, nil, project.Selection{}, err + } + c := api.New(cfg.APIURL, tokenSource(cfg)) + wcfg, err := workspace.Load("") + if err != nil { + return nil, nil, project.Selection{}, err + } + selected, err := project.Resolve(ctx, project.ResolveOptions{ + Explicit: projectFlag(cmd), + EnvProject: os.Getenv("GROUNDS_PROJECT"), + Config: cfg, + WorkspaceConfig: wcfg, + WorkDir: currentDir(), + Client: c, + }) + if err != nil { + return nil, nil, project.Selection{}, err + } + c.ProjectID = selected.ID + return c, cfg, selected, nil +} + +func BaseClient() (*api.Client, *config.Config, error) { + cfg, err := config.Load("") + if err != nil { + return nil, nil, err + } + return api.New(cfg.APIURL, tokenSource(cfg)), cfg, nil +} + +func tokenSource(cfg *config.Config) api.TokenSource { + ts := api.NewEnvTokenSource() + if ts != nil { + return ts + } + return &auth.FileTokenSource{ + Store: auth.NewStore(cfg.Dir), + Device: defaultDeviceClient(), + } +} + +func defaultDeviceClient() *auth.DeviceClient { + return &auth.DeviceClient{ + Issuer: "https://account.grounds.gg/realms/grounds", + ClientID: "grounds-cli", + HTTP: &http.Client{Timeout: 30 * time.Second}, + } +} + +func projectFlag(cmd *cobra.Command) string { + if cmd != nil { + if flag := cmd.Flag("project"); flag != nil { + return flag.Value.String() + } + } + return "" +} + +func currentDir() string { + wd, err := os.Getwd() + if err != nil { + return "" + } + return wd +} diff --git a/cmd/grounds/commands/internal/projectscope/client_test.go b/cmd/grounds/commands/internal/projectscope/client_test.go new file mode 100644 index 0000000..640c11e --- /dev/null +++ b/cmd/grounds/commands/internal/projectscope/client_test.go @@ -0,0 +1,23 @@ +package projectscope + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestProjectFlagReadsInheritedPersistentFlag(t *testing.T) { + root := &cobra.Command{Use: "grounds"} + root.PersistentFlags().String("project", "", "project id") + child := &cobra.Command{Use: "child"} + child.Run = func(*cobra.Command, []string) {} + root.AddCommand(child) + root.SetArgs([]string{"--project", "project-1", "child"}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if got := projectFlag(child); got != "project-1" { + t.Fatalf("projectFlag = %q, want project-1", got) + } +} diff --git a/cmd/grounds/commands/logs/logs.go b/cmd/grounds/commands/logs/logs.go index 9526479..6aeb83f 100644 --- a/cmd/grounds/commands/logs/logs.go +++ b/cmd/grounds/commands/logs/logs.go @@ -2,16 +2,16 @@ package logs import ( "context" + "fmt" "io" "net/http" + "net/url" "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/cmd/grounds/commands/internal/projectscope" "github.com/groundsgg/grounds-cli/internal/sse" ) @@ -23,7 +23,7 @@ func NewLogsCommand() *cobra.Command { Example: " grounds logs \n grounds logs --follow\n grounds logs deployment ", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return streamLogs(cmd.Context(), args[0], "push", follow) + return streamLogs(cmd.Context(), cmd, args[0], "push", follow) }, } cmd.Flags().BoolVarP(&follow, "follow", "f", true, "follow until terminal status") @@ -38,34 +38,18 @@ func newDeployment() *cobra.Command { Short: "Stream deployment logs", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return streamLogs(cmd.Context(), args[0], "deployment", follow) + return streamLogs(cmd.Context(), cmd, args[0], "deployment", follow) }, } cmd.Flags().BoolVarP(&follow, "follow", "f", true, "follow") return cmd } -func streamLogs(ctx context.Context, target, kind string, follow bool) error { - cfg, err := config.Load("") +func streamLogs(ctx context.Context, cmd *cobra.Command, target, kind string, follow bool) error { + stream, err := buildStream(ctx, cmd, target, kind) if err != nil { return err } - ts := api.NewEnvTokenSource() - if ts == nil { - ts = &auth.FileTokenSource{Store: auth.NewStore(cfg.Dir), Device: defaultDevice()} - } - tok, err := ts.Token(ctx) - if err != nil { - return err - } - var path string - switch kind { - case "push": - path = "/v1/pushes/" + target + "/logs" - case "deployment": - path = "/v1/deployments/" + target + "/logs" - } - stream := &sse.Stream{URL: cfg.APIURL + path, Token: tok, Client: defaultHTTP()} if !follow { // No-follow: read until first terminal status, then exit. } @@ -77,15 +61,25 @@ func streamLogs(ctx context.Context, target, kind string, follow bool) error { }) } -// defaultDevice mirrors login.go (same issuer, same client ID). -// Lifted here so subcommands don't depend on the commands package. -func defaultDevice() *auth.DeviceClient { - // Avoid pkg-level constants from sibling pkg; hardcode same values - return &auth.DeviceClient{ - Issuer: "https://account.grounds.gg/realms/grounds", - ClientID: "grounds-cli", - HTTP: defaultHTTP(), +func buildStream(ctx context.Context, cmd *cobra.Command, target, kind string) (*sse.Stream, error) { + c, _, _, err := projectscope.BuildClient(ctx, cmd) + if err != nil { + return nil, err + } + tok, err := c.Tokens.Token(ctx) + if err != nil { + return nil, err + } + var path string + switch kind { + case "push": + path = "/v1/pushes/" + url.PathEscape(target) + "/logs" + case "deployment": + path = "/v1/deployments/" + url.PathEscape(target) + "/logs" + default: + return nil, fmt.Errorf("unknown log target kind: %s", kind) } + return &sse.Stream{URL: c.ScopedURL(path), Token: tok, Client: defaultHTTP()}, nil } func defaultHTTP() *http.Client { diff --git a/cmd/grounds/commands/logs/logs_test.go b/cmd/grounds/commands/logs/logs_test.go index fa89ea8..d52d6a0 100644 --- a/cmd/grounds/commands/logs/logs_test.go +++ b/cmd/grounds/commands/logs/logs_test.go @@ -1,8 +1,13 @@ package logs import ( + "context" + "net/http" + "net/http/httptest" "strings" "testing" + + "github.com/groundsgg/grounds-cli/internal/config" ) func TestLogsExamplesIncludeRequiredPushID(t *testing.T) { @@ -22,3 +27,71 @@ func TestLogsExamplesIncludeRequiredPushID(t *testing.T) { t.Fatalf("logs examples = %q, should include required ", cmd.Example) } } + +func TestBuildStreamScopesPushLogsToDefaultProject(t *testing.T) { + withLogsProjectAPITest(t, func(serverURL, configDir string) { + t.Setenv("GROUNDS_API_URL", serverURL) + t.Setenv("GROUNDS_CONFIG_DIR", configDir) + t.Setenv("GROUNDS_TOKEN", "token") + if err := config.Save(configDir, &config.Config{ + APIURL: serverURL, + DefaultTarget: "dev", + Output: "table", + Color: "auto", + DefaultProjectID: "p-team", + }); err != nil { + t.Fatalf("Save: %v", err) + } + + stream, err := buildStream(context.Background(), NewLogsCommand(), "push 1", "push") + if err != nil { + t.Fatalf("buildStream: %v", err) + } + want := serverURL + "/v1/pushes/push%201/logs?projectId=p-team" + if stream.URL != want { + t.Fatalf("URL = %q, want %q", stream.URL, want) + } + if stream.Token != "token" { + t.Fatalf("Token = %q", stream.Token) + } + }) +} + +func TestBuildStreamScopesDeploymentLogsToDefaultProject(t *testing.T) { + withLogsProjectAPITest(t, func(serverURL, configDir string) { + t.Setenv("GROUNDS_API_URL", serverURL) + t.Setenv("GROUNDS_CONFIG_DIR", configDir) + t.Setenv("GROUNDS_TOKEN", "token") + if err := config.Save(configDir, &config.Config{ + APIURL: serverURL, + DefaultTarget: "dev", + Output: "table", + Color: "auto", + DefaultProjectID: "p-team", + }); err != nil { + t.Fatalf("Save: %v", err) + } + + stream, err := buildStream(context.Background(), NewLogsCommand(), "service/api", "deployment") + if err != nil { + t.Fatalf("buildStream: %v", err) + } + want := serverURL + "/v1/deployments/service%2Fapi/logs?projectId=p-team" + if stream.URL != want { + t.Fatalf("URL = %q, want %q", stream.URL, want) + } + }) +} + +func withLogsProjectAPITest(t *testing.T, run func(serverURL, configDir string)) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/projects" { + t.Fatalf("path = %q, want /v1/projects", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[{"id":"p-main","slug":"main","name":"Main","role":"owner","createdAt":"2026-07-07T10:00:00.000Z"},{"id":"p-team","slug":"team","name":"Team","role":"editor","createdAt":"2026-07-07T10:00:00.000Z"}]}`)) + })) + t.Cleanup(server.Close) + run(server.URL, t.TempDir()) +} diff --git a/cmd/grounds/commands/preview/preview.go b/cmd/grounds/commands/preview/preview.go index ab90eef..fba0b57 100644 --- a/cmd/grounds/commands/preview/preview.go +++ b/cmd/grounds/commands/preview/preview.go @@ -4,15 +4,12 @@ import ( "context" "encoding/json" "fmt" - "net/http" - "os" "time" "github.com/spf13/cobra" + "github.com/groundsgg/grounds-cli/cmd/grounds/commands/internal/projectscope" "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" ) @@ -32,40 +29,9 @@ func NewPreviewCommand() *cobra.Command { 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 +func makeClient(ctx context.Context, cmd *cobra.Command) (*api.Client, error) { + c, _, _, err := projectscope.BuildClient(ctx, cmd) + return c, err } // ---------------------------------------------------------------------------- @@ -79,7 +45,7 @@ func newList() *cobra.Command { Short: "List preview environments in the current project", RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() - c, err := makeClient(cmd) + c, err := makeClient(ctx, cmd) if err != nil { return err } @@ -134,7 +100,7 @@ func newShow() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - c, err := makeClient(cmd) + c, err := makeClient(ctx, cmd) if err != nil { return err } @@ -179,7 +145,7 @@ func newPin(pin bool) *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - c, err := makeClient(cmd) + c, err := makeClient(ctx, cmd) if err != nil { return err } diff --git a/cmd/grounds/commands/projectcmd/project.go b/cmd/grounds/commands/projectcmd/project.go new file mode 100644 index 0000000..4e9834b --- /dev/null +++ b/cmd/grounds/commands/projectcmd/project.go @@ -0,0 +1,230 @@ +package projectcmd + +import ( + "context" + "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/project" + "github.com/groundsgg/grounds-cli/internal/render" + "github.com/groundsgg/grounds-cli/internal/workspace" +) + +func NewProjectCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "project", + Short: "Manage project selection", + Example: " grounds project list\n grounds project use main\n grounds project use main --local\n grounds project current\n grounds project clear", + } + cmd.AddCommand(newList(), newCurrent(), newUse(), newClear()) + return cmd +} + +func newList() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List available projects", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + c, _, err := baseClient() + if err != nil { + return err + } + projects, err := c.ListProjects(ctx) + if err != nil { + return err + } + rows := make([][]any, 0, len(projects.Items)) + for _, p := range projects.Items { + rows = append(rows, []any{p.ID, p.Slug, p.Name, p.Role}) + } + render.Table(cmd.OutOrStdout(), []string{"ID", "SLUG", "NAME", "ROLE"}, rows) + return nil + }, + } +} + +func newCurrent() *cobra.Command { + return &cobra.Command{ + Use: "current", + Short: "Show the selected project", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + c, cfg, err := baseClient() + if err != nil { + return err + } + wcfg, err := workspace.Load("") + if err != nil { + return err + } + selected, err := project.Resolve(ctx, project.ResolveOptions{ + Explicit: projectFlag(cmd), + EnvProject: os.Getenv("GROUNDS_PROJECT"), + Config: cfg, + WorkspaceConfig: wcfg, + WorkDir: currentDir(), + Client: c, + }) + if err != nil { + return err + } + render.Table(cmd.OutOrStdout(), []string{"ID", "SLUG", "NAME", "ROLE", "SOURCE"}, [][]any{{ + selected.ID, + selected.Project.Slug, + selected.Project.Name, + selected.Project.Role, + selected.Source, + }}) + return nil + }, + } +} + +func newUse() *cobra.Command { + var local bool + cmd := &cobra.Command{ + Use: "use ", + Short: "Set the default project", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + c, cfg, err := baseClient() + if err != nil { + return err + } + selected, err := project.Resolve(ctx, project.ResolveOptions{ + Explicit: args[0], + Config: cfg, + Client: c, + }) + if err != nil { + return err + } + if local { + wcfg, err := workspace.Load("") + if err != nil { + return err + } + if wcfg.ProjectDefaults == nil { + wcfg.ProjectDefaults = map[string]string{} + } + root := project.WorkspaceRoot(currentDir()) + wcfg.ProjectDefaults[root] = selected.ID + if err := workspace.Save("", wcfg); err != nil { + return err + } + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Project", fmt.Sprintf("using %s locally", displayProject(selected.Project))) + return nil + } + fileCfg, err := config.LoadFile(cfg.Dir) + if err != nil { + return err + } + fileCfg.DefaultProjectID = selected.ID + if err := config.Save(fileCfg.Dir, fileCfg); err != nil { + return err + } + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Project", fmt.Sprintf("using %s globally", displayProject(selected.Project))) + return nil + }, + } + cmd.Flags().BoolVar(&local, "local", false, "store the default for this workspace") + return cmd +} + +func newClear() *cobra.Command { + var local bool + cmd := &cobra.Command{ + Use: "clear", + Short: "Clear the saved default project", + RunE: func(cmd *cobra.Command, _ []string) error { + if local { + wcfg, err := workspace.Load("") + if err != nil { + return err + } + root := project.WorkspaceRoot(currentDir()) + delete(wcfg.ProjectDefaults, root) + if err := workspace.Save("", wcfg); err != nil { + return err + } + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Project", "cleared local default project") + return nil + } + cfg, err := config.Load("") + if err != nil { + return err + } + fileCfg, err := config.LoadFile(cfg.Dir) + if err != nil { + return err + } + fileCfg.DefaultProjectID = "" + if err := config.Save(fileCfg.Dir, fileCfg); err != nil { + return err + } + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Project", "cleared global default project") + return nil + }, + } + cmd.Flags().BoolVar(&local, "local", false, "clear the default for this workspace") + return cmd +} + +func baseClient() (*api.Client, *config.Config, error) { + cfg, err := config.Load("") + if err != nil { + return nil, nil, err + } + ts := api.NewEnvTokenSource() + if ts == nil { + ts = &auth.FileTokenSource{ + Store: auth.NewStore(cfg.Dir), + Device: defaultDeviceClient(), + } + } + return api.New(cfg.APIURL, ts), cfg, nil +} + +func defaultDeviceClient() *auth.DeviceClient { + return &auth.DeviceClient{ + Issuer: "https://account.grounds.gg/realms/grounds", + ClientID: "grounds-cli", + HTTP: &http.Client{Timeout: 30 * time.Second}, + } +} + +func projectFlag(cmd *cobra.Command) string { + if cmd != nil { + if p, _ := cmd.Flags().GetString("project"); p != "" { + return p + } + } + return "" +} + +func currentDir() string { + wd, err := os.Getwd() + if err != nil { + return "" + } + return wd +} + +func displayProject(p api.Project) string { + if p.Slug != "" { + return p.Slug + " (" + p.ID + ")" + } + if p.Name != "" { + return p.Name + " (" + p.ID + ")" + } + return p.ID +} diff --git a/cmd/grounds/commands/projectcmd/project_test.go b/cmd/grounds/commands/projectcmd/project_test.go new file mode 100644 index 0000000..6e18868 --- /dev/null +++ b/cmd/grounds/commands/projectcmd/project_test.go @@ -0,0 +1,161 @@ +package projectcmd + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/groundsgg/grounds-cli/internal/config" + "github.com/groundsgg/grounds-cli/internal/project" + "github.com/groundsgg/grounds-cli/internal/workspace" +) + +func TestProjectListPrintsAvailableProjects(t *testing.T) { + withProjectAPITest(t, func(serverURL, configDir string) { + t.Setenv("GROUNDS_API_URL", serverURL) + t.Setenv("GROUNDS_CONFIG_DIR", configDir) + t.Setenv("GROUNDS_TOKEN", "token") + + var out bytes.Buffer + cmd := NewProjectCommand() + cmd.SetOut(&out) + cmd.SetArgs([]string{"list"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + got := out.String() + for _, want := range []string{"main", "team", "owner", "editor"} { + if !strings.Contains(got, want) { + t.Fatalf("output = %q, want it to contain %q", got, want) + } + } + }) +} + +func TestProjectUseStoresGlobalDefaultByID(t *testing.T) { + withProjectAPITest(t, func(serverURL, configDir string) { + t.Setenv("GROUNDS_API_URL", serverURL) + t.Setenv("GROUNDS_CONFIG_DIR", configDir) + t.Setenv("GROUNDS_TOKEN", "token") + + cmd := NewProjectCommand() + cmd.SetArgs([]string{"use", "team"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + cfg, err := config.Load(configDir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.DefaultProjectID != "p-team" { + t.Fatalf("DefaultProjectID = %q", cfg.DefaultProjectID) + } + }) +} + +func TestProjectUsePreservesSavedAPIURLWhenEnvOverridesAPI(t *testing.T) { + withProjectAPITest(t, func(serverURL, configDir string) { + if err := config.Save(configDir, &config.Config{ + APIURL: "https://saved.grounds.test", + DefaultTarget: "dev", + Output: "table", + Color: "auto", + DefaultProjectID: "p-main", + }); err != nil { + t.Fatalf("Save: %v", err) + } + t.Setenv("GROUNDS_API_URL", serverURL) + t.Setenv("GROUNDS_CONFIG_DIR", configDir) + t.Setenv("GROUNDS_TOKEN", "token") + + cmd := NewProjectCommand() + cmd.SetArgs([]string{"use", "team"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + raw, err := os.ReadFile(filepath.Join(configDir, config.ConfigFileName)) + if err != nil { + t.Fatalf("ReadFile(config): %v", err) + } + got := string(raw) + if !strings.Contains(got, "apiUrl: https://saved.grounds.test") { + t.Fatalf("config = %q, want saved apiUrl to be preserved", got) + } + if strings.Contains(got, serverURL) { + t.Fatalf("config = %q, did not expect env API URL to be persisted", got) + } + if !strings.Contains(got, "defaultProjectId: p-team") { + t.Fatalf("config = %q, want updated default project", got) + } + }) +} + +func TestProjectUseStoresLocalDefaultByWorkspaceRoot(t *testing.T) { + withProjectAPITest(t, func(serverURL, configDir string) { + t.Setenv("GROUNDS_API_URL", serverURL) + t.Setenv("GROUNDS_CONFIG_DIR", configDir) + t.Setenv("GROUNDS_TOKEN", "token") + repo := t.TempDir() + if err := os.WriteFile(filepath.Join(repo, "grounds.yaml"), []byte("name: plugin-chat\n"), 0o600); err != nil { + t.Fatalf("write grounds.yaml: %v", err) + } + t.Chdir(repo) + + cmd := NewProjectCommand() + cmd.SetArgs([]string{"use", "team", "--local"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + cfg, err := workspace.Load("") + if err != nil { + t.Fatalf("workspace.Load: %v", err) + } + if cfg.ProjectDefaults[project.WorkspaceRoot(repo)] != "p-team" { + t.Fatalf("ProjectDefaults = %#v", cfg.ProjectDefaults) + } + }) +} + +func TestProjectClearRemovesGlobalDefault(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{APIURL: "https://api.grounds.gg", DefaultTarget: "dev", Output: "table", Color: "auto", DefaultProjectID: "p-team"} + if err := config.Save(dir, cfg); err != nil { + t.Fatalf("Save: %v", err) + } + t.Setenv("GROUNDS_CONFIG_DIR", dir) + + cmd := NewProjectCommand() + cmd.SetArgs([]string{"clear"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + got, err := config.Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.DefaultProjectID != "" { + t.Fatalf("DefaultProjectID = %q", got.DefaultProjectID) + } +} + +func withProjectAPITest(t *testing.T, run func(serverURL, configDir string)) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/projects" { + t.Fatalf("path = %q, want /v1/projects", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[{"id":"p-main","slug":"main","name":"Main","role":"owner","createdAt":"2026-07-07T10:00:00.000Z"},{"id":"p-team","slug":"team","name":"Team","role":"editor","createdAt":"2026-07-07T10:00:00.000Z"}]}`)) + })) + t.Cleanup(server.Close) + run(server.URL, t.TempDir()) +} diff --git a/cmd/grounds/commands/push/list.go b/cmd/grounds/commands/push/list.go index 84d7973..bbefebf 100644 --- a/cmd/grounds/commands/push/list.go +++ b/cmd/grounds/commands/push/list.go @@ -6,9 +6,7 @@ import ( "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/cmd/grounds/commands/internal/projectscope" "github.com/groundsgg/grounds-cli/internal/render" ) @@ -21,16 +19,10 @@ func newList() *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() - cfg, err := config.Load("") + c, _, _, err := projectscope.BuildClient(ctx, cmd) if err != nil { return 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) 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 6ddafe3..6ba4880 100644 --- a/cmd/grounds/commands/push/push.go +++ b/cmd/grounds/commands/push/push.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" + "github.com/groundsgg/grounds-cli/cmd/grounds/commands/internal/projectscope" "github.com/groundsgg/grounds-cli/internal/api" "github.com/groundsgg/grounds-cli/internal/auth" "github.com/groundsgg/grounds-cli/internal/config" @@ -24,6 +25,7 @@ import ( var ( findGradleWrapper = gradle.FindWrapper runGradleWrapper = gradle.Run + runGradleWithEnv = gradle.RunWithEnv newAPIClient = api.New ) @@ -154,7 +156,11 @@ func runGradlePush(ctx context.Context, cmd *cobra.Command, wrapper, target, fla } args = append(args, "--resolved-plugins-file="+resolvedPath) } - return runGradleWrapper(ctx, wrapper, args, cmd.OutOrStdout(), cmd.ErrOrStderr(), 0) + _, _, selected, err := projectscope.BuildClient(ctx, cmd) + if err != nil { + return err + } + return runGradleWithEnv(ctx, wrapper, args, []string{"GROUNDS_PROJECT=" + selected.ID}, cmd.OutOrStdout(), cmd.ErrOrStderr(), 0) } func runMinestomPush(ctx context.Context, cmd *cobra.Command, wrapper string, pushManifest *minestom.PushManifest, target, flavor string, force bool, local []string, withLocal bool) error { @@ -206,16 +212,10 @@ func runMinestomPush(ctx context.Context, cmd *cobra.Command, wrapper string, pu } defer cleanupArtifact() - cfg, err := config.Load("") + client, _, _, err := projectscope.BuildClient(ctx, cmd) if err != nil { return err } - ts := api.NewEnvTokenSource() - if ts == nil { - ts = &auth.FileTokenSource{Store: auth.NewStore(cfg.Dir), Device: defaultDevice()} - } - client := newAPIClient(cfg.APIURL, ts) - client.ProjectID = projectIDFrom(cmd) manifestPayload := any(map[string]any{ "name": pushManifest.Name, "type": pushManifest.Runtime.Type, @@ -280,18 +280,6 @@ func authRefreshError(err error) error { return fmt.Errorf("auth refresh failed: %w\n ! Run %s to re-authenticate.", err, render.Command("grounds login")) } -// 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/push_test.go b/cmd/grounds/commands/push/push_test.go index 488780c..3d07f9f 100644 --- a/cmd/grounds/commands/push/push_test.go +++ b/cmd/grounds/commands/push/push_test.go @@ -18,6 +18,7 @@ import ( "github.com/spf13/cobra" "github.com/groundsgg/grounds-cli/internal/api" + "github.com/groundsgg/grounds-cli/internal/config" ) // Validates the --target flag's allow-list before grounds-push gets @@ -99,6 +100,18 @@ func TestPushFlavorFlagIsForwardedToGradle(t *testing.T) { t.Fatalf("WriteFile(gradlew) error = %v", err) } t.Setenv("GROUNDS_TOKEN", "test-token") + configDir := filepath.Join(dir, "config") + t.Setenv("GROUNDS_CONFIG_DIR", configDir) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v1/projects" { + t.Errorf("request = %s %s, want GET /v1/projects", r.Method, r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{{"id": "project-1", "slug": "main", "name": "Main"}}, + }) + })) + defer srv.Close() + t.Setenv("GROUNDS_API_URL", srv.URL) cmd := newPush() cmd.SetArgs([]string{"--flavor= velocity "}) @@ -121,6 +134,179 @@ func TestPushFlavorFlagIsForwardedToGradle(t *testing.T) { } } +func TestPushPassesDefaultProjectToGradle(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test uses a POSIX shell gradle wrapper") + } + dir := t.TempDir() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(cwd); err != nil { + t.Fatalf("Chdir(%q) error = %v", cwd, err) + } + }) + if err := os.Chdir(dir); err != nil { + t.Fatalf("Chdir(%q) error = %v", dir, err) + } + if err := os.WriteFile("grounds.yaml", []byte("name: plugin-config\n"), 0o644); err != nil { + t.Fatalf("WriteFile(grounds.yaml) error = %v", err) + } + configDir := filepath.Join(dir, "config") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v1/projects" { + t.Errorf("request = %s %s, want GET /v1/projects", r.Method, r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{{"id": "project-1", "slug": "main", "name": "Main"}}, + }) + })) + defer srv.Close() + if err := config.Save(configDir, &config.Config{ + APIURL: srv.URL, + DefaultTarget: "dev", + Output: "table", + Color: "auto", + DefaultProjectID: "project-1", + }); err != nil { + t.Fatalf("Save config: %v", err) + } + t.Setenv("GROUNDS_CONFIG_DIR", configDir) + t.Setenv("GROUNDS_TOKEN", "test-token") + projectPath := filepath.Join(dir, "project.txt") + wrapper := "#!/bin/sh\nprintf '%s\\n' \"$GROUNDS_PROJECT\" > " + shellQuote(projectPath) + "\n" + if err := os.WriteFile("gradlew", []byte(wrapper), 0o755); err != nil { + t.Fatalf("WriteFile(gradlew) error = %v", err) + } + + cmd := newPush() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + raw, err := os.ReadFile(projectPath) + if err != nil { + t.Fatalf("ReadFile(project.txt) error = %v", err) + } + if got := strings.TrimSpace(string(raw)); got != "project-1" { + t.Fatalf("GROUNDS_PROJECT = %q, want project-1", got) + } +} + +func TestPushGradleRejectsMultipleProjectsWithoutSelection(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test uses a POSIX shell gradle wrapper") + } + dir := t.TempDir() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(cwd); err != nil { + t.Fatalf("Chdir(%q) error = %v", cwd, err) + } + }) + if err := os.Chdir(dir); err != nil { + t.Fatalf("Chdir(%q) error = %v", dir, err) + } + if err := os.WriteFile("grounds.yaml", []byte("name: plugin-config\n"), 0o644); err != nil { + t.Fatalf("WriteFile(grounds.yaml) error = %v", err) + } + if err := os.WriteFile("gradlew", []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\n"), 0o755); err != nil { + t.Fatalf("WriteFile(gradlew) error = %v", err) + } + configDir := filepath.Join(dir, "config") + t.Setenv("GROUNDS_CONFIG_DIR", configDir) + t.Setenv("GROUNDS_TOKEN", "test-token") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v1/projects" { + t.Errorf("request = %s %s, want GET /v1/projects", r.Method, r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"id": "project-1", "slug": "one", "name": "One"}, + {"id": "project-2", "slug": "two", "name": "Two"}, + }, + }) + })) + defer srv.Close() + t.Setenv("GROUNDS_API_URL", srv.URL) + + cmd := newPush() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + err = cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "multiple projects available") { + t.Fatalf("Execute() error = %v, want multiple project selection error", err) + } + if _, err := os.Stat("args.txt"); err == nil { + t.Fatal("gradle should not run without a resolved project") + } else if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Stat(args.txt) error = %v", err) + } +} + +func TestPushGradleUsesSingleProjectFallback(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test uses a POSIX shell gradle wrapper") + } + dir := t.TempDir() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(cwd); err != nil { + t.Fatalf("Chdir(%q) error = %v", cwd, err) + } + }) + if err := os.Chdir(dir); err != nil { + t.Fatalf("Chdir(%q) error = %v", dir, err) + } + if err := os.WriteFile("grounds.yaml", []byte("name: plugin-config\n"), 0o644); err != nil { + t.Fatalf("WriteFile(grounds.yaml) error = %v", err) + } + projectPath := filepath.Join(dir, "project.txt") + wrapper := "#!/bin/sh\nprintf '%s\\n' \"$GROUNDS_PROJECT\" > " + shellQuote(projectPath) + "\n" + if err := os.WriteFile("gradlew", []byte(wrapper), 0o755); err != nil { + t.Fatalf("WriteFile(gradlew) error = %v", err) + } + configDir := filepath.Join(dir, "config") + t.Setenv("GROUNDS_CONFIG_DIR", configDir) + t.Setenv("GROUNDS_TOKEN", "test-token") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v1/projects" { + t.Errorf("request = %s %s, want GET /v1/projects", r.Method, r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{{"id": "project-1", "slug": "main", "name": "Main"}}, + }) + })) + defer srv.Close() + t.Setenv("GROUNDS_API_URL", srv.URL) + + cmd := newPush() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + raw, err := os.ReadFile(projectPath) + if err != nil { + t.Fatalf("ReadFile(project.txt) error = %v", err) + } + if got := strings.TrimSpace(string(raw)); got != "project-1" { + t.Fatalf("GROUNDS_PROJECT = %q, want project-1", got) + } +} + func TestPushMinestomFlavorBuildsDistributionAndUploads(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("test uses POSIX shell gradle wrapper") @@ -179,6 +365,12 @@ repos: var uploaded bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/v1/projects" { + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{{"id": "project-1", "slug": "main", "name": "Main", "role": "owner"}}, + }) + return + } uploaded = true if r.Method != http.MethodPost || r.URL.Path != "/v1/pushes" { t.Errorf("request = %s %s, want POST /v1/pushes", r.Method, r.URL.Path) @@ -285,10 +477,16 @@ modules: } t.Setenv("GROUNDS_CONFIG_DIR", workspaceDir) t.Setenv("GROUNDS_TOKEN", "test-token") - writePushTestFile(t, filepath.Join(workspaceDir, "workspace.yaml"), "repos: [\n") + writePushTestFile(t, filepath.Join(workspaceDir, "workspace.yaml"), "repos: {}\n") var uploaded bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/v1/projects" { + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{{"id": "project-1", "slug": "main", "name": "Main", "role": "owner"}}, + }) + return + } uploaded = true if got := r.FormValue("flavor"); got != "" { t.Errorf("form flavor = %q, want empty for top-level manifest", got) @@ -441,6 +639,21 @@ func TestPushRootOwnsDeployFlagsAndSubcommands(t *testing.T) { } } +func TestRetryLogStreamScopesProject(t *testing.T) { + client := api.New("https://api.example.test", nil) + client.ProjectID = "project-1" + + stream := retryLogStream(client, "push 1", "token") + + want := "https://api.example.test/v1/pushes/push%201/logs?projectId=project-1" + if stream.URL != want { + t.Fatalf("URL = %q, want %q", stream.URL, want) + } + if stream.Token != "token" { + t.Fatalf("Token = %q", stream.Token) + } +} + func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } diff --git a/cmd/grounds/commands/push/retry.go b/cmd/grounds/commands/push/retry.go index 12ca206..566c20d 100644 --- a/cmd/grounds/commands/push/retry.go +++ b/cmd/grounds/commands/push/retry.go @@ -3,13 +3,13 @@ package push import ( "context" "io" + "net/url" "os" "github.com/spf13/cobra" + "github.com/groundsgg/grounds-cli/cmd/grounds/commands/internal/projectscope" "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" "github.com/groundsgg/grounds-cli/internal/sse" ) @@ -22,16 +22,10 @@ func newRetry() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - cfg, err := config.Load("") + c, _, _, err := projectscope.BuildClient(ctx, cmd) if err != nil { return 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) p, err := c.RetryPush(ctx, args[0]) if err != nil { return err @@ -40,15 +34,11 @@ func newRetry() *cobra.Command { if !follow { return nil } - tok, err := ts.Token(ctx) + tok, err := c.Tokens.Token(ctx) if err != nil { return err } - stream := &sse.Stream{ - URL: cfg.APIURL + "/v1/pushes/" + p.ID + "/logs", - Token: tok, - Client: defaultHTTP(), - } + stream := retryLogStream(c, p.ID, tok) return stream.Subscribe(ctx, func(ev *sse.Event) error { if sse.Render(os.Stdout, ev) { return io.EOF @@ -61,6 +51,14 @@ func newRetry() *cobra.Command { return cmd } +func retryLogStream(c *api.Client, pushID, token string) *sse.Stream { + return &sse.Stream{ + URL: c.ScopedURL("/v1/pushes/" + url.PathEscape(pushID) + "/logs"), + Token: token, + Client: defaultHTTP(), + } +} + func renderRetryTriggered(out io.Writer, p *api.Push) { render.StatusLine(out, render.StatusOK, "Push", "Retry triggered for "+p.ID) render.DetailLine(out, render.StatusOK, "Status: "+p.Status) diff --git a/cmd/grounds/main.go b/cmd/grounds/main.go index 482a6e9..740e5e1 100644 --- a/cmd/grounds/main.go +++ b/cmd/grounds/main.go @@ -12,6 +12,7 @@ import ( "github.com/groundsgg/grounds-cli/cmd/grounds/commands/logs" "github.com/groundsgg/grounds-cli/cmd/grounds/commands/onboarding" "github.com/groundsgg/grounds-cli/cmd/grounds/commands/preview" + "github.com/groundsgg/grounds-cli/cmd/grounds/commands/projectcmd" "github.com/groundsgg/grounds-cli/cmd/grounds/commands/push" "github.com/groundsgg/grounds-cli/cmd/grounds/commands/workspace" "github.com/groundsgg/grounds-cli/internal/observability" @@ -35,6 +36,7 @@ func main() { root.AddCommand(commands.NewDoctorCommand()) root.AddCommand(commands.NewInitCommand()) root.AddCommand(onboarding.NewOnboardingCommand()) + root.AddCommand(projectcmd.NewProjectCommand()) root.AddCommand(bundle.NewBundleCommand()) root.AddCommand(cluster.NewClusterCommand()) root.AddCommand(devspace.NewDevspaceCommand()) diff --git a/internal/api/client.go b/internal/api/client.go index e88e09f..c37547f 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -18,9 +18,9 @@ type Client struct { 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). + // project-scoped request. Command handlers resolve it from --project, + // GROUNDS_PROJECT, saved project defaults, or the account's only + // project before making project-scoped calls. ProjectID string } @@ -33,6 +33,12 @@ func (c *Client) WithProject(id string) *Client { return &clone } +// ScopedURL returns an absolute API URL with the client's project scope +// applied to the path. +func (c *Client) ScopedURL(path string) string { + return c.BaseURL + c.scopedPath(path) +} + // TokenSource produces a fresh bearer token, refreshing on demand. The // CLI wires this to read the keyring + refresh through DeviceClient. type TokenSource interface { @@ -84,9 +90,8 @@ func (c *Client) doRequest(ctx context.Context, method, path string, body any, o } // 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. +// a project id. Idempotent: does nothing when ProjectID is empty or when the +// path already contains a `projectId=` query. func (c *Client) scopedPath(path string) string { if c.ProjectID == "" { return path diff --git a/internal/api/projects.go b/internal/api/projects.go new file mode 100644 index 0000000..f80757b --- /dev/null +++ b/internal/api/projects.go @@ -0,0 +1,27 @@ +package api + +import ( + "context" + "net/http" +) + +type Project struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Role string `json:"role"` + CreatedAt string `json:"createdAt"` +} + +type ProjectList struct { + Items []Project `json:"items"` +} + +func (c *Client) ListProjects(ctx context.Context) (*ProjectList, error) { + out := &ProjectList{} + base := c.WithProject("") + if err := base.doRequest(ctx, http.MethodGet, "/v1/projects", nil, out); err != nil { + return nil, err + } + return out, nil +} diff --git a/internal/api/projects_test.go b/internal/api/projects_test.go new file mode 100644 index 0000000..8c41ac3 --- /dev/null +++ b/internal/api/projects_test.go @@ -0,0 +1,32 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestListProjects(t *testing.T) { + var seenPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.String() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[{"id":"p1","slug":"main","name":"Main","role":"owner","createdAt":"2026-07-07T10:00:00.000Z"}]}`)) + })) + t.Cleanup(server.Close) + + c := New(server.URL, nil) + c.ProjectID = "ignored" + + got, err := c.ListProjects(context.Background()) + if err != nil { + t.Fatalf("ListProjects: %v", err) + } + if seenPath != "/v1/projects" { + t.Fatalf("path = %q, want /v1/projects", seenPath) + } + if len(got.Items) != 1 || got.Items[0].ID != "p1" || got.Items[0].Slug != "main" { + t.Fatalf("items = %#v", got.Items) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 932e503..09c123b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,17 +5,27 @@ import ( "path/filepath" "github.com/spf13/viper" + "gopkg.in/yaml.v3" ) type Config struct { - APIURL string `mapstructure:"apiUrl"` - DefaultTarget string `mapstructure:"defaultTarget"` - Output string `mapstructure:"output"` - Color string `mapstructure:"color"` - Dir string `mapstructure:"-"` + APIURL string `mapstructure:"apiUrl" yaml:"apiUrl"` + DefaultTarget string `mapstructure:"defaultTarget" yaml:"defaultTarget"` + DefaultProjectID string `mapstructure:"defaultProjectId" yaml:"defaultProjectId,omitempty"` + Output string `mapstructure:"output" yaml:"output"` + Color string `mapstructure:"color" yaml:"color"` + Dir string `mapstructure:"-" yaml:"-"` } func Load(dir string) (*Config, error) { + return load(dir, true) +} + +func LoadFile(dir string) (*Config, error) { + return load(dir, false) +} + +func load(dir string, includeEnv bool) (*Config, error) { if dir == "" { var err error dir, err = ResolveDir() @@ -34,10 +44,13 @@ func Load(dir string) (*Config, error) { v.SetEnvPrefix("GROUNDS") v.SetDefault("apiUrl", DefaultAPIURL) v.SetDefault("defaultTarget", DefaultTarget) + v.SetDefault("defaultProjectId", "") v.SetDefault("output", DefaultOutput) v.SetDefault("color", DefaultColor) - v.AutomaticEnv() - v.BindEnv("apiUrl", "GROUNDS_API_URL") + if includeEnv { + v.AutomaticEnv() + v.BindEnv("apiUrl", "GROUNDS_API_URL") + } if err := v.ReadInConfig(); err != nil { if _, missing := err.(viper.ConfigFileNotFoundError); !missing { @@ -52,6 +65,34 @@ func Load(dir string) (*Config, error) { return cfg, nil } +func Save(dir string, cfg *Config) error { + if dir == "" { + var err error + dir, err = ResolveDir() + if err != nil { + return err + } + } + if cfg == nil { + cfg = &Config{} + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + if err := os.Chmod(dir, 0o700); err != nil { + return err + } + raw, err := yaml.Marshal(cfg) + if err != nil { + return err + } + path := filepath.Join(dir, ConfigFileName) + if err := os.WriteFile(path, raw, 0o600); err != nil { + return err + } + return os.Chmod(path, 0o600) +} + // ResolveDir picks the OS-appropriate config directory. // // Linux: $XDG_CONFIG_HOME/grounds (default ~/.config/grounds) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6316731..f76d252 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -26,7 +26,7 @@ func TestLoadDefaults(t *testing.T) { func TestLoadFromFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yaml") - os.WriteFile(path, []byte("apiUrl: https://example.test\noutput: json\n"), 0600) + os.WriteFile(path, []byte("apiUrl: https://example.test\noutput: json\ndefaultProjectId: project-id\n"), 0600) cfg, err := Load(dir) if err != nil { @@ -41,6 +41,9 @@ func TestLoadFromFile(t *testing.T) { if cfg.DefaultTarget != "dev" { t.Errorf("DefaultTarget = %q (expected default)", cfg.DefaultTarget) } + if cfg.DefaultProjectID != "project-id" { + t.Errorf("DefaultProjectID = %q", cfg.DefaultProjectID) + } } func TestLoadFromEnv(t *testing.T) { @@ -51,3 +54,22 @@ func TestLoadFromEnv(t *testing.T) { t.Errorf("env override failed: %q", cfg.APIURL) } } + +func TestSaveProjectDefault(t *testing.T) { + dir := t.TempDir() + cfg, err := Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + cfg.DefaultProjectID = "project-id" + if err := Save(dir, cfg); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := Load(dir) + if err != nil { + t.Fatalf("Load after Save: %v", err) + } + if got.DefaultProjectID != "project-id" { + t.Fatalf("DefaultProjectID = %q", got.DefaultProjectID) + } +} diff --git a/internal/gradle/wrap.go b/internal/gradle/wrap.go index 42dc3a4..83d7732 100644 --- a/internal/gradle/wrap.go +++ b/internal/gradle/wrap.go @@ -34,6 +34,10 @@ func FindWrapper(cwd string) (string, error) { // caller's writers in real time. Honours `timeout` (defaults to // DefaultTimeout). func Run(ctx context.Context, wrapper string, args []string, stdout, stderr io.Writer, timeout time.Duration) error { + return RunWithEnv(ctx, wrapper, args, nil, stdout, stderr, timeout) +} + +func RunWithEnv(ctx context.Context, wrapper string, args []string, extraEnv []string, stdout, stderr io.Writer, timeout time.Duration) error { if timeout == 0 { timeout = DefaultTimeout } @@ -44,6 +48,9 @@ func Run(ctx context.Context, wrapper string, args []string, stdout, stderr io.W cmd.Stdout = stdout cmd.Stderr = stderr cmd.Dir = filepath.Dir(wrapper) + if len(extraEnv) > 0 { + cmd.Env = append(os.Environ(), extraEnv...) + } if err := cmd.Run(); err != nil { if ctx.Err() == context.DeadlineExceeded { return fmt.Errorf("gradlew timed out after %s", timeout) diff --git a/internal/project/resolve.go b/internal/project/resolve.go new file mode 100644 index 0000000..d3e4086 --- /dev/null +++ b/internal/project/resolve.go @@ -0,0 +1,121 @@ +package project + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/groundsgg/grounds-cli/internal/api" + "github.com/groundsgg/grounds-cli/internal/config" + "github.com/groundsgg/grounds-cli/internal/workspace" +) + +type Source string + +const ( + SourceExplicit Source = "explicit" + SourceEnv Source = "env" + SourceLocalDefault Source = "local-default" + SourceGlobalDefault Source = "global-default" + SourceSingleProject Source = "single-project" +) + +type Lister interface { + ListProjects(context.Context) (*api.ProjectList, error) +} + +type ResolveOptions struct { + Explicit string + EnvProject string + Config *config.Config + WorkspaceConfig *workspace.Config + WorkDir string + Client Lister +} + +type Selection struct { + ID string + Project api.Project + Source Source +} + +func Resolve(ctx context.Context, opts ResolveOptions) (Selection, error) { + if opts.Client == nil { + return Selection{}, fmt.Errorf("project resolver requires a projects client") + } + list, err := opts.Client.ListProjects(ctx) + if err != nil { + return Selection{}, err + } + projects := list.Items + + if value := strings.TrimSpace(opts.Explicit); value != "" { + return selectNamed(projects, value, SourceExplicit) + } + if value := strings.TrimSpace(opts.EnvProject); value != "" { + return selectNamed(projects, value, SourceEnv) + } + if value := localDefault(opts.WorkspaceConfig, opts.WorkDir); value != "" { + return selectNamed(projects, value, SourceLocalDefault) + } + if opts.Config != nil && strings.TrimSpace(opts.Config.DefaultProjectID) != "" { + return selectNamed(projects, opts.Config.DefaultProjectID, SourceGlobalDefault) + } + if len(projects) == 1 { + return Selection{ID: projects[0].ID, Project: projects[0], Source: SourceSingleProject}, nil + } + if len(projects) == 0 { + return Selection{}, fmt.Errorf("no projects available; open the portal or run a project-scoped command again after account setup") + } + return Selection{}, fmt.Errorf("multiple projects available; select one with `grounds project use ` or pass `--project `") +} + +func selectNamed(projects []api.Project, value string, source Source) (Selection, error) { + for _, p := range projects { + if p.ID == value || p.Slug == value { + return Selection{ID: p.ID, Project: p, Source: source}, nil + } + } + return Selection{}, fmt.Errorf("project %q not found; run `grounds project list` to see available projects", value) +} + +func localDefault(cfg *workspace.Config, workDir string) string { + if cfg == nil || len(cfg.ProjectDefaults) == 0 { + return "" + } + root := WorkspaceRoot(workDir) + return strings.TrimSpace(cfg.ProjectDefaults[root]) +} + +func WorkspaceRoot(workDir string) string { + if workDir == "" { + if wd, err := os.Getwd(); err == nil { + workDir = wd + } + } + if workDir == "" { + return "" + } + abs, err := filepath.Abs(workDir) + if err != nil { + abs = workDir + } + current := abs + for { + if exists(filepath.Join(current, "grounds.yaml")) || exists(filepath.Join(current, ".git")) { + return current + } + parent := filepath.Dir(current) + if parent == current { + return abs + } + current = parent + } +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/internal/project/resolve_test.go b/internal/project/resolve_test.go new file mode 100644 index 0000000..c9ad7cc --- /dev/null +++ b/internal/project/resolve_test.go @@ -0,0 +1,123 @@ +package project + +import ( + "context" + "strings" + "testing" + + "github.com/groundsgg/grounds-cli/internal/api" + "github.com/groundsgg/grounds-cli/internal/config" + "github.com/groundsgg/grounds-cli/internal/workspace" +) + +type fakeProjectsClient struct { + projects []api.Project +} + +func (f fakeProjectsClient) ListProjects(context.Context) (*api.ProjectList, error) { + return &api.ProjectList{Items: f.projects}, nil +} + +func TestResolveUsesExplicitProjectBeforeDefaults(t *testing.T) { + got, err := Resolve(context.Background(), ResolveOptions{ + Explicit: "team", + Config: &config.Config{DefaultProjectID: "personal-id"}, + WorkspaceConfig: &workspace.Config{ProjectDefaults: map[string]string{"/repo": "local-id"}}, + WorkDir: "/repo", + Client: fakeProjectsClient{projects: []api.Project{ + {ID: "team-id", Slug: "team", Name: "Team"}, + {ID: "personal-id", Slug: "personal", Name: "Personal"}, + {ID: "local-id", Slug: "local", Name: "Local"}, + }}, + }) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got.ID != "team-id" || got.Source != SourceExplicit { + t.Fatalf("selection = %#v", got) + } +} + +func TestResolveUsesEnvBeforeLocalDefault(t *testing.T) { + got, err := Resolve(context.Background(), ResolveOptions{ + EnvProject: "team-id", + WorkspaceConfig: &workspace.Config{ProjectDefaults: map[string]string{"/repo": "local-id"}}, + WorkDir: "/repo", + Client: fakeProjectsClient{projects: []api.Project{ + {ID: "team-id", Slug: "team", Name: "Team"}, + {ID: "local-id", Slug: "local", Name: "Local"}, + }}, + }) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got.ID != "team-id" || got.Source != SourceEnv { + t.Fatalf("selection = %#v", got) + } +} + +func TestResolveUsesLocalDefaultBeforeGlobalDefault(t *testing.T) { + repo := t.TempDir() + root := WorkspaceRoot(repo) + got, err := Resolve(context.Background(), ResolveOptions{ + Config: &config.Config{DefaultProjectID: "global-id"}, + WorkspaceConfig: &workspace.Config{ProjectDefaults: map[string]string{root: "local-id"}}, + WorkDir: repo, + Client: fakeProjectsClient{projects: []api.Project{ + {ID: "global-id", Slug: "global", Name: "Global"}, + {ID: "local-id", Slug: "local", Name: "Local"}, + }}, + }) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got.ID != "local-id" || got.Source != SourceLocalDefault { + t.Fatalf("selection = %#v", got) + } +} + +func TestResolveUsesSingleProjectWhenNoDefaultExists(t *testing.T) { + got, err := Resolve(context.Background(), ResolveOptions{ + Config: &config.Config{}, + WorkspaceConfig: &workspace.Config{}, + WorkDir: "/repo", + Client: fakeProjectsClient{projects: []api.Project{{ID: "only-id", Slug: "only", Name: "Only"}}}, + }) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got.ID != "only-id" || got.Source != SourceSingleProject { + t.Fatalf("selection = %#v", got) + } +} + +func TestResolveFailsWhenMultipleProjectsHaveNoDefault(t *testing.T) { + _, err := Resolve(context.Background(), ResolveOptions{ + Config: &config.Config{}, + WorkspaceConfig: &workspace.Config{}, + WorkDir: "/repo", + Client: fakeProjectsClient{projects: []api.Project{ + {ID: "one", Slug: "one", Name: "One"}, + {ID: "two", Slug: "two", Name: "Two"}, + }}, + }) + if err == nil { + t.Fatal("Resolve error = nil, want multiple-project guidance") + } + if !strings.Contains(err.Error(), "multiple projects") || !strings.Contains(err.Error(), "grounds project use") { + t.Fatalf("error = %q", err.Error()) + } +} + +func TestResolveRejectsUnknownExplicitProject(t *testing.T) { + _, err := Resolve(context.Background(), ResolveOptions{ + Explicit: "missing", + Client: fakeProjectsClient{projects: []api.Project{{ID: "p1", Slug: "main", Name: "Main"}}}, + }) + if err == nil { + t.Fatal("Resolve error = nil, want project not found") + } + if !strings.Contains(err.Error(), "project \"missing\" not found") { + t.Fatalf("error = %q", err.Error()) + } +} diff --git a/internal/workspace/config.go b/internal/workspace/config.go index 43489f6..30031c6 100644 --- a/internal/workspace/config.go +++ b/internal/workspace/config.go @@ -12,7 +12,8 @@ import ( const FileName = "workspace.yaml" type Config struct { - Repos map[string]Repo `yaml:"repos,omitempty"` + Repos map[string]Repo `yaml:"repos,omitempty"` + ProjectDefaults map[string]string `yaml:"projectDefaults,omitempty"` } type Repo struct { @@ -70,6 +71,9 @@ func Load(path string) (*Config, error) { if cfg.Repos == nil { cfg.Repos = map[string]Repo{} } + if cfg.ProjectDefaults == nil { + cfg.ProjectDefaults = map[string]string{} + } return cfg, nil } @@ -87,6 +91,9 @@ func Save(path string, cfg *Config) error { if cfg.Repos == nil { cfg.Repos = map[string]Repo{} } + if cfg.ProjectDefaults == nil { + cfg.ProjectDefaults = map[string]string{} + } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } diff --git a/internal/workspace/project_defaults_test.go b/internal/workspace/project_defaults_test.go new file mode 100644 index 0000000..08e4a00 --- /dev/null +++ b/internal/workspace/project_defaults_test.go @@ -0,0 +1,25 @@ +package workspace + +import ( + "path/filepath" + "testing" +) + +func TestSaveLoadProjectDefaults(t *testing.T) { + path := filepath.Join(t.TempDir(), "workspace.yaml") + cfg := &Config{ + ProjectDefaults: map[string]string{ + "/repo": "project-id", + }, + } + if err := Save(path, cfg); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.ProjectDefaults["/repo"] != "project-id" { + t.Fatalf("ProjectDefaults = %#v", got.ProjectDefaults) + } +}