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
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,16 @@ curl -sSL https://github.com/groundsgg/grounds-cli/releases/latest/download/inst
```bash
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 workspace scan ../ --yes # discover sibling plugin repos
grounds push # first push
grounds push --flavor=paper # push one app flavor from grounds.yaml
grounds cluster status # observe namespace
grounds logs <pushId> --follow # tail logs
```

`grounds init` loads base-image choices from Forge's runtime catalog and falls back to built-in defaults when the API is unavailable.
Use `grounds init --flavor=<key>` to scaffold a flavor manifest while legacy top-level runtime fields remain valid.

## Commands

Expand All @@ -52,7 +55,7 @@ grounds logs <pushId> --follow # tail logs
| `grounds init` | Scaffold a grounds.yaml |
| `grounds workspace scan/add/list/enable/doctor` | Manage local plugin workspace overrides |
| `grounds cluster up/down/delete/status` | Workspace lifecycle |
| `grounds push [--target=dev]` | Build + deploy via Gradle plugin |
| `grounds push [--target=dev] [--flavor=<key>]` | Build + deploy via Gradle plugin |
| `grounds push retry/list` | Re-run / list pushes |
| `grounds logs <pushId> [--follow]` | Stream logs |
| `grounds logs deployment <name> [--follow]` | Stream deployment logs |
Expand All @@ -74,6 +77,21 @@ grounds push --local plugin-chat,plugin-permissions
grounds push --with-local # override every enabled workspace entry in grounds.yaml
```

Flavor manifests keep runtime fields under `flavors.<key>`:

```yaml
name: plugin-config
flavors:
paper:
type: plugin-paper
baseImage: paper
velocity:
type: plugin-velocity
baseImage: velocity
```

Use `grounds push --flavor=paper` or `grounds push --flavor=velocity` to select one. When `jar` is omitted, the Gradle plugin uses its existing artifact detection.

## Troubleshooting

Run `grounds doctor`. If something looks off, the report tells you which check failed and how to fix it.
Expand Down
76 changes: 65 additions & 11 deletions cmd/grounds/commands/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"os"
"path/filepath"
"regexp"
"strings"

"github.com/charmbracelet/huh"
Expand All @@ -17,7 +18,7 @@ import (
)

type initFlags struct {
appName, type_, baseImage, jar string
appName, type_, baseImage, jar, flavor string
}

var loadInitBaseImageCatalog = func(ctx context.Context, cmd *cobra.Command) (*api.BaseImageCatalog, error) {
Expand Down Expand Up @@ -46,6 +47,12 @@ func NewInitCommand() *cobra.Command {
if err := validateBaseImageChoice(catalog, f.type_, f.baseImage); err != nil {
return err
}
if err := validateJarPath(f.jar); err != nil {
return err
}
if err := validateFlavorKey(f.flavor); err != nil {
return err
}
return writeGroundsYaml(cmd.OutOrStdout(), f)
}
firstStep := huh.NewForm(huh.NewGroup(
Expand All @@ -59,24 +66,29 @@ func NewInitCommand() *cobra.Command {
secondStep := huh.NewForm(huh.NewGroup(
huh.NewSelect[string]().Title("Base image").
Options(baseImageOptionsForType(catalog, f.type_)...).Value(&f.baseImage),
huh.NewInput().Title("JAR glob").Placeholder("build/libs/*.jar").Value(&f.jar),
huh.NewInput().Title("JAR path (optional)").Placeholder("build/libs/my-plugin.jar").Value(&f.jar),
huh.NewInput().Title("App flavor key (optional)").Placeholder("paper").Value(&f.flavor),
))
if err := secondStep.Run(); err != nil {
return err
}
if f.jar == "" {
f.jar = "build/libs/*.jar"
}
if err := validateBaseImageChoice(catalog, f.type_, f.baseImage); err != nil {
return err
}
if err := validateJarPath(f.jar); err != nil {
return err
}
if err := validateFlavorKey(f.flavor); err != nil {
return err
}
return writeGroundsYaml(cmd.OutOrStdout(), f)
},
}
cmd.Flags().StringVar(&f.appName, "app-name", "", "")
cmd.Flags().StringVar(&f.type_, "type", "", "gamemode | plugin-paper | plugin-velocity | service")
cmd.Flags().StringVar(&f.baseImage, "base-image", "", "base image catalog key, e.g. paper or paper@0.8.2")
cmd.Flags().StringVar(&f.jar, "jar", "build/libs/*.jar", "")
cmd.Flags().StringVar(&f.jar, "jar", "", "exact JAR path; omit for Gradle auto-detection")
cmd.Flags().StringVar(&f.flavor, "flavor", "", "app flavor key to scaffold under grounds.yaml flavors")
return cmd
}

Expand Down Expand Up @@ -145,6 +157,25 @@ func validateBaseImageChoice(catalog *api.BaseImageCatalog, manifestType, baseIm
return fmt.Errorf("unknown baseImage %s for type %s", key, manifestType)
}

func validateJarPath(jar string) error {
if strings.ContainsAny(jar, "*?[") {
return fmt.Errorf("jar must be an exact JAR path; leave it empty for Gradle auto-detection")
}
return nil
}

var flavorKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{1,31}$`)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align flavor validation with push input contract

