diff --git a/cmd/grounds/commands/push/push.go b/cmd/grounds/commands/push/push.go index 28a87d5..ecdfa4a 100644 --- a/cmd/grounds/commands/push/push.go +++ b/cmd/grounds/commands/push/push.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "github.com/spf13/cobra" @@ -27,13 +28,14 @@ func NewPushCommand() *cobra.Command { func newPush() *cobra.Command { var target string + var flavor string var force bool var local []string var withLocal bool cmd := &cobra.Command{ - Use: "push [--target=dev|staging] [--force] [--local=[,]] [--with-local]", + Use: "push [--target=dev|staging] [--flavor=] [--force] [--local=[,]] [--with-local]", Short: "Build via Gradle plugin and deploy to a target", - Example: " grounds push\n grounds push --target=staging\n grounds push --force\n grounds push --local=plugin-chat\n grounds push --with-local", + Example: " grounds push\n grounds push --flavor=velocity\n grounds push --target=staging\n grounds push --force\n grounds push --local=plugin-chat\n grounds push --with-local", Long: `Build the current project with the grounds-push Gradle plugin and deploy it. Targets: @@ -49,6 +51,7 @@ image moved under a stable tag, or to re-observe the build flow.`, if target != "dev" && target != "staging" { return fmt.Errorf("invalid --target %q: must be \"dev\" or \"staging\"", target) } + flavor = strings.TrimSpace(flavor) cwd, err := os.Getwd() if err != nil { return err @@ -80,6 +83,9 @@ image moved under a stable tag, or to re-observe the build flow.`, } args := []string{"groundsPush", "--target=" + target} + if flavor != "" { + args = append(args, "--flavor="+flavor) + } if force { args = append(args, "--force") } @@ -92,6 +98,7 @@ image moved under a stable tag, or to re-observe the build flow.`, plan, err := internalworkspace.Resolve(ctx, manifestPath, workspaceConfig, internalworkspace.ResolveOptions{ LocalIDs: local, WithLocal: withLocal, + Flavor: flavor, Stdout: cmd.OutOrStdout(), Stderr: cmd.ErrOrStderr(), }) @@ -120,6 +127,10 @@ image moved under a stable tag, or to re-observe the build flow.`, _ = cmd.RegisterFlagCompletionFunc("target", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { return []string{"dev", "staging"}, cobra.ShellCompDirectiveNoFileComp }) + cmd.Flags().StringVar(&flavor, "flavor", "", "app flavor from grounds.yaml flavors (for example paper or velocity)") + _ = cmd.RegisterFlagCompletionFunc("flavor", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveNoFileComp + }) cmd.Flags().BoolVar(&force, "force", false, "skip contentHash dedup and force a fresh build") cmd.Flags().StringArrayVar(&local, "local", nil, "use local workspace override for plugin id (repeatable, comma-separated)") cmd.Flags().BoolVar(&withLocal, "with-local", false, "use all enabled local workspace overrides present in grounds.yaml") diff --git a/cmd/grounds/commands/push/push_test.go b/cmd/grounds/commands/push/push_test.go index 79e985e..0b4c9f2 100644 --- a/cmd/grounds/commands/push/push_test.go +++ b/cmd/grounds/commands/push/push_test.go @@ -4,7 +4,9 @@ import ( "bytes" "errors" "os" + "path/filepath" "reflect" + "runtime" "strings" "testing" @@ -67,6 +69,54 @@ func TestPushTargetCompletion(t *testing.T) { } } +func TestPushFlavorFlagIsForwardedToGradle(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) + } + argsPath := filepath.Join(dir, "args.txt") + wrapper := "#!/bin/sh\nprintf '%s\\n' \"$@\" > " + shellQuote(argsPath) + "\n" + if err := os.WriteFile("gradlew", []byte(wrapper), 0o755); err != nil { + t.Fatalf("WriteFile(gradlew) error = %v", err) + } + t.Setenv("GROUNDS_TOKEN", "test-token") + + cmd := newPush() + cmd.SetArgs([]string{"--flavor= velocity "}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + raw, err := os.ReadFile(argsPath) + if err != nil { + t.Fatalf("ReadFile(args) error = %v", err) + } + got := string(raw) + if !strings.Contains(got, "groundsPush\n") || !strings.Contains(got, "--flavor=velocity\n") { + t.Fatalf("gradle args = %q, want groundsPush and --flavor=velocity", got) + } + if strings.Contains(got, "--flavor= velocity ") { + t.Fatalf("gradle args = %q, want normalized flavor value", got) + } +} + func TestPushRootOwnsDeployFlagsAndSubcommands(t *testing.T) { cmd := NewPushCommand() @@ -89,6 +139,10 @@ func TestPushRootOwnsDeployFlagsAndSubcommands(t *testing.T) { } } +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + func TestPushRootRejectsUnexpectedArgsBeforeDeployWork(t *testing.T) { for _, args := range [][]string{ {"definitely-not-a-command"}, diff --git a/internal/workspace/resolve.go b/internal/workspace/resolve.go index 9595380..014fdee 100644 --- a/internal/workspace/resolve.go +++ b/internal/workspace/resolve.go @@ -20,6 +20,7 @@ import ( type ResolveOptions struct { LocalIDs []string WithLocal bool + Flavor string Stdout io.Writer Stderr io.Writer } @@ -76,7 +77,7 @@ func NormalizeLocalIDs(values []string) []string { } func Resolve(ctx context.Context, manifestPath string, cfg *Config, opts ResolveOptions) (*Plan, error) { - plugins, err := loadManifestPlugins(manifestPath) + plugins, err := loadManifestPlugins(manifestPath, opts.Flavor) if err != nil { return nil, err } @@ -141,20 +142,53 @@ func WritePlanFile(path string, plan *Plan) error { return os.WriteFile(path, raw, 0o600) } -func loadManifestPlugins(path string) ([]manifestPlugin, error) { +func loadManifestPlugins(path, flavor string) ([]manifestPlugin, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } var doc struct { Plugins []yaml.Node `yaml:"plugins"` + Flavors map[string]struct { + Plugins []yaml.Node `yaml:"plugins"` + } `yaml:"flavors"` } if err := yaml.Unmarshal(raw, &doc); err != nil { return nil, err } + if len(doc.Flavors) > 0 { + if len(doc.Plugins) > 0 { + return nil, fmt.Errorf("grounds.yaml: found both top-level plugins and flavors; use only one") + } + return parseFlavorManifestPlugins(doc.Flavors, flavor) + } + return parsePluginNodes(doc.Plugins) +} + +func parseFlavorManifestPlugins(flavors map[string]struct { + Plugins []yaml.Node `yaml:"plugins"` +}, flavor string) ([]manifestPlugin, error) { + flavor = strings.TrimSpace(flavor) + keys := make([]string, 0, len(flavors)) + for key := range flavors { + keys = append(keys, key) + } + sort.Strings(keys) + available := strings.Join(keys, ", ") + if flavor == "" { + return nil, fmt.Errorf("grounds.yaml: flavor selection required (available=%s)", available) + } + selected, ok := flavors[flavor] + if !ok { + return nil, fmt.Errorf("grounds.yaml: unknown flavor %q (available=%s)", flavor, available) + } + return parsePluginNodes(selected.Plugins) +} + +func parsePluginNodes(nodes []yaml.Node) ([]manifestPlugin, error) { var plugins []manifestPlugin - for i := range doc.Plugins { - node := doc.Plugins[i] + for i := range nodes { + node := nodes[i] switch node.Kind { case yaml.ScalarNode: source := strings.TrimSpace(node.Value) diff --git a/internal/workspace/resolve_test.go b/internal/workspace/resolve_test.go index 147710e..c8d29b7 100644 --- a/internal/workspace/resolve_test.go +++ b/internal/workspace/resolve_test.go @@ -136,6 +136,103 @@ plugins: } } +func TestResolveUsesSelectedManifestFlavorPlugins(t *testing.T) { + app := t.TempDir() + writeFile(t, filepath.Join(app, "grounds.yaml"), ` +name: plugin-config +flavors: + paper: + type: paper + baseImage: paper + plugins: + - id: plugin-chat + variant: paper + source: github:groundsgg/plugin-chat@v1.2.3:plugin-chat-paper.jar + velocity: + type: velocity + baseImage: velocity + plugins: + - id: plugin-chat + variant: velocity + source: github:groundsgg/plugin-chat@v1.2.3:plugin-chat-velocity.jar + - id: plugin-proxy + variant: velocity + source: github:groundsgg/plugin-proxy@v1.0.0:plugin-proxy.jar +`) + repo := t.TempDir() + mkdirAll(t, filepath.Join(repo, "velocity", "build", "libs")) + writeFile(t, filepath.Join(repo, "velocity", "build", "libs", "plugin-chat.jar"), "jar") + initGitRepo(t, repo) + + plan, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), &Config{Repos: map[string]Repo{ + "plugin-chat": { + Path: repo, + Variants: map[string]Variant{ + "velocity": {Artifact: "velocity/build/libs/*.jar", Enabled: true}, + }, + }, + }}, ResolveOptions{ + Flavor: "velocity", + LocalIDs: []string{"plugin-chat"}, + WithLocal: true, + }) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if len(plan.Plugins) != 2 { + t.Fatalf("Plugins len = %d, want 2", len(plan.Plugins)) + } + if plan.Plugins[0].Variant != "velocity" || plan.Plugins[0].LocalPath == "" { + t.Fatalf("plugin-chat should use local velocity variant: %#v", plan.Plugins[0]) + } + if plan.Plugins[1].Source != "github:groundsgg/plugin-proxy@v1.0.0:plugin-proxy.jar" { + t.Fatalf("second selected flavor plugin = %#v", plan.Plugins[1]) + } +} + +func TestResolveRequiresFlavorForFlavorManifest(t *testing.T) { + app := t.TempDir() + writeFile(t, filepath.Join(app, "grounds.yaml"), ` +name: plugin-config +flavors: + paper: + type: paper + baseImage: paper + plugins: + - id: plugin-chat + source: github:groundsgg/plugin-chat@v1.2.3:plugin-chat.jar +`) + + _, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), &Config{}, ResolveOptions{WithLocal: true}) + if err == nil || !strings.Contains(err.Error(), "flavor selection required") { + t.Fatalf("Resolve() error = %v, want flavor selection error", err) + } +} + +func TestResolveRejectsManifestWithTopLevelPluginsAndFlavors(t *testing.T) { + app := t.TempDir() + writeFile(t, filepath.Join(app, "grounds.yaml"), ` +name: plugin-config +plugins: + - github:groundsgg/plugin-chat@v1.2.3:plugin-chat.jar +flavors: + paper: + type: paper + baseImage: paper + plugins: + - id: plugin-chat + source: github:groundsgg/plugin-chat@v1.2.3:plugin-chat.jar +`) + + _, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), &Config{}, ResolveOptions{}) + if err == nil { + t.Fatal("Resolve() error = nil, want mixed plugins/flavors error") + } + if !strings.Contains(err.Error(), "grounds.yaml: found both top-level plugins and flavors") { + t.Fatalf("Resolve() error = %v, want prefixed mixed plugins/flavors error", err) + } +} + func TestResolveWithLocalSelectsEnabledSingleVariantForLegacyPluginString(t *testing.T) { app := t.TempDir() writeFile(t, filepath.Join(app, "grounds.yaml"), "plugins:\n - github:groundsgg/plugin-chat@v1.2.3:plugin-chat.jar\n")