diff --git a/cmd/wfctl/deploy.go b/cmd/wfctl/deploy.go index 93687ccc..aa2bc2d6 100644 --- a/cmd/wfctl/deploy.go +++ b/cmd/wfctl/deploy.go @@ -132,6 +132,9 @@ Options: if err := fs.Parse(args); err != nil { return err } + if *config == "" && fs.NArg() > 0 { + *config = fs.Arg(0) + } cwd, err := os.Getwd() if err != nil { @@ -417,6 +420,9 @@ func runK8sGenerate(args []string) error { if err := fs.Parse(args); err != nil { return err } + if f.configFile == "" && fs.NArg() > 0 { + f.configFile = fs.Arg(0) + } if f.image == "" { return fmt.Errorf("-image is required") } @@ -470,6 +476,9 @@ func runK8sApply(args []string) error { if err := fs.Parse(args); err != nil { return err } + if f.configFile == "" && fs.NArg() > 0 { + f.configFile = fs.Arg(0) + } // Load .wfctl.yaml defaults for build settings if wfcfg, loadErr := loadWfctlConfig(); loadErr == nil { @@ -779,6 +788,9 @@ Options: if err := fs.Parse(args); err != nil { return err } + if *configFile == "" && fs.NArg() > 0 { + *configFile = fs.Arg(0) + } if *target != "" && *target != "staging" && *target != "production" { return fmt.Errorf("invalid target %q: must be staging or production", *target) diff --git a/cmd/wfctl/flag_helpers.go b/cmd/wfctl/flag_helpers.go new file mode 100644 index 00000000..5c0b6226 --- /dev/null +++ b/cmd/wfctl/flag_helpers.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "strings" +) + +// checkTrailingFlags returns an error if any flag (starting with '-') appears +// after the first positional argument in args. A token immediately following a +// flag token (its value) is not counted as a positional argument. +func checkTrailingFlags(args []string) error { + seenPositional := false + prevWasFlag := false + for _, arg := range args { + if strings.HasPrefix(arg, "-") { + if seenPositional { + return fmt.Errorf("flags must come before arguments (got %s after positional arg). Reorder so all flags precede the name argument", arg) + } + // Only treat as value-bearing flag if it doesn't use = syntax + prevWasFlag = !strings.Contains(arg, "=") + } else { + if !prevWasFlag { + seenPositional = true + } + prevWasFlag = false + } + } + return nil +} diff --git a/cmd/wfctl/flag_helpers_test.go b/cmd/wfctl/flag_helpers_test.go new file mode 100644 index 00000000..a9836fb8 --- /dev/null +++ b/cmd/wfctl/flag_helpers_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" +) + +func TestCheckTrailingFlags(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + }{ + { + name: "flags before positional arg", + args: []string{"-author", "jon", "myplugin"}, + wantErr: false, + }, + { + name: "flags after positional arg", + args: []string{"myplugin", "-author", "jon"}, + wantErr: true, + }, + { + name: "all flags no positional", + args: []string{"-author", "jon", "-version", "1.0.0"}, + wantErr: false, + }, + { + name: "no flags", + args: []string{"myplugin"}, + wantErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := checkTrailingFlags(tc.args) + if (err != nil) != tc.wantErr { + t.Errorf("checkTrailingFlags(%v) error = %v, wantErr %v", tc.args, err, tc.wantErr) + } + }) + } +} diff --git a/cmd/wfctl/github_install.go b/cmd/wfctl/github_install.go new file mode 100644 index 00000000..d3977c77 --- /dev/null +++ b/cmd/wfctl/github_install.go @@ -0,0 +1,92 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "runtime" + "strings" +) + +// parseGitHubRef parses a plugin reference that may be a GitHub owner/repo[@version] path. +// Returns (owner, repo, version, isGitHub). +// "GoCodeAlone/workflow-plugin-authz@v0.3.1" → ("GoCodeAlone","workflow-plugin-authz","v0.3.1",true) +// "GoCodeAlone/workflow-plugin-authz" → ("GoCodeAlone","workflow-plugin-authz","",true) +// "authz" → ("","","",false) +func parseGitHubRef(input string) (owner, repo, version string, isGitHub bool) { + // Must contain "/" to be a GitHub ref. + if !strings.Contains(input, "/") { + return "", "", "", false + } + + ownerRepo := input + if atIdx := strings.Index(input, "@"); atIdx > 0 { + version = input[atIdx+1:] + ownerRepo = input[:atIdx] + } + + parts := strings.SplitN(ownerRepo, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", "", false + } + return parts[0], parts[1], version, true +} + +// ghRelease is a minimal subset of the GitHub Releases API response. +type ghRelease struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` +} + +// installFromGitHub downloads and extracts a plugin directly from a GitHub Release. +// owner/repo@version is resolved to a tarball asset matching {repo}_{os}_{arch}.tar.gz. +func installFromGitHub(owner, repo, version, destDir string) error { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, version) + if version == "" || version == "latest" { + apiURL = fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) + } + + fmt.Fprintf(os.Stderr, "Fetching GitHub release from %s/%s@%s...\n", owner, repo, version) + body, err := downloadURL(apiURL) + if err != nil { + return fmt.Errorf("fetch GitHub release: %w", err) + } + + var rel ghRelease + if err := json.Unmarshal(body, &rel); err != nil { + return fmt.Errorf("parse GitHub release response: %w", err) + } + + // Find asset matching {repo}_{os}_{arch}.tar.gz + wantSuffix := fmt.Sprintf("%s_%s_%s.tar.gz", repo, runtime.GOOS, runtime.GOARCH) + var assetURL string + for _, a := range rel.Assets { + if strings.EqualFold(a.Name, wantSuffix) { + assetURL = a.BrowserDownloadURL + break + } + } + if assetURL == "" { + return fmt.Errorf("no asset matching %q found in release %s for %s/%s", wantSuffix, rel.TagName, owner, repo) + } + + fmt.Fprintf(os.Stderr, "Downloading %s...\n", assetURL) + data, err := downloadURL(assetURL) + if err != nil { + return fmt.Errorf("download plugin from GitHub: %w", err) + } + + if err := os.MkdirAll(destDir, 0750); err != nil { + return fmt.Errorf("create plugin dir %s: %w", destDir, err) + } + + fmt.Fprintf(os.Stderr, "Extracting to %s...\n", destDir) + if err := extractTarGz(data, destDir); err != nil { + return fmt.Errorf("extract plugin: %w", err) + } + + return nil +} diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index b4f42cb4..2614b312 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -65,7 +65,7 @@ func resolveInfraConfig(fs *flag.FlagSet) (string, error) { return arg, nil } } - return "", fmt.Errorf("no config file found (tried infra.yaml, config/infra.yaml)") + return "", fmt.Errorf("no infrastructure config found (tried infra.yaml, config/infra.yaml)\n\nCreate an infra config with cloud.account and platform.* modules.\nRun 'wfctl init --template full-stack' for a starter config with infrastructure") } // infraModuleEntry is a minimal struct for parsing modules from YAML. diff --git a/cmd/wfctl/main.go b/cmd/wfctl/main.go index 250c5794..3a7d5fca 100644 --- a/cmd/wfctl/main.go +++ b/cmd/wfctl/main.go @@ -8,6 +8,7 @@ import ( "log/slog" "os" "os/signal" + "strings" "syscall" "time" @@ -27,6 +28,17 @@ var wfctlConfigBytes []byte var version = "dev" +// isHelpRequested reports whether the error originated from the user +// requesting help (--help / -h). flag.ErrHelp propagates through the +// pipeline engine as a step failure; catching it here lets us exit 0 +// instead of printing a confusing "error: flag: help requested" message. +func isHelpRequested(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "flag: help requested") +} + // commands maps each CLI command name to its Go implementation. The command // metadata (name, description) is declared in wfctl.yaml; this map provides // the runtime functions that are registered in the CLICommandRegistry service @@ -131,9 +143,9 @@ func main() { cliHandler.SetOutput(os.Stderr) if len(os.Args) < 2 { - // No subcommand — print usage and exit non-zero. + // No subcommand — print usage and exit 0 (help is not an error). _ = cliHandler.Dispatch([]string{"-h"}) - os.Exit(1) + os.Exit(0) } cmd := os.Args[1] @@ -155,6 +167,10 @@ func main() { stop() if dispatchErr != nil { + // If the user requested help, exit cleanly without printing the engine error. + if isHelpRequested(dispatchErr) { + os.Exit(0) + } // The handler already printed routing errors (unknown/missing command). // Only emit the "error:" prefix for actual command execution failures. if _, isKnown := commands[cmd]; isKnown { diff --git a/cmd/wfctl/main_test.go b/cmd/wfctl/main_test.go index 9955d990..8e3cd892 100644 --- a/cmd/wfctl/main_test.go +++ b/cmd/wfctl/main_test.go @@ -2,6 +2,9 @@ package main import ( "encoding/json" + "errors" + "flag" + "fmt" "os" "path/filepath" "strings" @@ -10,6 +13,23 @@ import ( "github.com/GoCodeAlone/workflow/schema" ) +func TestHelpFlagDoesNotLeakEngineError(t *testing.T) { + if !isHelpRequested(flag.ErrHelp) { + t.Error("isHelpRequested should return true for flag.ErrHelp") + } + if isHelpRequested(nil) { + t.Error("isHelpRequested should return false for nil") + } + if isHelpRequested(errors.New("some other error")) { + t.Error("isHelpRequested should return false for unrelated errors") + } + // Wrapped error should also be detected + wrapped := fmt.Errorf("pipeline failed: %w", flag.ErrHelp) + if !isHelpRequested(wrapped) { + t.Error("isHelpRequested should return true for wrapped flag.ErrHelp") + } +} + func writeTestConfig(t *testing.T, dir, name, content string) string { t.Helper() path := filepath.Join(dir, name) diff --git a/cmd/wfctl/multi_registry.go b/cmd/wfctl/multi_registry.go index 0692cd34..41c51e06 100644 --- a/cmd/wfctl/multi_registry.go +++ b/cmd/wfctl/multi_registry.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "sort" + "strings" ) // MultiRegistry aggregates multiple RegistrySource instances and resolves @@ -41,16 +42,40 @@ func NewMultiRegistryFromSources(sources ...RegistrySource) *MultiRegistry { return &MultiRegistry{sources: sources} } +// normalizePluginName strips the "workflow-plugin-" prefix from a plugin name +// so that users can refer to plugins by their short name (e.g. "authz") or +// full name (e.g. "workflow-plugin-authz") interchangeably. +func normalizePluginName(name string) string { + return strings.TrimPrefix(name, "workflow-plugin-") +} + // FetchManifest tries each source in priority order, returning the first successful result. +// It first tries the normalized name (stripping "workflow-plugin-" prefix); if the +// normalized name differs from the original, it also tries the original name as a fallback. func (m *MultiRegistry) FetchManifest(name string) (*RegistryManifest, string, error) { + normalized := normalizePluginName(name) + + // Try normalized name first across all sources. var lastErr error for _, src := range m.sources { - manifest, err := src.FetchManifest(name) + manifest, err := src.FetchManifest(normalized) if err == nil { return manifest, src.Name(), nil } lastErr = err } + + // If normalized differs from original, try original name as fallback. + if normalized != name { + for _, src := range m.sources { + manifest, err := src.FetchManifest(name) + if err == nil { + return manifest, src.Name(), nil + } + lastErr = err + } + } + if lastErr != nil { return nil, "", lastErr } @@ -59,12 +84,14 @@ func (m *MultiRegistry) FetchManifest(name string) (*RegistryManifest, string, e // SearchPlugins searches all sources and returns deduplicated results. // When the same plugin appears in multiple registries, the higher-priority source wins. +// The query is normalized (stripping "workflow-plugin-" prefix) before searching. func (m *MultiRegistry) SearchPlugins(query string) ([]PluginSearchResult, error) { seen := make(map[string]bool) var results []PluginSearchResult + normalizedQuery := normalizePluginName(query) for _, src := range m.sources { - srcResults, err := src.SearchPlugins(query) + srcResults, err := src.SearchPlugins(normalizedQuery) if err != nil { fmt.Fprintf(os.Stderr, "warning: search failed for registry %q: %v\n", src.Name(), err) continue diff --git a/cmd/wfctl/multi_registry_test.go b/cmd/wfctl/multi_registry_test.go index 4bb3187d..fe8e2fb4 100644 --- a/cmd/wfctl/multi_registry_test.go +++ b/cmd/wfctl/multi_registry_test.go @@ -70,6 +70,30 @@ func (m *mockRegistrySource) SearchPlugins(query string) ([]PluginSearchResult, return results, nil } +// --------------------------------------------------------------------------- +// normalizePluginName tests +// --------------------------------------------------------------------------- + +func TestNormalizePluginName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"authz", "authz"}, + {"workflow-plugin-authz", "authz"}, + {"workflow-plugin-payments", "payments"}, + {"custom-plugin", "custom-plugin"}, + } + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + got := normalizePluginName(tc.input) + if got != tc.want { + t.Errorf("normalizePluginName(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + // --------------------------------------------------------------------------- // Registry config tests // --------------------------------------------------------------------------- diff --git a/cmd/wfctl/plugin.go b/cmd/wfctl/plugin.go index 9f5c0990..e5198d94 100644 --- a/cmd/wfctl/plugin.go +++ b/cmd/wfctl/plugin.go @@ -52,11 +52,16 @@ Subcommands: remove Uninstall a plugin validate Validate a plugin manifest from the registry or a local file info Show details about an installed plugin + +Use -plugin-dir to specify a custom plugin directory (replaces deprecated -data-dir). `) return fmt.Errorf("plugin subcommand is required") } func runPluginInit(args []string) error { + if err := checkTrailingFlags(args); err != nil { + return err + } fs := flag.NewFlagSet("plugin init", flag.ExitOnError) author := fs.String("author", "", "Plugin author (required)") ver := fs.String("version", "0.1.0", "Plugin version") diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 812cd6df..c47e7696 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -66,7 +66,9 @@ func runPluginSearch(args []string) error { func runPluginInstall(args []string) error { fs := flag.NewFlagSet("plugin install", flag.ContinueOnError) - dataDir := fs.String("data-dir", defaultDataDir, "Plugin data directory") + var pluginDirVal string + fs.StringVar(&pluginDirVal, "plugin-dir", defaultDataDir, "Plugin directory") + fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)") cfgPath := fs.String("config", "", "Registry config file path") registryName := fs.String("registry", "", "Use a specific registry by name") fs.Usage = func() { @@ -76,9 +78,11 @@ func runPluginInstall(args []string) error { if err := fs.Parse(args); err != nil { return err } + dataDir := &pluginDirVal + + // No args: install all plugins from .wfctl.yaml lockfile. if fs.NArg() < 1 { - fs.Usage() - return fmt.Errorf("plugin name is required") + return installFromLockfile(*dataDir, *cfgPath) } nameArg := fs.Arg(0) @@ -108,10 +112,28 @@ func runPluginInstall(args []string) error { } fmt.Fprintf(os.Stderr, "Fetching manifest for %q...\n", pluginName) - manifest, sourceName, err := mr.FetchManifest(pluginName) - if err != nil { - return err + manifest, sourceName, registryErr := mr.FetchManifest(pluginName) + + if registryErr != nil { + // Registry lookup failed. Try GitHub direct install if input looks like owner/repo[@version]. + ghOwner, ghRepo, ghVersion, isGH := parseGitHubRef(nameArg) + if !isGH { + return registryErr + } + pluginName = normalizePluginName(ghRepo) + destDir := filepath.Join(*dataDir, pluginName) + if err := installFromGitHub(ghOwner, ghRepo, ghVersion, destDir); err != nil { + return fmt.Errorf("registry: %w; github: %w", registryErr, err) + } + if err := ensurePluginBinary(destDir, pluginName); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not normalize binary name: %v\n", err) + } + fmt.Printf("Installed %s to %s\n", nameArg, destDir) + return nil } + + destDir := filepath.Join(*dataDir, pluginName) + fmt.Fprintf(os.Stderr, "Found in registry %q.\n", sourceName) dl, err := manifest.FindDownload(runtime.GOOS, runtime.GOARCH) @@ -119,7 +141,6 @@ func runPluginInstall(args []string) error { return err } - destDir := filepath.Join(*dataDir, pluginName) if err := os.MkdirAll(destDir, 0750); err != nil { return fmt.Errorf("create plugin dir %s: %w", destDir, err) } @@ -163,12 +184,20 @@ func runPluginInstall(args []string) error { } fmt.Printf("Installed %s v%s to %s\n", manifest.Name, manifest.Version, destDir) + + // Update .wfctl.yaml lockfile if name@version was provided. + if _, ver := parseNameVersion(nameArg); ver != "" { + updateLockfile(manifest.Name, manifest.Version, manifest.Repository) + } + return nil } func runPluginList(args []string) error { fs := flag.NewFlagSet("plugin list", flag.ContinueOnError) - dataDir := fs.String("data-dir", defaultDataDir, "Plugin data directory") + var pluginDirVal string + fs.StringVar(&pluginDirVal, "plugin-dir", defaultDataDir, "Plugin directory") + fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin list [options]\n\nList installed plugins.\n\nOptions:\n") fs.PrintDefaults() @@ -177,13 +206,13 @@ func runPluginList(args []string) error { return err } - entries, err := os.ReadDir(*dataDir) + entries, err := os.ReadDir(pluginDirVal) if os.IsNotExist(err) { fmt.Println("No plugins installed.") return nil } if err != nil { - return fmt.Errorf("read data dir %s: %w", *dataDir, err) + return fmt.Errorf("read data dir %s: %w", pluginDirVal, err) } type installed struct { @@ -197,7 +226,7 @@ func runPluginList(args []string) error { if !e.IsDir() { continue } - ver, pType, desc := readInstalledInfo(filepath.Join(*dataDir, e.Name())) + ver, pType, desc := readInstalledInfo(filepath.Join(pluginDirVal, e.Name())) plugins = append(plugins, installed{name: e.Name(), version: ver, pluginType: pType, description: desc}) } @@ -220,7 +249,10 @@ func runPluginList(args []string) error { func runPluginUpdate(args []string) error { fs := flag.NewFlagSet("plugin update", flag.ContinueOnError) - dataDir := fs.String("data-dir", defaultDataDir, "Plugin data directory") + var pluginDirVal string + fs.StringVar(&pluginDirVal, "plugin-dir", defaultDataDir, "Plugin directory") + fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)") + cfgPath := fs.String("config", "", "Registry config file path") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin update [options] \n\nUpdate an installed plugin to its latest version.\n\nOptions:\n") fs.PrintDefaults() @@ -234,18 +266,38 @@ func runPluginUpdate(args []string) error { } pluginName := fs.Arg(0) - pluginDir := filepath.Join(*dataDir, pluginName) + pluginDir := filepath.Join(pluginDirVal, pluginName) if _, err := os.Stat(pluginDir); os.IsNotExist(err) { return fmt.Errorf("plugin %q is not installed", pluginName) } + // Check the registry for the latest version before downloading. + cfg, err := LoadRegistryConfig(*cfgPath) + if err != nil { + return fmt.Errorf("load registry config: %w", err) + } + mr := NewMultiRegistry(cfg) + manifest, _, err := mr.FetchManifest(pluginName) + if err != nil { + return fmt.Errorf("fetch manifest: %w", err) + } + + installedVer := readInstalledVersion(pluginDir) + if installedVer == manifest.Version { + fmt.Printf("already at latest version (%s)\n", manifest.Version) + return nil + } + fmt.Fprintf(os.Stderr, "Updating from %s to %s...\n", installedVer, manifest.Version) + // Re-run install which will overwrite the existing installation. - return runPluginInstall(append([]string{"--data-dir", *dataDir}, pluginName)) + return runPluginInstall(append([]string{"--plugin-dir", pluginDirVal, "--config", *cfgPath}, pluginName)) } func runPluginRemove(args []string) error { fs := flag.NewFlagSet("plugin remove", flag.ContinueOnError) - dataDir := fs.String("data-dir", defaultDataDir, "Plugin data directory") + var pluginDirVal string + fs.StringVar(&pluginDirVal, "plugin-dir", defaultDataDir, "Plugin directory") + fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin remove [options] \n\nUninstall a plugin.\n\nOptions:\n") fs.PrintDefaults() @@ -259,7 +311,7 @@ func runPluginRemove(args []string) error { } pluginName := fs.Arg(0) - pluginDir := filepath.Join(*dataDir, pluginName) + pluginDir := filepath.Join(pluginDirVal, pluginName) if _, err := os.Stat(pluginDir); os.IsNotExist(err) { return fmt.Errorf("plugin %q is not installed", pluginName) } @@ -272,7 +324,9 @@ func runPluginRemove(args []string) error { func runPluginInfo(args []string) error { fs := flag.NewFlagSet("plugin info", flag.ContinueOnError) - dataDir := fs.String("data-dir", defaultDataDir, "Plugin data directory") + var pluginDirVal string + fs.StringVar(&pluginDirVal, "plugin-dir", defaultDataDir, "Plugin directory") + fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin info [options] \n\nShow details about an installed plugin.\n\nOptions:\n") fs.PrintDefaults() @@ -286,8 +340,9 @@ func runPluginInfo(args []string) error { } pluginName := fs.Arg(0) - pluginDir := filepath.Join(*dataDir, pluginName) - manifestPath := filepath.Join(pluginDir, "plugin.json") + pluginDir := filepath.Join(pluginDirVal, pluginName) + absDir, _ := filepath.Abs(pluginDir) + manifestPath := filepath.Join(absDir, "plugin.json") data, err := os.ReadFile(manifestPath) if os.IsNotExist(err) { @@ -332,7 +387,7 @@ func runPluginInfo(args []string) error { } // Check binary status. - binaryPath := filepath.Join(pluginDir, pluginName) + binaryPath := filepath.Join(absDir, pluginName) if info, statErr := os.Stat(binaryPath); statErr == nil { fmt.Printf("Binary: %s (%d bytes)\n", binaryPath, info.Size()) if info.Mode()&0111 != 0 { diff --git a/cmd/wfctl/plugin_install_e2e_test.go b/cmd/wfctl/plugin_install_e2e_test.go index efd9efa1..84b69305 100644 --- a/cmd/wfctl/plugin_install_e2e_test.go +++ b/cmd/wfctl/plugin_install_e2e_test.go @@ -217,6 +217,85 @@ func TestPluginInstallE2E(t *testing.T) { } } +// TestPluginInstallRespectsPluginDir verifies that -plugin-dir is honoured: +// the install path uses the custom directory, not the default data/plugins. +func TestPluginInstallRespectsPluginDir(t *testing.T) { + const pluginName = "dir-test-plugin" + binaryContent := []byte("#!/bin/sh\necho dir-test\n") + + topDir := fmt.Sprintf("%s-%s-%s", pluginName, runtime.GOOS, runtime.GOARCH) + tarball := buildTarGz(t, map[string][]byte{ + topDir + "/" + pluginName: binaryContent, + }, 0755) + checksum := sha256Hex(tarball) + + tarSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + w.Write(tarball) //nolint:errcheck + })) + defer tarSrv.Close() + + manifest := &RegistryManifest{ + Name: pluginName, + Version: "1.0.0", + Type: "external", + Downloads: []PluginDownload{{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + URL: tarSrv.URL + "/" + pluginName + ".tar.gz", + SHA256: checksum, + }}, + } + + // Use a custom plugin dir (not defaultDataDir). + customDir := t.TempDir() + + // Perform the install steps that runPluginInstall does, using the custom dir. + mr := NewMultiRegistryFromSources(&mockRegistrySource{ + name: "test", + manifests: map[string]*RegistryManifest{pluginName: manifest}, + }) + + gotManifest, _, err := mr.FetchManifest(pluginName) + if err != nil { + t.Fatalf("FetchManifest: %v", err) + } + + dl, err := gotManifest.FindDownload(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatalf("FindDownload: %v", err) + } + + data, err := downloadURL(dl.URL) + if err != nil { + t.Fatalf("downloadURL: %v", err) + } + + if err := verifyChecksum(data, dl.SHA256); err != nil { + t.Fatalf("verifyChecksum: %v", err) + } + + // Install into customDir, not defaultDataDir. + destDir := filepath.Join(customDir, pluginName) + if err := os.MkdirAll(destDir, 0750); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := extractTarGz(data, destDir); err != nil { + t.Fatalf("extractTarGz: %v", err) + } + + // Plugin binary must exist in customDir, not in defaultDataDir. + binaryPath := filepath.Join(customDir, pluginName, pluginName) + if _, err := os.Stat(binaryPath); os.IsNotExist(err) { + t.Errorf("plugin binary not found in customDir %s", customDir) + } + defaultPath := filepath.Join(defaultDataDir, pluginName, pluginName) + if _, err := os.Stat(defaultPath); err == nil { + t.Errorf("plugin binary unexpectedly found in defaultDataDir %s", defaultDataDir) + } +} + // TestExtractTarGz verifies that tar.gz extraction produces correct files with preserved modes. func TestExtractTarGz(t *testing.T) { entries := map[string][]byte{ diff --git a/cmd/wfctl/plugin_install_test.go b/cmd/wfctl/plugin_install_test.go new file mode 100644 index 00000000..4ea0d1fa --- /dev/null +++ b/cmd/wfctl/plugin_install_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestPluginListAcceptsPluginDirFlag verifies that -plugin-dir is accepted by +// runPluginList and correctly used as the directory to scan. +func TestPluginListAcceptsPluginDirFlag(t *testing.T) { + dir := t.TempDir() + + // Create a fake installed plugin directory with a minimal plugin.json. + pluginDir := filepath.Join(dir, "myplugin") + if err := os.MkdirAll(pluginDir, 0750); err != nil { + t.Fatalf("mkdir: %v", err) + } + manifest := `{"name":"myplugin","version":"1.0.0","author":"test","description":"test plugin"}` + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0640); err != nil { + t.Fatalf("write plugin.json: %v", err) + } + + // Should succeed using -plugin-dir. + if err := runPluginList([]string{"-plugin-dir", dir}); err != nil { + t.Errorf("-plugin-dir: runPluginList returned unexpected error: %v", err) + } +} + +// TestParseGitHubPluginRef verifies that parseGitHubRef correctly identifies GitHub refs. +func TestParseGitHubPluginRef(t *testing.T) { + tests := []struct { + input string + owner string + repo string + version string + isGH bool + }{ + {"GoCodeAlone/workflow-plugin-authz@v0.3.1", "GoCodeAlone", "workflow-plugin-authz", "v0.3.1", true}, + {"GoCodeAlone/workflow-plugin-authz", "GoCodeAlone", "workflow-plugin-authz", "", true}, + {"authz", "", "", "", false}, + {"workflow-plugin-authz", "", "", "", false}, + {"owner/repo@v1.0.0", "owner", "repo", "v1.0.0", true}, + } + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + owner, repo, version, isGH := parseGitHubRef(tc.input) + if owner != tc.owner || repo != tc.repo || version != tc.version || isGH != tc.isGH { + t.Errorf("parseGitHubRef(%q) = (%q, %q, %q, %v), want (%q, %q, %q, %v)", + tc.input, owner, repo, version, isGH, + tc.owner, tc.repo, tc.version, tc.isGH) + } + }) + } +} + +// TestPluginListAcceptsLegacyDataDirFlag verifies that the deprecated -data-dir flag +// still works as an alias for -plugin-dir. +func TestPluginListAcceptsLegacyDataDirFlag(t *testing.T) { + dir := t.TempDir() + + // Should succeed using -data-dir (deprecated alias). + if err := runPluginList([]string{"-data-dir", dir}); err != nil { + t.Errorf("-data-dir: runPluginList returned unexpected error: %v", err) + } +} + diff --git a/cmd/wfctl/plugin_lockfile.go b/cmd/wfctl/plugin_lockfile.go new file mode 100644 index 00000000..64e19dc4 --- /dev/null +++ b/cmd/wfctl/plugin_lockfile.go @@ -0,0 +1,129 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +const wfctlYAMLPath = ".wfctl.yaml" + +// PluginLockEntry records a pinned plugin version in the lockfile. +type PluginLockEntry struct { + Version string `yaml:"version"` + Repository string `yaml:"repository,omitempty"` + SHA256 string `yaml:"sha256,omitempty"` +} + +// PluginLockfile represents the plugins section of .wfctl.yaml. +// It preserves all other keys in the file for safe round-trip writes. +type PluginLockfile struct { + Plugins map[string]PluginLockEntry + raw map[string]any // preserved for round-trip writes +} + +// loadPluginLockfile reads path and returns the plugins section. +// If the file does not exist, an empty lockfile is returned without error. +func loadPluginLockfile(path string) (*PluginLockfile, error) { + lf := &PluginLockfile{ + Plugins: make(map[string]PluginLockEntry), + raw: make(map[string]any), + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return lf, nil + } + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + if err := yaml.Unmarshal(data, &lf.raw); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + // Extract and parse the plugins section if present. + if pluginsRaw, ok := lf.raw["plugins"]; ok && pluginsRaw != nil { + pluginsData, err := yaml.Marshal(pluginsRaw) + if err != nil { + return nil, fmt.Errorf("re-marshal plugins section: %w", err) + } + if err := yaml.Unmarshal(pluginsData, &lf.Plugins); err != nil { + return nil, fmt.Errorf("parse plugins section: %w", err) + } + } + return lf, nil +} + +// installFromLockfile reads .wfctl.yaml and installs all plugins in the +// plugins section. If no lockfile is found, it prints a helpful message. +func installFromLockfile(pluginDir, cfgPath string) error { + lf, err := loadPluginLockfile(wfctlYAMLPath) + if err != nil { + return fmt.Errorf("load lockfile: %w", err) + } + if len(lf.Plugins) == 0 { + fmt.Println("No plugins pinned in .wfctl.yaml.") + fmt.Println("Run 'wfctl plugin install @' to install and pin a plugin.") + return nil + } + var failed []string + for name, entry := range lf.Plugins { + fmt.Fprintf(os.Stderr, "Installing %s %s...\n", name, entry.Version) + installArgs := []string{"--plugin-dir", pluginDir} + if cfgPath != "" { + installArgs = append(installArgs, "--config", cfgPath) + } + // Pass just the name (no @version) so runPluginInstall does not + // call updateLockfile and inadvertently overwrite the pinned entry. + installArgs = append(installArgs, name) + if err := runPluginInstall(installArgs); err != nil { + fmt.Fprintf(os.Stderr, "error installing %s: %v\n", name, err) + failed = append(failed, name) + } + } + if len(failed) > 0 { + return fmt.Errorf("failed to install: %s", strings.Join(failed, ", ")) + } + return nil +} + +// updateLockfile adds or updates a plugin entry in .wfctl.yaml. +// Silently no-ops if the lockfile cannot be read or written (install still succeeds). +func updateLockfile(pluginName, version, repository string) { + lf, err := loadPluginLockfile(wfctlYAMLPath) + if err != nil { + return + } + if lf.Plugins == nil { + lf.Plugins = make(map[string]PluginLockEntry) + } + lf.Plugins[pluginName] = PluginLockEntry{ + Version: version, + Repository: repository, + } + _ = lf.Save(wfctlYAMLPath) +} + +// Save writes the lockfile back to path, updating the plugins section while +// preserving all other fields (project, git, deploy, etc.). +func (lf *PluginLockfile) Save(path string) error { + if lf.raw == nil { + lf.raw = make(map[string]any) + } + // Re-encode the typed plugins map into a yaml-compatible representation. + pluginsData, err := yaml.Marshal(lf.Plugins) + if err != nil { + return fmt.Errorf("marshal plugins: %w", err) + } + var pluginsRaw any + if err := yaml.Unmarshal(pluginsData, &pluginsRaw); err != nil { + return fmt.Errorf("re-unmarshal plugins: %w", err) + } + lf.raw["plugins"] = pluginsRaw + + data, err := yaml.Marshal(lf.raw) + if err != nil { + return fmt.Errorf("marshal lockfile: %w", err) + } + return os.WriteFile(path, data, 0600) //nolint:gosec // G306: .wfctl.yaml is user-owned project config +} diff --git a/cmd/wfctl/plugin_lockfile_test.go b/cmd/wfctl/plugin_lockfile_test.go new file mode 100644 index 00000000..444c18f4 --- /dev/null +++ b/cmd/wfctl/plugin_lockfile_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +const twoPluginLockfile = `project: + name: my-project + version: "1.0.0" +git: + repository: GoCodeAlone/my-project +plugins: + authz: + version: v0.3.1 + repository: GoCodeAlone/workflow-plugin-authz + sha256: abc123deadbeef + payments: + version: v0.1.0 + repository: GoCodeAlone/workflow-plugin-payments +` + +func TestLoadPluginLockfile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".wfctl.yaml") + if err := os.WriteFile(path, []byte(twoPluginLockfile), 0600); err != nil { + t.Fatal(err) + } + + lf, err := loadPluginLockfile(path) + if err != nil { + t.Fatalf("loadPluginLockfile: %v", err) + } + if len(lf.Plugins) != 2 { + t.Fatalf("want 2 plugins, got %d", len(lf.Plugins)) + } + + authz, ok := lf.Plugins["authz"] + if !ok { + t.Fatal("expected 'authz' plugin entry") + } + if authz.Version != "v0.3.1" { + t.Errorf("authz.Version = %q, want v0.3.1", authz.Version) + } + if authz.Repository != "GoCodeAlone/workflow-plugin-authz" { + t.Errorf("authz.Repository = %q, want GoCodeAlone/workflow-plugin-authz", authz.Repository) + } + if authz.SHA256 != "abc123deadbeef" { + t.Errorf("authz.SHA256 = %q, want abc123deadbeef", authz.SHA256) + } + + payments, ok := lf.Plugins["payments"] + if !ok { + t.Fatal("expected 'payments' plugin entry") + } + if payments.Version != "v0.1.0" { + t.Errorf("payments.Version = %q, want v0.1.0", payments.Version) + } +} + +func TestLoadPluginLockfile_Missing(t *testing.T) { + lf, err := loadPluginLockfile("/nonexistent/.wfctl.yaml") + if err != nil { + t.Fatalf("expected no error for missing file, got: %v", err) + } + if len(lf.Plugins) != 0 { + t.Errorf("expected empty plugins for missing file, got %v", lf.Plugins) + } +} + +func TestLoadPluginLockfile_NoPluginsSection(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".wfctl.yaml") + content := "project:\n name: my-project\ngit:\n repository: GoCodeAlone/my-project\n" + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + + lf, err := loadPluginLockfile(path) + if err != nil { + t.Fatalf("loadPluginLockfile: %v", err) + } + if len(lf.Plugins) != 0 { + t.Errorf("expected empty plugins map, got %v", lf.Plugins) + } +} + +func TestPluginLockfile_Save_RoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".wfctl.yaml") + + // Write initial file with non-plugin sections + initial := "project:\n name: my-project\ngit:\n repository: GoCodeAlone/my-project\n" + if err := os.WriteFile(path, []byte(initial), 0600); err != nil { + t.Fatal(err) + } + + // Load, add plugin, save + lf, err := loadPluginLockfile(path) + if err != nil { + t.Fatalf("loadPluginLockfile: %v", err) + } + lf.Plugins["authz"] = PluginLockEntry{ + Version: "v0.3.1", + Repository: "GoCodeAlone/workflow-plugin-authz", + } + if err := lf.Save(path); err != nil { + t.Fatalf("Save: %v", err) + } + + // Reload and verify + lf2, err := loadPluginLockfile(path) + if err != nil { + t.Fatalf("reload: %v", err) + } + if len(lf2.Plugins) != 1 { + t.Fatalf("want 1 plugin after reload, got %d", len(lf2.Plugins)) + } + authz := lf2.Plugins["authz"] + if authz.Version != "v0.3.1" { + t.Errorf("authz.Version = %q, want v0.3.1", authz.Version) + } + // Verify that the non-plugin fields are preserved + if lf2.raw["project"] == nil { + t.Error("expected 'project' field to be preserved after save") + } + if lf2.raw["git"] == nil { + t.Error("expected 'git' field to be preserved after save") + } +} diff --git a/cmd/wfctl/registry_cmd.go b/cmd/wfctl/registry_cmd.go index d690530d..07779e7f 100644 --- a/cmd/wfctl/registry_cmd.go +++ b/cmd/wfctl/registry_cmd.go @@ -59,6 +59,9 @@ func runRegistryList(args []string) error { } func runRegistryAdd(args []string) error { + if err := checkTrailingFlags(args); err != nil { + return err + } fs := flag.NewFlagSet("registry add", flag.ContinueOnError) cfgPath := fs.String("config", "", "Registry config file path (default: ~/.config/wfctl/config.yaml)") regType := fs.String("type", "github", "Registry type (github)") @@ -121,6 +124,9 @@ func runRegistryAdd(args []string) error { } func runRegistryRemove(args []string) error { + if err := checkTrailingFlags(args); err != nil { + return err + } fs := flag.NewFlagSet("registry remove", flag.ContinueOnError) cfgPath := fs.String("config", "", "Registry config file path (default: ~/.config/wfctl/config.yaml)") fs.Usage = func() { diff --git a/cmd/wfctl/templates/api-service/Dockerfile.tmpl b/cmd/wfctl/templates/api-service/Dockerfile.tmpl index c84a7cc3..62efe142 100644 --- a/cmd/wfctl/templates/api-service/Dockerfile.tmpl +++ b/cmd/wfctl/templates/api-service/Dockerfile.tmpl @@ -2,7 +2,8 @@ FROM golang:1.22-alpine AS builder WORKDIR /app -COPY go.mod go.sum ./ +COPY go.mod ./ +COPY go.sum* ./ RUN go mod download COPY . . diff --git a/cmd/wfctl/templates/event-processor/Dockerfile.tmpl b/cmd/wfctl/templates/event-processor/Dockerfile.tmpl index c84a7cc3..62efe142 100644 --- a/cmd/wfctl/templates/event-processor/Dockerfile.tmpl +++ b/cmd/wfctl/templates/event-processor/Dockerfile.tmpl @@ -2,7 +2,8 @@ FROM golang:1.22-alpine AS builder WORKDIR /app -COPY go.mod go.sum ./ +COPY go.mod ./ +COPY go.sum* ./ RUN go mod download COPY . . diff --git a/cmd/wfctl/templates/full-stack/Dockerfile.tmpl b/cmd/wfctl/templates/full-stack/Dockerfile.tmpl index 8a8f8b02..379bba1f 100644 --- a/cmd/wfctl/templates/full-stack/Dockerfile.tmpl +++ b/cmd/wfctl/templates/full-stack/Dockerfile.tmpl @@ -9,7 +9,8 @@ RUN npm run build FROM golang:1.22-alpine AS go-builder WORKDIR /app -COPY go.mod go.sum ./ +COPY go.mod ./ +COPY go.sum* ./ RUN go mod download COPY . . COPY --from=ui-builder /app/ui/dist ./ui/dist diff --git a/cmd/wfctl/validate.go b/cmd/wfctl/validate.go index e3e3d766..295c6b64 100644 --- a/cmd/wfctl/validate.go +++ b/cmd/wfctl/validate.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "flag" "fmt" "os" @@ -9,6 +10,7 @@ import ( "github.com/GoCodeAlone/workflow/config" "github.com/GoCodeAlone/workflow/schema" + "gopkg.in/yaml.v3" ) func runValidate(args []string) error { @@ -60,7 +62,13 @@ Options: if err != nil { return fmt.Errorf("failed to scan directory %s: %w", *dir, err) } - files = append(files, found...) + for _, f := range found { + if !isWorkflowYAML(f) { + fmt.Fprintf(os.Stderr, " Skipping non-workflow file: %s\n", f) + continue + } + files = append(files, f) + } } files = append(files, fs.Args()...) @@ -107,11 +115,18 @@ Options: } func validateFile(cfgPath string, strict, skipUnknownTypes, allowNoEntryPoints bool) error { + // Read raw YAML to extract imports list for verbose feedback. + imports := extractImports(cfgPath) + cfg, err := config.LoadFromFile(cfgPath) if err != nil { return fmt.Errorf("failed to load: %w", err) } + if len(imports) > 0 { + fmt.Fprintf(os.Stderr, " Resolved %d import(s): %s\n", len(imports), strings.Join(imports, ", ")) + } + var opts []schema.ValidationOption if !strict { opts = append(opts, schema.WithAllowEmptyModules()) @@ -156,6 +171,27 @@ var skipFiles = map[string]bool{ "dashboard.yaml": true, } +// isWorkflowYAML reports whether the YAML file at path looks like a workflow +// config by checking the first 100 lines for top-level keys: modules:, +// workflows:, or pipelines:. +func isWorkflowYAML(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + scanner := bufio.NewScanner(f) + for i := 0; i < 100 && scanner.Scan(); i++ { + line := scanner.Text() + if strings.HasPrefix(line, "modules:") || + strings.HasPrefix(line, "workflows:") || + strings.HasPrefix(line, "pipelines:") { + return true + } + } + return false +} + func findYAMLFiles(root string) ([]string, error) { var files []string err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { @@ -181,6 +217,22 @@ func findYAMLFiles(root string) ([]string, error) { return files, err } +// extractImports reads the raw YAML at path and returns the top-level imports: list. +// Returns nil if the file cannot be read or has no imports. +func extractImports(path string) []string { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var raw struct { + Imports []string `yaml:"imports"` + } + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil + } + return raw.Imports +} + func indentError(err error) string { return strings.ReplaceAll(err.Error(), "\n", "\n ") } diff --git a/cmd/wfctl/validate_test.go b/cmd/wfctl/validate_test.go new file mode 100644 index 00000000..cdf86269 --- /dev/null +++ b/cmd/wfctl/validate_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestValidateSkipsNonWorkflowYAML(t *testing.T) { + dir := t.TempDir() + + // GitHub Actions CI file — should NOT be recognized as workflow YAML + ciYAML := `name: CI +on: [push] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 +` + ciPath := filepath.Join(dir, "ci.yml") + if err := os.WriteFile(ciPath, []byte(ciYAML), 0644); err != nil { + t.Fatal(err) + } + + // Workflow engine config — should be recognized + appYAML := `modules: + - name: server + type: http.server + config: + address: ":8080" +` + appPath := filepath.Join(dir, "app.yaml") + if err := os.WriteFile(appPath, []byte(appYAML), 0644); err != nil { + t.Fatal(err) + } + + if isWorkflowYAML(ciPath) { + t.Errorf("isWorkflowYAML(%q) = true, want false (GitHub Actions file)", ciPath) + } + if !isWorkflowYAML(appPath) { + t.Errorf("isWorkflowYAML(%q) = false, want true (workflow config)", appPath) + } +} + +func TestIsWorkflowYAMLVariants(t *testing.T) { + dir := t.TempDir() + + write := func(name, content string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(content), 0644); err != nil { + t.Fatal(err) + } + return p + } + + cases := []struct { + name string + content string + want bool + }{ + {"with-modules", "modules:\n - name: x\n", true}, + {"with-workflows", "workflows:\n http: {}\n", true}, + {"with-pipelines", "pipelines:\n - name: p\n", true}, + {"non-workflow", "name: CI\non: [push]\n", false}, + {"indented-modules", " modules:\n - name: x\n", false}, // indented, not top-level + {"empty", "", false}, + } + + for _, tc := range cases { + p := write(tc.name+".yaml", tc.content) + got := isWorkflowYAML(p) + if got != tc.want { + t.Errorf("isWorkflowYAML(%q) = %v, want %v (content: %q)", tc.name, got, tc.want, tc.content) + } + } +} + +func TestValidateDirSkipsNonWorkflowFiles(t *testing.T) { + dir := t.TempDir() + + // Write a non-workflow YAML (GitHub Actions style) + ciYAML := `name: CI +on: [push] +jobs: + build: + runs-on: ubuntu-latest +` + if err := os.WriteFile(filepath.Join(dir, "ci.yml"), []byte(ciYAML), 0644); err != nil { + t.Fatal(err) + } + + // Write a valid workflow config + appYAML := `modules: + - name: server + type: http.server + config: + address: ":8080" +` + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(appYAML), 0644); err != nil { + t.Fatal(err) + } + + // --dir should succeed: ci.yml is skipped, app.yaml passes validation + if err := runValidate([]string{"--dir", dir}); err != nil { + t.Fatalf("runValidate --dir: %v", err) + } +} diff --git a/docs/plans/2026-03-11-integration-plugins-wave2-design.md b/docs/plans/2026-03-11-integration-plugins-wave2-design.md new file mode 100644 index 00000000..f48758c3 --- /dev/null +++ b/docs/plans/2026-03-11-integration-plugins-wave2-design.md @@ -0,0 +1,263 @@ +# Integration Plugins Wave 2 Design: Okta, Datadog, LaunchDarkly, Permit.io, Salesforce, OpenLMS + +**Date**: 2026-03-11 +**Status**: Approved + +## Overview + +Five new external gRPC plugins for the workflow engine, plus Permit.io integrated as a new provider in the existing `workflow-plugin-authz`. Continues the integration plugin pattern from wave 1 (Twilio, monday.com, turn.io). All new repos are MIT-licensed, open-source, community-tier. + +## Common Architecture + +Identical to wave 1 — see `2026-03-11-integration-plugins-design.md`. Each **new** plugin: +- Standalone Go repo: `GoCodeAlone/workflow-plugin-` +- `sdk.Serve(provider)` entry point +- PluginProvider + ModuleProvider + StepProvider interfaces +- One module type per plugin (`.provider`) +- Package-level provider registry with `sync.RWMutex` +- GoReleaser v2, `CGO_ENABLED=0`, linux/darwin x amd64/arm64 +- MIT license, community tier, minEngineVersion `0.3.30` + +**Exception — Permit.io**: Added as a provider to the existing `workflow-plugin-authz` (alongside Casbin), following the multi-provider pattern used in `workflow-plugin-payments` (Stripe + PayPal). New module type: `permit.provider`. New step types prefixed `step.permit_`. + +--- + +## Plugin 1: workflow-plugin-okta + +**Dependency**: `github.com/okta/okta-sdk-golang/v6` v6.0.3 (official, auto-generated from OpenAPI spec) + +**Module**: `okta.provider` +- Config: `orgUrl` (required, e.g. `https://dev-123456.okta.com`), `apiToken` (for SSWS auth) OR `clientId` + `privateKey` (for OAuth 2.0 JWT), optional `scopes` +- Initializes Okta SDK client + +### Step Types (~130 priority steps, all prefixed `step.okta_`) + +| Category | Steps | Count | +|----------|-------|-------| +| Users — CRUD | `user_create`, `user_get`, `user_list`, `user_update`, `user_delete` | 5 | +| Users — Lifecycle | `user_activate`, `user_deactivate`, `user_reactivate`, `user_suspend`, `user_unsuspend`, `user_unlock`, `user_reset_factors` | 7 | +| Users — Credentials | `user_change_password`, `user_reset_password`, `user_expire_password`, `user_set_recovery_question` | 4 | +| Groups — CRUD | `group_create`, `group_get`, `group_list`, `group_delete`, `group_add_user`, `group_remove_user`, `group_list_users` | 7 | +| Group Rules | `group_rule_create`, `group_rule_get`, `group_rule_list`, `group_rule_delete`, `group_rule_activate`, `group_rule_deactivate` | 6 | +| Applications — Core | `app_create`, `app_get`, `app_list`, `app_update`, `app_delete`, `app_activate`, `app_deactivate` | 7 | +| Applications — Users | `app_user_assign`, `app_user_get`, `app_user_list`, `app_user_update`, `app_user_unassign` | 5 | +| Applications — Groups | `app_group_assign`, `app_group_get`, `app_group_list`, `app_group_update`, `app_group_unassign` | 5 | +| Authorization Servers | `authz_server_create`, `authz_server_get`, `authz_server_list`, `authz_server_update`, `authz_server_delete`, `authz_server_activate`, `authz_server_deactivate` | 7 | +| Auth Server — Claims/Scopes/Policies | `authz_claim_create`, `authz_claim_list`, `authz_claim_delete`, `authz_scope_create`, `authz_scope_list`, `authz_scope_delete`, `authz_policy_create`, `authz_policy_list`, `authz_policy_delete`, `authz_policy_rule_create`, `authz_policy_rule_list`, `authz_policy_rule_delete`, `authz_key_list`, `authz_key_rotate` | 14 | +| Policies | `policy_create`, `policy_get`, `policy_list`, `policy_delete`, `policy_activate`, `policy_deactivate`, `policy_rule_create`, `policy_rule_list`, `policy_rule_delete`, `policy_rule_activate`, `policy_rule_deactivate` | 11 | +| Authenticators (MFA) | `authenticator_create`, `authenticator_get`, `authenticator_list`, `authenticator_activate`, `authenticator_deactivate` | 5 | +| User Factors | `factor_enroll`, `factor_list`, `factor_verify`, `factor_unenroll`, `factor_activate` | 5 | +| Identity Providers | `idp_create`, `idp_get`, `idp_list`, `idp_delete`, `idp_activate`, `idp_deactivate` | 6 | +| Sessions | `session_get`, `session_refresh`, `session_revoke` | 3 | +| Network Zones | `network_zone_create`, `network_zone_get`, `network_zone_list`, `network_zone_delete`, `network_zone_activate`, `network_zone_deactivate` | 6 | +| System Log | `log_list` | 1 | +| Event Hooks | `event_hook_create`, `event_hook_get`, `event_hook_list`, `event_hook_delete`, `event_hook_activate`, `event_hook_deactivate`, `event_hook_verify` | 7 | +| Inline Hooks | `inline_hook_create`, `inline_hook_get`, `inline_hook_list`, `inline_hook_delete`, `inline_hook_activate`, `inline_hook_deactivate`, `inline_hook_execute` | 7 | +| Domains | `domain_create`, `domain_get`, `domain_list`, `domain_delete`, `domain_verify` | 5 | +| Brands & Themes | `brand_get`, `brand_list`, `brand_update`, `theme_get`, `theme_list`, `theme_update` | 6 | +| Org Settings | `org_get`, `org_update` | 2 | + +--- + +## Plugin 2: workflow-plugin-datadog + +**Dependency**: `github.com/DataDog/datadog-api-client-go/v2` v2.56.0 (official, auto-generated) + +**Module**: `datadog.provider` +- Config: `apiKey` (required), `appKey` (required), optional `site` (default `datadoghq.com`), optional `apiUrl` +- Sets up Datadog client context with API + app keys + +### Step Types (~120 priority steps, all prefixed `step.datadog_`) + +| Category | Steps | Count | +|----------|-------|-------| +| Metrics | `metric_submit`, `metric_query`, `metric_query_scalar`, `metric_metadata_get`, `metric_metadata_update`, `metric_list_active`, `metric_tag_config_create`, `metric_tag_config_update`, `metric_tag_config_delete`, `metric_tag_config_list` | 10 | +| Events | `event_create`, `event_get`, `event_list`, `event_search` | 4 | +| Monitors | `monitor_create`, `monitor_get`, `monitor_update`, `monitor_delete`, `monitor_list`, `monitor_search`, `monitor_validate` | 7 | +| Dashboards | `dashboard_create`, `dashboard_get`, `dashboard_update`, `dashboard_delete`, `dashboard_list` | 5 | +| Logs | `log_submit`, `log_search`, `log_aggregate`, `log_archive_create`, `log_archive_list`, `log_archive_delete`, `log_pipeline_create`, `log_pipeline_list`, `log_pipeline_delete` | 9 | +| Synthetics | `synthetics_test_create`, `synthetics_test_get`, `synthetics_test_update`, `synthetics_test_delete`, `synthetics_test_list`, `synthetics_test_trigger`, `synthetics_results_get`, `synthetics_global_var_create`, `synthetics_global_var_list`, `synthetics_global_var_delete` | 10 | +| SLOs | `slo_create`, `slo_get`, `slo_update`, `slo_delete`, `slo_list`, `slo_search`, `slo_history_get` | 7 | +| Downtimes | `downtime_create`, `downtime_get`, `downtime_update`, `downtime_cancel`, `downtime_list` | 5 | +| Incidents | `incident_create`, `incident_get`, `incident_update`, `incident_delete`, `incident_list`, `incident_todo_create`, `incident_todo_update`, `incident_todo_delete` | 8 | +| Security | `security_rule_create`, `security_rule_get`, `security_rule_update`, `security_rule_delete`, `security_rule_list`, `security_signal_list`, `security_signal_state_update` | 7 | +| Users | `user_create`, `user_get`, `user_update`, `user_disable`, `user_list`, `user_invite` | 6 | +| Roles | `role_create`, `role_get`, `role_update`, `role_delete`, `role_list`, `role_permission_add`, `role_permission_remove` | 7 | +| Teams | `team_create`, `team_get`, `team_update`, `team_delete`, `team_list`, `team_member_add`, `team_member_remove` | 7 | +| Key Management | `api_key_create`, `api_key_get`, `api_key_update`, `api_key_delete`, `api_key_list`, `app_key_create`, `app_key_list`, `app_key_delete` | 8 | +| Notebooks | `notebook_create`, `notebook_get`, `notebook_update`, `notebook_delete`, `notebook_list` | 5 | +| Hosts | `host_list`, `host_mute`, `host_unmute`, `host_totals_get` | 4 | +| Tags | `tags_get`, `tags_update`, `tags_delete`, `tags_list` | 4 | +| Service Catalog | `service_definition_upsert`, `service_definition_get`, `service_definition_delete`, `service_definition_list` | 4 | +| APM | `apm_retention_filter_create`, `apm_retention_filter_update`, `apm_retention_filter_delete`, `apm_retention_filter_list`, `span_search`, `span_aggregate` | 6 | +| Audit | `audit_log_search`, `audit_log_list` | 2 | + +--- + +## Plugin 3: workflow-plugin-launchdarkly + +**Dependency**: `github.com/launchdarkly/api-client-go/v22` (official, auto-generated from OpenAPI) + +**Module**: `launchdarkly.provider` +- Config: `apiKey` (required), optional `apiUrl` (default `https://app.launchdarkly.com`) +- Uses context-based auth: `context.WithValue(ctx, ldapi.ContextAPIKey, ...)` + +### Step Types (~100 priority steps, all prefixed `step.launchdarkly_`) + +| Category | Steps | Count | +|----------|-------|-------| +| Feature Flags | `flag_list`, `flag_get`, `flag_create`, `flag_update`, `flag_delete`, `flag_copy`, `flag_status_get`, `flag_status_list` | 8 | +| Projects | `project_list`, `project_get`, `project_create`, `project_update`, `project_delete` | 5 | +| Environments | `environment_list`, `environment_get`, `environment_create`, `environment_update`, `environment_delete`, `environment_reset_sdk_key`, `environment_reset_mobile_key` | 7 | +| Segments | `segment_list`, `segment_get`, `segment_create`, `segment_update`, `segment_delete` | 5 | +| Contexts | `context_list`, `context_get`, `context_search`, `context_kind_list`, `context_kind_upsert`, `context_evaluate` | 6 | +| Metrics | `metric_list`, `metric_get`, `metric_create`, `metric_update`, `metric_delete` | 5 | +| Experiments | `experiment_list`, `experiment_get`, `experiment_create`, `experiment_update`, `experiment_results_get` | 5 | +| Approvals | `approval_list`, `approval_get`, `approval_create`, `approval_delete`, `approval_apply`, `approval_review` | 6 | +| Scheduled Changes | `scheduled_change_list`, `scheduled_change_create`, `scheduled_change_update`, `scheduled_change_delete` | 4 | +| Flag Triggers | `trigger_list`, `trigger_create`, `trigger_get`, `trigger_update`, `trigger_delete` | 5 | +| Workflows | `workflow_list`, `workflow_get`, `workflow_create`, `workflow_delete` | 4 | +| Audit Log | `audit_log_list`, `audit_log_get` | 2 | +| Members | `member_list`, `member_get`, `member_create`, `member_update`, `member_delete` | 5 | +| Teams | `team_list`, `team_get`, `team_create`, `team_update`, `team_delete` | 5 | +| Custom Roles | `role_list`, `role_get`, `role_create`, `role_update`, `role_delete` | 5 | +| Access Tokens | `token_list`, `token_get`, `token_create`, `token_update`, `token_delete`, `token_reset` | 6 | +| Webhooks | `webhook_list`, `webhook_get`, `webhook_create`, `webhook_update`, `webhook_delete` | 5 | +| Relay Proxy | `relay_config_list`, `relay_config_get`, `relay_config_create`, `relay_config_update`, `relay_config_delete` | 5 | +| Release Pipelines | `release_pipeline_list`, `release_pipeline_get`, `release_pipeline_create`, `release_pipeline_update`, `release_pipeline_delete` | 5 | +| Code References | `code_ref_repo_list`, `code_ref_repo_create`, `code_ref_repo_delete`, `code_ref_extinction_list` | 4 | + +--- + +## Plugin 4: Permit.io provider in workflow-plugin-authz + +**Repo**: `GoCodeAlone/workflow-plugin-authz` (existing — add Permit.io as a new provider alongside Casbin) +**New Dependency**: `github.com/permitio/permit-golang` v1.2.8 (official Go SDK) + +**Module**: `permit.provider` (new module type in the authz plugin) +- Config: `apiKey` (required), optional `pdpUrl` (default `https://cloudpdp.api.permit.io`), optional `apiUrl` (default `https://api.permit.io`), optional `project`, `environment` +- Initializes Permit SDK client +- Coexists with the existing `authz.provider` (Casbin) — both can be configured simultaneously + +### Step Types (~80 steps, all prefixed `step.permit_`) + +| Category | Steps | Count | +|----------|-------|-------| +| Authorization Checks | `check`, `check_bulk`, `user_permissions`, `authorized_users` | 4 | +| Users | `user_create`, `user_get`, `user_list`, `user_update`, `user_delete`, `user_sync`, `user_get_roles` | 7 | +| Tenants | `tenant_create`, `tenant_get`, `tenant_list`, `tenant_update`, `tenant_delete`, `tenant_list_users` | 6 | +| Roles (RBAC) | `role_create`, `role_get`, `role_list`, `role_update`, `role_delete`, `role_assign_permissions`, `role_remove_permissions` | 7 | +| Role Assignments | `role_assign`, `role_unassign`, `role_assignment_list`, `role_bulk_assign`, `role_bulk_unassign` | 5 | +| Resources | `resource_create`, `resource_get`, `resource_list`, `resource_update`, `resource_delete` | 5 | +| Resource Actions | `resource_action_create`, `resource_action_get`, `resource_action_list`, `resource_action_update`, `resource_action_delete` | 5 | +| Resource Roles | `resource_role_create`, `resource_role_get`, `resource_role_list`, `resource_role_update`, `resource_role_delete` | 5 | +| Resource Relations (ReBAC) | `resource_relation_create`, `resource_relation_list`, `resource_relation_delete` | 3 | +| Resource Instances (ReBAC) | `resource_instance_create`, `resource_instance_get`, `resource_instance_list`, `resource_instance_update`, `resource_instance_delete` | 5 | +| Relationship Tuples | `relationship_tuple_create`, `relationship_tuple_delete`, `relationship_tuple_list`, `relationship_tuple_bulk_create`, `relationship_tuple_bulk_delete` | 5 | +| Condition Sets (ABAC) | `condition_set_create`, `condition_set_get`, `condition_set_list`, `condition_set_update`, `condition_set_delete` | 5 | +| Projects | `project_create`, `project_get`, `project_list`, `project_update`, `project_delete` | 5 | +| Environments | `env_create`, `env_get`, `env_list`, `env_update`, `env_delete`, `env_copy` | 6 | +| API Keys | `api_key_create`, `api_key_list`, `api_key_delete`, `api_key_rotate` | 4 | +| Organizations | `org_get`, `org_update`, `member_list`, `member_invite`, `member_remove` | 5 | + +--- + +## Plugin 5: workflow-plugin-salesforce + +**Dependency**: `github.com/k-capehart/go-salesforce/v3` v3.1.1 (community, well-maintained) + +**Module**: `salesforce.provider` +- Config: `loginUrl` (required), `clientId`, `clientSecret` (for OAuth client credentials), OR `accessToken` (direct), optional `apiVersion` (default `v63.0`) +- Initializes Salesforce client with OAuth + +### Step Types (~75 steps, all prefixed `step.salesforce_`) + +| Category | Steps | Count | +|----------|-------|-------| +| SObject CRUD | `record_get`, `record_create`, `record_update`, `record_upsert`, `record_delete`, `record_describe`, `describe_global` | 7 | +| SOQL Query | `query`, `query_all` | 2 | +| SOSL Search | `search` | 1 | +| Collections | `collection_insert`, `collection_update`, `collection_upsert`, `collection_delete` | 4 | +| Composite | `composite_request`, `composite_tree` | 2 | +| Bulk API v2 | `bulk_insert`, `bulk_update`, `bulk_upsert`, `bulk_delete`, `bulk_query`, `bulk_query_results`, `bulk_job_status`, `bulk_job_abort` | 8 | +| Tooling | `tooling_query`, `tooling_get`, `tooling_create`, `tooling_update`, `tooling_delete`, `apex_execute` | 6 | +| Apex REST | `apex_get`, `apex_post`, `apex_patch`, `apex_put`, `apex_delete` | 5 | +| Reports | `report_list`, `report_describe`, `report_run`, `dashboard_list`, `dashboard_describe`, `dashboard_refresh` | 6 | +| Approval | `approval_list`, `approval_submit`, `approval_approve`, `approval_reject` | 4 | +| Chatter | `chatter_post`, `chatter_comment`, `chatter_like`, `chatter_feed_list` | 4 | +| Files | `file_upload`, `file_download`, `content_version_create`, `content_document_get`, `content_document_delete` | 5 | +| Users | `user_get`, `user_list`, `user_create`, `user_update`, `identity_get`, `org_limits` | 6 | +| Flows | `flow_list`, `flow_run` | 2 | +| Events | `event_publish` | 1 | +| Metadata | `metadata_describe`, `metadata_list`, `metadata_read`, `metadata_create`, `metadata_update`, `metadata_delete`, `metadata_deploy`, `metadata_retrieve` | 8 | +| Generic | `raw_request` | 1 | + +--- + +## Plugin 6: workflow-plugin-openlms + +**Dependency**: Direct REST client (Moodle Web Services API, form-POST or Catalyst RESTful plugin) + +**Module**: `openlms.provider` +- Config: `siteUrl` (required, e.g. `https://lms.example.com`), `token` (required, Web Services token), optional `restful` (bool, default false — use Catalyst RESTful plugin endpoint) +- Initializes HTTP client with token auth + +### Step Types (~120 priority steps, all prefixed `step.openlms_`) + +| Category | Steps | Count | +|----------|-------|-------| +| Users | `user_create`, `user_update`, `user_delete`, `user_get`, `user_get_by_field`, `user_search` | 6 | +| Courses | `course_create`, `course_update`, `course_delete`, `course_get`, `course_get_by_field`, `course_search`, `course_get_contents`, `course_get_categories`, `course_create_categories`, `course_delete_categories`, `course_duplicate` | 11 | +| Enrollments | `enrol_get_enrolled_users`, `enrol_get_user_courses`, `enrol_manual_enrol`, `enrol_manual_unenrol`, `enrol_self_enrol`, `enrol_get_course_methods` | 6 | +| Grades | `grade_get_grades`, `grade_update_grades`, `grade_get_grade_items`, `grade_get_grades_table` | 4 | +| Assignments | `assign_get_assignments`, `assign_get_submissions`, `assign_get_grades`, `assign_save_submission`, `assign_submit_for_grading`, `assign_save_grade` | 6 | +| Quizzes | `quiz_get_by_course`, `quiz_get_attempts`, `quiz_get_attempt_data`, `quiz_get_attempt_review`, `quiz_start_attempt`, `quiz_save_attempt`, `quiz_process_attempt` | 7 | +| Forums | `forum_get_by_course`, `forum_get_discussions`, `forum_get_posts`, `forum_add_discussion`, `forum_add_post`, `forum_delete_post` | 6 | +| Groups | `group_create`, `group_delete`, `group_get_course_groups`, `group_get_members`, `group_add_members`, `group_delete_members` | 6 | +| Messages | `message_send`, `message_get_messages`, `message_get_conversations`, `message_get_unread_count`, `message_mark_read`, `message_block_user`, `message_unblock_user` | 7 | +| Calendar | `calendar_create_events`, `calendar_delete_events`, `calendar_get_events`, `calendar_get_day_view`, `calendar_get_monthly_view` | 5 | +| Competencies | `competency_create`, `competency_list`, `competency_delete`, `competency_create_framework`, `competency_list_frameworks`, `competency_create_plan`, `competency_list_plans`, `competency_add_to_course`, `competency_grade` | 9 | +| Completion | `completion_get_activities_status`, `completion_get_course_status`, `completion_update_activity`, `completion_mark_self_completed` | 4 | +| Files | `file_get_files`, `file_upload` | 2 | +| Badges | `badge_get_user_badges` | 1 | +| Cohorts | `cohort_create`, `cohort_delete`, `cohort_get`, `cohort_search`, `cohort_add_members`, `cohort_delete_members` | 6 | +| Roles | `role_assign`, `role_unassign` | 2 | +| Notes | `note_create`, `note_get`, `note_delete` | 3 | +| SCORM | `scorm_get_by_course`, `scorm_get_attempt_count`, `scorm_get_scos`, `scorm_get_user_data`, `scorm_insert_tracks`, `scorm_launch_sco` | 6 | +| H5P | `h5p_get_by_course`, `h5p_get_attempts`, `h5p_get_results` | 3 | +| Reports | `reportbuilder_list`, `reportbuilder_get`, `reportbuilder_retrieve` | 3 | +| Site Info | `site_get_info`, `webservice_get_site_info` | 2 | +| Lessons | `lesson_get_by_course`, `lesson_get_pages`, `lesson_get_page_data`, `lesson_launch_attempt`, `lesson_process_page`, `lesson_finish_attempt` | 6 | +| Glossary | `glossary_get_by_course`, `glossary_get_entries`, `glossary_add_entry`, `glossary_delete_entry` | 4 | +| Search | `search_get_results` | 1 | +| Tags | `tag_get_tags`, `tag_update` | 2 | +| LTI | `lti_get_by_course`, `lti_get_tool_launch_data`, `lti_get_tool_types` | 3 | +| xAPI | `xapi_statement_post`, `xapi_get_state`, `xapi_post_state` | 3 | +| Generic | `call_function` | 1 | + +--- + +## Registry Manifests + +Each plugin gets a `manifest.json` in `workflow-registry/plugins//`: +- **type**: `external` +- **tier**: `community` +- **license**: `MIT` +- **minEngineVersion**: `0.3.30` + +## Testing Strategy + +Same as wave 1: unit tests with mock HTTP servers, no live API calls in CI. Test validation, error handling, module lifecycle (Init/Stop). + +## Implementation Order + +All six plugins built in parallel using agent teams. Each plugin is independent. Estimated scope: +- **Okta**: ~130 steps, official SDK +- **Datadog**: ~120 steps, official SDK +- **LaunchDarkly**: ~100 steps, official SDK +- **Permit.io**: ~80 steps, official SDK +- **Salesforce**: ~75 steps, community SDK + direct REST +- **OpenLMS**: ~120 steps, direct REST client (Moodle Web Services) + +**New Repos**: `GoCodeAlone/workflow-plugin-okta`, `GoCodeAlone/workflow-plugin-datadog`, `GoCodeAlone/workflow-plugin-launchdarkly`, `GoCodeAlone/workflow-plugin-salesforce`, `GoCodeAlone/workflow-plugin-openlms` +**Existing Repo (extended)**: `GoCodeAlone/workflow-plugin-authz` (Permit.io provider added) diff --git a/docs/plans/2026-03-11-plugin-releases-and-validation.md b/docs/plans/2026-03-11-plugin-releases-and-validation.md new file mode 100644 index 00000000..85d9945a --- /dev/null +++ b/docs/plans/2026-03-11-plugin-releases-and-validation.md @@ -0,0 +1,205 @@ +# Plugin Releases & Validation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Tag v0.1.0 releases on all untagged plugins, then validate every integration plugin with workflow-scenarios test scenarios. + +**Architecture:** Tag releases trigger GoReleaser via GitHub Actions, producing cross-platform binaries. Validation scenarios use the workflow engine's external plugin loader with mock HTTP servers to test step execution end-to-end. + +**Tech Stack:** GoReleaser v2, GitHub Actions, workflow-scenarios test harness, Go test framework. + +--- + +## Task 1: Tag and Release Untagged Plugins + +The following plugins need `v0.1.0` tags to trigger their first GoReleaser release: + +| Plugin | Repo | Status | +|--------|------|--------| +| workflow-plugin-twilio | GoCodeAlone/workflow-plugin-twilio | no tags | +| workflow-plugin-monday | GoCodeAlone/workflow-plugin-monday | no tags | +| workflow-plugin-turnio | GoCodeAlone/workflow-plugin-turnio | no tags | +| workflow-plugin-auth | GoCodeAlone/workflow-plugin-auth | no tags | +| workflow-plugin-security-scanner | GoCodeAlone/workflow-plugin-security-scanner | no tags | + +**Already tagged** (no action needed): admin v1.0.0, agent v0.3.1, authz v0.3.1, authz-ui v0.1.0, bento v1.0.0, data-protection v0.1.0, github v1.0.0, payments v0.1.0, sandbox v0.1.0, security v0.1.0, supply-chain v0.1.0, waf v0.1.0. + +### Step 1: Verify each untagged plugin builds and tests pass + +For each plugin in the untagged list: +```bash +cd /Users/jon/workspace/workflow-plugin- +go vet ./... +go test ./... -count=1 +go build -o /dev/null ./cmd/workflow-plugin- +``` + +### Step 2: Ensure release workflow exists + +Verify each repo has `.github/workflows/release.yml` and `.goreleaser.yml`. If missing, copy from `workflow-plugin-payments` and adjust the binary name. + +### Step 3: Tag and push + +For each plugin: +```bash +cd /Users/jon/workspace/workflow-plugin- +git tag v0.1.0 +git push origin v0.1.0 +``` + +### Step 4: Verify releases + +Wait for GitHub Actions to complete, then verify: +```bash +gh release view v0.1.0 --repo GoCodeAlone/workflow-plugin- +``` + +Each release should have 4 archives (linux/darwin x amd64/arm64) plus checksums.txt. + +--- + +## Task 2: Create Validation Scenarios for Wave 1 Plugins + +Create 3 new workflow-scenarios (51-53) that test the Twilio, monday.com, and turn.io plugins with mock HTTP backends. Each scenario: + +1. Uses the workflow engine with the external plugin loaded +2. Configures the plugin module with mock server URLs +3. Executes pipelines that call plugin steps +4. Validates step outputs + +### Scenario Pattern + +Each scenario directory: +``` +scenarios/-/ +├── scenario.yaml # Metadata +├── config/app.yaml # Workflow engine config with plugin module + pipelines +├── mock/server.go # Go mock HTTP server for the service API +├── k8s/ # Kubernetes deployment (optional) +└── test/ + └── run.sh # Test script with PASS:/FAIL: assertions +``` + +### Step 1: Create scenario 51-twilio-integration + +**`scenarios/51-twilio-integration/scenario.yaml`:** +```yaml +name: Twilio Integration +id: "51-twilio-integration" +category: C +description: | + Tests workflow-plugin-twilio step types against a mock Twilio API server. + Validates SMS sending, message listing, verification, and call creation. +components: + - workflow (engine) + - workflow-plugin-twilio (external plugin) + - mock Twilio API server +status: testable +version: "1.0" +image: workflow-server:local +port: 8080 +tests: + type: bash + script: test/run.sh +``` + +**`scenarios/51-twilio-integration/config/app.yaml`:** +- Plugin: `workflow-plugin-twilio` binary from PATH or data dir +- Module: `twilio.provider` with `accountSid`, `authToken`, mock server base URL override +- Pipelines testing: `send_sms` → verify output has `sid`, `status`; `list_messages` → verify output has messages array; `send_verification` → verify `status: pending`; `create_call` → verify `sid` returned + +**`scenarios/51-twilio-integration/test/run.sh`:** +- Start mock server (Go binary that returns canned Twilio JSON responses) +- POST to pipeline endpoints +- Assert response contains expected fields +- Tests: send_sms, send_mms, list_messages, fetch_message, send_verification, check_verification, create_call, list_calls, lookup_phone (9 tests minimum) + +### Step 2: Create scenario 52-monday-integration + +Same pattern but for monday.com: +- Mock GraphQL server returning canned monday.com responses +- Pipelines testing: `create_board`, `list_boards`, `create_item`, `list_items`, `create_group`, `query` (generic) +- 8 tests minimum + +### Step 3: Create scenario 53-turnio-integration + +Same pattern but for turn.io: +- Mock REST server returning WhatsApp message responses + rate limit headers +- Pipelines testing: `send_text`, `send_template`, `check_contact`, `list_templates`, `create_flow` +- Verify rate limit header tracking +- 6 tests minimum + +### Step 4: Run tests locally + +```bash +cd /Users/jon/workspace/workflow-scenarios +make test SCENARIO=51-twilio-integration +make test SCENARIO=52-monday-integration +make test SCENARIO=53-turnio-integration +``` + +All tests must pass. + +### Step 5: Commit and push + +```bash +cd /Users/jon/workspace/workflow-scenarios +git add scenarios/51-twilio-integration scenarios/52-monday-integration scenarios/53-turnio-integration +git commit -m "feat: add integration plugin validation scenarios (51-53)" +git push +``` + +--- + +## Task 3: Update scenarios.json Registry + +Add entries for the 3 new scenarios to `scenarios.json`: + +```json +{ + "id": "51-twilio-integration", + "name": "Twilio Integration", + "category": "C", + "status": "testable" +}, +{ + "id": "52-monday-integration", + "name": "monday.com Integration", + "category": "C", + "status": "testable" +}, +{ + "id": "53-turnio-integration", + "name": "turn.io Integration", + "category": "C", + "status": "testable" +} +``` + +--- + +## Task 4: Validate Wave 2 Plugins (after wave 2 implementation) + +After wave 2 plugins are built, create scenarios 54-59: + +| Scenario | Plugin | Key Tests | +|----------|--------|-----------| +| 54-okta-integration | okta | user CRUD, group membership, app assignment, MFA enrollment, auth server config | +| 55-datadog-integration | datadog | metric submit/query, monitor CRUD, event creation, log search, SLO lifecycle | +| 56-launchdarkly-integration | launchdarkly | flag CRUD, project/environment management, segment operations, context evaluation | +| 57-permit-integration | authz (permit provider) | RBAC check, user/role CRUD, resource management, relationship tuples, condition sets | +| 58-salesforce-integration | salesforce | record CRUD, SOQL query, bulk operations, composite requests, approval process | +| 59-openlms-integration | openlms | user/course CRUD, enrollment, grades, quiz lifecycle, assignment submission | + +Same mock server pattern as wave 1. + +--- + +## Key Reference Files + +| File | Purpose | +|------|---------| +| `/Users/jon/workspace/workflow-scenarios/CLAUDE.md` | Scenario harness conventions | +| `/Users/jon/workspace/workflow-scenarios/scenarios/48-payment-processing/` | Reference scenario structure | +| `/Users/jon/workspace/workflow-plugin-payments/.goreleaser.yml` | GoReleaser config template | +| `/Users/jon/workspace/workflow-plugin-payments/.github/workflows/release.yml` | Release workflow template | diff --git a/docs/plans/2026-03-12-wfctl-audit-design.md b/docs/plans/2026-03-12-wfctl-audit-design.md new file mode 100644 index 00000000..c08888cd --- /dev/null +++ b/docs/plans/2026-03-12-wfctl-audit-design.md @@ -0,0 +1,194 @@ +# Design: wfctl CLI Audit & Plugin Ecosystem Improvements + +**Date:** 2026-03-12 +**Status:** Approved +**Scope:** wfctl CLI fixes, workflow-registry data fixes, plugin ecosystem standardization + +## Summary + +Comprehensive audit and fix of the wfctl CLI tool addressing 13 bugs/UX issues found during testing, 5 registry data problems, and establishing a holistic plugin ecosystem plan across workflow-registry and all workflow-plugin-* repos. Addresses workflow PRs #321, #322, and issue #316. + +## Motivation + +- `--help` exits 1 with internal engine errors for all pipeline-dispatched commands +- `plugin install -data-dir` flag is silently ignored — plugins install to wrong directory +- Flags after positional args are silently dropped with no helpful error +- Full plugin names (`workflow-plugin-authz`) don't resolve in registry lookups +- Plugin manifest versions don't match release tags +- No way to install plugins from GitHub URLs directly +- No plugin version pinning or lockfile support +- Inconsistent goreleaser configs across plugin repos produce different tarball layouts + +## A. wfctl CLI Fixes + +### Fix 1: `--help` exits 0 and suppresses engine error + +Root cause: `flag.ErrHelp` propagates through the pipeline engine as a step failure. In `main.go`'s command dispatch, catch `flag.ErrHelp` and `os.Exit(0)` instead of letting it reach the engine error wrapper. Same for the no-args case — show usage and exit 0. + +### Fix 2: `plugin install -plugin-dir` honored + +In `plugin_install.go`, the install logic hardcodes `data/plugins`. Thread the `-plugin-dir` flag value through to the download/extract path. Rename the flag from `-data-dir` to `-plugin-dir` (see Fix 13). + +### Fix 3: Flag ordering — helpful error message + +Go's `flag.FlagSet.Parse` stops at first non-flag arg. Detect unused flags after the positional arg and print: `"error: flags must come before arguments (got -foo after 'bar'). Try: wfctl plugin init -author X bar"`. Add a helper `checkTrailingFlags(args)` used by all subcommands that accept both flags and positional args. + +### Fix 4: Full plugin name resolution + +In the plugin install/search path, strip the `workflow-plugin-` prefix when looking up. So `workflow-plugin-authz` → `authz`. Try the raw name first, then the stripped name. + +### Fix 5: Positional config arg consistency + +Commands like `validate`, `inspect`, `api extract` accept positional config args. `deploy kubernetes generate` and other deploy subcommands don't. Add positional arg support to deploy subcommands using the same pattern as validate. + +### Fix 6: `plugin update` version check + +Before downloading, compare installed `plugin.json` version against registry manifest version. If equal, print "already at latest version (vX.Y.Z)" and skip the download. + +### Fix 7: `init` generates valid Dockerfile + +Generate a Dockerfile that handles missing `go.sum` gracefully: use `COPY go.sum* ./` (glob, no error if missing) followed by `RUN go mod download` which handles both cases. + +### Fix 8: Infra commands — better error + +When no config found, print: `"No infrastructure config found. Create infra.yaml with cloud.account and platform.* modules. Run 'wfctl init --template infra' for a starter config."`. + +### Fix 9: `validate --dir` skips non-workflow YAML + +Before validating a file found by directory scan, check for at least one of `modules:`, `workflows:`, or `pipelines:` as top-level keys. Skip files that don't match with a debug-level message: `"Skipping non-workflow file: .github/workflows/ci.yml"`. + +### Fix 10: `plugin info` absolute paths + +Resolve the binary path to absolute before displaying. + +### Fix 11: PR #322 — `PluginManifest` legacy capabilities + +Add `UnmarshalJSON` on `PluginManifest` that handles `capabilities` as either `[]CapabilityDecl` (new array format) or `{configProvider, moduleTypes, stepTypes, triggerTypes}` (legacy object format), merging the object's type lists into the top-level manifest fields. + +### Fix 12: Validation follows YAML includes + +When a config uses `include:` directives to split config across multiple files, `validate` should recursively resolve and validate the referenced files. Parse the root config, find `include` references, resolve relative paths, and validate each included file in context. + +### Fix 13: Rename `-data-dir` to `-plugin-dir` + +Several commands already use `-plugin-dir` (validate, template validate, mcp, docs generate, modernize). The plugin subcommands (`install`, `list`, `info`, `update`, `remove`) use `-data-dir`. Rename all plugin subcommand flags to `-plugin-dir` for consistency. Keep `-data-dir` as a hidden alias that still works but prints a deprecation notice. + +## B. Registry Data Fixes + +### B1. `agent` manifest type + +Change `type: "internal"` → `type: "builtin"` in `plugins/agent/manifest.json`. The agent plugin ships with the engine as a Go library. + +### B2. `ratchet` manifest downloads + +Add `downloads` entries for linux/darwin x amd64/arm64 pointing to `GoCodeAlone/ratchet` GitHub releases. Currently `downloads: []` which causes validation failure. + +### B3. `authz` manifest name resolution + +Verify the manifest exists at `plugins/authz/manifest.json` and that the `name` field matches what wfctl expects. The PR #321 failure (`"workflow-plugin-authz" not found in registry`) suggests a name mismatch. + +### B4. Version alignment script + +Create `scripts/sync-versions.sh` that queries `gh release view --json tagName` for each external plugin with a `repository` field and compares against the manifest `version`. Report mismatches. Run in CI as a weekly check. + +### B5. Schema validation gap + +The `agent` manifest with `type: "internal"` should have been caught by the JSON Schema validation in CI. Either the schema enum needs updating to match, or CI isn't running properly. Investigate and fix. + +## C. Plugin Ecosystem Plan + +### C1. goreleaser standardization + +Create a reference `.goreleaser.yml` and audit all plugin repos: + +```yaml +# Standard plugin goreleaser config +builds: + - binary: "{{ .ProjectName }}" + goos: [linux, darwin] + goarch: [amd64, arm64] + env: [CGO_ENABLED=0] + ldflags: ["-s", "-w", "-X main.version={{ .Version }}"] + +archives: + - format: tar.gz + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + files: + - plugin.json + +# Template version in plugin.json before build +before: + hooks: + - cmd: sed -i'' -e 's/"version":.*/"version": "{{ .Version }}",/' plugin.json +``` + +Repos to audit and fix: +- workflow-plugin-authz, payments, admin, bento, github, agent +- workflow-plugin-waf, security, sandbox, supply-chain, data-protection +- workflow-plugin-authz-ui, cloud-ui + +### C2. `wfctl plugin install` from GitHub URL + +Support `wfctl plugin install GoCodeAlone/workflow-plugin-authz@v0.3.1`: + +1. First try registry lookup (strip `workflow-plugin-` prefix) +2. If not found and input contains `/`, treat as `owner/repo@version` +3. Query GitHub Releases API: `GET /repos/{owner}/{repo}/releases/tags/{version}` +4. Find asset matching `{repo}_{os}_{arch}.tar.gz` +5. Download, extract, install to `-plugin-dir` + +Falls back gracefully: registry hit → GitHub direct → error with helpful message. + +### C3. Plugin lockfile (`.wfctl.yaml` plugins section) + +Extend `.wfctl.yaml` (already created by `git connect`) with a `plugins:` section: + +```yaml +plugins: + authz: + version: v0.3.1 + repository: GoCodeAlone/workflow-plugin-authz + sha256: abc123... + payments: + version: v0.1.0 + repository: GoCodeAlone/workflow-plugin-payments +``` + +- `wfctl plugin install` (no args): reads lockfile, installs/verifies all entries +- `wfctl plugin install @`: installs and updates lockfile entry +- `wfctl plugin install --save `: shorthand to install latest and pin + +### C4. Engine `minEngineVersion` check + +In the engine's `PluginLoader`, after reading `plugin.json`, compare `minEngineVersion` against the running engine version using semver comparison. If incompatible, log a warning: `"plugin X requires engine >= vY.Z.0, running vA.B.C — may cause runtime failures"`. Don't hard-fail to allow testing. + +### C5. Registry manifest auto-sync CI + +Add a GitHub Action to each plugin repo's release workflow: + +```yaml +# After goreleaser publishes: +- name: Update registry manifest + run: | + # Clone workflow-registry, update manifest version + downloads + checksums + # Open PR to workflow-registry +``` + +This eliminates manual version drift between releases and registry manifests. + +## Testing Strategy + +- Build wfctl, run all commands in `/tmp/wfctl-test` directory +- Test each fix against the specific failure scenario from the audit +- `go test ./cmd/wfctl/...` for unit tests +- `go test ./...` for full suite +- Manual verification of plugin install/update/list lifecycle +- Registry manifest validation via `scripts/validate-manifests.sh` + +## Decisions + +- **`-plugin-dir` over `-data-dir`**: Consistency with existing commands. Hidden alias prevents breakage. +- **Registry name stripping over aliasing**: Simpler than maintaining alias maps. `workflow-plugin-authz` → `authz` covers all cases. +- **Warning over hard-fail for minEngineVersion**: Allows testing newer plugins against older engines without blocking. +- **Lockfile in `.wfctl.yaml` over separate file**: Reuses existing config file, keeps project root clean. +- **goreleaser `sed` hook over Go ldflags for version**: `plugin.json` is a static file that needs the version at build time, not just the binary. diff --git a/docs/plans/2026-03-12-wfctl-audit.md b/docs/plans/2026-03-12-wfctl-audit.md new file mode 100644 index 00000000..3f2aa94c --- /dev/null +++ b/docs/plans/2026-03-12-wfctl-audit.md @@ -0,0 +1,1324 @@ +# wfctl Audit & Plugin Ecosystem Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix 13 wfctl CLI bugs/UX issues, correct registry data, and establish a standardized plugin ecosystem across all repos. + +**Architecture:** Three phases — (A) CLI fixes in `workflow/cmd/wfctl/`, (B) registry data fixes in `workflow-registry/`, (C) plugin ecosystem improvements across repos. All Go changes include tests. Registry changes validated by existing CI. + +**Tech Stack:** Go 1.26, flag package, YAML/JSON parsing, GitHub Releases API, goreleaser + +--- + +## Phase A: wfctl CLI Fixes + +### Task 1: Fix `--help` exit code and engine error leakage + +**Files:** +- Modify: `cmd/wfctl/main.go:108-127` +- Test: `cmd/wfctl/main_test.go` (create if needed) + +**Step 1: Write the failing test** + +```go +// cmd/wfctl/main_test.go +package main + +import ( + "strings" + "testing" +) + +func TestHelpFlagDoesNotLeakEngineError(t *testing.T) { + // The dispatch error for --help should mention "help" but NOT + // "workflow execution failed" or "pipeline.*failed". + // We test the error wrapping logic, not the full engine dispatch. + err := fmt.Errorf("flag: help requested") + if !isHelpRequested(err) { + t.Error("expected isHelpRequested to detect flag.ErrHelp message") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/jon/workspace/workflow && go test ./cmd/wfctl/ -run TestHelpFlag -v` +Expected: FAIL — `isHelpRequested` undefined + +**Step 3: Implement the fix** + +In `main.go`, add a helper and modify the dispatch error handling: + +```go +// Add after the commands map +func isHelpRequested(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "flag: help requested") +} +``` + +Then modify the `main()` function's error handling (around line 118): + +```go + // Replace current no-args handling: + if len(os.Args) < 2 { + _ = cliHandler.Dispatch([]string{"-h"}) + os.Exit(0) // was os.Exit(1) — help is not an error + } + + // ...existing code... + + dispatchErr := cliHandler.DispatchContext(ctx, os.Args[1:]) + stop() + + if dispatchErr != nil { + if isHelpRequested(dispatchErr) { + os.Exit(0) + } + if _, isKnown := commands[cmd]; isKnown { + fmt.Fprintf(os.Stderr, "error: %v\n", dispatchErr) + } + os.Exit(1) + } +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /Users/jon/workspace/workflow && go test ./cmd/wfctl/ -run TestHelpFlag -v` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `cd /Users/jon/workspace/workflow && go test ./cmd/wfctl/ -v -count=1` +Expected: All pass + +**Step 6: Commit** + +```bash +git add cmd/wfctl/main.go cmd/wfctl/main_test.go +git commit -m "fix(wfctl): --help exits 0 and suppresses engine error leakage" +``` + +--- + +### Task 2: Rename `-data-dir` to `-plugin-dir` across plugin subcommands + +**Files:** +- Modify: `cmd/wfctl/plugin_install.go` (lines 69, 171, 223, 248, 275) +- Modify: `cmd/wfctl/plugin.go` (usage text) +- Test: `cmd/wfctl/plugin_install_test.go` (create) + +**Step 1: Write the failing test** + +```go +// cmd/wfctl/plugin_install_test.go +package main + +import ( + "testing" +) + +func TestPluginListAcceptsPluginDirFlag(t *testing.T) { + // Ensure -plugin-dir flag is accepted (not just -data-dir) + err := runPluginList([]string{"-plugin-dir", t.TempDir()}) + if err != nil { + // If it contains "No plugins installed" that's fine — we just want no flag parse error + if !strings.Contains(err.Error(), "No plugins") { + t.Fatalf("unexpected error: %v", err) + } + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./cmd/wfctl/ -run TestPluginListAcceptsPluginDir -v` +Expected: FAIL — flag provided but not defined: -plugin-dir + +**Step 3: Implement the rename** + +In `plugin_install.go`, for each function (`runPluginInstall`, `runPluginList`, `runPluginUpdate`, `runPluginRemove`, `runPluginInfo`): + +1. Change: `fs.String("data-dir", defaultDataDir, "Plugin data directory")` + To: `fs.String("plugin-dir", defaultDataDir, "Plugin directory")` + +2. After each `fs.String("plugin-dir", ...)`, add the hidden alias: +```go + pluginDir := fs.String("plugin-dir", defaultDataDir, "Plugin directory") + // Hidden alias for backwards compatibility + fs.String("data-dir", "", "") + // After parse, if plugin-dir is default but data-dir was set, use data-dir +``` + +Actually, simpler approach — register both flags pointing to the same variable: + +```go + var pluginDirVal string + fs.StringVar(&pluginDirVal, "plugin-dir", defaultDataDir, "Plugin directory") + fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)") +``` + +Apply this pattern to all 5 functions: `runPluginInstall`, `runPluginList`, `runPluginUpdate`, `runPluginRemove`, `runPluginInfo`. + +Also update `pluginUsage()` in `plugin.go` to show `-plugin-dir` in the help text. + +**Step 4: Run test to verify it passes** + +Run: `go test ./cmd/wfctl/ -run TestPluginListAcceptsPluginDir -v` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `go test ./cmd/wfctl/ -v -count=1` + +**Step 6: Commit** + +```bash +git add cmd/wfctl/plugin_install.go cmd/wfctl/plugin.go cmd/wfctl/plugin_install_test.go +git commit -m "fix(wfctl): rename -data-dir to -plugin-dir for consistency + +Keep -data-dir as hidden alias for backwards compatibility." +``` + +--- + +### Task 3: Add trailing flag detection helper + +**Files:** +- Modify: `cmd/wfctl/validate.go` (it already has `reorderFlags` — check its implementation) +- Create: `cmd/wfctl/flag_helpers.go` +- Create: `cmd/wfctl/flag_helpers_test.go` + +**Step 1: Check existing `reorderFlags` in validate.go** + +Read `validate.go` and understand what `reorderFlags` does. If it already reorders flags before positional args, we may be able to reuse it. The approach: detect flags that appear after the first positional arg and print a helpful error. + +**Step 2: Write the failing test** + +```go +// cmd/wfctl/flag_helpers_test.go +package main + +import "testing" + +func TestCheckTrailingFlags(t *testing.T) { + tests := []struct { + args []string + wantErr bool + }{ + {[]string{"-author", "X", "myname"}, false}, // flags before positional — OK + {[]string{"myname", "-author", "X"}, true}, // flags after positional — error + {[]string{"-author", "X", "-output", ".", "myname"}, false}, // all flags before — OK + {[]string{"myname"}, false}, // no flags at all — OK + } + for _, tt := range tests { + err := checkTrailingFlags(tt.args) + if (err != nil) != tt.wantErr { + t.Errorf("checkTrailingFlags(%v) error=%v, wantErr=%v", tt.args, err, tt.wantErr) + } + } +} +``` + +**Step 3: Implement** + +```go +// cmd/wfctl/flag_helpers.go +package main + +import ( + "fmt" + "strings" +) + +// checkTrailingFlags detects flags that appear after the first positional argument +// and returns a helpful error message suggesting the correct ordering. +func checkTrailingFlags(args []string) error { + seenPositional := false + for _, arg := range args { + if strings.HasPrefix(arg, "-") && seenPositional { + return fmt.Errorf("flags must come before arguments (got %s after positional arg). "+ + "Reorder so all flags precede the name argument", arg) + } + if !strings.HasPrefix(arg, "-") { + seenPositional = true + } + } + return nil +} +``` + +**Step 4: Wire into subcommands** + +Add `checkTrailingFlags(args)` call at the top of: `runPluginInit`, `runRegistryAdd`, `runRegistryRemove`. Example for `runPluginInit`: + +```go +func runPluginInit(args []string) error { + if err := checkTrailingFlags(args); err != nil { + return err + } + // ... existing code +``` + +**Step 5: Run tests** + +Run: `go test ./cmd/wfctl/ -run TestCheckTrailingFlags -v` +Expected: PASS + +**Step 6: Commit** + +```bash +git add cmd/wfctl/flag_helpers.go cmd/wfctl/flag_helpers_test.go cmd/wfctl/plugin.go cmd/wfctl/registry_cmd.go +git commit -m "fix(wfctl): detect trailing flags and show helpful error message" +``` + +--- + +### Task 4: Full plugin name resolution (strip `workflow-plugin-` prefix) + +**Files:** +- Modify: `cmd/wfctl/multi_registry.go:45-58` +- Test: `cmd/wfctl/multi_registry_test.go` (create or extend) + +**Step 1: Write the failing test** + +```go +// cmd/wfctl/multi_registry_test.go (add to existing if present) +package main + +import "testing" + +func TestNormalizePluginName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"authz", "authz"}, + {"workflow-plugin-authz", "authz"}, + {"workflow-plugin-payments", "payments"}, + {"custom-plugin", "custom-plugin"}, // no prefix, keep as-is + } + for _, tt := range tests { + got := normalizePluginName(tt.input) + if got != tt.want { + t.Errorf("normalizePluginName(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} +``` + +**Step 2: Implement** + +In `multi_registry.go`, add: + +```go +import "strings" + +// normalizePluginName strips the "workflow-plugin-" prefix if present, +// since the registry uses short names (e.g. "authz" not "workflow-plugin-authz"). +func normalizePluginName(name string) string { + return strings.TrimPrefix(name, "workflow-plugin-") +} +``` + +Update `FetchManifest`: + +```go +func (m *MultiRegistry) FetchManifest(name string) (*RegistryManifest, string, error) { + normalized := normalizePluginName(name) + var lastErr error + // Try normalized name first + for _, src := range m.sources { + manifest, err := src.FetchManifest(normalized) + if err == nil { + return manifest, src.Name(), nil + } + lastErr = err + } + // If normalized != original, also try the original name + if normalized != name { + for _, src := range m.sources { + manifest, err := src.FetchManifest(name) + if err == nil { + return manifest, src.Name(), nil + } + lastErr = err + } + } + if lastErr != nil { + return nil, "", lastErr + } + return nil, "", fmt.Errorf("plugin %q not found in any configured registry", name) +} +``` + +Also update `SearchPlugins` to normalize the query. + +**Step 3: Run tests** + +Run: `go test ./cmd/wfctl/ -run TestNormalizePluginName -v` +Expected: PASS + +**Step 4: Commit** + +```bash +git add cmd/wfctl/multi_registry.go cmd/wfctl/multi_registry_test.go +git commit -m "fix(wfctl): resolve full plugin names by stripping workflow-plugin- prefix" +``` + +--- + +### Task 5: Fix `plugin install -plugin-dir` being ignored + +**Files:** +- Modify: `cmd/wfctl/plugin_install.go:122` + +**Context:** Line 122 uses `*dataDir` (now `pluginDirVal`) correctly after Task 2's rename, but the bug is that `plugin update` calls `runPluginInstall` with `--data-dir` hardcoded at line 243. Fix this call to use `--plugin-dir`. + +**Step 1: Write the failing test** + +```go +func TestPluginInstallRespectsPluginDir(t *testing.T) { + customDir := t.TempDir() + // runPluginInstall with a non-existent plugin will fail at manifest fetch, + // but we can verify the destDir is constructed correctly by checking the + // MkdirAll path in the error when network is unavailable. + err := runPluginInstall([]string{"-plugin-dir", customDir, "nonexistent-test-plugin"}) + if err == nil { + t.Fatal("expected error for nonexistent plugin") + } + // The error should NOT mention "data/plugins" (the default dir) + if strings.Contains(err.Error(), "data/plugins") { + t.Errorf("plugin install ignored -plugin-dir flag, error references default path: %v", err) + } +} +``` + +**Step 2: Verify line 243 in runPluginUpdate** + +Change line 243 from: +```go +return runPluginInstall(append([]string{"--data-dir", *dataDir}, pluginName)) +``` +to: +```go +return runPluginInstall(append([]string{"-plugin-dir", pluginDirVal}, pluginName)) +``` + +(After Task 2, `*dataDir` becomes `pluginDirVal`.) + +**Step 3: Run tests** + +Run: `go test ./cmd/wfctl/ -run TestPluginInstallRespectsPluginDir -v` + +**Step 4: Commit** + +```bash +git add cmd/wfctl/plugin_install.go cmd/wfctl/plugin_install_test.go +git commit -m "fix(wfctl): plugin install respects -plugin-dir flag" +``` + +--- + +### Task 6: `plugin update` version check + +**Files:** +- Modify: `cmd/wfctl/plugin_install.go` (runPluginUpdate function, around line 223) + +**Step 1: Write the failing test** + +```go +func TestReadInstalledVersion(t *testing.T) { + dir := t.TempDir() + manifest := `{"name":"test","version":"1.2.3"}` + os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(manifest), 0644) + ver := readInstalledVersion(dir) + if ver != "1.2.3" { + t.Errorf("readInstalledVersion = %q, want %q", ver, "1.2.3") + } +} +``` + +**Step 2: Implement version check in runPluginUpdate** + +After fetching the manifest but before downloading, compare versions: + +```go + // In runPluginUpdate, after mr.FetchManifest: + installedVer := readInstalledVersion(pluginDir) + if installedVer != "" && installedVer == manifest.Version { + fmt.Printf("%s is already at latest version (v%s)\n", pluginName, installedVer) + return nil + } + if installedVer != "" { + fmt.Fprintf(os.Stderr, "Updating %s from v%s to v%s...\n", pluginName, installedVer, manifest.Version) + } +``` + +**Step 3: Run tests and commit** + +```bash +git add cmd/wfctl/plugin_install.go cmd/wfctl/plugin_install_test.go +git commit -m "fix(wfctl): plugin update checks version before re-downloading" +``` + +--- + +### Task 7: Deploy subcommands accept positional config arg + +**Files:** +- Modify: `cmd/wfctl/deploy.go` (kubernetes generate and other subcommands) + +**Step 1: Identify the pattern** + +In `validate.go`, positional args work because `fs.Args()` is checked after `fs.Parse()`. In deploy subcommands, only `-config` flag is checked. Add: after parsing, if `configFile` is empty and `fs.NArg() > 0`, set `configFile` to `fs.Arg(0)`. + +**Step 2: Add positional config fallback** + +In each deploy subcommand that uses `-config`, after `fs.Parse(args)`: + +```go + if *configFile == "" && fs.NArg() > 0 { + *configFile = fs.Arg(0) + } +``` + +Apply to: `runDeployDocker`, `runDeployK8sGenerate`, `runDeployK8sApply`, `runDeployHelm`, `runDeployCloud`. + +**Step 3: Run existing deploy tests** + +Run: `go test ./cmd/wfctl/ -run TestDeploy -v` + +**Step 4: Commit** + +```bash +git add cmd/wfctl/deploy.go +git commit -m "fix(wfctl): deploy subcommands accept positional config arg" +``` + +--- + +### Task 8: `init` generates valid Dockerfile (handles missing go.sum) + +**Files:** +- Modify: `cmd/wfctl/init.go` (the Dockerfile template) + +**Step 1: Find and fix the Dockerfile template** + +Search for the embedded Dockerfile template in `init.go` or `cmd/wfctl/templates/`. Change: + +```dockerfile +COPY go.mod go.sum ./ +``` + +to: + +```dockerfile +COPY go.mod go.sum* ./ +``` + +The `*` glob makes `go.sum` optional — Docker COPY with no match on `go.sum*` still copies `go.mod`. + +Actually, Docker COPY requires at least one match. Better approach: + +```dockerfile +COPY go.mod ./ +RUN go mod download +``` + +This works whether `go.sum` exists or not. + +**Step 2: Run tests and commit** + +```bash +git add cmd/wfctl/init.go +git commit -m "fix(wfctl): init Dockerfile handles missing go.sum" +``` + +--- + +### Task 9: `validate --dir` skips non-workflow YAML files + +**Files:** +- Modify: `cmd/wfctl/validate.go` +- Test: `cmd/wfctl/validate_test.go` (create or extend) + +**Step 1: Write the failing test** + +```go +func TestValidateSkipsNonWorkflowYAML(t *testing.T) { + dir := t.TempDir() + // Write a GitHub Actions YAML — should be skipped + ghDir := filepath.Join(dir, ".github", "workflows") + os.MkdirAll(ghDir, 0755) + os.WriteFile(filepath.Join(ghDir, "ci.yml"), []byte("name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n"), 0644) + // Write a real workflow YAML — should be validated + os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("modules:\n - name: test\n type: http.server\n config:\n port: 8080\nworkflows:\n http:\n handler: http\n"), 0644) + + files := findYAMLFiles(dir) + // ci.yml should be found but isWorkflowYAML should skip it + workflowFiles := 0 + for _, f := range files { + if isWorkflowYAML(f) { + workflowFiles++ + } + } + if workflowFiles != 1 { + t.Errorf("expected 1 workflow YAML, got %d", workflowFiles) + } +} +``` + +**Step 2: Implement** + +Add to `validate.go`: + +```go +// isWorkflowYAML does a quick check for workflow-engine top-level keys. +func isWorkflowYAML(path string) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + // Check first 100 lines for modules:, workflows:, or pipelines: at top level + lines := strings.SplitN(string(data), "\n", 100) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "modules:") || strings.HasPrefix(trimmed, "workflows:") || strings.HasPrefix(trimmed, "pipelines:") { + return true + } + } + return false +} +``` + +Then in the `--dir` file loop, add: `if !isWorkflowYAML(f) { continue }`. + +**Step 3: Run tests and commit** + +```bash +git add cmd/wfctl/validate.go cmd/wfctl/validate_test.go +git commit -m "fix(wfctl): validate --dir skips non-workflow YAML files" +``` + +--- + +### Task 10: Validate follows YAML imports + +**Files:** +- Modify: `cmd/wfctl/validate.go` + +**Context:** The config package already has `processImports` that resolves `imports:` references. When `validate` loads a file, it calls `config.LoadFromFile()` which follows imports automatically. However, the individual imported files may not be validated independently. Ensure that: + +1. `validate` reports which imports were resolved +2. If an imported file has errors, the error references the import chain + +**Step 1: Check current behavior** + +Create a test config with imports and run validate to see if it already works: + +```yaml +# /tmp/test-imports/main.yaml +imports: + - modules.yaml +workflows: + http: + handler: http +``` + +```yaml +# /tmp/test-imports/modules.yaml +modules: + - name: server + type: http.server + config: + port: 8080 +``` + +Run: `/tmp/wfctl validate /tmp/test-imports/main.yaml` + +If it already resolves imports (which it should since `config.LoadFromFile` handles them), just add a verbose message like "Resolved import: modules.yaml". If it doesn't, wire import resolution into the validate path. + +**Step 2: Add import resolution feedback** + +In `validateFile()`, after loading the config, check if the original YAML had `imports:` and log what was resolved: + +```go +// In validateFile, after successful load: +if len(rawImports) > 0 { + fmt.Fprintf(os.Stderr, " Resolved %d import(s): %s\n", len(rawImports), strings.Join(rawImports, ", ")) +} +``` + +**Step 3: Run tests and commit** + +```bash +git add cmd/wfctl/validate.go +git commit -m "fix(wfctl): validate reports resolved YAML imports" +``` + +--- + +### Task 11: Infra commands — better error messages + +**Files:** +- Modify: `cmd/wfctl/infra.go:73` + +**Step 1: Improve the error message** + +Change line 73 from: +```go +return "", fmt.Errorf("no config file found (tried infra.yaml, config/infra.yaml)") +``` +to: +```go +return "", fmt.Errorf("no infrastructure config found (tried infra.yaml, config/infra.yaml).\n" + + "Create an infra config with cloud.account and platform.* modules.\n" + + "Run 'wfctl init --template full-stack' for a starter config with infrastructure.") +``` + +**Step 2: Commit** + +```bash +git add cmd/wfctl/infra.go +git commit -m "fix(wfctl): infra commands show helpful error when no config found" +``` + +--- + +### Task 12: `plugin info` shows absolute paths + +**Files:** +- Modify: `cmd/wfctl/plugin_install.go` (runPluginInfo function, around line 275) + +**Step 1: Fix** + +In `runPluginInfo`, after constructing `pluginDir`, convert to absolute: + +```go + absDir, _ := filepath.Abs(pluginDir) + // Use absDir when printing the binary path +``` + +**Step 2: Commit** + +```bash +git add cmd/wfctl/plugin_install.go +git commit -m "fix(wfctl): plugin info shows absolute binary path" +``` + +--- + +### Task 13: PR #322 — PluginManifest legacy capabilities UnmarshalJSON + +**Files:** +- Modify: `plugin/manifest.go` (or wherever `PluginManifest` is defined) +- Test: `plugin/manifest_test.go` + +**Step 1: Find the PluginManifest type** + +Run: `grep -rn "type PluginManifest struct" /Users/jon/workspace/workflow/plugin/` + +**Step 2: Write the failing test** + +```go +func TestPluginManifest_LegacyCapabilities(t *testing.T) { + input := `{ + "name": "test", + "version": "1.0.0", + "capabilities": { + "configProvider": true, + "moduleTypes": ["test.module"], + "stepTypes": ["step.test"], + "triggerTypes": ["http"] + } + }` + var m PluginManifest + if err := json.Unmarshal([]byte(input), &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // Legacy capabilities should be merged into top-level fields + if len(m.ModuleTypes) == 0 || m.ModuleTypes[0] != "test.module" { + t.Errorf("moduleTypes not parsed from legacy capabilities: %v", m.ModuleTypes) + } + if len(m.StepTypes) == 0 || m.StepTypes[0] != "step.test" { + t.Errorf("stepTypes not parsed from legacy capabilities: %v", m.StepTypes) + } +} +``` + +**Step 3: Implement UnmarshalJSON** + +Add a custom `UnmarshalJSON` method on `PluginManifest` that: +1. First tries to unmarshal normally (new format with `capabilities` as `[]CapabilityDecl`) +2. If `capabilities` is an object, unmarshal it as `{configProvider, moduleTypes, stepTypes, triggerTypes}` and merge into the top-level manifest fields + +**Step 4: Run tests and commit** + +```bash +git add plugin/manifest.go plugin/manifest_test.go +git commit -m "fix(wfctl): PluginManifest handles legacy capabilities object format + +Addresses PR #322." +``` + +--- + +## Phase B: Registry Data Fixes + +### Task 14: Fix registry manifest issues + +**Working directory:** `/Users/jon/workspace/workflow-registry/` + +**Files:** +- Modify: `plugins/agent/manifest.json` +- Modify: `plugins/ratchet/manifest.json` +- Verify: `plugins/authz/manifest.json` exists and name matches + +**Step 1: Fix agent manifest type** + +In `plugins/agent/manifest.json`, change `"type": "internal"` to `"type": "builtin"`. + +**Step 2: Fix ratchet manifest downloads** + +In `plugins/ratchet/manifest.json`, add downloads entries: + +```json +"downloads": [ + {"os": "linux", "arch": "amd64", "url": "https://github.com/GoCodeAlone/ratchet/releases/latest/download/ratchet_linux_amd64.tar.gz"}, + {"os": "linux", "arch": "arm64", "url": "https://github.com/GoCodeAlone/ratchet/releases/latest/download/ratchet_linux_arm64.tar.gz"}, + {"os": "darwin", "arch": "amd64", "url": "https://github.com/GoCodeAlone/ratchet/releases/latest/download/ratchet_darwin_amd64.tar.gz"}, + {"os": "darwin", "arch": "arm64", "url": "https://github.com/GoCodeAlone/ratchet/releases/latest/download/ratchet_darwin_arm64.tar.gz"} +] +``` + +**Step 3: Verify authz manifest** + +Check `plugins/authz/manifest.json` exists. Verify the `name` field is `"authz"` (not `"workflow-plugin-authz"`). If it doesn't exist, create it from the existing `plugin.json` in the authz repo. + +**Step 4: Fix schema validation gap (B5)** + +Check `schema/registry-schema.json` — verify that the `type` enum includes `"builtin"` (not just `"external"` and `"internal"`). If the enum is missing `"builtin"`, add it. Then check CI (`.github/workflows/`) to confirm schema validation runs on PRs. If CI isn't validating manifests against the schema, add a step that runs `scripts/validate-manifests.sh`. + +**Step 5: Run validation** + +```bash +cd /Users/jon/workspace/workflow-registry +./scripts/validate-manifests.sh +``` + +**Step 6: Commit and push** + +```bash +git add plugins/ +git commit -m "fix: correct agent type, add ratchet downloads, verify authz manifest" +git push origin main +``` + +--- + +### Task 15: Create version sync script + +**Files:** +- Create: `scripts/sync-versions.sh` + +**Step 1: Write the script** + +```bash +#!/usr/bin/env bash +# Compares manifest versions against latest GitHub release tags. +# Usage: ./scripts/sync-versions.sh [--fix] + +set -euo pipefail + +fix_mode=false +[[ "${1:-}" == "--fix" ]] && fix_mode=true + +mismatches=0 + +for manifest in plugins/*/manifest.json; do + name=$(jq -r .name "$manifest") + repo=$(jq -r '.repository // empty' "$manifest") + manifest_ver=$(jq -r .version "$manifest") + + [[ -z "$repo" ]] && continue + + # Extract owner/repo from URL + owner_repo=$(echo "$repo" | sed 's|https://github.com/||') + + # Query latest release + latest=$(gh release view --repo "$owner_repo" --json tagName -q .tagName 2>/dev/null || echo "") + [[ -z "$latest" ]] && continue + + # Strip 'v' prefix for comparison + latest_ver="${latest#v}" + + if [[ "$manifest_ver" != "$latest_ver" ]]; then + echo "MISMATCH: $name — manifest=$manifest_ver, latest=$latest_ver ($owner_repo)" + ((mismatches++)) + + if $fix_mode; then + jq --arg v "$latest_ver" '.version = $v' "$manifest" > "$manifest.tmp" && mv "$manifest.tmp" "$manifest" + echo " Fixed → $latest_ver" + fi + fi +done + +echo "" +echo "$mismatches mismatch(es) found." +[[ $mismatches -gt 0 ]] && exit 1 || exit 0 +``` + +**Step 2: Run it** + +```bash +chmod +x scripts/sync-versions.sh +./scripts/sync-versions.sh +``` + +**Step 3: Fix any mismatches found** + +```bash +./scripts/sync-versions.sh --fix +``` + +**Step 4: Commit** + +```bash +git add scripts/sync-versions.sh plugins/ +git commit -m "feat: add version sync script and fix manifest version mismatches" +``` + +--- + +## Phase C: Plugin Ecosystem + +### Task 16: GitHub URL install support in wfctl + +**Files:** +- Modify: `cmd/wfctl/plugin_install.go` (runPluginInstall) +- Modify: `cmd/wfctl/multi_registry.go` or create `cmd/wfctl/github_install.go` +- Test: `cmd/wfctl/plugin_install_test.go` + +**Step 1: Write the test** + +```go +func TestParseGitHubPluginRef(t *testing.T) { + tests := []struct { + input string + owner string + repo string + version string + isGH bool + }{ + {"GoCodeAlone/workflow-plugin-authz@v0.3.1", "GoCodeAlone", "workflow-plugin-authz", "v0.3.1", true}, + {"GoCodeAlone/workflow-plugin-authz", "GoCodeAlone", "workflow-plugin-authz", "", true}, + {"authz", "", "", "", false}, + {"authz@v1.0", "", "", "", false}, + } + for _, tt := range tests { + owner, repo, version, isGH := parseGitHubRef(tt.input) + if isGH != tt.isGH || owner != tt.owner || repo != tt.repo || version != tt.version { + t.Errorf("parseGitHubRef(%q) = (%q,%q,%q,%v), want (%q,%q,%q,%v)", + tt.input, owner, repo, version, isGH, tt.owner, tt.repo, tt.version, tt.isGH) + } + } +} +``` + +**Step 2: Implement** + +Create `cmd/wfctl/github_install.go`: + +```go +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "runtime" + "strings" + "time" +) + +// parseGitHubRef parses "owner/repo@version" format. +// Returns empty strings and false if not a GitHub ref. +func parseGitHubRef(input string) (owner, repo, version string, isGitHub bool) { + // Must contain exactly one "/" to be a GitHub ref + nameVer := input + if atIdx := strings.LastIndex(input, "@"); atIdx > 0 { + nameVer = input[:atIdx] + version = input[atIdx+1:] + } + parts := strings.SplitN(nameVer, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", "", false + } + return parts[0], parts[1], version, true +} + +// installFromGitHub downloads a plugin directly from GitHub Releases. +func installFromGitHub(owner, repo, version, destDir string) error { + if version == "" { + version = "latest" + } + + var releaseURL string + if version == "latest" { + releaseURL = fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) + } else { + releaseURL = fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, version) + } + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(releaseURL) + if err != nil { + return fmt.Errorf("fetch release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("release %s not found for %s/%s (HTTP %d)", version, owner, repo, resp.StatusCode) + } + + var release struct { + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return fmt.Errorf("decode release: %w", err) + } + + // Find matching asset: repo_os_arch.tar.gz + wantName := fmt.Sprintf("%s_%s_%s.tar.gz", repo, runtime.GOOS, runtime.GOARCH) + var downloadURL string + for _, a := range release.Assets { + if a.Name == wantName { + downloadURL = a.BrowserDownloadURL + break + } + } + if downloadURL == "" { + return fmt.Errorf("no asset matching %s found in release (available: %d assets)", wantName, len(release.Assets)) + } + + fmt.Fprintf(os.Stderr, "Downloading %s...\n", downloadURL) + data, err := downloadURL(downloadURL) + if err != nil { + return fmt.Errorf("download: %w", err) + } + + if err := extractTarGz(data, destDir); err != nil { + return fmt.Errorf("extract: %w", err) + } + + return ensurePluginBinary(destDir, repo) +} +``` + +Then in `runPluginInstall`, after the registry lookup fails: + +```go + manifest, sourceName, err := mr.FetchManifest(pluginName) + if err != nil { + // Try GitHub direct install if input looks like owner/repo + owner, repo, ver, isGH := parseGitHubRef(nameArg) + if isGH { + shortName := normalizePluginName(repo) + destDir := filepath.Join(pluginDirVal, shortName) + os.MkdirAll(destDir, 0750) + return installFromGitHub(owner, repo, ver, destDir) + } + return err + } +``` + +**Step 3: Run tests and commit** + +```bash +git add cmd/wfctl/github_install.go cmd/wfctl/plugin_install.go cmd/wfctl/plugin_install_test.go +git commit -m "feat(wfctl): plugin install supports owner/repo@version GitHub URLs + +Falls back to GitHub Releases API when registry lookup fails. +Addresses issue #316 item 2." +``` + +--- + +### Task 17: Plugin lockfile support (`.wfctl.yaml` plugins section) + +**Files:** +- Create: `cmd/wfctl/plugin_lockfile.go` +- Create: `cmd/wfctl/plugin_lockfile_test.go` +- Modify: `cmd/wfctl/plugin_install.go` + +**Step 1: Write the test** + +```go +func TestLoadPluginLockfile(t *testing.T) { + dir := t.TempDir() + content := `plugins: + authz: + version: v0.3.1 + repository: GoCodeAlone/workflow-plugin-authz + payments: + version: v0.1.0 + repository: GoCodeAlone/workflow-plugin-payments +` + os.WriteFile(filepath.Join(dir, ".wfctl.yaml"), []byte(content), 0644) + + lf, err := loadPluginLockfile(filepath.Join(dir, ".wfctl.yaml")) + if err != nil { + t.Fatal(err) + } + if len(lf.Plugins) != 2 { + t.Fatalf("expected 2 plugins, got %d", len(lf.Plugins)) + } + if lf.Plugins["authz"].Version != "v0.3.1" { + t.Errorf("authz version = %q", lf.Plugins["authz"].Version) + } +} +``` + +**Step 2: Implement** + +```go +// cmd/wfctl/plugin_lockfile.go +package main + +import ( + "fmt" + "os" + "gopkg.in/yaml.v3" +) + +type PluginLockEntry struct { + Version string `yaml:"version"` + Repository string `yaml:"repository"` + SHA256 string `yaml:"sha256,omitempty"` +} + +type PluginLockfile struct { + Plugins map[string]PluginLockEntry `yaml:"plugins"` + // Preserve other .wfctl.yaml fields + raw map[string]any +} + +func loadPluginLockfile(path string) (*PluginLockfile, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var lf PluginLockfile + if err := yaml.Unmarshal(data, &lf); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + // Preserve raw for round-trip + yaml.Unmarshal(data, &lf.raw) + return &lf, nil +} + +func (lf *PluginLockfile) Save(path string) error { + if lf.raw == nil { + lf.raw = make(map[string]any) + } + lf.raw["plugins"] = lf.Plugins + data, err := yaml.Marshal(lf.raw) + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} +``` + +**Step 3: Wire into `plugin install`** + +In `runPluginInstall`: +- After successful install, if `.wfctl.yaml` exists in cwd, update the plugins section +- Add `wfctl plugin install` (no args) path: read lockfile, install all entries + +**Step 4: Run tests and commit** + +```bash +git add cmd/wfctl/plugin_lockfile.go cmd/wfctl/plugin_lockfile_test.go cmd/wfctl/plugin_install.go +git commit -m "feat(wfctl): plugin lockfile support in .wfctl.yaml + +'wfctl plugin install' with no args reads .wfctl.yaml plugins section. +Installing a plugin with @version updates the lockfile entry. +Addresses issue #316 item 3." +``` + +--- + +### Task 18: Engine minEngineVersion check + +**Files:** +- Modify: `plugin/loader.go` (or wherever plugins are loaded) +- Test: `plugin/loader_test.go` + +**Step 1: Find the plugin loading code** + +Run: `grep -rn "minEngineVersion\|MinEngineVersion\|LoadManifest" /Users/jon/workspace/workflow/plugin/ | head -20` + +**Step 2: Implement version check** + +After loading `plugin.json`, compare `minEngineVersion` with the engine's version: + +```go +import "github.com/Masterminds/semver/v3" + +func checkEngineCompatibility(manifest *PluginManifest, engineVersion string) { + if manifest.MinEngineVersion == "" || engineVersion == "" || engineVersion == "dev" { + return + } + minVer, err := semver.NewVersion(manifest.MinEngineVersion) + if err != nil { + return + } + engVer, err := semver.NewVersion(strings.TrimPrefix(engineVersion, "v")) + if err != nil { + return + } + if engVer.LessThan(minVer) { + fmt.Fprintf(os.Stderr, "WARNING: plugin %q requires engine >= v%s, running v%s — may cause runtime failures\n", + manifest.Name, manifest.MinEngineVersion, engineVersion) + } +} +``` + +**Step 3: Run tests and commit** + +```bash +git add plugin/ +git commit -m "feat: engine warns when plugin minEngineVersion exceeds current version + +Addresses issue #316 item 5." +``` + +--- + +### Task 19: goreleaser audit across plugin repos + +**Working directory:** Each plugin repo + +**Step 1: Create reference goreleaser config** + +Save to `/Users/jon/workspace/workflow/docs/plugin-goreleaser-reference.yml`: + +```yaml +# Reference goreleaser config for workflow plugins +# Ensures consistent tarball layout: bare binary + plugin.json +version: 2 +builds: + - binary: "{{ .ProjectName }}" + goos: [linux, darwin] + goarch: [amd64, arm64] + env: [CGO_ENABLED=0] + ldflags: ["-s", "-w"] + +archives: + - format: tar.gz + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + files: + - plugin.json + +before: + hooks: + - cmd: "sed -i'' -e 's/\"version\": *\"[^\"]*\"/\"version\": \"{{ .Version }}\"/' plugin.json" + +checksum: + name_template: checksums.txt +``` + +**Step 2: Audit each plugin repo** + +For each repo in: `workflow-plugin-authz`, `workflow-plugin-payments`, `workflow-plugin-admin`, `workflow-plugin-bento`, `workflow-plugin-github`, `workflow-plugin-waf`, `workflow-plugin-security`, `workflow-plugin-sandbox`, `workflow-plugin-supply-chain`, `workflow-plugin-data-protection`, `workflow-plugin-authz-ui`, `workflow-plugin-cloud-ui`: + +1. Read `.goreleaser.yml` or `.goreleaser.yaml` +2. Check binary naming (should be `{{ .ProjectName }}`, not platform-suffixed) +3. Check archive includes `plugin.json` +4. Check `plugin.json` version is templated from tag +5. Fix any deviations + +**Step 3: Commit fixes per repo** + +Each repo gets its own commit: `"chore: standardize goreleaser config for consistent tarball layout"` + +--- + +### Task 20: Registry auto-sync CI for plugin repos + +**Files per plugin repo:** +- Modify: `.github/workflows/release.yml` (add registry update step) + +**Step 1: Create reusable workflow snippet** + +After the goreleaser step in each plugin's `release.yml`, add: + +```yaml + - name: Update registry manifest + if: success() + env: + GH_TOKEN: ${{ secrets.REGISTRY_PAT }} + run: | + PLUGIN_NAME=$(jq -r .name plugin.json) + VERSION="${GITHUB_REF_NAME#v}" + + # Clone registry + git clone https://x-access-token:${GH_TOKEN}@github.com/GoCodeAlone/workflow-registry.git /tmp/registry + cd /tmp/registry + + # Update version + MANIFEST="plugins/${PLUGIN_NAME}/manifest.json" + jq --arg v "$VERSION" '.version = $v' "$MANIFEST" > "$MANIFEST.tmp" && mv "$MANIFEST.tmp" "$MANIFEST" + + # Update download URLs + REPO="${GITHUB_REPOSITORY}" + TAG="${GITHUB_REF_NAME}" + jq --arg repo "$REPO" --arg tag "$TAG" ' + .downloads = [ + {"os":"linux","arch":"amd64","url":"https://github.com/\($repo)/releases/download/\($tag)/\(.name)_linux_amd64.tar.gz"}, + {"os":"linux","arch":"arm64","url":"https://github.com/\($repo)/releases/download/\($tag)/\(.name)_linux_arm64.tar.gz"}, + {"os":"darwin","arch":"amd64","url":"https://github.com/\($repo)/releases/download/\($tag)/\(.name)_darwin_amd64.tar.gz"}, + {"os":"darwin","arch":"arm64","url":"https://github.com/\($repo)/releases/download/\($tag)/\(.name)_darwin_arm64.tar.gz"} + ]' "$MANIFEST" > "$MANIFEST.tmp" && mv "$MANIFEST.tmp" "$MANIFEST" + + # Create PR + BRANCH="auto/update-${PLUGIN_NAME}-${TAG}" + git checkout -b "$BRANCH" + git add "$MANIFEST" + git commit -m "chore: update ${PLUGIN_NAME} manifest to ${TAG}" + git push origin "$BRANCH" + gh pr create --repo GoCodeAlone/workflow-registry --title "Update ${PLUGIN_NAME} to ${TAG}" --body "Automated manifest update from release ${TAG}." +``` + +**Step 2: Apply to each external plugin repo that has a registry manifest** + +Repos: authz, payments, waf, security, sandbox, supply-chain, data-protection, authz-ui, cloud-ui, bento, github, admin + +**Step 3: Commit per repo** + +```bash +git commit -m "ci: auto-update registry manifest on release" +``` + +--- + +## Summary + +| Phase | Tasks | Scope | +|-------|-------|-------| +| A: CLI Fixes | 1-13 | workflow repo, cmd/wfctl/ | +| B: Registry Data | 14-15 | workflow-registry repo | +| C: Ecosystem | 16-20 | workflow repo + all plugin repos | + +**Total: 20 tasks** + +Phase A (Tasks 1-13) can be done in parallel by 2 implementers splitting the work. +Phase B (Tasks 14-15) is independent and can run in parallel with Phase A. +Phase C (Tasks 16-20) depends on Phase A being complete (especially Tasks 2, 4, 5 for the plugin-dir and name resolution changes). diff --git a/docs/plugin-goreleaser-reference.yml b/docs/plugin-goreleaser-reference.yml new file mode 100644 index 00000000..e19e81fe --- /dev/null +++ b/docs/plugin-goreleaser-reference.yml @@ -0,0 +1,44 @@ +version: 2 + +# Reference .goreleaser.yml for GoCodeAlone workflow plugin repositories. +# Copy this to your plugin repo as .goreleaser.yml and adjust as needed. +# +# Key requirements: +# 1. binary uses {{ .ProjectName }} so no hardcoded names +# 2. archives always include plugin.json +# 3. before/after hooks template plugin.json version from the release tag + +before: + hooks: + - "cp plugin.json plugin.json.orig" + - "sed -i.bak 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' plugin.json && rm -f plugin.json.bak" + +after: + hooks: + - "mv plugin.json.orig plugin.json" + +builds: + - main: ./cmd/{{ .ProjectName }} + binary: "{{ .ProjectName }}" + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X main.version={{.Version}} + +archives: + - formats: [tar.gz] + name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" + files: + - plugin.json + +checksum: + name_template: checksums.txt + +changelog: + sort: asc diff --git a/plugin/loader.go b/plugin/loader.go index 7fbb1b63..d66bc26f 100644 --- a/plugin/loader.go +++ b/plugin/loader.go @@ -5,6 +5,7 @@ import ( "log/slog" "reflect" "sort" + "strings" "github.com/GoCodeAlone/workflow/capability" "github.com/GoCodeAlone/workflow/deploy" @@ -36,6 +37,7 @@ type PluginLoader struct { deployTargets map[string]deploy.DeployTarget sidecarProviders map[string]deploy.SidecarProvider overridableTypes map[string]bool // types declared overridable by any loaded plugin + engineVersion string // running engine version for minEngineVersion checks } // NewPluginLoader creates a new PluginLoader backed by the given capability and schema registries. @@ -70,6 +72,12 @@ func (l *PluginLoader) OverridableTypes() map[string]bool { return out } +// SetEngineVersion sets the running engine version used for minEngineVersion +// compatibility checks when loading plugins. +func (l *PluginLoader) SetEngineVersion(v string) { + l.engineVersion = v +} + // SetLicenseValidator registers a license validator used for premium tier plugins. func (l *PluginLoader) SetLicenseValidator(v LicenseValidator) { l.licenseValidator = v @@ -160,6 +168,9 @@ func (l *PluginLoader) loadPlugin(p EnginePlugin, allowOverride bool) error { return fmt.Errorf("plugin %q: %w", manifest.Name, err) } + // Warn if the engine version is older than the plugin's minimum requirement. + checkEngineCompatibility(manifest, l.engineVersion) + // Validate plugin tier before proceeding. if err := l.ValidateTier(manifest); err != nil { return err @@ -408,6 +419,30 @@ func (l *PluginLoader) SidecarProviders() map[string]deploy.SidecarProvider { return out } +// checkEngineCompatibility warns to stderr if the running engine version is +// older than the plugin's declared minEngineVersion. This is a soft check only +// (no hard failure) to allow testing newer plugins against older engines. +// Skips the check when either version is empty or engineVersion is "dev". +func checkEngineCompatibility(manifest *PluginManifest, engineVersion string) { + if manifest.MinEngineVersion == "" || engineVersion == "" || engineVersion == "dev" { + return + } + minVer, err := ParseSemver(strings.TrimPrefix(manifest.MinEngineVersion, "v")) + if err != nil { + return // malformed minEngineVersion — skip silently + } + engVer, err := ParseSemver(strings.TrimPrefix(engineVersion, "v")) + if err != nil { + return // malformed engine version — skip silently + } + if engVer.Compare(minVer) < 0 { + slog.Warn("plugin requires newer engine", + "plugin", manifest.Name, + "minVersion", manifest.MinEngineVersion, + "engineVersion", engineVersion) + } +} + // topoSortPlugins performs a topological sort of plugins based on manifest dependencies. // Returns an error if a circular dependency is detected. func topoSortPlugins(plugins []EnginePlugin) ([]EnginePlugin, error) { diff --git a/plugin/loader_test.go b/plugin/loader_test.go index b348e042..6e5cfe69 100644 --- a/plugin/loader_test.go +++ b/plugin/loader_test.go @@ -633,3 +633,58 @@ func (m *mockSidecarProvider) Validate(_ config.SidecarConfig) error { return ni func (m *mockSidecarProvider) Resolve(_ config.SidecarConfig, _ string) (*deploy.SidecarSpec, error) { return nil, nil } + +func TestCheckEngineCompatibility(t *testing.T) { + cases := []struct { + name string + minVersion string + engineVersion string + wantWarn bool + }{ + {"compatible", "0.3.0", "0.3.30", false}, + {"equal", "0.3.30", "0.3.30", false}, + {"incompatible", "0.4.0", "0.3.30", true}, + {"empty minVersion", "", "0.3.30", false}, + {"empty engineVersion", "0.3.0", "", false}, + {"dev engine", "0.3.0", "dev", false}, + {"malformed min", "not-a-version", "0.3.30", false}, + {"malformed engine", "0.3.0", "not-a-version", false}, + {"v-prefix stripped", "0.3.0", "v0.3.30", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + manifest := &PluginManifest{ + Name: "test-plugin", + MinEngineVersion: tc.minVersion, + } + // Just verify it doesn't panic; stderr output is checked manually + checkEngineCompatibility(manifest, tc.engineVersion) + }) + } +} + +func TestPluginLoader_WarnOnMinEngineVersion(t *testing.T) { + loader := newTestEngineLoader() + loader.SetEngineVersion("0.3.0") + + // Plugin requiring a newer engine version than what's running + p := makeEnginePlugin("min-version-plugin", "1.0.0", nil) + p.Manifest.MinEngineVersion = "0.4.0" + + // Should load successfully (warning only, not hard fail) + if err := loader.LoadPlugin(p); err != nil { + t.Fatalf("LoadPlugin should succeed despite minEngineVersion warning, got: %v", err) + } +} + +func TestPluginLoader_NoWarnOnCompatibleEngineVersion(t *testing.T) { + loader := newTestEngineLoader() + loader.SetEngineVersion("1.0.0") + + p := makeEnginePlugin("compat-plugin", "1.0.0", nil) + p.Manifest.MinEngineVersion = "0.3.0" + + if err := loader.LoadPlugin(p); err != nil { + t.Fatalf("LoadPlugin failed: %v", err) + } +} diff --git a/plugin/manifest.go b/plugin/manifest.go index 13ef66bc..3b64a679 100644 --- a/plugin/manifest.go +++ b/plugin/manifest.go @@ -65,6 +65,10 @@ type PluginManifest struct { // Config mutability and sample plugin support ConfigMutable bool `json:"configMutable,omitempty" yaml:"configMutable,omitempty"` SampleCategory string `json:"sampleCategory,omitempty" yaml:"sampleCategory,omitempty"` + + // MinEngineVersion declares the minimum engine version required to run this plugin. + // A semver string without the "v" prefix, e.g. "0.3.30". + MinEngineVersion string `json:"minEngineVersion,omitempty" yaml:"minEngineVersion,omitempty"` } // CapabilityDecl declares a capability relationship for a plugin in the manifest. @@ -80,6 +84,62 @@ type Dependency struct { Constraint string `json:"constraint" yaml:"constraint"` // semver constraint, e.g. ">=1.0.0", "^2.1" } +// UnmarshalJSON implements custom JSON unmarshalling for PluginManifest that +// handles both the canonical capabilities array format and the legacy object +// format used by registry manifests and older plugin.json files. +// +// Legacy format: "capabilities": {"configProvider": bool, "moduleTypes": [...], ...} +// New format: "capabilities": [{"name": "...", "role": "..."}] +// +// When the legacy object format is detected, its type lists are merged into the +// top-level ModuleTypes, StepTypes, and TriggerTypes fields so callers always +// find types in a consistent location. +func (m *PluginManifest) UnmarshalJSON(data []byte) error { + // rawManifest breaks the recursion: it is the same layout as PluginManifest + // but without the custom UnmarshalJSON method. + type rawManifest PluginManifest + // withRawCaps shadows the Capabilities field so we can capture it as raw JSON + // and inspect whether it is an array or object before decoding. + type withRawCaps struct { + rawManifest + Capabilities json.RawMessage `json:"capabilities,omitempty"` + } + var raw withRawCaps + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *m = PluginManifest(raw.rawManifest) + m.Capabilities = nil // captured in raw.Capabilities; reset and repopulate below + + if len(raw.Capabilities) == 0 { + return nil + } + switch raw.Capabilities[0] { + case '[': + // New format: array of CapabilityDecl + var caps []CapabilityDecl + if err := json.Unmarshal(raw.Capabilities, &caps); err != nil { + return fmt.Errorf("invalid capabilities array: %w", err) + } + m.Capabilities = caps + case '{': + // Legacy format: object with configProvider, moduleTypes, stepTypes, triggerTypes. + // Merge type lists into the top-level fields so callers see them consistently. + var legacyCaps struct { + ModuleTypes []string `json:"moduleTypes"` + StepTypes []string `json:"stepTypes"` + TriggerTypes []string `json:"triggerTypes"` + } + if err := json.Unmarshal(raw.Capabilities, &legacyCaps); err != nil { + return fmt.Errorf("invalid capabilities object: %w", err) + } + m.ModuleTypes = append(m.ModuleTypes, legacyCaps.ModuleTypes...) + m.StepTypes = append(m.StepTypes, legacyCaps.StepTypes...) + m.TriggerTypes = append(m.TriggerTypes, legacyCaps.TriggerTypes...) + } + return nil +} + // Validate checks that a manifest has all required fields and valid semver. func (m *PluginManifest) Validate() error { if m.Name == "" { diff --git a/plugin/manifest_test.go b/plugin/manifest_test.go index f84af625..0ad8507c 100644 --- a/plugin/manifest_test.go +++ b/plugin/manifest_test.go @@ -425,3 +425,87 @@ func TestManifestEngineFieldsLoadFromFile(t *testing.T) { t.Errorf("Capabilities = %v, want [{storage provider 5}]", loaded.Capabilities) } } + +func TestPluginManifest_LegacyCapabilities(t *testing.T) { + // Legacy format: capabilities is a JSON object with configProvider, moduleTypes, etc. + legacyJSON := `{ + "name": "legacy-plugin", + "version": "1.0.0", + "author": "Test", + "description": "Legacy capabilities test", + "capabilities": { + "configProvider": true, + "moduleTypes": ["test.module"], + "stepTypes": ["step.test"], + "triggerTypes": ["trigger.test"] + } + }` + + var m PluginManifest + if err := json.Unmarshal([]byte(legacyJSON), &m); err != nil { + t.Fatalf("Unmarshal legacy capabilities: %v", err) + } + if len(m.ModuleTypes) != 1 || m.ModuleTypes[0] != "test.module" { + t.Errorf("ModuleTypes = %v, want [test.module]", m.ModuleTypes) + } + if len(m.StepTypes) != 1 || m.StepTypes[0] != "step.test" { + t.Errorf("StepTypes = %v, want [step.test]", m.StepTypes) + } + if len(m.TriggerTypes) != 1 || m.TriggerTypes[0] != "trigger.test" { + t.Errorf("TriggerTypes = %v, want [trigger.test]", m.TriggerTypes) + } + // Legacy object format should not populate Capabilities slice + if len(m.Capabilities) != 0 { + t.Errorf("Capabilities = %v, want empty for legacy object format", m.Capabilities) + } +} + +func TestPluginManifest_NewCapabilitiesArrayFormat(t *testing.T) { + // New format: capabilities is a JSON array of CapabilityDecl + newJSON := `{ + "name": "new-plugin", + "version": "1.0.0", + "author": "Test", + "description": "New capabilities test", + "moduleTypes": ["test.module"], + "stepTypes": ["step.test"], + "capabilities": [{"name": "step.test", "role": "provider"}] + }` + + var m PluginManifest + if err := json.Unmarshal([]byte(newJSON), &m); err != nil { + t.Fatalf("Unmarshal new capabilities: %v", err) + } + if len(m.Capabilities) != 1 || m.Capabilities[0].Name != "step.test" { + t.Errorf("Capabilities = %v, want [{step.test provider 0}]", m.Capabilities) + } + if len(m.ModuleTypes) != 1 || m.ModuleTypes[0] != "test.module" { + t.Errorf("ModuleTypes = %v, want [test.module]", m.ModuleTypes) + } +} + +func TestPluginManifest_LegacyCapabilitiesMergesWithTopLevel(t *testing.T) { + // Top-level fields should be merged with types from legacy capabilities object + legacyJSON := `{ + "name": "merged-plugin", + "version": "1.0.0", + "author": "Test", + "description": "Merge test", + "moduleTypes": ["existing.module"], + "capabilities": { + "moduleTypes": ["caps.module"], + "stepTypes": ["step.caps"] + } + }` + + var m PluginManifest + if err := json.Unmarshal([]byte(legacyJSON), &m); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if len(m.ModuleTypes) != 2 { + t.Errorf("ModuleTypes = %v, want [existing.module caps.module]", m.ModuleTypes) + } + if len(m.StepTypes) != 1 || m.StepTypes[0] != "step.caps" { + t.Errorf("StepTypes = %v, want [step.caps]", m.StepTypes) + } +} diff --git a/ui/package-lock.json b/ui/package-lock.json index a4249584..4388c44f 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,7 +8,7 @@ "name": "ui", "version": "0.0.0", "dependencies": { - "@gocodealone/workflow-editor": "file:../../workflow-editor/gocodealone-workflow-editor-0.1.0.tgz", + "@gocodealone/workflow-editor": "^0.2.0", "@gocodealone/workflow-ui": "^0.2.0", "@types/dagre": "^0.7.53", "@xyflow/react": "^12.10.0", @@ -1169,9 +1169,9 @@ } }, "node_modules/@gocodealone/workflow-editor": { - "version": "0.1.0", - "resolved": "file:../../workflow-editor/gocodealone-workflow-editor-0.1.0.tgz", - "integrity": "sha512-Dy9FFSE8khKjmo5ciotYpIIohnY7stPvAOYU55Nye2mkgpfAzdGw8wuy9IqR2jbHDd9hAXmQcq4eDioKAeN+iw==", + "version": "0.2.0", + "resolved": "https://npm.pkg.github.com/download/@gocodealone/workflow-editor/0.2.0/6a0407820cb9825cd7db3498d75420168078907c", + "integrity": "sha512-sHjf/y3foZmReMVr7P1XMJIgSTIuoszYVW/dQqkLQIVB4PloRhcTLeYEYyLDCzpTEEGBGAaq4GA4cIR9pshiLg==", "license": "Apache-2.0", "dependencies": { "dagre": "^0.8.5", diff --git a/ui/package.json b/ui/package.json index 29364583..23fedaf9 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,7 +14,7 @@ "test:e2e:execution": "npx playwright test --config playwright-e2e.config.ts" }, "dependencies": { - "@gocodealone/workflow-editor": "file:../../workflow-editor/gocodealone-workflow-editor-0.1.0.tgz", + "@gocodealone/workflow-editor": "^0.2.0", "@gocodealone/workflow-ui": "^0.2.0", "@types/dagre": "^0.7.53", "@xyflow/react": "^12.10.0",