init now rejects flavor keys that push accepts, because this regex requires at least two characters and only [a-z0-9-] after the first letter. For example, --flavor=v is rejected during grounds init even though grounds push trims and forwards arbitrary non-empty flavor strings, so valid existing flavor naming conventions can no longer be scaffolded by the CLI.

Useful? React with 👍 / 👎.


func validateFlavorKey(flavor string) error {
if flavor == "" {
return nil
}
if !flavorKeyPattern.MatchString(flavor) {
return fmt.Errorf("flavor must match %s", flavorKeyPattern.String())
}
return nil
}

func writeGroundsYaml(out io.Writer, f *initFlags) error {
cwd, err := os.Getwd()
if err != nil {
Expand All @@ -154,14 +185,37 @@ func writeGroundsYaml(out io.Writer, f *initFlags) error {
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("grounds.yaml already exists at %s", path)
}
body := fmt.Sprintf(
"name: %s\ntype: %s\nbaseImage: %s\njar: %s\n",
f.appName, f.type_, f.baseImage, f.jar,
)
body := renderGroundsYaml(f)
if err := os.WriteFile(path, []byte(body), 0644); err != nil {
return err
}
render.StatusLine(out, render.StatusOK, "Init", "Wrote grounds.yaml")
render.DetailLine(out, render.StatusOK, "Next: run "+render.Command("grounds push")+".")
next := "Next: run " + render.Command("grounds push") + "."
if f.flavor != "" {
next = "Next: run " + render.Command("grounds push --flavor="+f.flavor) + "."
}
render.DetailLine(out, render.StatusOK, next)
return nil
}

func renderGroundsYaml(f *initFlags) string {
if f.flavor == "" {
body := fmt.Sprintf("name: %s\ntype: %s\nbaseImage: %s\n", f.appName, f.type_, f.baseImage)
if f.jar != "" {
body += fmt.Sprintf("jar: %s\n", f.jar)
}
return body
}

body := fmt.Sprintf(
"name: %s\nflavors:\n %s:\n type: %s\n baseImage: %s\n",
f.appName,
f.flavor,
f.type_,
f.baseImage,
)
if f.jar != "" {
body += fmt.Sprintf(" jar: %s\n", f.jar)
}
return body
}
158 changes: 158 additions & 0 deletions cmd/grounds/commands/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,169 @@ func TestInit_NonInteractive(t *testing.T) {
if !bytes.Contains(body, []byte("baseImage: paper-gamemode")) {
t.Errorf("body = %s", body)
}
if bytes.Contains(body, []byte("jar:")) {
t.Errorf("body should omit default jar field = %s", body)
}
if got := buf.String(); got != "[✓] Init - Wrote grounds.yaml\n • Next: run `grounds push`.\n" {
t.Fatalf("output = %q", got)
}
}

func TestInit_NonInteractiveWritesExplicitJarPath(t *testing.T) {
stubInitCatalog(t, fallbackBaseImageCatalog(), nil)
dir := t.TempDir()
cwd, _ := os.Getwd()
defer os.Chdir(cwd)
os.Chdir(dir)

cmd := NewInitCommand()
cmd.SetArgs([]string{
"--app-name=my-plugin",
"--type=plugin-paper",
"--base-image=paper",
"--jar=paper/build/libs/my-plugin.jar",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
Comment on lines +55 to +64
body, err := os.ReadFile("grounds.yaml")
if err != nil {
t.Fatalf("read: %v", err)
}
if !bytes.Contains(body, []byte("jar: paper/build/libs/my-plugin.jar")) {
t.Fatalf("body = %s", body)
}
}

func TestInit_NonInteractiveWritesFlavorManifest(t *testing.T) {
stubInitCatalog(t, fallbackBaseImageCatalog(), nil)
dir := t.TempDir()
cwd, _ := os.Getwd()
defer os.Chdir(cwd)
os.Chdir(dir)

cmd := NewInitCommand()
cmd.SetArgs([]string{
"--app-name=plugin-config",
"--type=plugin-paper",
"--base-image=paper",
"--flavor=paper",
})
buf := &bytes.Buffer{}
cmd.SetOut(buf)
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}

body, err := os.ReadFile("grounds.yaml")
if err != nil {
t.Fatalf("read: %v", err)
}
if !bytes.Contains(body, []byte("name: plugin-config\n")) {
t.Fatalf("body = %s", body)
}
for _, want := range []string{
"flavors:\n",
" paper:\n",
" type: plugin-paper\n",
" baseImage: paper\n",
} {
if !bytes.Contains(body, []byte(want)) {
t.Fatalf("body = %s, want %q", body, want)
}
}
for _, forbidden := range []string{
"\ntype: plugin-paper\n",
"\nbaseImage: paper\n",
"\njar:",
} {
if bytes.Contains(body, []byte(forbidden)) {
t.Fatalf("body = %s, should not contain top-level %q", body, forbidden)
}
}
if got := buf.String(); got != "[✓] Init - Wrote grounds.yaml\n • Next: run `grounds push --flavor=paper`.\n" {
t.Fatalf("output = %q", got)
}
}

func TestInit_NonInteractiveWritesFlavorJarPath(t *testing.T) {
stubInitCatalog(t, fallbackBaseImageCatalog(), nil)
dir := t.TempDir()
cwd, _ := os.Getwd()
defer os.Chdir(cwd)
os.Chdir(dir)

cmd := NewInitCommand()
cmd.SetArgs([]string{
"--app-name=plugin-config",
"--type=plugin-paper",
"--base-image=paper",
"--flavor=paper",
"--jar=paper/build/libs/plugin-config-paper.jar",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}

body, err := os.ReadFile("grounds.yaml")
if err != nil {
t.Fatalf("read: %v", err)
}
if !bytes.Contains(body, []byte(" jar: paper/build/libs/plugin-config-paper.jar\n")) {
t.Fatalf("body = %s", body)
}
if bytes.Contains(body, []byte("\njar: paper/build/libs/plugin-config-paper.jar\n")) {
t.Fatalf("body = %s, jar should be nested under flavor", body)
}
}

func TestInit_NonInteractiveRejectsInvalidFlavorKey(t *testing.T) {
stubInitCatalog(t, fallbackBaseImageCatalog(), nil)
dir := t.TempDir()
cwd, _ := os.Getwd()
defer os.Chdir(cwd)
os.Chdir(dir)

cmd := NewInitCommand()
cmd.SetArgs([]string{
"--app-name=plugin-config",
"--type=plugin-paper",
"--base-image=paper",
"--flavor=Paper",
})

err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "flavor must match") {
t.Fatalf("error = %v", err)
}
if _, statErr := os.Stat("grounds.yaml"); !os.IsNotExist(statErr) {
t.Fatalf("grounds.yaml should not be written, statErr = %v", statErr)
}
}

func TestInit_NonInteractiveRejectsJarGlob(t *testing.T) {
stubInitCatalog(t, fallbackBaseImageCatalog(), nil)
dir := t.TempDir()
cwd, _ := os.Getwd()
defer os.Chdir(cwd)
os.Chdir(dir)

cmd := NewInitCommand()
cmd.SetArgs([]string{
"--app-name=my-plugin",
"--type=plugin-paper",
"--base-image=paper",
"--jar=paper/build/libs/*.jar",
})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "must be an exact JAR path") {
t.Fatalf("error = %v", err)
}
if _, statErr := os.Stat("grounds.yaml"); !os.IsNotExist(statErr) {
t.Fatalf("grounds.yaml should not be written, statErr = %v", statErr)
}
}

func TestInit_NonInteractiveRejectsMismatchedCatalogBaseImage(t *testing.T) {
stubInitCatalog(t, &api.BaseImageCatalog{Items: []api.BaseImageSource{{
Key: "velocity", DisplayName: "Velocity", ManifestType: "plugin-velocity",
Expand Down
Loading