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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -53,6 +54,7 @@ Use `grounds init --flavor=<key>` to scaffold a flavor manifest while legacy top
| `grounds completion <shell>` | 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=<key>]` | Build + deploy via Gradle plugin |
Expand All @@ -64,6 +66,12 @@ Use `grounds init --flavor=<key>` 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 <id-or-slug>` to save a global default, or `grounds project use <id-or-slug> --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:
Expand Down
44 changes: 4 additions & 40 deletions cmd/grounds/commands/bundle/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
57 changes: 4 additions & 53 deletions cmd/grounds/commands/cluster/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
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, cmd)
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 @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/grounds/commands/cluster/reset.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
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 @@ -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
}
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 @@ -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
}
Expand Down
51 changes: 4 additions & 47 deletions cmd/grounds/commands/devspace/devspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
2 changes: 1 addition & 1 deletion cmd/grounds/commands/devspace/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Examples:
}

ctx := context.Background()
c, _, err := buildClient(ctx, cmd)
c, _, _, err := buildClient(ctx, cmd)
if err != nil {
return err
}
Expand Down
85 changes: 85 additions & 0 deletions cmd/grounds/commands/internal/projectscope/client.go
Original file line number Diff line number Diff line change
@@ -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
}
23 changes: 23 additions & 0 deletions cmd/grounds/commands/internal/projectscope/client_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading