diff --git a/README.md b/README.md index b582235..c44ce7e 100644 --- a/README.md +++ b/README.md @@ -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 --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=` to scaffold a flavor manifest while legacy top-level runtime fields remain valid. ## Commands @@ -52,7 +55,7 @@ grounds logs --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=]` | Build + deploy via Gradle plugin | | `grounds push retry/list` | Re-run / list pushes | | `grounds logs [--follow]` | Stream logs | | `grounds logs deployment [--follow]` | Stream deployment logs | @@ -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.`: + +```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. diff --git a/cmd/grounds/commands/init.go b/cmd/grounds/commands/init.go index e77f5d0..2dcb3d3 100644 --- a/cmd/grounds/commands/init.go +++ b/cmd/grounds/commands/init.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "regexp" "strings" "github.com/charmbracelet/huh" @@ -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) { @@ -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( @@ -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 } @@ -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}$`) + +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 { @@ -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 +} diff --git a/cmd/grounds/commands/init_test.go b/cmd/grounds/commands/init_test.go index b978f10..f85b74a 100644 --- a/cmd/grounds/commands/init_test.go +++ b/cmd/grounds/commands/init_test.go @@ -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) + } + 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",