diff --git a/cmd/server/main.go b/cmd/server/main.go index 36c41a4e..dee939d7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -104,10 +104,24 @@ func buildEngine(cfg *config.WorkflowConfig, logger *slog.Logger) (*workflow.Std } } + // Auto-fetch declared external plugins before discovery so newly + // downloaded plugins are available in the current startup. + extPluginDir := filepath.Join(*dataDir, "plugins") + if cfg.Plugins != nil && len(cfg.Plugins.External) > 0 { + decls := make([]plugin.AutoFetchDecl, len(cfg.Plugins.External)) + for i, ep := range cfg.Plugins.External { + decls[i] = plugin.AutoFetchDecl{ + Name: ep.Name, + Version: ep.Version, + AutoFetch: ep.AutoFetch, + } + } + plugin.AutoFetchDeclaredPlugins(decls, extPluginDir, logger) + } + // Discover and load external plugins from data/plugins/ directory. // External plugins run as separate processes communicating over gRPC. // Failures are non-fatal — the engine works fine with only builtin plugins. - extPluginDir := filepath.Join(*dataDir, "plugins") extMgr := pluginexternal.NewExternalPluginManager(extPluginDir, log.Default()) discovered, discoverErr := extMgr.DiscoverPlugins() if discoverErr != nil { diff --git a/cmd/wfctl/plugin_init_test.go b/cmd/wfctl/plugin_init_test.go new file mode 100644 index 00000000..7865ad20 --- /dev/null +++ b/cmd/wfctl/plugin_init_test.go @@ -0,0 +1,323 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// ============================================================ +// Test 7: plugin init scaffold +// ============================================================ + +// TestRunPluginInit_AllFiles verifies that runPluginInit creates all expected +// files for a new plugin project. +func TestRunPluginInit_AllFiles(t *testing.T) { + outDir := filepath.Join(t.TempDir(), "test-plugin") + + if err := runPluginInit([]string{ + "-author", "TestOrg", + "-description", "Test plugin for unit tests", + "-output", outDir, + "test-plugin", + }); err != nil { + t.Fatalf("runPluginInit: %v", err) + } + + // All expected files/dirs. + expectedFiles := []string{ + "plugin.json", + "go.mod", + ".goreleaser.yml", + "Makefile", + "README.md", + filepath.Join("cmd", "workflow-plugin-test-plugin", "main.go"), + filepath.Join("internal", "provider.go"), + filepath.Join("internal", "steps.go"), + filepath.Join(".github", "workflows", "ci.yml"), + filepath.Join(".github", "workflows", "release.yml"), + } + + for _, rel := range expectedFiles { + path := filepath.Join(outDir, rel) + if _, err := os.Stat(path); err != nil { + t.Errorf("expected file missing: %s (%v)", rel, err) + } + } +} + +// TestRunPluginInit_PluginJSON verifies that plugin.json has correct fields. +func TestRunPluginInit_PluginJSON(t *testing.T) { + outDir := filepath.Join(t.TempDir(), "myplugin") + + if err := runPluginInit([]string{ + "-author", "AcmeCorp", + "-description", "My awesome plugin", + "-version", "0.2.0", + "-output", outDir, + "myplugin", + }); err != nil { + t.Fatalf("runPluginInit: %v", err) + } + + pjPath := filepath.Join(outDir, "plugin.json") + data, err := os.ReadFile(pjPath) + if err != nil { + t.Fatalf("read plugin.json: %v", err) + } + + var pj map[string]interface{} + if err := json.Unmarshal(data, &pj); err != nil { + t.Fatalf("unmarshal plugin.json: %v", err) + } + + // Required fields. + if pj["name"] == nil || pj["name"].(string) == "" { + t.Error("plugin.json: missing or empty name") + } + if pj["version"] == nil || pj["version"].(string) == "" { + t.Error("plugin.json: missing or empty version") + } + if pj["author"] == nil || pj["author"].(string) == "" { + t.Error("plugin.json: missing or empty author") + } + if pj["description"] == nil || pj["description"].(string) == "" { + t.Error("plugin.json: missing or empty description") + } + + // author and description should match what was passed. + if pj["author"].(string) != "AcmeCorp" { + t.Errorf("author: got %q, want %q", pj["author"], "AcmeCorp") + } + if pj["description"].(string) != "My awesome plugin" { + t.Errorf("description: got %q, want %q", pj["description"], "My awesome plugin") + } +} + +// TestRunPluginInit_GoMod verifies that go.mod has the correct module path. +func TestRunPluginInit_GoMod(t *testing.T) { + outDir := filepath.Join(t.TempDir(), "mymod-plugin") + + if err := runPluginInit([]string{ + "-author", "MyOrg", + "-output", outDir, + "mymod-plugin", + }); err != nil { + t.Fatalf("runPluginInit: %v", err) + } + + goModPath := filepath.Join(outDir, "go.mod") + data, err := os.ReadFile(goModPath) + if err != nil { + t.Fatalf("read go.mod: %v", err) + } + content := string(data) + + // Module path should start with "module ". + if !strings.Contains(content, "module ") { + t.Error("go.mod: missing 'module' directive") + } + // Should reference the author/binary-name convention. + if !strings.Contains(content, "MyOrg") { + t.Errorf("go.mod: expected 'MyOrg' in module path, got:\n%s", content) + } + if !strings.Contains(content, "workflow-plugin-") { + t.Errorf("go.mod: expected 'workflow-plugin-' in module path, got:\n%s", content) + } + // Should have a go directive. + if !strings.Contains(content, "\ngo ") { + t.Error("go.mod: missing 'go' version directive") + } +} + +// TestRunPluginInit_GoMod_CustomModule verifies that a custom -module flag +// overrides the default module path. +func TestRunPluginInit_GoMod_CustomModule(t *testing.T) { + outDir := filepath.Join(t.TempDir(), "custmod") + const customModule = "example.com/internal/my-plugin" + + if err := runPluginInit([]string{ + "-author", "SomeOrg", + "-module", customModule, + "-output", outDir, + "custmod", + }); err != nil { + t.Fatalf("runPluginInit: %v", err) + } + + data, err := os.ReadFile(filepath.Join(outDir, "go.mod")) + if err != nil { + t.Fatalf("read go.mod: %v", err) + } + if !strings.Contains(string(data), customModule) { + t.Errorf("go.mod: expected custom module %q, got:\n%s", customModule, data) + } +} + +// TestRunPluginInit_GoReleaserYML verifies that .goreleaser.yml references +// the correct binary name and is valid YAML. +func TestRunPluginInit_GoReleaserYML(t *testing.T) { + outDir := filepath.Join(t.TempDir(), "gr-plugin") + + if err := runPluginInit([]string{ + "-author", "GoOrg", + "-output", outDir, + "gr-plugin", + }); err != nil { + t.Fatalf("runPluginInit: %v", err) + } + + grPath := filepath.Join(outDir, ".goreleaser.yml") + data, err := os.ReadFile(grPath) + if err != nil { + t.Fatalf("read .goreleaser.yml: %v", err) + } + + // Must be valid YAML. + var parsed map[string]interface{} + if err := yaml.Unmarshal(data, &parsed); err != nil { + t.Fatalf(".goreleaser.yml is not valid YAML: %v", err) + } + + content := string(data) + const wantBinary = "workflow-plugin-gr-plugin" + + // Binary name must appear in the file. + if !strings.Contains(content, wantBinary) { + t.Errorf(".goreleaser.yml: expected binary name %q, got:\n%s", wantBinary, content) + } + + // GoReleaser v2 must be specified. + if !strings.Contains(content, "version: 2") { + t.Errorf(".goreleaser.yml: expected 'version: 2', got:\n%s", content) + } +} + +// TestRunPluginInit_CIWorkflow verifies that ci.yml is valid YAML with the +// expected triggers and steps. +func TestRunPluginInit_CIWorkflow(t *testing.T) { + outDir := filepath.Join(t.TempDir(), "ci-plugin") + + if err := runPluginInit([]string{ + "-author", "CIOrg", + "-output", outDir, + "ci-plugin", + }); err != nil { + t.Fatalf("runPluginInit: %v", err) + } + + ciPath := filepath.Join(outDir, ".github", "workflows", "ci.yml") + data, err := os.ReadFile(ciPath) + if err != nil { + t.Fatalf("read ci.yml: %v", err) + } + + // Must be valid YAML. + var parsed map[string]interface{} + if err := yaml.Unmarshal(data, &parsed); err != nil { + t.Fatalf("ci.yml is not valid YAML: %v", err) + } + + content := string(data) + if !strings.Contains(content, "push") { + t.Error("ci.yml: expected 'push' trigger") + } + if !strings.Contains(content, "pull_request") { + t.Error("ci.yml: expected 'pull_request' trigger") + } + if !strings.Contains(content, "go test") { + t.Error("ci.yml: expected 'go test' step") + } +} + +// TestRunPluginInit_ReleaseWorkflow verifies that release.yml is valid YAML +// and references the correct binary name. +func TestRunPluginInit_ReleaseWorkflow(t *testing.T) { + outDir := filepath.Join(t.TempDir(), "rel-plugin") + const binaryName = "workflow-plugin-rel-plugin" + + if err := runPluginInit([]string{ + "-author", "RelOrg", + "-output", outDir, + "rel-plugin", + }); err != nil { + t.Fatalf("runPluginInit: %v", err) + } + + relPath := filepath.Join(outDir, ".github", "workflows", "release.yml") + data, err := os.ReadFile(relPath) + if err != nil { + t.Fatalf("read release.yml: %v", err) + } + + // Must be valid YAML. + var parsed map[string]interface{} + if err := yaml.Unmarshal(data, &parsed); err != nil { + t.Fatalf("release.yml is not valid YAML: %v", err) + } + + content := string(data) + // Should trigger on tags. + if !strings.Contains(content, "tags") { + t.Error("release.yml: expected 'tags' trigger") + } + // Should use GoReleaser. + if !strings.Contains(content, "goreleaser") { + t.Error("release.yml: expected 'goreleaser' action reference") + } + _ = binaryName // variable used for documentation +} + +// TestRunPluginInit_MissingAuthor verifies that -author is required. +func TestRunPluginInit_MissingAuthor(t *testing.T) { + err := runPluginInit([]string{ + "-output", t.TempDir(), + "no-author", + }) + if err == nil { + t.Fatal("expected error for missing -author, got nil") + } +} + +// TestRunPluginInit_MissingName verifies that a plugin name is required. +func TestRunPluginInit_MissingName(t *testing.T) { + err := runPluginInit([]string{ + "-author", "SomeOrg", + }) + if err == nil { + t.Fatal("expected error for missing name argument, got nil") + } +} + +// TestRunPluginInit_DefaultOutputDir verifies that the output defaults to +// the plugin name when -output is not provided. +func TestRunPluginInit_DefaultOutputDir(t *testing.T) { + // Change to a temp dir so the auto-created dir doesn't pollute the repo. + orig, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + tmpDir := t.TempDir() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { os.Chdir(orig) }) //nolint:errcheck + + const name = "auto-dir-plugin" + if err := runPluginInit([]string{ + "-author", "TestOrg", + name, + }); err != nil { + t.Fatalf("runPluginInit: %v", err) + } + + // Output directory should be named after the plugin. + expectedDir := filepath.Join(tmpDir, name) + if _, err := os.Stat(expectedDir); os.IsNotExist(err) { + t.Errorf("expected default output dir %s to be created", expectedDir) + } +} diff --git a/cmd/wfctl/registry_source.go b/cmd/wfctl/registry_source.go index 2b48827c..bd11fe85 100644 --- a/cmd/wfctl/registry_source.go +++ b/cmd/wfctl/registry_source.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "strings" + "time" ) // RegistrySource is the interface for a plugin registry backend. @@ -225,6 +226,9 @@ func (s *StaticRegistrySource) ListPlugins() ([]string, error) { return names, nil } +// registryHTTPClient is used for all registry HTTP requests with a reasonable timeout. +var registryHTTPClient = &http.Client{Timeout: 30 * time.Second} + // fetch performs an HTTP GET with optional auth token. func (s *StaticRegistrySource) fetch(url string) ([]byte, error) { req, err := http.NewRequest(http.MethodGet, url, nil) //nolint:gosec // G107: URL from user config @@ -234,7 +238,7 @@ func (s *StaticRegistrySource) fetch(url string) ([]byte, error) { if s.token != "" { req.Header.Set("Authorization", "Bearer "+s.token) } - resp, err := http.DefaultClient.Do(req) + resp, err := registryHTTPClient.Do(req) if err != nil { return nil, err } diff --git a/config/config.go b/config/config.go index 69af488f..724a0ee1 100644 --- a/config/config.go +++ b/config/config.go @@ -98,6 +98,28 @@ type SidecarConfig struct { DependsOn []string `json:"dependsOn,omitempty" yaml:"dependsOn,omitempty"` } +// ExternalPluginDecl declares an external plugin that the engine should load. +// When AutoFetch is true and the plugin is not found locally, the engine will +// call wfctl to download it from the registry before loading. +type ExternalPluginDecl struct { + // Name is the plugin name as registered in the plugin registry. + Name string `json:"name" yaml:"name"` + // Version is an optional version specifier forwarded to wfctl plugin install + // as name@version. Simple constraints (>=, ^, ~) are stripped to extract the + // version; compound constraints fall back to installing the latest. + // Used only when AutoFetch is true. + Version string `json:"version,omitempty" yaml:"version,omitempty"` + // AutoFetch controls whether the engine should download the plugin + // automatically if it is not found in the local plugin directory. + AutoFetch bool `json:"autoFetch,omitempty" yaml:"autoFetch,omitempty"` +} + +// PluginsConfig holds the top-level plugins configuration section. +type PluginsConfig struct { + // External lists external plugins that the engine should discover and load. + External []ExternalPluginDecl `json:"external,omitempty" yaml:"external,omitempty"` +} + // WorkflowConfig represents the overall configuration for the workflow engine type WorkflowConfig struct { Imports []string `json:"imports,omitempty" yaml:"imports,omitempty"` @@ -107,6 +129,7 @@ type WorkflowConfig struct { Pipelines map[string]any `json:"pipelines,omitempty" yaml:"pipelines,omitempty"` Platform map[string]any `json:"platform,omitempty" yaml:"platform,omitempty"` Requires *RequiresConfig `json:"requires,omitempty" yaml:"requires,omitempty"` + Plugins *PluginsConfig `json:"plugins,omitempty" yaml:"plugins,omitempty"` Sidecars []SidecarConfig `json:"sidecars,omitempty" yaml:"sidecars,omitempty"` Infrastructure *InfrastructureConfig `json:"infrastructure,omitempty" yaml:"infrastructure,omitempty"` ConfigDir string `json:"-" yaml:"-"` // directory containing the config file, used for relative path resolution @@ -258,6 +281,24 @@ func (cfg *WorkflowConfig) processImports(seen map[string]bool) error { } } + // Merge external plugin declarations — deduplicate by name (first definition wins) + if impCfg.Plugins != nil && len(impCfg.Plugins.External) > 0 { + if cfg.Plugins == nil { + cfg.Plugins = &PluginsConfig{} + } + existingPlugins := make(map[string]struct{}, len(cfg.Plugins.External)) + for _, ep := range cfg.Plugins.External { + existingPlugins[ep.Name] = struct{}{} + } + for _, ep := range impCfg.Plugins.External { + if _, exists := existingPlugins[ep.Name]; exists { + continue + } + cfg.Plugins.External = append(cfg.Plugins.External, ep) + existingPlugins[ep.Name] = struct{}{} + } + } + // Merge sidecars — deduplicate by name (first definition wins) existingSidecars := make(map[string]struct{}, len(cfg.Sidecars)) for _, sc := range cfg.Sidecars { diff --git a/config/config_test.go b/config/config_test.go index 0b7d481d..125ae887 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -135,3 +135,82 @@ modules: t.Errorf("expected config basePath '/api/v1', got %v", mod.Config["basePath"]) } } + +func TestExternalPluginDeclParsing(t *testing.T) { + yaml := ` +modules: [] +workflows: {} +triggers: {} +plugins: + external: + - name: my-plugin + autoFetch: true + version: ">=0.1.0" + - name: pinned-plugin + autoFetch: false + version: "0.2.0" + - name: no-version-plugin + autoFetch: true +` + cfg, err := LoadFromString(yaml) + if err != nil { + t.Fatalf("LoadFromString failed: %v", err) + } + if cfg.Plugins == nil { + t.Fatal("expected Plugins section to be non-nil") + } + if len(cfg.Plugins.External) != 3 { + t.Fatalf("expected 3 external plugin decls, got %d", len(cfg.Plugins.External)) + } + + // First plugin: autoFetch=true with version constraint + p0 := cfg.Plugins.External[0] + if p0.Name != "my-plugin" { + t.Errorf("plugins[0].name = %q, want %q", p0.Name, "my-plugin") + } + if !p0.AutoFetch { + t.Errorf("plugins[0].autoFetch = false, want true") + } + if p0.Version != ">=0.1.0" { + t.Errorf("plugins[0].version = %q, want %q", p0.Version, ">=0.1.0") + } + + // Second plugin: autoFetch=false with exact version + p1 := cfg.Plugins.External[1] + if p1.Name != "pinned-plugin" { + t.Errorf("plugins[1].name = %q, want %q", p1.Name, "pinned-plugin") + } + if p1.AutoFetch { + t.Errorf("plugins[1].autoFetch = true, want false") + } + if p1.Version != "0.2.0" { + t.Errorf("plugins[1].version = %q, want %q", p1.Version, "0.2.0") + } + + // Third plugin: no version specified + p2 := cfg.Plugins.External[2] + if p2.Name != "no-version-plugin" { + t.Errorf("plugins[2].name = %q, want %q", p2.Name, "no-version-plugin") + } + if !p2.AutoFetch { + t.Errorf("plugins[2].autoFetch = false, want true") + } + if p2.Version != "" { + t.Errorf("plugins[2].version = %q, want empty", p2.Version) + } +} + +func TestExternalPluginDeclParsing_NoPluginsSection(t *testing.T) { + yaml := ` +modules: [] +workflows: {} +triggers: {} +` + cfg, err := LoadFromString(yaml) + if err != nil { + t.Fatalf("LoadFromString failed: %v", err) + } + if cfg.Plugins != nil { + t.Errorf("expected Plugins to be nil when not declared, got %+v", cfg.Plugins) + } +} diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 24ba976c..b28eb759 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -101,11 +101,11 @@ func (p *Provider) CreateModule(typeName, name string, config map[string]any) (s ## Plugin Manifest -The `plugin.json` declares what your plugin provides: +The `plugin.json` declares what your plugin provides. The name should match what you passed to `wfctl plugin init`: ```json { - "name": "workflow-plugin-my-plugin", + "name": "my-plugin", "version": "0.1.0", "description": "My custom plugin", "author": "MyOrg", diff --git a/engine.go b/engine.go index fa8b7c8b..32a37652 100644 --- a/engine.go +++ b/engine.go @@ -93,6 +93,7 @@ type StdEngine struct { // configHash is the SHA-256 hash of the last config built via BuildFromConfig. // Format: "sha256:". Empty until BuildFromConfig is called. configHash string + } // App returns the underlying modular.Application. @@ -141,6 +142,7 @@ func (e *StdEngine) SetPluginInstaller(installer *plugin.PluginInstaller) { e.pluginInstaller = installer } + // NewStdEngine creates a new workflow engine func NewStdEngine(app modular.Application, logger modular.Logger) *StdEngine { e := &StdEngine{ diff --git a/plugin/autofetch.go b/plugin/autofetch.go new file mode 100644 index 00000000..d34f9701 --- /dev/null +++ b/plugin/autofetch.go @@ -0,0 +1,152 @@ +package plugin + +import ( + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// AutoFetchPlugin downloads a plugin from the registry if it's not already installed. +// It shells out to wfctl for the actual download/install logic. +// version is an optional semver constraint (e.g., ">=0.1.0" or "0.2.0"). +func AutoFetchPlugin(pluginName, version, pluginDir string) error { + // Check both pluginName and workflow-plugin- (or the short form + // if pluginName already has the "workflow-plugin-" prefix). + if isPluginInstalled(pluginName, pluginDir) { + return nil + } + + fmt.Fprintf(os.Stderr, "[auto-fetch] Plugin %q not found locally, fetching from registry...\n", pluginName) + + // Build install argument with version if specified. + installArg := pluginName + if version != "" { + stripped, ok := stripVersionConstraint(version) + if !ok { + // Complex constraint (e.g. ">=0.1.0,<0.2.0") — install latest instead. + fmt.Fprintf(os.Stderr, "[auto-fetch] Version constraint %q is complex; installing latest version of %q\n", version, pluginName) + stripped = "" + } + if stripped != "" { + installArg = pluginName + "@" + stripped + } + } + + args := []string{"plugin", "install", "--plugin-dir", pluginDir, installArg} + cmd := exec.Command("wfctl", args...) //nolint:gosec // G204: args are constructed from config, not user input + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("auto-fetch plugin %q: %w", pluginName, err) + } + return nil +} + +// isPluginInstalled returns true if the plugin is already present under pluginDir. +// It checks both pluginName and the "workflow-plugin-" alternate form. +func isPluginInstalled(pluginName, pluginDir string) bool { + if _, err := os.Stat(filepath.Join(pluginDir, pluginName, "plugin.json")); err == nil { + return true + } + + // Also check the alternate naming convention. + const prefix = "workflow-plugin-" + var alt string + if strings.HasPrefix(pluginName, prefix) { + // e.g. "workflow-plugin-foo" → check "foo" + alt = pluginName[len(prefix):] + } else { + // e.g. "foo" → check "workflow-plugin-foo" + alt = prefix + pluginName + } + if _, err := os.Stat(filepath.Join(pluginDir, alt, "plugin.json")); err == nil { + return true + } + + return false +} + +// stripVersionConstraint strips a simple semver constraint prefix (>=, ^, ~) from +// version and returns the bare version string. The second return value is false when +// the constraint is compound (contains commas or spaces between tokens) and cannot +// be reduced to a single version — callers should fall back to installing the latest. +func stripVersionConstraint(version string) (string, bool) { + if version == "" { + return "", true + } + + // Detect compound constraints such as ">=0.1.0,<0.2.0" or ">=0.1.0 <0.2.0" or "0.1.0 0.2.0". + if strings.Contains(version, ",") || strings.ContainsRune(version, ' ') { + return "", false + } + + v := version + for _, p := range []string{">=", "<=", "!=", "^", "~", ">", "<"} { + if strings.HasPrefix(v, p) { + v = v[len(p):] + break + } + } + + // After stripping, if the result still contains operators it's complex. + if strings.ContainsAny(v, "<>=!,") { + return "", false + } + + return v, true +} + +// AutoFetchDecl is the minimum interface the engine passes per declared external plugin. +type AutoFetchDecl struct { + Name string + Version string + AutoFetch bool +} + +// AutoFetchDeclaredPlugins iterates the declared external plugins and, for each +// with AutoFetch enabled, calls AutoFetchPlugin. If wfctl is not on PATH, a warning +// is logged and the plugin is skipped rather than failing startup. Other errors are +// logged as warnings but do not abort the remaining plugins. +// +// Callers should invoke this before plugin discovery/loading so that newly +// fetched plugins are available in the current startup. +func AutoFetchDeclaredPlugins(decls []AutoFetchDecl, pluginDir string, logger *slog.Logger) { + if pluginDir == "" || len(decls) == 0 { + return + } + + // Check wfctl availability once. + if _, err := exec.LookPath("wfctl"); err != nil { + if logger != nil { + logger.Warn("wfctl not found on PATH; skipping auto-fetch for declared plugins", + "plugin_dir", pluginDir) + } + return + } + + anyFetched := false + for _, d := range decls { + if !d.AutoFetch { + continue + } + // Record whether the plugin was already present before fetching. + alreadyPresent := isPluginInstalled(d.Name, pluginDir) + if err := AutoFetchPlugin(d.Name, d.Version, pluginDir); err != nil { + if logger != nil { + logger.Warn("auto-fetch failed for plugin", "plugin", d.Name, "error", err) + } + continue + } + if !alreadyPresent && isPluginInstalled(d.Name, pluginDir) { + anyFetched = true + } + } + + if anyFetched && logger != nil { + logger.Info("auto-fetch downloaded new plugins; they will be discovered during startup", + "plugin_dir", pluginDir) + } +} diff --git a/plugin/autofetch_test.go b/plugin/autofetch_test.go new file mode 100644 index 00000000..419ea220 --- /dev/null +++ b/plugin/autofetch_test.go @@ -0,0 +1,157 @@ +package plugin + +import ( + "log/slog" + "os" + "path/filepath" + "testing" +) + +// TestAutoFetchPlugin_AlreadyInstalled verifies that AutoFetchPlugin returns nil +// immediately when plugin.json already exists in the plugin directory. +func TestAutoFetchPlugin_AlreadyInstalled(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "plugins") + pluginName := "my-plugin" + + // Create the plugin directory with a plugin.json to simulate an installed plugin. + destDir := filepath.Join(pluginDir, pluginName) + if err := os.MkdirAll(destDir, 0755); err != nil { + t.Fatalf("failed to create plugin dir: %v", err) + } + if err := os.WriteFile(filepath.Join(destDir, "plugin.json"), []byte(`{"name":"my-plugin"}`), 0644); err != nil { + t.Fatalf("failed to write plugin.json: %v", err) + } + + // Should return nil without attempting any download. + err := AutoFetchPlugin(pluginName, "", pluginDir) + if err != nil { + t.Errorf("expected nil when plugin already installed, got: %v", err) + } +} + +// TestAutoFetchPlugin_WfctlNotFound verifies that AutoFetchPlugin returns an error +// when wfctl is not on PATH and the plugin is not installed locally. +func TestAutoFetchPlugin_WfctlNotFound(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "plugins") + + // Ensure the plugin directory exists but plugin is NOT installed. + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatalf("failed to create plugin dir: %v", err) + } + + // Temporarily set PATH to an empty/nonexistent directory so wfctl can't be found. + orig := os.Getenv("PATH") + t.Cleanup(func() { os.Setenv("PATH", orig) }) + os.Setenv("PATH", dir) // dir exists but has no wfctl binary + + err := AutoFetchPlugin("missing-plugin", "", pluginDir) + if err == nil { + t.Error("expected error when wfctl is not on PATH and plugin is missing, got nil") + } +} + +// TestStripVersionConstraint verifies that constraint prefixes are stripped and +// compound constraints are detected as invalid. +func TestStripVersionConstraint(t *testing.T) { + cases := []struct { + input string + want string + wantOK bool + }{ + {">=0.1.0", "0.1.0", true}, + {"<=0.1.0", "0.1.0", true}, + {"^0.2.0", "0.2.0", true}, + {"~1.0.0", "1.0.0", true}, + {"0.3.0", "0.3.0", true}, + {"", "", true}, + {">=0.1.0,<0.2.0", "", false}, // compound — not supported + {">=0.1.0 <0.2.0", "", false}, // compound with space + {"0.1.0 0.2.0", "", false}, // two bare versions separated by space + } + for _, tc := range cases { + got, ok := stripVersionConstraint(tc.input) + if ok != tc.wantOK { + t.Errorf("stripVersionConstraint(%q) ok=%v, want ok=%v", tc.input, ok, tc.wantOK) + } + if ok && got != tc.want { + t.Errorf("stripVersionConstraint(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +// TestAutoFetchPlugin_CorrectArgs verifies that AutoFetchPlugin constructs the +// expected wfctl arguments. We do this by ensuring the function short-circuits +// when the plugin is already installed (not executing wfctl), which confirms +// the plugin.json check is evaluated before any exec.Command call. +func TestAutoFetchPlugin_CorrectArgs(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "plugins") + pluginName := "test-plugin" + + destDir := filepath.Join(pluginDir, pluginName) + if err := os.MkdirAll(destDir, 0755); err != nil { + t.Fatalf("failed to create plugin dir: %v", err) + } + manifestPath := filepath.Join(destDir, "plugin.json") + if err := os.WriteFile(manifestPath, []byte(`{"name":"test-plugin","version":"0.1.0"}`), 0644); err != nil { + t.Fatalf("failed to write plugin.json: %v", err) + } + + // With plugin.json present, AutoFetchPlugin must return nil (no wfctl invoked). + if err := AutoFetchPlugin(pluginName, ">=0.1.0", pluginDir); err != nil { + t.Errorf("expected nil for already-installed plugin, got: %v", err) + } +} + +// TestAutoFetchDeclaredPlugins_SkipsWhenWfctlMissing verifies that +// AutoFetchDeclaredPlugins logs a warning and returns without error when +// wfctl is absent from PATH. +func TestAutoFetchDeclaredPlugins_SkipsWhenWfctlMissing(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "plugins") + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + orig := os.Getenv("PATH") + t.Cleanup(func() { os.Setenv("PATH", orig) }) + os.Setenv("PATH", dir) // no wfctl here + + decls := []AutoFetchDecl{ + {Name: "missing-plugin", Version: ">=0.1.0", AutoFetch: true}, + } + + // Should not panic or return an error — just log a warning. + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + AutoFetchDeclaredPlugins(decls, pluginDir, logger) + // If we reach here, the function handled the missing wfctl gracefully. +} + +// TestAutoFetchDeclaredPlugins_SkipsNonAutoFetch verifies that plugins +// with AutoFetch=false are not fetched, even if wfctl is missing from PATH. +func TestAutoFetchDeclaredPlugins_SkipsNonAutoFetch(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "plugins") + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + // Remove wfctl from PATH; if the function tries to look it up for an + // AutoFetch=false plugin it would still warn but not fail — the real + // check is that AutoFetch=false plugins are completely skipped. + decls := []AutoFetchDecl{ + {Name: "opt-out-plugin", Version: "0.1.0", AutoFetch: false}, + } + + // Should complete without touching wfctl at all. + AutoFetchDeclaredPlugins(decls, pluginDir, nil) +} + +// TestAutoFetchDeclaredPlugins_EmptyInputs verifies early-return on empty inputs. +func TestAutoFetchDeclaredPlugins_EmptyInputs(t *testing.T) { + // Neither pluginDir nor decls provided — must return immediately. + AutoFetchDeclaredPlugins(nil, "", nil) + AutoFetchDeclaredPlugins([]AutoFetchDecl{}, "/some/dir", nil) +} diff --git a/plugin/external/manager.go b/plugin/external/manager.go index 75341d1f..c2022cf3 100644 --- a/plugin/external/manager.go +++ b/plugin/external/manager.go @@ -94,6 +94,14 @@ func (m *ExternalPluginManager) LoadPlugin(name string) (*ExternalPluginAdapter, return nil, fmt.Errorf("validate manifest for plugin %q: %w", name, err) } + // Verify binary integrity against the lockfile checksum before loading. + // A mismatch is logged as a warning and the plugin is skipped, rather than + // crashing the engine so other plugins can still be loaded. + if err := pluginpkg.VerifyPluginIntegrity(m.pluginsDir, name); err != nil { + m.logger.Printf("WARNING: skipping plugin %q — integrity check failed: %v", name, err) + return nil, fmt.Errorf("integrity check failed for plugin %q: %w", name, err) + } + // Verify binary is executable info, err := os.Stat(binaryPath) //nolint:gosec // G703: plugin binary path from trusted data/plugins directory if err != nil { diff --git a/plugin/integrity.go b/plugin/integrity.go new file mode 100644 index 00000000..2687bffd --- /dev/null +++ b/plugin/integrity.go @@ -0,0 +1,83 @@ +package plugin + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// lockfileEntry is a minimal representation of a plugin lockfile entry. +type lockfileEntry struct { + SHA256 string `yaml:"sha256"` +} + +// lockfileData is the minimal structure of .wfctl.yaml for integrity checks. +type lockfileData struct { + Plugins map[string]lockfileEntry `yaml:"plugins"` +} + +// VerifyPluginIntegrity checks the plugin binary's SHA-256 against the lockfile. +// Returns nil if no lockfile exists, no entry for this plugin, or no checksum pinned. +func VerifyPluginIntegrity(pluginDir, pluginName string) error { + // Search for lockfile: CWD first, then parent dirs up to 3 levels + lockfilePath := findLockfile() + if lockfilePath == "" { + return nil + } + + data, err := os.ReadFile(lockfilePath) + if err != nil { + // The lockfile path was found via Stat, so if ReadFile fails the file exists + // but is unreadable. Fail closed to prevent integrity bypass. + return fmt.Errorf("read lockfile %s: %w", lockfilePath, err) + } + + var lf lockfileData + if err := yaml.Unmarshal(data, &lf); err != nil { + // A corrupt lockfile must not silently allow unverified plugins to load. + return fmt.Errorf("parse lockfile %s: %w", lockfilePath, err) + } + + entry, ok := lf.Plugins[pluginName] + if !ok || entry.SHA256 == "" { + return nil // not pinned + } + + binaryPath := filepath.Join(pluginDir, pluginName, pluginName) + binaryData, err := os.ReadFile(binaryPath) + if err != nil { + return fmt.Errorf("read plugin binary %s: %w", binaryPath, err) + } + + h := sha256.Sum256(binaryData) + got := hex.EncodeToString(h[:]) + if !strings.EqualFold(got, entry.SHA256) { + return fmt.Errorf("plugin %q integrity check failed: binary checksum %s does not match lockfile %s", pluginName, got, entry.SHA256) + } + return nil +} + +// findLockfile searches for .wfctl.yaml starting from CWD. +func findLockfile() string { + dir, err := os.Getwd() + if err != nil { + return "" + } + for i := 0; i < 4; i++ { + p := filepath.Join(dir, ".wfctl.yaml") + if _, err := os.Stat(p); err == nil { + return p + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "" +} diff --git a/plugin/integrity_test.go b/plugin/integrity_test.go new file mode 100644 index 00000000..ac3fcb30 --- /dev/null +++ b/plugin/integrity_test.go @@ -0,0 +1,289 @@ +package plugin + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" +) + +// writeLockfile creates a .wfctl.yaml in dir with the given YAML content. +// It returns the evaluated (symlink-resolved) path so comparisons with +// findLockfile (which resolves via os.Getwd) work on macOS where /tmp -> /private/tmp. +func writeLockfile(t *testing.T, dir, content string) string { + t.Helper() + p := filepath.Join(dir, ".wfctl.yaml") + if err := os.WriteFile(p, []byte(content), 0644); err != nil { + t.Fatalf("failed to write lockfile: %v", err) + } + resolved, err := filepath.EvalSymlinks(p) + if err != nil { + t.Fatalf("failed to resolve lockfile symlink: %v", err) + } + return resolved +} + +// writeBinary creates a fake plugin binary at // +// and returns its SHA-256 hex digest. +func writeBinary(t *testing.T, pluginDir, pluginName string, data []byte) string { + t.Helper() + dir := filepath.Join(pluginDir, pluginName) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("failed to mkdir plugin binary dir: %v", err) + } + binPath := filepath.Join(dir, pluginName) + if err := os.WriteFile(binPath, data, 0755); err != nil { + t.Fatalf("failed to write fake binary: %v", err) + } + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]) +} + +// TestVerifyPluginIntegrity_UnreadableLockfile verifies that the function fails +// closed when the lockfile exists but cannot be read. +func TestVerifyPluginIntegrity_UnreadableLockfile(t *testing.T) { + dir := t.TempDir() + + orig, _ := os.Getwd() + t.Cleanup(func() { os.Chdir(orig) }) + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + + // Create a lockfile with no read permission. + p := filepath.Join(dir, ".wfctl.yaml") + if err := os.WriteFile(p, []byte("plugins:\n my-plugin:\n sha256: abc\n"), 0000); err != nil { + t.Fatalf("write lockfile: %v", err) + } + + err := VerifyPluginIntegrity(filepath.Join(dir, "plugins"), "my-plugin") + if err == nil { + t.Error("expected error when lockfile is unreadable, got nil (fail-open)") + } +} + +// TestVerifyPluginIntegrity_MalformedLockfile verifies that the function fails +// closed when the lockfile exists but contains invalid YAML. +func TestVerifyPluginIntegrity_MalformedLockfile(t *testing.T) { + dir := t.TempDir() + + orig, _ := os.Getwd() + t.Cleanup(func() { os.Chdir(orig) }) + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + + writeLockfile(t, dir, "{{{{not valid yaml") + + err := VerifyPluginIntegrity(filepath.Join(dir, "plugins"), "my-plugin") + if err == nil { + t.Error("expected error when lockfile contains invalid YAML, got nil (fail-open)") + } +} + +// TestVerifyPluginIntegrity_NoLockfile verifies that the function returns nil +// when no lockfile can be found in the directory hierarchy. +func TestVerifyPluginIntegrity_NoLockfile(t *testing.T) { + // Use a fresh temp dir with no .wfctl.yaml anywhere. + dir := t.TempDir() + + orig, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { os.Chdir(orig) }) + + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + + err = VerifyPluginIntegrity(filepath.Join(dir, "plugins"), "my-plugin") + if err != nil { + t.Errorf("expected nil when no lockfile exists, got: %v", err) + } +} + +// TestVerifyPluginIntegrity_NoEntryForPlugin verifies nil is returned when the +// lockfile exists but has no entry for the requested plugin. +func TestVerifyPluginIntegrity_NoEntryForPlugin(t *testing.T) { + dir := t.TempDir() + + orig, _ := os.Getwd() + t.Cleanup(func() { os.Chdir(orig) }) + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + + writeLockfile(t, dir, "plugins:\n other-plugin:\n sha256: abc123\n") + + err := VerifyPluginIntegrity(filepath.Join(dir, "plugins"), "my-plugin") + if err != nil { + t.Errorf("expected nil when lockfile has no entry for plugin, got: %v", err) + } +} + +// TestVerifyPluginIntegrity_NoSHA256InEntry verifies nil is returned when the +// lockfile entry exists but has no sha256 field. +func TestVerifyPluginIntegrity_NoSHA256InEntry(t *testing.T) { + dir := t.TempDir() + + orig, _ := os.Getwd() + t.Cleanup(func() { os.Chdir(orig) }) + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + + writeLockfile(t, dir, "plugins:\n my-plugin:\n sha256: \"\"\n") + + err := VerifyPluginIntegrity(filepath.Join(dir, "plugins"), "my-plugin") + if err != nil { + t.Errorf("expected nil when lockfile entry has empty sha256, got: %v", err) + } +} + +// TestVerifyPluginIntegrity_ChecksumMatches verifies nil is returned when the +// binary checksum matches the lockfile entry. +func TestVerifyPluginIntegrity_ChecksumMatches(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "plugins") + + orig, _ := os.Getwd() + t.Cleanup(func() { os.Chdir(orig) }) + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + + binaryData := []byte("fake plugin binary content") + digest := writeBinary(t, pluginDir, "my-plugin", binaryData) + + lockContent := "plugins:\n my-plugin:\n sha256: " + digest + "\n" + writeLockfile(t, dir, lockContent) + + err := VerifyPluginIntegrity(pluginDir, "my-plugin") + if err != nil { + t.Errorf("expected nil when checksum matches, got: %v", err) + } +} + +// TestVerifyPluginIntegrity_ChecksumMismatch verifies an error is returned when +// the binary checksum does not match the lockfile entry. +func TestVerifyPluginIntegrity_ChecksumMismatch(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "plugins") + + orig, _ := os.Getwd() + t.Cleanup(func() { os.Chdir(orig) }) + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + + writeBinary(t, pluginDir, "my-plugin", []byte("correct binary")) + + // Write a lockfile with a wrong SHA. + writeLockfile(t, dir, "plugins:\n my-plugin:\n sha256: 0000000000000000000000000000000000000000000000000000000000000000\n") + + err := VerifyPluginIntegrity(pluginDir, "my-plugin") + if err == nil { + t.Error("expected error when checksum mismatches, got nil") + } +} + +// TestVerifyPluginIntegrity_ChecksumMismatch_CaseInsensitive verifies that the +// comparison is case-insensitive (hex digits may be upper or lower case). +func TestVerifyPluginIntegrity_ChecksumMismatch_CaseInsensitive(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "plugins") + + orig, _ := os.Getwd() + t.Cleanup(func() { os.Chdir(orig) }) + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + + binaryData := []byte("plugin binary") + digest := writeBinary(t, pluginDir, "my-plugin", binaryData) + + // Write lockfile with uppercase hex digest. + upper := "" + for _, c := range digest { + if c >= 'a' && c <= 'f' { + upper += string(rune(c - 32)) + } else { + upper += string(c) + } + } + writeLockfile(t, dir, "plugins:\n my-plugin:\n sha256: "+upper+"\n") + + err := VerifyPluginIntegrity(pluginDir, "my-plugin") + if err != nil { + t.Errorf("expected nil for case-insensitive match, got: %v", err) + } +} + +// TestFindLockfile_WalksUpDirectories verifies that findLockfile finds a +// .wfctl.yaml placed in a parent directory when CWD is a subdirectory. +func TestFindLockfile_WalksUpDirectories(t *testing.T) { + // Build a directory structure: root/.wfctl.yaml, root/sub/sub2/ (cwd) + root := t.TempDir() + subDir := filepath.Join(root, "sub", "sub2") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + lockPath := writeLockfile(t, root, "plugins: {}\n") + + orig, _ := os.Getwd() + t.Cleanup(func() { os.Chdir(orig) }) + if err := os.Chdir(subDir); err != nil { + t.Fatalf("chdir: %v", err) + } + + found := findLockfile() + if found != lockPath { + t.Errorf("findLockfile() = %q, want %q", found, lockPath) + } +} + +// TestFindLockfile_NotFound verifies that findLockfile returns "" when no +// .wfctl.yaml exists anywhere in the walked hierarchy. +func TestFindLockfile_NotFound(t *testing.T) { + dir := t.TempDir() + + orig, _ := os.Getwd() + t.Cleanup(func() { os.Chdir(orig) }) + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + + found := findLockfile() + if found != "" { + t.Errorf("expected empty string when no lockfile found, got: %q", found) + } +} + +// TestFindLockfile_CWDFirst verifies that findLockfile returns the lockfile in +// CWD when lockfiles exist in both CWD and a parent directory. +func TestFindLockfile_CWDFirst(t *testing.T) { + root := t.TempDir() + subDir := filepath.Join(root, "child") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + // Lockfile in parent. + writeLockfile(t, root, "plugins: {}\n") + // Lockfile in CWD (child). + childLock := writeLockfile(t, subDir, "plugins: {}\n") + + orig, _ := os.Getwd() + t.Cleanup(func() { os.Chdir(orig) }) + if err := os.Chdir(subDir); err != nil { + t.Fatalf("chdir: %v", err) + } + + found := findLockfile() + if found != childLock { + t.Errorf("findLockfile() = %q, want CWD lockfile %q", found, childLock) + } +} diff --git a/plugin/sdk/generator_test.go b/plugin/sdk/generator_test.go index 0431273e..996293da 100644 --- a/plugin/sdk/generator_test.go +++ b/plugin/sdk/generator_test.go @@ -145,6 +145,96 @@ func TestTemplateGeneratorInvalidName(t *testing.T) { } } +func TestGenerateProjectStructure(t *testing.T) { + dir := t.TempDir() + outputDir := filepath.Join(dir, "my-plugin") + + gen := NewTemplateGenerator() + err := gen.Generate(GenerateOptions{ + Name: "my-plugin", + Version: "0.2.0", + Author: "TestOrg", + Description: "Project structure test", + OutputDir: outputDir, + }) + if err != nil { + t.Fatalf("Generate error: %v", err) + } + + // Verify all expected project files exist. + expectedFiles := []string{ + "cmd/workflow-plugin-my-plugin/main.go", + "internal/provider.go", + "internal/steps.go", + "go.mod", + ".goreleaser.yml", + ".github/workflows/ci.yml", + ".github/workflows/release.yml", + "Makefile", + "README.md", + } + for _, f := range expectedFiles { + p := filepath.Join(outputDir, f) + if _, err := os.Stat(p); err != nil { + t.Errorf("expected file %s to exist: %v", f, err) + } + } + + // Verify main.go uses the external SDK import. + mainData, err := os.ReadFile(filepath.Join(outputDir, "cmd/workflow-plugin-my-plugin/main.go")) + if err != nil { + t.Fatalf("read main.go: %v", err) + } + mainSrc := string(mainData) + if !strings.Contains(mainSrc, `"github.com/GoCodeAlone/workflow/plugin/external/sdk"`) { + t.Error("main.go should import plugin/external/sdk") + } + if !strings.Contains(mainSrc, "sdk.Serve(") { + t.Error("main.go should call sdk.Serve()") + } + + // Verify provider.go uses external SDK types. + provData, err := os.ReadFile(filepath.Join(outputDir, "internal/provider.go")) + if err != nil { + t.Fatalf("read provider.go: %v", err) + } + provSrc := string(provData) + if !strings.Contains(provSrc, `"github.com/GoCodeAlone/workflow/plugin/external/sdk"`) { + t.Error("provider.go should import plugin/external/sdk") + } + if !strings.Contains(provSrc, "sdk.PluginManifest") { + t.Error("provider.go should use sdk.PluginManifest") + } + if !strings.Contains(provSrc, "sdk.StepInstance") { + t.Error("provider.go should return sdk.StepInstance") + } + if !strings.Contains(provSrc, `return nil, fmt.Errorf("unknown step type:`) { + t.Error("provider.go should return error for unknown step types") + } + + // Verify steps.go uses external SDK types. + stepsData, err := os.ReadFile(filepath.Join(outputDir, "internal/steps.go")) + if err != nil { + t.Fatalf("read steps.go: %v", err) + } + stepsSrc := string(stepsData) + if !strings.Contains(stepsSrc, `"github.com/GoCodeAlone/workflow/plugin/external/sdk"`) { + t.Error("steps.go should import plugin/external/sdk") + } + if !strings.Contains(stepsSrc, "*sdk.StepResult") { + t.Error("steps.go should return *sdk.StepResult") + } + + // Verify go.mod has correct module path. + modData, err := os.ReadFile(filepath.Join(outputDir, "go.mod")) + if err != nil { + t.Fatalf("read go.mod: %v", err) + } + if !strings.Contains(string(modData), "module github.com/TestOrg/workflow-plugin-my-plugin") { + t.Errorf("go.mod module path unexpected: %s", string(modData)) + } +} + func TestToCamelCase(t *testing.T) { tests := []struct { input string