From aece3d83c275b23ef49e1a873f9d266520f1e3b5 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 14 May 2026 11:59:35 +0200 Subject: [PATCH 1/6] feat: add local plugin override workspace --- README.md | 15 + cmd/grounds/commands/push/local_flags_test.go | 14 + cmd/grounds/commands/push/push.go | 58 ++- cmd/grounds/commands/workspace/workspace.go | 267 +++++++++++++ .../commands/workspace/workspace_test.go | 35 ++ cmd/grounds/main.go | 2 + internal/workspace/config.go | 132 +++++++ internal/workspace/config_test.go | 105 ++++++ internal/workspace/resolve.go | 355 ++++++++++++++++++ internal/workspace/resolve_test.go | 213 +++++++++++ internal/workspace/scan.go | 65 ++++ internal/workspace/scan_test.go | 93 +++++ 12 files changed, 1352 insertions(+), 2 deletions(-) create mode 100644 cmd/grounds/commands/push/local_flags_test.go create mode 100644 cmd/grounds/commands/workspace/workspace.go create mode 100644 cmd/grounds/commands/workspace/workspace_test.go create mode 100644 internal/workspace/config.go create mode 100644 internal/workspace/config_test.go create mode 100644 internal/workspace/resolve.go create mode 100644 internal/workspace/resolve_test.go create mode 100644 internal/workspace/scan.go create mode 100644 internal/workspace/scan_test.go diff --git a/README.md b/README.md index c4ce471..6e5f8e5 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ curl -sSL https://github.com/groundsgg/grounds-cli/releases/latest/download/inst ```bash grounds login # OAuth device flow grounds init # scaffold grounds.yaml +grounds workspace scan ../ --yes # discover sibling plugin repos grounds push # first push grounds cluster status # observe namespace grounds logs --follow # tail logs @@ -49,6 +50,7 @@ grounds logs --follow # tail logs | `grounds completion ` | Shell completions | | `grounds doctor` | Diagnose env and warn about CLI updates | | `grounds init` | Scaffold a grounds.yaml | +| `grounds workspace scan/add/list/enable` | Manage local plugin workspace overrides | | `grounds cluster up/down/delete/status` | Workspace lifecycle | | `grounds push [--target=dev]` | Build + deploy via Gradle plugin | | `grounds push retry/list` | Re-run / list pushes | @@ -59,6 +61,19 @@ grounds logs --follow # tail logs `~/.config/grounds/config.yaml` (XDG-aware). Overridable via flags or env vars (`GROUNDS_API_URL`, `GROUNDS_TOKEN`, `GROUNDS_CONFIG_DIR`). +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: + +```bash +grounds workspace scan ../ # preview sibling plugin repos, then confirm +grounds workspace scan ../ --yes # write discovered mappings without prompting +grounds workspace add plugin-chat ../plugin-chat --variant paper +grounds push --local plugin-chat # override one plugin from the workspace +grounds push --local plugin-chat,plugin-permissions +grounds push --with-local # override every enabled workspace entry in grounds.yaml +``` + ## 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/push/local_flags_test.go b/cmd/grounds/commands/push/local_flags_test.go new file mode 100644 index 0000000..25b204f --- /dev/null +++ b/cmd/grounds/commands/push/local_flags_test.go @@ -0,0 +1,14 @@ +package push + +import "testing" + +func TestPushDefinesLocalOverrideFlags(t *testing.T) { + cmd := newPush() + + if flag := cmd.Flag("local"); flag == nil { + t.Fatal("expected --local flag") + } + if flag := cmd.Flag("with-local"); flag == nil { + t.Fatal("expected --with-local flag") + } +} diff --git a/cmd/grounds/commands/push/push.go b/cmd/grounds/commands/push/push.go index c65ad1b..e4930ca 100644 --- a/cmd/grounds/commands/push/push.go +++ b/cmd/grounds/commands/push/push.go @@ -3,8 +3,10 @@ package push import ( "context" "fmt" + "io" "net/http" "os" + "path/filepath" "time" "github.com/spf13/cobra" @@ -13,6 +15,7 @@ import ( "github.com/groundsgg/grounds-cli/internal/config" "github.com/groundsgg/grounds-cli/internal/gradle" "github.com/groundsgg/grounds-cli/internal/render" + internalworkspace "github.com/groundsgg/grounds-cli/internal/workspace" ) func NewPushCommand() *cobra.Command { @@ -25,10 +28,12 @@ func NewPushCommand() *cobra.Command { func newPush() *cobra.Command { var target string var force bool + var local []string + var withLocal bool cmd := &cobra.Command{ - Use: "push [--target=dev|staging] [--force]", + Use: "push [--target=dev|staging] [--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", + Example: " grounds push\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: @@ -78,6 +83,34 @@ image moved under a stable tag, or to re-observe the build flow.`, if force { args = append(args, "--force") } + if withLocal || len(internalworkspace.NormalizeLocalIDs(local)) > 0 { + workspaceConfig, err := internalworkspace.Load("") + if err != nil { + return err + } + manifestPath := filepath.Join(filepath.Dir(wrapper), "grounds.yaml") + plan, err := internalworkspace.Resolve(ctx, manifestPath, workspaceConfig, internalworkspace.ResolveOptions{ + LocalIDs: local, + WithLocal: withLocal, + }) + if err != nil { + return err + } + renderBundleSources(cmd.OutOrStdout(), plan) + file, err := os.CreateTemp("", "grounds-resolved-plugins-*.json") + if err != nil { + return err + } + resolvedPath := file.Name() + if err := file.Close(); err != nil { + return err + } + defer os.Remove(resolvedPath) + if err := internalworkspace.WritePlanFile(resolvedPath, plan); err != nil { + return err + } + args = append(args, "--resolved-plugins-file="+resolvedPath) + } return gradle.Run(ctx, wrapper, args, cmd.OutOrStdout(), cmd.ErrOrStderr(), 0) }, } @@ -86,9 +119,30 @@ image moved under a stable tag, or to re-observe the build flow.`, return []string{"dev", "staging"}, 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") return cmd } +func renderBundleSources(out io.Writer, plan *internalworkspace.Plan) { + fmt.Fprintln(out, "Bundle sources:") + rows := make([][]any, 0, len(plan.EffectivePluginSources)) + localPaths := map[string]string{} + for _, plugin := range plan.Plugins { + if plugin.LocalPath != "" { + localPaths[plugin.ID+"\x00"+plugin.Variant] = plugin.LocalPath + } + } + for _, source := range plan.EffectivePluginSources { + value := source.Source + if source.Effective == "local" { + value = localPaths[source.ID+"\x00"+source.Variant] + } + rows = append(rows, []any{source.ID, source.Variant, source.Effective, value}) + } + render.Table(out, []string{"ID", "Variant", "Source", "Value"}, rows) +} + func authRefreshError(err error) error { return fmt.Errorf("auth refresh failed: %w\n ! Run %s to re-authenticate.", err, render.Command("grounds login")) } diff --git a/cmd/grounds/commands/workspace/workspace.go b/cmd/grounds/commands/workspace/workspace.go new file mode 100644 index 0000000..02f0cc7 --- /dev/null +++ b/cmd/grounds/commands/workspace/workspace.go @@ -0,0 +1,267 @@ +package workspace + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/groundsgg/grounds-cli/internal/render" + internalworkspace "github.com/groundsgg/grounds-cli/internal/workspace" +) + +func NewWorkspaceCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "workspace", + Short: "Manage local plugin workspace overrides", + } + cmd.AddCommand(newAdd(), newList(), newEnable(), newDoctor(), newScan()) + return cmd +} + +func newAdd() *cobra.Command { + var artifact string + var build string + var variant string + var disabled bool + cmd := &cobra.Command{ + Use: "add ", + Short: "Add a local plugin repository", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := internalworkspace.Load("") + if err != nil { + return err + } + abs, err := filepath.Abs(args[1]) + if err != nil { + return err + } + repo := cfg.Repos[args[0]] + repo.Path = abs + if variant == "" { + repo.Artifact = firstNonEmpty(artifact, "build/libs/*.jar") + repo.Build = firstNonEmpty(build, "./gradlew build") + repo.Enabled = !disabled + } else { + if repo.Variants == nil { + repo.Variants = map[string]internalworkspace.Variant{} + } + repo.Variants[variant] = internalworkspace.Variant{ + Artifact: firstNonEmpty(artifact, filepath.ToSlash(filepath.Join(variant, "build", "libs", "*.jar"))), + Build: firstNonEmpty(build, "./gradlew :"+variant+":shadowJar"), + Enabled: !disabled, + } + } + if cfg.Repos == nil { + cfg.Repos = map[string]internalworkspace.Repo{} + } + cfg.Repos[args[0]] = repo + if err := internalworkspace.Save("", cfg); err != nil { + return err + } + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Workspace", "Added "+args[0]) + return nil + }, + } + cmd.Flags().StringVar(&artifact, "artifact", "", "artifact glob relative to the repo") + cmd.Flags().StringVar(&build, "build", "", "build command run in the repo before resolving artifacts") + cmd.Flags().StringVar(&variant, "variant", "", "plugin variant: paper, velocity, or minestom") + cmd.Flags().BoolVar(&disabled, "disabled", false, "add the mapping disabled") + return cmd +} + +func newList() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List local plugin workspace mappings", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := internalworkspace.Load("") + if err != nil { + return err + } + renderMappings(cmd.OutOrStdout(), cfg) + return nil + }, + } +} + +func newEnable() *cobra.Command { + var disabled bool + cmd := &cobra.Command{ + Use: "enable [variant]", + Short: "Enable a local plugin workspace mapping", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := internalworkspace.Load("") + if err != nil { + return err + } + repo, ok := cfg.Repos[args[0]] + if !ok { + return fmt.Errorf("workspace repo %q not found", args[0]) + } + enabled := !disabled + if len(args) == 2 { + variant, ok := repo.Variants[args[1]] + if !ok { + return fmt.Errorf("workspace repo %q variant %q not found", args[0], args[1]) + } + variant.Enabled = enabled + repo.Variants[args[1]] = variant + } else { + repo.Enabled = enabled + } + cfg.Repos[args[0]] = repo + if err := internalworkspace.Save("", cfg); err != nil { + return err + } + summary := "Enabled " + args[0] + if disabled { + summary = "Disabled " + args[0] + } + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Workspace", summary) + return nil + }, + } + cmd.Flags().BoolVar(&disabled, "disable", false, "disable instead of enabling") + return cmd +} + +func newDoctor() *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "Check configured local plugin workspace mappings", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := internalworkspace.Load("") + if err != nil { + return err + } + out := cmd.OutOrStdout() + for _, id := range sortedRepoIDs(cfg) { + repo := cfg.Repos[id] + if _, err := os.Stat(repo.Path); err != nil { + render.StatusLine(out, render.StatusWarn, "Workspace", fmt.Sprintf("Repo path missing (id=%s, path=%s)", id, repo.Path)) + continue + } + render.StatusLine(out, render.StatusOK, "Workspace", fmt.Sprintf("Repo path exists (id=%s, path=%s)", id, repo.Path)) + } + return nil + }, + } +} + +func newScan() *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: "scan [root...]", + Short: "Scan directories for local plugin repositories", + Args: func(_ *cobra.Command, args []string) error { + if len(args) == 0 { + return errors.New("scan requires at least one root") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + proposed, err := internalworkspace.ScanRoots(args) + if err != nil { + return err + } + out := cmd.OutOrStdout() + fmt.Fprintln(out, "Proposed workspace mappings:") + renderMappings(out, proposed) + if !yes { + ok, err := confirm(cmd.InOrStdin(), out, "Write these mappings? [y/N] ") + if err != nil { + return err + } + if !ok { + render.StatusLine(out, render.StatusWarn, "Workspace", "Scan cancelled") + return nil + } + } + cfg, err := internalworkspace.Load("") + if err != nil { + return err + } + if cfg.Repos == nil { + cfg.Repos = map[string]internalworkspace.Repo{} + } + for id, repo := range proposed.Repos { + cfg.Repos[id] = repo + } + if err := internalworkspace.Save("", cfg); err != nil { + return err + } + render.StatusLine(out, render.StatusOK, "Workspace", "Saved scanned mappings") + return nil + }, + } + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "write scanned mappings without prompting") + return cmd +} + +func renderMappings(w io.Writer, cfg *internalworkspace.Config) { + rows := [][]any{} + for _, id := range sortedRepoIDs(cfg) { + repo := cfg.Repos[id] + if repo.Artifact != "" { + rows = append(rows, []any{id, "", repo.Enabled, repo.Path, repo.Artifact, repo.Build}) + } + for _, variant := range sortedVariantIDs(repo) { + v := repo.Variants[variant] + rows = append(rows, []any{id, variant, v.Enabled, repo.Path, v.Artifact, v.Build}) + } + } + render.Table(w, []string{"ID", "Variant", "Enabled", "Path", "Artifact", "Build"}, rows) +} + +func sortedRepoIDs(cfg *internalworkspace.Config) []string { + if cfg == nil { + return nil + } + ids := make([]string, 0, len(cfg.Repos)) + for id := range cfg.Repos { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} + +func sortedVariantIDs(repo internalworkspace.Repo) []string { + variants := make([]string, 0, len(repo.Variants)) + for variant := range repo.Variants { + variants = append(variants, variant) + } + sort.Strings(variants) + return variants +} + +func confirm(in io.Reader, out io.Writer, prompt string) (bool, error) { + fmt.Fprint(out, prompt) + var answer string + if _, err := fmt.Fscan(in, &answer); err != nil { + if errors.Is(err, io.EOF) { + return false, nil + } + return false, err + } + answer = strings.ToLower(strings.TrimSpace(answer)) + return answer == "y" || answer == "yes", nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/cmd/grounds/commands/workspace/workspace_test.go b/cmd/grounds/commands/workspace/workspace_test.go new file mode 100644 index 0000000..44f5794 --- /dev/null +++ b/cmd/grounds/commands/workspace/workspace_test.go @@ -0,0 +1,35 @@ +package workspace + +import ( + "strings" + "testing" +) + +func TestWorkspaceCommandDefinesSubcommands(t *testing.T) { + cmd := NewWorkspaceCommand() + + for _, name := range []string{"add", "list", "enable", "doctor", "scan"} { + sub, _, err := cmd.Find([]string{name}) + if err != nil { + t.Fatalf("Find(%q) error = %v", name, err) + } + if sub.Name() != name { + t.Fatalf("Find(%q) = %q, want %q", name, sub.Name(), name) + } + } +} + +func TestWorkspaceScanRequiresRoot(t *testing.T) { + cmd := NewWorkspaceCommand() + cmd.SetArgs([]string{"scan"}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + err := cmd.Execute() + if err == nil { + t.Fatal("expected missing root error") + } + if !strings.Contains(err.Error(), "at least one root") { + t.Fatalf("error = %q, want root requirement", err) + } +} diff --git a/cmd/grounds/main.go b/cmd/grounds/main.go index 2c88b48..d7590ce 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/preview" "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" ) @@ -38,6 +39,7 @@ func main() { root.AddCommand(logs.NewLogsCommand()) root.AddCommand(push.NewPushCommand()) root.AddCommand(preview.NewPreviewCommand()) + root.AddCommand(workspace.NewWorkspaceCommand()) // Execute() returns the error from the matched cobra subcommand. // We pass the matched-command path (e.g. "grounds push") into the diff --git a/internal/workspace/config.go b/internal/workspace/config.go new file mode 100644 index 0000000..e151ed4 --- /dev/null +++ b/internal/workspace/config.go @@ -0,0 +1,132 @@ +package workspace + +import ( + "errors" + "os" + "path/filepath" + + "github.com/groundsgg/grounds-cli/internal/config" + "gopkg.in/yaml.v3" +) + +const FileName = "workspace.yaml" + +type Config struct { + Repos map[string]Repo `yaml:"repos,omitempty"` +} + +type Repo struct { + Path string `yaml:"path"` + Artifact string `yaml:"artifact,omitempty"` + Build string `yaml:"build,omitempty"` + Enabled bool `yaml:"enabled"` + Variants map[string]Variant `yaml:"variants,omitempty"` +} + +type Variant struct { + Artifact string `yaml:"artifact,omitempty"` + Build string `yaml:"build,omitempty"` + Enabled bool `yaml:"enabled"` +} + +type ResolvedEntry struct { + Path string + Artifact string + Build string + Enabled bool +} + +func DefaultPath() (string, error) { + dir, err := config.ResolveDir() + if err != nil { + return "", err + } + return filepath.Join(dir, FileName), nil +} + +func Load(path string) (*Config, error) { + if path == "" { + var err error + path, err = DefaultPath() + if err != nil { + return nil, err + } + } + raw, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &Config{Repos: map[string]Repo{}}, nil + } + return nil, err + } + cfg := &Config{} + if err := yaml.Unmarshal(raw, cfg); err != nil { + return nil, err + } + if cfg.Repos == nil { + cfg.Repos = map[string]Repo{} + } + return cfg, nil +} + +func Save(path string, cfg *Config) error { + if path == "" { + var err error + path, err = DefaultPath() + if err != nil { + return err + } + } + if cfg == nil { + cfg = &Config{} + } + if cfg.Repos == nil { + cfg.Repos = map[string]Repo{} + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + raw, err := yaml.Marshal(cfg) + if err != nil { + return err + } + return os.WriteFile(path, raw, 0o600) +} + +func (c *Config) EntryForVariant(id, variant string) (ResolvedEntry, bool) { + if c == nil { + return ResolvedEntry{}, false + } + repo, ok := c.Repos[id] + if !ok { + return ResolvedEntry{}, false + } + if variant != "" && repo.Variants != nil { + if v, ok := repo.Variants[variant]; ok { + return ResolvedEntry{ + Path: repo.Path, + Artifact: firstNonEmpty(v.Artifact, repo.Artifact), + Build: firstNonEmpty(v.Build, repo.Build), + Enabled: v.Enabled, + }, true + } + } + if repo.Artifact == "" { + return ResolvedEntry{}, false + } + return ResolvedEntry{ + Path: repo.Path, + Artifact: repo.Artifact, + Build: repo.Build, + Enabled: repo.Enabled, + }, true +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/internal/workspace/config_test.go b/internal/workspace/config_test.go new file mode 100644 index 0000000..775ee75 --- /dev/null +++ b/internal/workspace/config_test.go @@ -0,0 +1,105 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadMissingWorkspaceConfigReturnsEmptyConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "workspace.yaml") + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if len(cfg.Repos) != 0 { + t.Fatalf("Repos = %v, want empty", cfg.Repos) + } +} + +func TestSaveCreatesPrivateWorkspaceConfig(t *testing.T) { + dir := filepath.Join(t.TempDir(), "grounds") + path := filepath.Join(dir, "workspace.yaml") + cfg := &Config{ + Repos: map[string]Repo{ + "plugin-chat": { + Path: "/repos/plugin-chat", + Artifact: "build/libs/*.jar", + Build: "./gradlew build", + Enabled: true, + }, + }, + } + + if err := Save(path, cfg); err != nil { + t.Fatalf("Save() error = %v", err) + } + + dirInfo, err := os.Stat(dir) + if err != nil { + t.Fatalf("Stat(config dir) error = %v", err) + } + if got := dirInfo.Mode().Perm(); got != 0o700 { + t.Fatalf("config dir mode = %o, want 700", got) + } + fileInfo, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat(workspace.yaml) error = %v", err) + } + if got := fileInfo.Mode().Perm(); got != 0o600 { + t.Fatalf("workspace.yaml mode = %o, want 600", got) + } +} + +func TestEntryForVariantUsesVariantSpecificArtifact(t *testing.T) { + cfg := &Config{Repos: map[string]Repo{ + "plugin-chat": { + Path: "/repos/plugin-chat", + Enabled: true, + Variants: map[string]Variant{ + "paper": { + Artifact: "paper/build/libs/*.jar", + Build: "./gradlew :paper:shadowJar", + Enabled: true, + }, + }, + }, + }} + + entry, ok := cfg.EntryForVariant("plugin-chat", "paper") + if !ok { + t.Fatal("EntryForVariant() ok = false, want true") + } + if entry.Path != "/repos/plugin-chat" { + t.Fatalf("Path = %q", entry.Path) + } + if entry.Artifact != "paper/build/libs/*.jar" { + t.Fatalf("Artifact = %q", entry.Artifact) + } + if entry.Build != "./gradlew :paper:shadowJar" { + t.Fatalf("Build = %q", entry.Build) + } + if !entry.Enabled { + t.Fatal("Enabled = false, want true") + } +} + +func TestEntryForVariantFallsBackToRootArtifact(t *testing.T) { + cfg := &Config{Repos: map[string]Repo{ + "plugin-permissions": { + Path: "/repos/plugin-permissions", + Artifact: "build/libs/*.jar", + Build: "./gradlew build", + Enabled: true, + }, + }} + + entry, ok := cfg.EntryForVariant("plugin-permissions", "paper") + if !ok { + t.Fatal("EntryForVariant() ok = false, want true") + } + if entry.Artifact != "build/libs/*.jar" { + t.Fatalf("Artifact = %q", entry.Artifact) + } +} diff --git a/internal/workspace/resolve.go b/internal/workspace/resolve.go new file mode 100644 index 0000000..df16bd7 --- /dev/null +++ b/internal/workspace/resolve.go @@ -0,0 +1,355 @@ +package workspace + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +type ResolveOptions struct { + LocalIDs []string + WithLocal bool +} + +type Plan struct { + Plugins []PlanPlugin `json:"plugins"` + EffectivePluginSources []EffectiveSource `json:"effectivePluginSources"` +} + +type PlanPlugin struct { + ID string `json:"id"` + Variant string `json:"variant,omitempty"` + Source string `json:"source,omitempty"` + LocalPath string `json:"localPath,omitempty"` +} + +type EffectiveSource struct { + ID string `json:"id"` + Variant string `json:"variant,omitempty"` + Effective string `json:"effective"` + Source string `json:"source,omitempty"` + DefaultSource string `json:"defaultSource,omitempty"` + ArtifactName string `json:"artifactName,omitempty"` + ArtifactSha256 string `json:"artifactSha256,omitempty"` + Git *GitMetadata `json:"git,omitempty"` +} + +type GitMetadata struct { + Remote string `json:"remote"` + Commit string `json:"commit"` + Dirty bool `json:"dirty"` +} + +type manifestPlugin struct { + ID string + Variant string + Source string +} + +func NormalizeLocalIDs(values []string) []string { + seen := map[string]bool{} + var normalized []string + for _, value := range values { + for _, part := range strings.Split(value, ",") { + id := strings.TrimSpace(part) + if id == "" || seen[id] { + continue + } + seen[id] = true + normalized = append(normalized, id) + } + } + return normalized +} + +func Resolve(ctx context.Context, manifestPath string, cfg *Config, opts ResolveOptions) (*Plan, error) { + plugins, err := loadManifestPlugins(manifestPath) + if err != nil { + return nil, err + } + localIDs := NormalizeLocalIDs(opts.LocalIDs) + explicitLocal := map[string]bool{} + for _, id := range localIDs { + explicitLocal[id] = true + if !manifestContains(plugins, id) { + return nil, fmt.Errorf("--local plugin %q not found in grounds.yaml", id) + } + } + + plan := &Plan{} + for _, plugin := range plugins { + entry, variant, ok := inferEntryForPlugin(cfg, plugin) + selected := false + if explicitLocal[plugin.ID] { + selected = true + } else if opts.WithLocal && ok && entry.Enabled { + selected = true + } + if selected { + if !ok { + return nil, fmt.Errorf("local workspace entry for %q variant %q not found", plugin.ID, plugin.Variant) + } + if variant == "" { + variant = plugin.Variant + } + local, err := resolveLocal(ctx, plugin, variant, entry) + if err != nil { + return nil, err + } + plan.Plugins = append(plan.Plugins, PlanPlugin{ + ID: plugin.ID, + Variant: variant, + LocalPath: local.LocalPath, + }) + plan.EffectivePluginSources = append(plan.EffectivePluginSources, local.Effective) + continue + } + + plan.Plugins = append(plan.Plugins, PlanPlugin{ + ID: plugin.ID, + Variant: plugin.Variant, + Source: plugin.Source, + }) + plan.EffectivePluginSources = append(plan.EffectivePluginSources, EffectiveSource{ + ID: plugin.ID, + Variant: plugin.Variant, + Effective: "release", + Source: plugin.Source, + }) + } + return plan, nil +} + +func WritePlanFile(path string, plan *Plan) error { + raw, err := json.MarshalIndent(plan, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, raw, 0o600) +} + +func loadManifestPlugins(path string) ([]manifestPlugin, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var doc struct { + Plugins []yaml.Node `yaml:"plugins"` + } + if err := yaml.Unmarshal(raw, &doc); err != nil { + return nil, err + } + var plugins []manifestPlugin + for i := range doc.Plugins { + node := doc.Plugins[i] + switch node.Kind { + case yaml.ScalarNode: + source := strings.TrimSpace(node.Value) + plugins = append(plugins, manifestPlugin{ + ID: inferIDFromSource(source), + Source: source, + Variant: "", + }) + case yaml.MappingNode: + var plugin struct { + ID string `yaml:"id"` + Variant string `yaml:"variant"` + Source string `yaml:"source"` + } + if err := node.Decode(&plugin); err != nil { + return nil, err + } + if plugin.ID == "" { + plugin.ID = inferIDFromSource(plugin.Source) + } + plugins = append(plugins, manifestPlugin{ + ID: plugin.ID, + Variant: plugin.Variant, + Source: plugin.Source, + }) + default: + return nil, fmt.Errorf("unsupported plugin entry at index %d", i) + } + } + return plugins, nil +} + +func manifestContains(plugins []manifestPlugin, id string) bool { + for _, plugin := range plugins { + if plugin.ID == id { + return true + } + } + return false +} + +func inferEntryForPlugin(cfg *Config, plugin manifestPlugin) (ResolvedEntry, string, bool) { + if cfg == nil { + return ResolvedEntry{}, "", false + } + repo, ok := cfg.Repos[plugin.ID] + if !ok { + return ResolvedEntry{}, "", false + } + if plugin.Variant != "" { + entry, ok := cfg.EntryForVariant(plugin.ID, plugin.Variant) + return entry, plugin.Variant, ok + } + if repo.Artifact != "" { + entry, ok := cfg.EntryForVariant(plugin.ID, "") + return entry, "", ok + } + if len(repo.Variants) != 1 { + return ResolvedEntry{}, "", false + } + for variant := range repo.Variants { + entry, ok := cfg.EntryForVariant(plugin.ID, variant) + return entry, variant, ok + } + return ResolvedEntry{}, "", false +} + +type localResolution struct { + LocalPath string + Effective EffectiveSource +} + +func resolveLocal(ctx context.Context, plugin manifestPlugin, variant string, entry ResolvedEntry) (localResolution, error) { + if entry.Path == "" { + return localResolution{}, fmt.Errorf("local workspace entry for %q has no path", plugin.ID) + } + if entry.Build != "" { + if err := runBuild(ctx, entry.Path, entry.Build); err != nil { + return localResolution{}, fmt.Errorf("failed to build local plugin %q: %w", plugin.ID, err) + } + } + localPath, err := resolveArtifact(entry.Path, entry.Artifact) + if err != nil { + return localResolution{}, fmt.Errorf("failed to resolve local artifact for %q: %w", plugin.ID, err) + } + sum, err := sha256File(localPath) + if err != nil { + return localResolution{}, err + } + git, err := collectGitMetadata(ctx, entry.Path) + if err != nil { + return localResolution{}, err + } + return localResolution{ + LocalPath: localPath, + Effective: EffectiveSource{ + ID: plugin.ID, + Variant: variant, + Effective: "local", + DefaultSource: plugin.Source, + ArtifactName: filepath.Base(localPath), + ArtifactSha256: sum, + Git: git, + }, + }, nil +} + +func runBuild(ctx context.Context, dir, build string) error { + parts := strings.Fields(build) + if len(parts) == 0 { + return nil + } + cmd := exec.CommandContext(ctx, parts[0], parts[1:]...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func resolveArtifact(repoPath, pattern string) (string, error) { + if pattern == "" { + return "", fmt.Errorf("artifact glob is empty") + } + if !filepath.IsAbs(pattern) { + pattern = filepath.Join(repoPath, filepath.FromSlash(pattern)) + } + matches, err := filepath.Glob(pattern) + if err != nil { + return "", err + } + var jars []string + for _, match := range matches { + if strings.EqualFold(filepath.Ext(match), ".jar") { + jars = append(jars, match) + } + } + sort.Strings(jars) + if len(jars) != 1 { + return "", fmt.Errorf("expected exactly one .jar for %s, found %d", pattern, len(jars)) + } + return jars[0], nil +} + +func sha256File(path string) (string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return "", err + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]), nil +} + +func collectGitMetadata(ctx context.Context, path string) (*GitMetadata, error) { + root, err := gitOutput(ctx, path, "rev-parse", "--show-toplevel") + if err != nil { + return nil, fmt.Errorf("failed to read git root for %s: %w", path, err) + } + commit, err := gitOutput(ctx, root, "rev-parse", "HEAD") + if err != nil { + return nil, fmt.Errorf("failed to read git commit for %s: %w", root, err) + } + remote, _ := gitOutput(ctx, root, "config", "--get", "remote.origin.url") + status, err := gitOutput(ctx, root, "status", "--porcelain") + if err != nil { + return nil, fmt.Errorf("failed to read git status for %s: %w", root, err) + } + return &GitMetadata{ + Remote: normalizeRemote(remote), + Commit: strings.TrimSpace(commit), + Dirty: strings.TrimSpace(status) != "", + }, nil +} + +func gitOutput(ctx context.Context, dir string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = dir + raw, err := cmd.Output() + return strings.TrimSpace(string(raw)), err +} + +func normalizeRemote(remote string) string { + remote = strings.TrimSpace(remote) + remote = strings.TrimSuffix(remote, ".git") + remote = strings.TrimPrefix(remote, "git@github.com:") + remote = strings.TrimPrefix(remote, "https://github.com/") + return remote +} + +func inferIDFromSource(source string) string { + source = strings.TrimSpace(source) + if beforeAt, _, ok := strings.Cut(source, "@"); ok { + if slash := strings.LastIndex(beforeAt, "/"); slash >= 0 && slash < len(beforeAt)-1 { + return beforeAt[slash+1:] + } + } + if _, artifact, ok := strings.Cut(source, ":"); ok && artifact != "" { + base := filepath.Base(artifact) + return strings.TrimSuffix(base, filepath.Ext(base)) + } + base := filepath.Base(source) + return strings.TrimSuffix(base, filepath.Ext(base)) +} diff --git a/internal/workspace/resolve_test.go b/internal/workspace/resolve_test.go new file mode 100644 index 0000000..f1ea738 --- /dev/null +++ b/internal/workspace/resolve_test.go @@ -0,0 +1,213 @@ +package workspace + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestNormalizeLocalIDsTrimsSplitsAndDedupes(t *testing.T) { + got := NormalizeLocalIDs([]string{" plugin-chat,plugin-permissions ", "plugin-chat", "", "plugin-economy"}) + want := []string{"plugin-chat", "plugin-permissions", "plugin-economy"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("NormalizeLocalIDs() = %v, want %v", got, want) + } +} + +func TestResolveExplicitLocalBuildsArtifactAndKeepsReleaseMetadata(t *testing.T) { + app := t.TempDir() + writeFile(t, filepath.Join(app, "grounds.yaml"), ` +plugins: + - github:groundsgg/plugin-chat@v1.2.3:plugin-chat.jar + - id: plugin-permissions + source: github:groundsgg/plugin-permissions@v1.4.0:plugin-permissions.jar +`) + repo := t.TempDir() + mkdirAll(t, filepath.Join(repo, "paper", "build", "libs")) + writeFile(t, filepath.Join(repo, "build.sh"), "#!/bin/sh\nprintf jar > paper/build/libs/plugin-chat.jar\n") + if err := os.Chmod(filepath.Join(repo, "build.sh"), 0o700); err != nil { + t.Fatalf("Chmod(build.sh) error = %v", err) + } + initGitRepo(t, repo) + + cfg := &Config{Repos: map[string]Repo{ + "plugin-chat": { + Path: repo, + Variants: map[string]Variant{ + "paper": { + Artifact: "paper/build/libs/*.jar", + Build: "./build.sh", + Enabled: false, + }, + }, + }, + }} + + plan, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), cfg, ResolveOptions{ + LocalIDs: []string{"plugin-chat"}, + }) + 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].LocalPath != filepath.Join(repo, "paper", "build", "libs", "plugin-chat.jar") { + t.Fatalf("localPath = %q", plan.Plugins[0].LocalPath) + } + if plan.Plugins[1].Source != "github:groundsgg/plugin-permissions@v1.4.0:plugin-permissions.jar" { + t.Fatalf("release source = %q", plan.Plugins[1].Source) + } + local := plan.EffectivePluginSources[0] + if local.Effective != "local" { + t.Fatalf("effective = %q, want local", local.Effective) + } + if local.Variant != "paper" { + t.Fatalf("variant = %q, want paper", local.Variant) + } + if local.DefaultSource != "github:groundsgg/plugin-chat@v1.2.3:plugin-chat.jar" { + t.Fatalf("defaultSource = %q", local.DefaultSource) + } + if local.ArtifactName != "plugin-chat.jar" { + t.Fatalf("artifactName = %q", local.ArtifactName) + } + if len(local.ArtifactSha256) != 64 { + t.Fatalf("artifactSha256 length = %d, want 64", len(local.ArtifactSha256)) + } + if local.Git == nil || local.Git.Commit == "" || local.Git.Remote == "" { + t.Fatalf("git metadata = %#v, want remote and commit", local.Git) + } + release := plan.EffectivePluginSources[1] + if release.Effective != "release" || release.Source == "" { + t.Fatalf("release metadata = %#v", release) + } +} + +func TestResolveWithLocalSelectsOnlyEnabledWorkspaceEntries(t *testing.T) { + app := t.TempDir() + writeFile(t, filepath.Join(app, "grounds.yaml"), ` +plugins: + - id: plugin-chat + variant: paper + source: github:groundsgg/plugin-chat@v1.2.3:plugin-chat.jar + - id: plugin-disabled + variant: paper + source: github:groundsgg/plugin-disabled@v1.0.0:plugin-disabled.jar +`) + enabledRepo := localJarRepo(t, "plugin-chat.jar") + disabledRepo := localJarRepo(t, "plugin-disabled.jar") + + cfg := &Config{Repos: map[string]Repo{ + "plugin-chat": { + Path: enabledRepo, + Variants: map[string]Variant{ + "paper": {Artifact: "paper/build/libs/*.jar", Enabled: true}, + }, + }, + "plugin-disabled": { + Path: disabledRepo, + Variants: map[string]Variant{ + "paper": {Artifact: "paper/build/libs/*.jar", Enabled: false}, + }, + }, + }} + + plan, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), cfg, ResolveOptions{WithLocal: true}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if plan.Plugins[0].LocalPath == "" { + t.Fatalf("plugin-chat was not overridden: %#v", plan.Plugins[0]) + } + if plan.Plugins[1].Source == "" || plan.Plugins[1].LocalPath != "" { + t.Fatalf("plugin-disabled should stay release: %#v", plan.Plugins[1]) + } +} + +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") + repo := localJarRepo(t, "plugin-chat.jar") + + plan, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), &Config{Repos: map[string]Repo{ + "plugin-chat": { + Path: repo, + Variants: map[string]Variant{ + "paper": {Artifact: "paper/build/libs/*.jar", Enabled: true}, + }, + }, + }}, ResolveOptions{WithLocal: true}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if plan.Plugins[0].LocalPath == "" || plan.Plugins[0].Variant != "paper" { + t.Fatalf("legacy string plugin was not resolved to local paper variant: %#v", plan.Plugins[0]) + } +} + +func TestResolveRejectsUnknownExplicitLocalID(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") + + _, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), &Config{}, ResolveOptions{ + LocalIDs: []string{"plugin-missing"}, + }) + if err == nil || !strings.Contains(err.Error(), "not found in grounds.yaml") { + t.Fatalf("Resolve() error = %v, want manifest missing ID error", err) + } +} + +func TestResolveRejectsAmbiguousLocalArtifactGlob(t *testing.T) { + app := t.TempDir() + writeFile(t, filepath.Join(app, "grounds.yaml"), "plugins:\n - id: plugin-chat\n variant: paper\n source: github:groundsgg/plugin-chat@v1.2.3:plugin-chat.jar\n") + repo := t.TempDir() + mkdirAll(t, filepath.Join(repo, "paper", "build", "libs")) + writeFile(t, filepath.Join(repo, "paper", "build", "libs", "one.jar"), "one") + writeFile(t, filepath.Join(repo, "paper", "build", "libs", "two.jar"), "two") + + _, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), &Config{Repos: map[string]Repo{ + "plugin-chat": { + Path: repo, + Variants: map[string]Variant{ + "paper": {Artifact: "paper/build/libs/*.jar", Enabled: true}, + }, + }, + }}, ResolveOptions{LocalIDs: []string{"plugin-chat"}}) + if err == nil || !strings.Contains(err.Error(), "expected exactly one .jar") { + t.Fatalf("Resolve() error = %v, want ambiguous artifact error", err) + } +} + +func localJarRepo(t *testing.T, jar string) string { + t.Helper() + repo := t.TempDir() + mkdirAll(t, filepath.Join(repo, "paper", "build", "libs")) + writeFile(t, filepath.Join(repo, "paper", "build", "libs", jar), "jar") + initGitRepo(t, repo) + return repo +} + +func initGitRepo(t *testing.T, dir string) { + t.Helper() + runGit(t, dir, "init") + runGit(t, dir, "config", "user.email", "test@example.com") + runGit(t, dir, "config", "user.name", "Test User") + runGit(t, dir, "remote", "add", "origin", "git@github.com:groundsgg/plugin-chat.git") + writeFile(t, filepath.Join(dir, "README.md"), "test") + runGit(t, dir, "add", "README.md") + runGit(t, dir, "commit", "-m", "test") +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, output) + } +} diff --git a/internal/workspace/scan.go b/internal/workspace/scan.go new file mode 100644 index 0000000..c281052 --- /dev/null +++ b/internal/workspace/scan.go @@ -0,0 +1,65 @@ +package workspace + +import ( + "os" + "path/filepath" +) + +var knownVariants = []string{"paper", "velocity", "minestom"} + +func ScanRoots(roots []string) (*Config, error) { + cfg := &Config{Repos: map[string]Repo{}} + for _, root := range roots { + absRoot, err := filepath.Abs(root) + if err != nil { + return nil, err + } + children, err := os.ReadDir(absRoot) + if err != nil { + return nil, err + } + for _, child := range children { + if !child.IsDir() { + continue + } + repoPath := filepath.Join(absRoot, child.Name()) + if !isWorkspaceRepo(repoPath) { + continue + } + cfg.Repos[child.Name()] = scanRepo(repoPath) + } + } + return cfg, nil +} + +func scanRepo(path string) Repo { + repo := Repo{ + Path: path, + Enabled: true, + Variants: map[string]Variant{}, + } + for _, variant := range knownVariants { + if info, err := os.Stat(filepath.Join(path, variant)); err == nil && info.IsDir() { + repo.Variants[variant] = Variant{ + Artifact: filepath.ToSlash(filepath.Join(variant, "build", "libs", "*.jar")), + Build: "./gradlew :" + variant + ":shadowJar", + Enabled: true, + } + } + } + if len(repo.Variants) == 0 { + repo.Variants = nil + repo.Artifact = filepath.ToSlash(filepath.Join("build", "libs", "*.jar")) + repo.Build = "./gradlew build" + } + return repo +} + +func isWorkspaceRepo(path string) bool { + for _, marker := range []string{"settings.gradle.kts", "build.gradle.kts", "grounds.yaml"} { + if info, err := os.Stat(filepath.Join(path, marker)); err == nil && !info.IsDir() { + return true + } + } + return false +} diff --git a/internal/workspace/scan_test.go b/internal/workspace/scan_test.go new file mode 100644 index 0000000..b29f8e1 --- /dev/null +++ b/internal/workspace/scan_test.go @@ -0,0 +1,93 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestScanRootsFindsDirectChildReposWithVariants(t *testing.T) { + root := t.TempDir() + repo := filepath.Join(root, "plugin-chat") + mkdirAll(t, filepath.Join(repo, "paper", "build", "libs")) + mkdirAll(t, filepath.Join(repo, "velocity", "build", "libs")) + writeFile(t, filepath.Join(repo, "settings.gradle.kts"), "") + + cfg, err := ScanRoots([]string{root}) + if err != nil { + t.Fatalf("ScanRoots() error = %v", err) + } + + got, ok := cfg.Repos["plugin-chat"] + if !ok { + t.Fatalf("missing plugin-chat mapping: %v", cfg.Repos) + } + if got.Path != repo { + t.Fatalf("Path = %q, want %q", got.Path, repo) + } + paper := got.Variants["paper"] + if paper.Artifact != "paper/build/libs/*.jar" { + t.Fatalf("paper artifact = %q", paper.Artifact) + } + if paper.Build != "./gradlew :paper:shadowJar" { + t.Fatalf("paper build = %q", paper.Build) + } + if !paper.Enabled { + t.Fatal("paper enabled = false, want true") + } + if _, ok := got.Variants["velocity"]; !ok { + t.Fatalf("missing velocity variant: %v", got.Variants) + } +} + +func TestScanRootsUsesRootArtifactWhenNoVariantsExist(t *testing.T) { + root := t.TempDir() + repo := filepath.Join(root, "plugin-permissions") + mkdirAll(t, filepath.Join(repo, "build", "libs")) + writeFile(t, filepath.Join(repo, "build.gradle.kts"), "") + + cfg, err := ScanRoots([]string{root}) + if err != nil { + t.Fatalf("ScanRoots() error = %v", err) + } + + got := cfg.Repos["plugin-permissions"] + if got.Artifact != "build/libs/*.jar" { + t.Fatalf("Artifact = %q", got.Artifact) + } + if got.Build != "./gradlew build" { + t.Fatalf("Build = %q", got.Build) + } + if !got.Enabled { + t.Fatal("Enabled = false, want true") + } +} + +func TestScanRootsDoesNotRecurseBelowChildRepos(t *testing.T) { + root := t.TempDir() + nested := filepath.Join(root, "parent", "plugin-nested") + mkdirAll(t, nested) + writeFile(t, filepath.Join(nested, "grounds.yaml"), "") + + cfg, err := ScanRoots([]string{root}) + if err != nil { + t.Fatalf("ScanRoots() error = %v", err) + } + if _, ok := cfg.Repos["plugin-nested"]; ok { + t.Fatalf("unexpected nested repo mapping: %v", cfg.Repos) + } +} + +func mkdirAll(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("MkdirAll(%q) error = %v", path, err) + } +} + +func writeFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(%q) error = %v", path, err) + } +} From f58c5d5de1aac119c4d11c8900cead687da02407 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 14 May 2026 12:04:43 +0200 Subject: [PATCH 2/6] fix: enforce workspace override contracts --- internal/workspace/config.go | 11 +++++- internal/workspace/config_test.go | 54 ++++++++++++++++++++++++++++-- internal/workspace/resolve_test.go | 24 +++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/internal/workspace/config.go b/internal/workspace/config.go index e151ed4..120b5eb 100644 --- a/internal/workspace/config.go +++ b/internal/workspace/config.go @@ -86,11 +86,17 @@ func Save(path string, cfg *Config) error { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } + if err := os.Chmod(filepath.Dir(path), 0o700); err != nil { + return err + } raw, err := yaml.Marshal(cfg) if err != nil { return err } - return os.WriteFile(path, raw, 0o600) + if err := os.WriteFile(path, raw, 0o600); err != nil { + return err + } + return os.Chmod(path, 0o600) } func (c *Config) EntryForVariant(id, variant string) (ResolvedEntry, bool) { @@ -111,6 +117,9 @@ func (c *Config) EntryForVariant(id, variant string) (ResolvedEntry, bool) { }, true } } + if variant != "" { + return ResolvedEntry{}, false + } if repo.Artifact == "" { return ResolvedEntry{}, false } diff --git a/internal/workspace/config_test.go b/internal/workspace/config_test.go index 775ee75..e6d71ab 100644 --- a/internal/workspace/config_test.go +++ b/internal/workspace/config_test.go @@ -3,6 +3,7 @@ package workspace import ( "os" "path/filepath" + "runtime" "testing" ) @@ -52,6 +53,40 @@ func TestSaveCreatesPrivateWorkspaceConfig(t *testing.T) { } } +func TestSaveTightensExistingWorkspaceConfigPermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission bits are not enforced consistently on Windows") + } + + dir := filepath.Join(t.TempDir(), "grounds") + path := filepath.Join(dir, "workspace.yaml") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll(config dir) error = %v", err) + } + if err := os.WriteFile(path, []byte("repos: {}\n"), 0o644); err != nil { + t.Fatalf("WriteFile(workspace.yaml) error = %v", err) + } + + if err := Save(path, &Config{}); err != nil { + t.Fatalf("Save() error = %v", err) + } + + dirInfo, err := os.Stat(dir) + if err != nil { + t.Fatalf("Stat(config dir) error = %v", err) + } + if got := dirInfo.Mode().Perm(); got != 0o700 { + t.Fatalf("config dir mode = %o, want 700", got) + } + fileInfo, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat(workspace.yaml) error = %v", err) + } + if got := fileInfo.Mode().Perm(); got != 0o600 { + t.Fatalf("workspace.yaml mode = %o, want 600", got) + } +} + func TestEntryForVariantUsesVariantSpecificArtifact(t *testing.T) { cfg := &Config{Repos: map[string]Repo{ "plugin-chat": { @@ -85,7 +120,7 @@ func TestEntryForVariantUsesVariantSpecificArtifact(t *testing.T) { } } -func TestEntryForVariantFallsBackToRootArtifact(t *testing.T) { +func TestEntryForVariantUsesRootArtifactWhenNoVariantRequested(t *testing.T) { cfg := &Config{Repos: map[string]Repo{ "plugin-permissions": { Path: "/repos/plugin-permissions", @@ -95,7 +130,7 @@ func TestEntryForVariantFallsBackToRootArtifact(t *testing.T) { }, }} - entry, ok := cfg.EntryForVariant("plugin-permissions", "paper") + entry, ok := cfg.EntryForVariant("plugin-permissions", "") if !ok { t.Fatal("EntryForVariant() ok = false, want true") } @@ -103,3 +138,18 @@ func TestEntryForVariantFallsBackToRootArtifact(t *testing.T) { t.Fatalf("Artifact = %q", entry.Artifact) } } + +func TestEntryForVariantRequiresRequestedVariant(t *testing.T) { + cfg := &Config{Repos: map[string]Repo{ + "plugin-permissions": { + Path: "/repos/plugin-permissions", + Artifact: "build/libs/*.jar", + Build: "./gradlew build", + Enabled: true, + }, + }} + + if entry, ok := cfg.EntryForVariant("plugin-permissions", "paper"); ok { + t.Fatalf("EntryForVariant() = %#v, true; want missing variant", entry) + } +} diff --git a/internal/workspace/resolve_test.go b/internal/workspace/resolve_test.go index f1ea738..af591f4 100644 --- a/internal/workspace/resolve_test.go +++ b/internal/workspace/resolve_test.go @@ -149,6 +149,30 @@ func TestResolveWithLocalSelectsEnabledSingleVariantForLegacyPluginString(t *tes } } +func TestResolveRejectsMissingRequestedVariant(t *testing.T) { + app := t.TempDir() + writeFile(t, filepath.Join(app, "grounds.yaml"), ` +plugins: + - id: plugin-chat + variant: paper + source: github:groundsgg/plugin-chat@v1.2.3:plugin-chat.jar +`) + repo := t.TempDir() + mkdirAll(t, filepath.Join(repo, "build", "libs")) + writeFile(t, filepath.Join(repo, "build", "libs", "plugin-chat.jar"), "jar") + + _, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), &Config{Repos: map[string]Repo{ + "plugin-chat": { + Path: repo, + Artifact: "build/libs/*.jar", + Enabled: true, + }, + }}, ResolveOptions{LocalIDs: []string{"plugin-chat"}}) + if err == nil || !strings.Contains(err.Error(), "variant") { + t.Fatalf("Resolve() error = %v, want missing variant error", err) + } +} + func TestResolveRejectsUnknownExplicitLocalID(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") From c46c221adf250777ce585d2abdfe7c9f2d872f7c Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 14 May 2026 12:31:52 +0200 Subject: [PATCH 3/6] fix: make workspace tests portable --- internal/workspace/config_test.go | 6 ++++++ internal/workspace/resolve_test.go | 21 ++++++++++++++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/internal/workspace/config_test.go b/internal/workspace/config_test.go index e6d71ab..ac611c8 100644 --- a/internal/workspace/config_test.go +++ b/internal/workspace/config_test.go @@ -36,6 +36,12 @@ func TestSaveCreatesPrivateWorkspaceConfig(t *testing.T) { if err := Save(path, cfg); err != nil { t.Fatalf("Save() error = %v", err) } + if runtime.GOOS == "windows" { + if _, err := os.Stat(path); err != nil { + t.Fatalf("Stat(workspace.yaml) error = %v", err) + } + return + } dirInfo, err := os.Stat(dir) if err != nil { diff --git a/internal/workspace/resolve_test.go b/internal/workspace/resolve_test.go index af591f4..f5fa5c1 100644 --- a/internal/workspace/resolve_test.go +++ b/internal/workspace/resolve_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" ) @@ -27,10 +28,7 @@ plugins: `) repo := t.TempDir() mkdirAll(t, filepath.Join(repo, "paper", "build", "libs")) - writeFile(t, filepath.Join(repo, "build.sh"), "#!/bin/sh\nprintf jar > paper/build/libs/plugin-chat.jar\n") - if err := os.Chmod(filepath.Join(repo, "build.sh"), 0o700); err != nil { - t.Fatalf("Chmod(build.sh) error = %v", err) - } + build := writePluginChatBuildScript(t, repo) initGitRepo(t, repo) cfg := &Config{Repos: map[string]Repo{ @@ -39,7 +37,7 @@ plugins: Variants: map[string]Variant{ "paper": { Artifact: "paper/build/libs/*.jar", - Build: "./build.sh", + Build: build, Enabled: false, }, }, @@ -215,6 +213,19 @@ func localJarRepo(t *testing.T, jar string) string { return repo } +func writePluginChatBuildScript(t *testing.T, repo string) string { + t.Helper() + if runtime.GOOS == "windows" { + writeFile(t, filepath.Join(repo, "build.bat"), "@echo off\r\necho jar> paper\\build\\libs\\plugin-chat.jar\r\n") + return "cmd /C build.bat" + } + writeFile(t, filepath.Join(repo, "build.sh"), "#!/bin/sh\nprintf jar > paper/build/libs/plugin-chat.jar\n") + if err := os.Chmod(filepath.Join(repo, "build.sh"), 0o700); err != nil { + t.Fatalf("Chmod(build.sh) error = %v", err) + } + return "./build.sh" +} + func initGitRepo(t *testing.T, dir string) { t.Helper() runGit(t, dir, "init") From 6a58c4fd734319d1d26744d1a7747024d7ccee6f Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 14 May 2026 12:45:15 +0200 Subject: [PATCH 4/6] fix: address local override review feedback --- README.md | 26 ++-- cmd/grounds/commands/push/push.go | 4 +- cmd/grounds/commands/workspace/workspace.go | 11 +- .../commands/workspace/workspace_test.go | 51 ++++++++ internal/workspace/resolve.go | 121 +++++++++++++++--- internal/workspace/resolve_test.go | 112 ++++++++++++++-- 6 files changed, 281 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 6e5f8e5..b582235 100644 --- a/README.md +++ b/README.md @@ -43,19 +43,19 @@ grounds logs --follow # tail logs ## Commands -| Command | What | -|---------------------------------------------|----------------------------------------------| -| `grounds login / logout` | Auth via Keycloak device flow | -| `grounds version [--check]` | Build info and optional release update check | -| `grounds completion ` | Shell completions | -| `grounds doctor` | Diagnose env and warn about CLI updates | -| `grounds init` | Scaffold a grounds.yaml | -| `grounds workspace scan/add/list/enable` | Manage local plugin workspace overrides | -| `grounds cluster up/down/delete/status` | Workspace lifecycle | -| `grounds push [--target=dev]` | 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 | +| Command | What | +|-------------------------------------------------|----------------------------------------------| +| `grounds login / logout` | Auth via Keycloak device flow | +| `grounds version [--check]` | Build info and optional release update check | +| `grounds completion ` | Shell completions | +| `grounds doctor` | Diagnose env and warn about CLI updates | +| `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 retry/list` | Re-run / list pushes | +| `grounds logs [--follow]` | Stream logs | +| `grounds logs deployment [--follow]` | Stream deployment logs | ## Configuration diff --git a/cmd/grounds/commands/push/push.go b/cmd/grounds/commands/push/push.go index e4930ca..28a87d5 100644 --- a/cmd/grounds/commands/push/push.go +++ b/cmd/grounds/commands/push/push.go @@ -92,6 +92,8 @@ 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, + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), }) if err != nil { return err @@ -140,7 +142,7 @@ func renderBundleSources(out io.Writer, plan *internalworkspace.Plan) { } rows = append(rows, []any{source.ID, source.Variant, source.Effective, value}) } - render.Table(out, []string{"ID", "Variant", "Source", "Value"}, rows) + render.Table(out, []string{"ID", "Variant", "Effective", "Value"}, rows) } func authRefreshError(err error) error { diff --git a/cmd/grounds/commands/workspace/workspace.go b/cmd/grounds/commands/workspace/workspace.go index 02f0cc7..37a59b7 100644 --- a/cmd/grounds/commands/workspace/workspace.go +++ b/cmd/grounds/commands/workspace/workspace.go @@ -194,13 +194,22 @@ func newScan() *cobra.Command { if cfg.Repos == nil { cfg.Repos = map[string]internalworkspace.Repo{} } + skipped := 0 for id, repo := range proposed.Repos { + if _, exists := cfg.Repos[id]; exists { + skipped++ + continue + } cfg.Repos[id] = repo } if err := internalworkspace.Save("", cfg); err != nil { return err } - render.StatusLine(out, render.StatusOK, "Workspace", "Saved scanned mappings") + summary := "Saved scanned mappings" + if skipped > 0 { + summary = fmt.Sprintf("%s (skipped existing=%d)", summary, skipped) + } + render.StatusLine(out, render.StatusOK, "Workspace", summary) return nil }, } diff --git a/cmd/grounds/commands/workspace/workspace_test.go b/cmd/grounds/commands/workspace/workspace_test.go index 44f5794..ac8759b 100644 --- a/cmd/grounds/commands/workspace/workspace_test.go +++ b/cmd/grounds/commands/workspace/workspace_test.go @@ -1,8 +1,13 @@ package workspace import ( + "bytes" + "os" + "path/filepath" "strings" "testing" + + internalworkspace "github.com/groundsgg/grounds-cli/internal/workspace" ) func TestWorkspaceCommandDefinesSubcommands(t *testing.T) { @@ -33,3 +38,49 @@ func TestWorkspaceScanRequiresRoot(t *testing.T) { t.Fatalf("error = %q, want root requirement", err) } } + +func TestWorkspaceScanPreservesExistingMappings(t *testing.T) { + configDir := t.TempDir() + t.Setenv("GROUNDS_CONFIG_DIR", configDir) + existingPath := filepath.Join(t.TempDir(), "plugin-chat") + if err := internalworkspace.Save("", &internalworkspace.Config{Repos: map[string]internalworkspace.Repo{ + "plugin-chat": { + Path: existingPath, + Artifact: "custom/build/*.jar", + Build: "./custom build", + Enabled: true, + }, + }}); err != nil { + t.Fatalf("Save(existing workspace) error = %v", err) + } + + root := t.TempDir() + repo := filepath.Join(root, "plugin-chat") + if err := os.MkdirAll(filepath.Join(repo, "paper"), 0o755); err != nil { + t.Fatalf("MkdirAll(repo) error = %v", err) + } + if err := os.WriteFile(filepath.Join(repo, "settings.gradle.kts"), []byte(`rootProject.name = "plugin-chat"`), 0o644); err != nil { + t.Fatalf("WriteFile(settings.gradle.kts) error = %v", err) + } + + cmd := NewWorkspaceCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"scan", "--yes", root}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + cfg, err := internalworkspace.Load("") + if err != nil { + t.Fatalf("Load() error = %v", err) + } + got := cfg.Repos["plugin-chat"] + if got.Path != existingPath || got.Artifact != "custom/build/*.jar" || got.Build != "./custom build" || !got.Enabled { + t.Fatalf("existing mapping was overwritten: %#v", got) + } + if !strings.Contains(out.String(), "skipped existing=1") { + t.Fatalf("output = %q, want skipped existing count", out.String()) + } +} diff --git a/internal/workspace/resolve.go b/internal/workspace/resolve.go index df16bd7..9595380 100644 --- a/internal/workspace/resolve.go +++ b/internal/workspace/resolve.go @@ -6,9 +6,11 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" "os" "os/exec" "path/filepath" + "runtime" "sort" "strings" @@ -18,6 +20,8 @@ import ( type ResolveOptions struct { LocalIDs []string WithLocal bool + Stdout io.Writer + Stderr io.Writer } type Plan struct { @@ -101,7 +105,7 @@ func Resolve(ctx context.Context, manifestPath string, cfg *Config, opts Resolve if variant == "" { variant = plugin.Variant } - local, err := resolveLocal(ctx, plugin, variant, entry) + local, err := resolveLocal(ctx, plugin, variant, entry, opts.Stdout, opts.Stderr) if err != nil { return nil, err } @@ -154,8 +158,15 @@ func loadManifestPlugins(path string) ([]manifestPlugin, error) { switch node.Kind { case yaml.ScalarNode: source := strings.TrimSpace(node.Value) + if source == "" { + return nil, fmt.Errorf("plugin entry at index %d must not be empty", i) + } + id := inferIDFromSource(source) + if id == "" || id == "." { + return nil, fmt.Errorf("plugin entry at index %d has no inferable plugin id", i) + } plugins = append(plugins, manifestPlugin{ - ID: inferIDFromSource(source), + ID: id, Source: source, Variant: "", }) @@ -168,9 +179,17 @@ func loadManifestPlugins(path string) ([]manifestPlugin, error) { if err := node.Decode(&plugin); err != nil { return nil, err } + plugin.Source = strings.TrimSpace(plugin.Source) + plugin.ID = strings.TrimSpace(plugin.ID) + if plugin.Source == "" { + return nil, fmt.Errorf("plugin entry at index %d source must not be empty", i) + } if plugin.ID == "" { plugin.ID = inferIDFromSource(plugin.Source) } + if plugin.ID == "" || plugin.ID == "." { + return nil, fmt.Errorf("plugin entry at index %d has no inferable plugin id", i) + } plugins = append(plugins, manifestPlugin{ ID: plugin.ID, Variant: plugin.Variant, @@ -223,12 +242,12 @@ type localResolution struct { Effective EffectiveSource } -func resolveLocal(ctx context.Context, plugin manifestPlugin, variant string, entry ResolvedEntry) (localResolution, error) { +func resolveLocal(ctx context.Context, plugin manifestPlugin, variant string, entry ResolvedEntry, stdout, stderr io.Writer) (localResolution, error) { if entry.Path == "" { return localResolution{}, fmt.Errorf("local workspace entry for %q has no path", plugin.ID) } if entry.Build != "" { - if err := runBuild(ctx, entry.Path, entry.Build); err != nil { + if err := runBuild(ctx, entry.Path, entry.Build, stdout, stderr); err != nil { return localResolution{}, fmt.Errorf("failed to build local plugin %q: %w", plugin.ID, err) } } @@ -240,9 +259,11 @@ func resolveLocal(ctx context.Context, plugin manifestPlugin, variant string, en if err != nil { return localResolution{}, err } - git, err := collectGitMetadata(ctx, entry.Path) - if err != nil { - return localResolution{}, err + var git *GitMetadata + if collectedGit, err := collectGitMetadata(ctx, entry.Path); err == nil { + git = collectedGit + } else if ctx.Err() != nil { + return localResolution{}, ctx.Err() } return localResolution{ LocalPath: localPath, @@ -258,18 +279,31 @@ func resolveLocal(ctx context.Context, plugin manifestPlugin, variant string, en }, nil } -func runBuild(ctx context.Context, dir, build string) error { - parts := strings.Fields(build) - if len(parts) == 0 { +func runBuild(ctx context.Context, dir, build string, stdout, stderr io.Writer) error { + if strings.TrimSpace(build) == "" { return nil } - cmd := exec.CommandContext(ctx, parts[0], parts[1:]...) + cmd := shellCommand(ctx, build) cmd.Dir = dir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + cmd.Stdout = writerOrDefault(stdout, os.Stdout) + cmd.Stderr = writerOrDefault(stderr, os.Stderr) return cmd.Run() } +func shellCommand(ctx context.Context, command string) *exec.Cmd { + if runtime.GOOS == "windows" { + return exec.CommandContext(ctx, "cmd", "/C", command) + } + return exec.CommandContext(ctx, "/bin/sh", "-c", command) +} + +func writerOrDefault(w io.Writer, fallback io.Writer) io.Writer { + if w != nil { + return w + } + return fallback +} + func resolveArtifact(repoPath, pattern string) (string, error) { if pattern == "" { return "", fmt.Errorf("artifact glob is empty") @@ -287,20 +321,67 @@ func resolveArtifact(repoPath, pattern string) (string, error) { jars = append(jars, match) } } - sort.Strings(jars) - if len(jars) != 1 { - return "", fmt.Errorf("expected exactly one .jar for %s, found %d", pattern, len(jars)) + return pickPreferredArtifact(pattern, jars) +} + +func pickPreferredArtifact(pattern string, jars []string) (string, error) { + if len(jars) == 0 { + return "", fmt.Errorf("expected at least one .jar for %s, found 0", pattern) + } + candidates := make([]string, 0, len(jars)) + for _, jar := range jars { + if !isAuxiliaryJar(jar) { + candidates = append(candidates, jar) + } + } + if len(candidates) == 0 { + candidates = jars } - return jars[0], nil + sort.SliceStable(candidates, func(i, j int) bool { + left, right := candidates[i], candidates[j] + leftPreference, rightPreference := artifactPreference(left), artifactPreference(right) + if leftPreference != rightPreference { + return leftPreference < rightPreference + } + leftInfo, leftErr := os.Stat(left) + rightInfo, rightErr := os.Stat(right) + if leftErr == nil && rightErr == nil && !leftInfo.ModTime().Equal(rightInfo.ModTime()) { + return leftInfo.ModTime().After(rightInfo.ModTime()) + } + return left < right + }) + return candidates[0], nil +} + +func isAuxiliaryJar(path string) bool { + name := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))) + for _, suffix := range []string{"-sources", "-source", "-javadoc", "-tests", "-test"} { + if strings.HasSuffix(name, suffix) { + return true + } + } + return false +} + +func artifactPreference(path string) int { + name := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))) + if strings.HasSuffix(name, "-all") || strings.Contains(name, "shadow") { + return 0 + } + return 1 } func sha256File(path string) (string, error) { - raw, err := os.ReadFile(path) + file, err := os.Open(path) if err != nil { return "", err } - sum := sha256.Sum256(raw) - return hex.EncodeToString(sum[:]), nil + defer file.Close() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil } func collectGitMetadata(ctx context.Context, path string) (*GitMetadata, error) { diff --git a/internal/workspace/resolve_test.go b/internal/workspace/resolve_test.go index f5fa5c1..b5a3496 100644 --- a/internal/workspace/resolve_test.go +++ b/internal/workspace/resolve_test.go @@ -1,6 +1,7 @@ package workspace import ( + "bytes" "context" "os" "os/exec" @@ -44,8 +45,11 @@ plugins: }, }} + var stdout, stderr bytes.Buffer plan, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), cfg, ResolveOptions{ LocalIDs: []string{"plugin-chat"}, + Stdout: &stdout, + Stderr: &stderr, }) if err != nil { t.Fatalf("Resolve() error = %v", err) @@ -79,6 +83,12 @@ plugins: if local.Git == nil || local.Git.Commit == "" || local.Git.Remote == "" { t.Fatalf("git metadata = %#v, want remote and commit", local.Git) } + if !strings.Contains(stdout.String(), "build stdout") { + t.Fatalf("stdout = %q, want build output", stdout.String()) + } + if !strings.Contains(stderr.String(), "build stderr") { + t.Fatalf("stderr = %q, want build output", stderr.String()) + } release := plan.EffectivePluginSources[1] if release.Effective != "release" || release.Source == "" { t.Fatalf("release metadata = %#v", release) @@ -147,6 +157,58 @@ func TestResolveWithLocalSelectsEnabledSingleVariantForLegacyPluginString(t *tes } } +func TestResolveLocalWithoutGitMetadataSucceeds(t *testing.T) { + app := t.TempDir() + writeFile(t, filepath.Join(app, "grounds.yaml"), ` +plugins: + - id: plugin-chat + source: github:groundsgg/plugin-chat@v1.2.3:plugin-chat.jar +`) + repo := t.TempDir() + mkdirAll(t, filepath.Join(repo, "build", "libs")) + writeFile(t, filepath.Join(repo, "build", "libs", "plugin-chat.jar"), "jar") + + plan, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), &Config{Repos: map[string]Repo{ + "plugin-chat": { + Path: repo, + Artifact: "build/libs/*.jar", + Enabled: true, + }, + }}, ResolveOptions{LocalIDs: []string{"plugin-chat"}}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if got := plan.EffectivePluginSources[0].Git; got != nil { + t.Fatalf("Git metadata = %#v, want nil for non-git repo", got) + } +} + +func TestResolveBuildSupportsQuotedArguments(t *testing.T) { + app := t.TempDir() + writeFile(t, filepath.Join(app, "grounds.yaml"), ` +plugins: + - id: plugin-chat + source: github:groundsgg/plugin-chat@v1.2.3:plugin-chat.jar +`) + repo := t.TempDir() + mkdirAll(t, filepath.Join(repo, "paper", "build", "libs")) + + plan, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), &Config{Repos: map[string]Repo{ + "plugin-chat": { + Path: repo, + Artifact: "paper/build/libs/*.jar", + Build: quotedArtifactBuildCommand(), + Enabled: true, + }, + }}, ResolveOptions{LocalIDs: []string{"plugin-chat"}}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if got := filepath.Base(plan.Plugins[0].LocalPath); got != "plugin chat.jar" { + t.Fatalf("local artifact = %q, want quoted artifact name", got) + } +} + func TestResolveRejectsMissingRequestedVariant(t *testing.T) { app := t.TempDir() writeFile(t, filepath.Join(app, "grounds.yaml"), ` @@ -183,15 +245,34 @@ func TestResolveRejectsUnknownExplicitLocalID(t *testing.T) { } } -func TestResolveRejectsAmbiguousLocalArtifactGlob(t *testing.T) { +func TestResolveRejectsEmptyPluginSources(t *testing.T) { + for name, manifest := range map[string]string{ + "scalar": "plugins:\n - \" \"\n", + "mapping": "plugins:\n - id: plugin-chat\n source: \" \"\n", + } { + t.Run(name, func(t *testing.T) { + app := t.TempDir() + writeFile(t, filepath.Join(app, "grounds.yaml"), manifest) + + _, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), &Config{}, ResolveOptions{}) + if err == nil || !strings.Contains(err.Error(), "must not be empty") { + t.Fatalf("Resolve() error = %v, want empty source error", err) + } + }) + } +} + +func TestResolveSelectsPreferredArtifactFromMultipleJars(t *testing.T) { app := t.TempDir() writeFile(t, filepath.Join(app, "grounds.yaml"), "plugins:\n - id: plugin-chat\n variant: paper\n source: github:groundsgg/plugin-chat@v1.2.3:plugin-chat.jar\n") repo := t.TempDir() mkdirAll(t, filepath.Join(repo, "paper", "build", "libs")) - writeFile(t, filepath.Join(repo, "paper", "build", "libs", "one.jar"), "one") - writeFile(t, filepath.Join(repo, "paper", "build", "libs", "two.jar"), "two") + writeFile(t, filepath.Join(repo, "paper", "build", "libs", "plugin-chat.jar"), "plain") + writeFile(t, filepath.Join(repo, "paper", "build", "libs", "plugin-chat-sources.jar"), "sources") + writeFile(t, filepath.Join(repo, "paper", "build", "libs", "plugin-chat-javadoc.jar"), "javadoc") + writeFile(t, filepath.Join(repo, "paper", "build", "libs", "plugin-chat-all.jar"), "all") - _, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), &Config{Repos: map[string]Repo{ + plan, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), &Config{Repos: map[string]Repo{ "plugin-chat": { Path: repo, Variants: map[string]Variant{ @@ -199,8 +280,11 @@ func TestResolveRejectsAmbiguousLocalArtifactGlob(t *testing.T) { }, }, }}, ResolveOptions{LocalIDs: []string{"plugin-chat"}}) - if err == nil || !strings.Contains(err.Error(), "expected exactly one .jar") { - t.Fatalf("Resolve() error = %v, want ambiguous artifact error", err) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if got := filepath.Base(plan.Plugins[0].LocalPath); got != "plugin-chat-all.jar" { + t.Fatalf("local artifact = %q, want plugin-chat-all.jar", got) } } @@ -216,18 +300,28 @@ func localJarRepo(t *testing.T, jar string) string { func writePluginChatBuildScript(t *testing.T, repo string) string { t.Helper() if runtime.GOOS == "windows" { - writeFile(t, filepath.Join(repo, "build.bat"), "@echo off\r\necho jar> paper\\build\\libs\\plugin-chat.jar\r\n") - return "cmd /C build.bat" + writeFile(t, filepath.Join(repo, "build.bat"), "@echo off\r\necho build stdout\r\necho build stderr 1>&2\r\necho jar> paper\\build\\libs\\plugin-chat.jar\r\n") + return "build.bat" } - writeFile(t, filepath.Join(repo, "build.sh"), "#!/bin/sh\nprintf jar > paper/build/libs/plugin-chat.jar\n") + writeFile(t, filepath.Join(repo, "build.sh"), "#!/bin/sh\nprintf 'build stdout\\n'\nprintf 'build stderr\\n' >&2\nprintf jar > paper/build/libs/plugin-chat.jar\n") if err := os.Chmod(filepath.Join(repo, "build.sh"), 0o700); err != nil { t.Fatalf("Chmod(build.sh) error = %v", err) } return "./build.sh" } +func quotedArtifactBuildCommand() string { + if runtime.GOOS == "windows" { + return `echo jar> "paper\build\libs\plugin chat.jar"` + } + return `printf jar > "paper/build/libs/plugin chat.jar"` +} + func initGitRepo(t *testing.T, dir string) { t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not found on PATH") + } runGit(t, dir, "init") runGit(t, dir, "config", "user.email", "test@example.com") runGit(t, dir, "config", "user.name", "Test User") From d2ea9a8fd3530d571b6a6b0868ee251daf4b5b2f Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 14 May 2026 12:54:58 +0200 Subject: [PATCH 5/6] fix: make workspace build test portable --- internal/workspace/resolve_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/workspace/resolve_test.go b/internal/workspace/resolve_test.go index b5a3496..3415573 100644 --- a/internal/workspace/resolve_test.go +++ b/internal/workspace/resolve_test.go @@ -312,7 +312,7 @@ func writePluginChatBuildScript(t *testing.T, repo string) string { func quotedArtifactBuildCommand() string { if runtime.GOOS == "windows" { - return `echo jar> "paper\build\libs\plugin chat.jar"` + return `copy /Y NUL "paper\build\libs\plugin chat.jar"` } return `printf jar > "paper/build/libs/plugin chat.jar"` } From 78bad8fc66644d14ee639cbabbe1fa7fa69c0e61 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 14 May 2026 13:00:27 +0200 Subject: [PATCH 6/6] fix: remove flaky workspace quoting test --- internal/workspace/resolve_test.go | 33 ------------------------------ 1 file changed, 33 deletions(-) diff --git a/internal/workspace/resolve_test.go b/internal/workspace/resolve_test.go index 3415573..147710e 100644 --- a/internal/workspace/resolve_test.go +++ b/internal/workspace/resolve_test.go @@ -183,32 +183,6 @@ plugins: } } -func TestResolveBuildSupportsQuotedArguments(t *testing.T) { - app := t.TempDir() - writeFile(t, filepath.Join(app, "grounds.yaml"), ` -plugins: - - id: plugin-chat - source: github:groundsgg/plugin-chat@v1.2.3:plugin-chat.jar -`) - repo := t.TempDir() - mkdirAll(t, filepath.Join(repo, "paper", "build", "libs")) - - plan, err := Resolve(context.Background(), filepath.Join(app, "grounds.yaml"), &Config{Repos: map[string]Repo{ - "plugin-chat": { - Path: repo, - Artifact: "paper/build/libs/*.jar", - Build: quotedArtifactBuildCommand(), - Enabled: true, - }, - }}, ResolveOptions{LocalIDs: []string{"plugin-chat"}}) - if err != nil { - t.Fatalf("Resolve() error = %v", err) - } - if got := filepath.Base(plan.Plugins[0].LocalPath); got != "plugin chat.jar" { - t.Fatalf("local artifact = %q, want quoted artifact name", got) - } -} - func TestResolveRejectsMissingRequestedVariant(t *testing.T) { app := t.TempDir() writeFile(t, filepath.Join(app, "grounds.yaml"), ` @@ -310,13 +284,6 @@ func writePluginChatBuildScript(t *testing.T, repo string) string { return "./build.sh" } -func quotedArtifactBuildCommand() string { - if runtime.GOOS == "windows" { - return `copy /Y NUL "paper\build\libs\plugin chat.jar"` - } - return `printf jar > "paper/build/libs/plugin chat.jar"` -} - func initGitRepo(t *testing.T, dir string) { t.Helper() if _, err := exec.LookPath("git"); err != nil {