From d256f9621093c342b213e8a210df8227f3155387 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 15:19:17 -0400 Subject: [PATCH 01/14] feat(wfctl): add plugin install --url for direct URL installs Adds --url flag to wfctl plugin install that downloads a tar.gz archive from a direct URL, extracts plugin.json to identify the plugin name, installs to the plugin directory, and records the SHA-256 checksum in the lockfile. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/plugin_install.go | 126 +++++++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 80d0db66..0b392ba3 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -71,6 +71,8 @@ func runPluginInstall(args []string) error { 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") + directURL := fs.String("url", "", "Install from a direct download URL (tar.gz archive)") + localPath := fs.String("local", "", "Install from a local plugin directory") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin install [options] [@]\n\nDownload and install a plugin from the registry.\n\nOptions:\n") fs.PrintDefaults() @@ -79,6 +81,14 @@ func runPluginInstall(args []string) error { return err } + if *directURL != "" { + return installFromURL(*directURL, pluginDirVal) + } + + if *localPath != "" { + return installFromLocal(*localPath, pluginDirVal) + } + // No args: install all plugins from .wfctl.yaml lockfile. if fs.NArg() < 1 { return installFromLockfile(pluginDirVal, *cfgPath) @@ -139,7 +149,11 @@ func runPluginInstall(args []string) error { // Update .wfctl.yaml lockfile if name@version was provided. if _, ver := parseNameVersion(nameArg); ver != "" { - updateLockfile(manifest.Name, manifest.Version, manifest.Repository) + sha := "" + if dl, dlErr := manifest.FindDownload(runtime.GOOS, runtime.GOARCH); dlErr == nil { + sha = dl.SHA256 + } + updateLockfileWithChecksum(manifest.Name, manifest.Version, manifest.Repository, sha) } return nil @@ -443,6 +457,116 @@ func runPluginInfo(args []string) error { return nil } +// installFromURL downloads a plugin tarball from a direct URL and installs it. +func installFromURL(url, pluginDir string) error { + fmt.Fprintf(os.Stderr, "Downloading %s...\n", url) + data, err := downloadURL(url) + if err != nil { + return fmt.Errorf("download: %w", err) + } + + tmpDir, err := os.MkdirTemp("", "wfctl-plugin-*") + if err != nil { + return fmt.Errorf("create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + if err := extractTarGz(data, tmpDir); err != nil { + return fmt.Errorf("extract: %w", err) + } + + pjData, err := os.ReadFile(filepath.Join(tmpDir, "plugin.json")) + if err != nil { + return fmt.Errorf("no plugin.json found in archive: %w", err) + } + var pj installedPluginJSON + if err := json.Unmarshal(pjData, &pj); err != nil { + return fmt.Errorf("parse plugin.json: %w", err) + } + if pj.Name == "" { + return fmt.Errorf("plugin.json missing name field") + } + + pluginName := normalizePluginName(pj.Name) + destDir := filepath.Join(pluginDir, pluginName) + if err := os.MkdirAll(destDir, 0750); err != nil { + return fmt.Errorf("create plugin dir: %w", err) + } + + if err := extractTarGz(data, destDir); err != nil { + return fmt.Errorf("extract to dest: %w", err) + } + + if err := ensurePluginBinary(destDir, pluginName); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not normalize binary name: %v\n", err) + } + + h := sha256.Sum256(data) + checksum := hex.EncodeToString(h[:]) + updateLockfileWithChecksum(pluginName, pj.Version, pj.Repository, checksum) + + fmt.Printf("Installed %s v%s to %s\n", pluginName, pj.Version, destDir) + return nil +} + +// verifyInstalledChecksum reads the plugin binary and verifies its SHA-256 checksum. +func verifyInstalledChecksum(pluginDir, pluginName, expectedSHA256 string) error { + binaryPath := filepath.Join(pluginDir, pluginName) + data, err := os.ReadFile(binaryPath) + if err != nil { + return fmt.Errorf("read binary %s: %w", binaryPath, err) + } + h := sha256.Sum256(data) + got := hex.EncodeToString(h[:]) + if !strings.EqualFold(got, expectedSHA256) { + return fmt.Errorf("binary checksum mismatch: got %s, want %s", got, expectedSHA256) + } + return nil +} + +// installFromLocal installs a plugin from a local directory. +func installFromLocal(srcDir, pluginDir string) error { + pjPath := filepath.Join(srcDir, "plugin.json") + pjData, err := os.ReadFile(pjPath) + if err != nil { + return fmt.Errorf("read plugin.json in %s: %w", srcDir, err) + } + var pj installedPluginJSON + if err := json.Unmarshal(pjData, &pj); err != nil { + return fmt.Errorf("parse plugin.json: %w", err) + } + if pj.Name == "" { + return fmt.Errorf("plugin.json missing name field") + } + + pluginName := normalizePluginName(pj.Name) + destDir := filepath.Join(pluginDir, pluginName) + if err := os.MkdirAll(destDir, 0750); err != nil { + return fmt.Errorf("create plugin dir: %w", err) + } + + // Copy plugin.json + if err := copyFile(pjPath, filepath.Join(destDir, "plugin.json"), 0640); err != nil { + return err + } + + // Find and copy the binary + srcBinary := filepath.Join(srcDir, pluginName) + if _, err := os.Stat(srcBinary); os.IsNotExist(err) { + fullName := "workflow-plugin-" + pluginName + srcBinary = filepath.Join(srcDir, fullName) + if _, err := os.Stat(srcBinary); os.IsNotExist(err) { + return fmt.Errorf("no plugin binary found in %s (tried %s and %s)", srcDir, pluginName, fullName) + } + } + if err := copyFile(srcBinary, filepath.Join(destDir, pluginName), 0750); err != nil { + return err + } + + fmt.Printf("Installed %s v%s from %s to %s\n", pluginName, pj.Version, srcDir, destDir) + return nil +} + // parseNameVersion splits "name@version" into (name, version). Version is empty if absent. func parseNameVersion(arg string) (name, ver string) { if idx := strings.Index(arg, "@"); idx >= 0 { From 15f9848fb4c927543d6af75fbfe528431c10f1fe Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 15:19:21 -0400 Subject: [PATCH 02/14] feat(wfctl): verify SHA-256 checksums from lockfile on install Adds Registry field to PluginLockEntry, adds updateLockfileWithChecksum to store SHA-256 alongside version/repository, and verifies installed binary checksums after lockfile-based installs to detect tampering. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/plugin_lockfile.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/cmd/wfctl/plugin_lockfile.go b/cmd/wfctl/plugin_lockfile.go index 64e19dc4..af385831 100644 --- a/cmd/wfctl/plugin_lockfile.go +++ b/cmd/wfctl/plugin_lockfile.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "path/filepath" "strings" "gopkg.in/yaml.v3" @@ -15,6 +16,7 @@ type PluginLockEntry struct { Version string `yaml:"version"` Repository string `yaml:"repository,omitempty"` SHA256 string `yaml:"sha256,omitempty"` + Registry string `yaml:"registry,omitempty"` } // PluginLockfile represents the plugins section of .wfctl.yaml. @@ -79,6 +81,15 @@ func installFromLockfile(pluginDir, cfgPath string) error { if err := runPluginInstall(installArgs); err != nil { fmt.Fprintf(os.Stderr, "error installing %s: %v\n", name, err) failed = append(failed, name) + continue + } + if entry.SHA256 != "" { + pluginInstallDir := filepath.Join(pluginDir, name) + if verifyErr := verifyInstalledChecksum(pluginInstallDir, name, entry.SHA256); verifyErr != nil { + fmt.Fprintf(os.Stderr, "CHECKSUM MISMATCH for %s: %v\n", name, verifyErr) + failed = append(failed, name) + continue + } } } if len(failed) > 0 { @@ -104,6 +115,24 @@ func updateLockfile(pluginName, version, repository string) { _ = lf.Save(wfctlYAMLPath) } +// updateLockfileWithChecksum adds or updates a plugin entry in .wfctl.yaml with SHA-256 checksum. +// Silently no-ops if the lockfile cannot be read or written (install still succeeds). +func updateLockfileWithChecksum(pluginName, version, repository, sha256Hash 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, + SHA256: sha256Hash, + } + _ = 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 { From ebc564ca02321bb83fc2ae9230d8f5b3905a55e0 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 15:21:55 -0400 Subject: [PATCH 03/14] feat(wfctl): enhanced plugin init scaffold with full project structure Extends wfctl plugin init to generate cmd/workflow-plugin-/main.go, internal/provider.go, internal/steps.go, go.mod, .goreleaser.yml, CI/release GitHub Actions workflows, Makefile, and README.md. Adds --module flag for custom Go module paths. Preserves existing plugin.json and .go skeleton for backward compatibility. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/plugin.go | 2 + plugin/sdk/generator.go | 357 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 356 insertions(+), 3 deletions(-) diff --git a/cmd/wfctl/plugin.go b/cmd/wfctl/plugin.go index e5198d94..bd3fc613 100644 --- a/cmd/wfctl/plugin.go +++ b/cmd/wfctl/plugin.go @@ -69,6 +69,7 @@ func runPluginInit(args []string) error { license := fs.String("license", "", "Plugin license") output := fs.String("output", "", "Output directory (defaults to plugin name)") withContract := fs.Bool("contract", false, "Include a contract skeleton") + module := fs.String("module", "", "Go module path (default: github.com//workflow-plugin-)") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin init [options] \n\nScaffold a new plugin project.\n\nOptions:\n") fs.PrintDefaults() @@ -94,6 +95,7 @@ func runPluginInit(args []string) error { License: *license, OutputDir: *output, WithContract: *withContract, + GoModule: *module, } if err := gen.Generate(opts); err != nil { return err diff --git a/plugin/sdk/generator.go b/plugin/sdk/generator.go index e0f5fc50..2dfb20ca 100644 --- a/plugin/sdk/generator.go +++ b/plugin/sdk/generator.go @@ -1,6 +1,7 @@ package sdk import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -27,9 +28,11 @@ type GenerateOptions struct { License string OutputDir string WithContract bool + GoModule string // e.g. "github.com/MyOrg/workflow-plugin-foo" } -// Generate creates a new plugin directory with manifest and component skeleton. +// Generate creates a new plugin directory with manifest and component skeleton, +// plus a full project structure (cmd/, internal/, CI workflows, Makefile, README). func (g *TemplateGenerator) Generate(opts GenerateOptions) error { if opts.Name == "" { return fmt.Errorf("plugin name is required") @@ -75,22 +78,370 @@ func (g *TemplateGenerator) Generate(opts GenerateOptions) error { return fmt.Errorf("create output directory: %w", err) } - // Write manifest + // Write manifest (plugin.json at root — required by tests and engine) manifestPath := filepath.Join(opts.OutputDir, "plugin.json") if err := plugin.SaveManifest(manifestPath, manifest); err != nil { return fmt.Errorf("write manifest: %w", err) } - // Write component skeleton + // Write component skeleton (legacy flat file — preserved for test compatibility) componentPath := filepath.Join(opts.OutputDir, opts.Name+".go") source := generateComponentSource(opts) if err := os.WriteFile(componentPath, []byte(source), 0600); err != nil { return fmt.Errorf("write component: %w", err) } + // Write full project structure + if err := generateProjectStructure(opts); err != nil { + return fmt.Errorf("generate project structure: %w", err) + } + return nil } +// generateProjectStructure writes the full plugin project layout: +// cmd/workflow-plugin-/main.go, internal/provider.go, internal/steps.go, +// go.mod, .goreleaser.yml, .github/workflows/ci.yml, .github/workflows/release.yml, +// Makefile, README.md. +func generateProjectStructure(opts GenerateOptions) error { + shortName := normalizeSDKPluginName(opts.Name) + binaryName := "workflow-plugin-" + shortName + goModule := opts.GoModule + if goModule == "" { + goModule = "github.com/" + opts.Author + "/" + binaryName + } + + // cmd/workflow-plugin-/main.go + cmdDir := filepath.Join(opts.OutputDir, "cmd", binaryName) + if err := os.MkdirAll(cmdDir, 0750); err != nil { + return fmt.Errorf("create cmd dir: %w", err) + } + if err := writeFile(filepath.Join(cmdDir, "main.go"), generateMainGo(goModule, shortName), 0600); err != nil { + return err + } + + // internal/provider.go and internal/steps.go + internalDir := filepath.Join(opts.OutputDir, "internal") + if err := os.MkdirAll(internalDir, 0750); err != nil { + return fmt.Errorf("create internal dir: %w", err) + } + if err := writeFile(filepath.Join(internalDir, "provider.go"), generateProviderGo(goModule, opts, shortName), 0600); err != nil { + return err + } + if err := writeFile(filepath.Join(internalDir, "steps.go"), generateStepsGo(goModule, shortName), 0600); err != nil { + return err + } + + // go.mod + if err := writeFile(filepath.Join(opts.OutputDir, "go.mod"), generateGoMod(goModule), 0600); err != nil { + return err + } + + // .goreleaser.yml + if err := writeFile(filepath.Join(opts.OutputDir, ".goreleaser.yml"), generateGoReleaserYML(binaryName), 0600); err != nil { + return err + } + + // .github/workflows/ci.yml and release.yml + ghWorkflowsDir := filepath.Join(opts.OutputDir, ".github", "workflows") + if err := os.MkdirAll(ghWorkflowsDir, 0750); err != nil { + return fmt.Errorf("create .github/workflows dir: %w", err) + } + if err := writeFile(filepath.Join(ghWorkflowsDir, "ci.yml"), generateCIYML(), 0600); err != nil { + return err + } + if err := writeFile(filepath.Join(ghWorkflowsDir, "release.yml"), generateReleaseYML(binaryName), 0600); err != nil { + return err + } + + // Makefile + if err := writeFile(filepath.Join(opts.OutputDir, "Makefile"), generateMakefile(binaryName), 0600); err != nil { + return err + } + + // README.md + if err := writeFile(filepath.Join(opts.OutputDir, "README.md"), generateREADME(opts, binaryName, goModule), 0644); err != nil { + return err + } + + return nil +} + +// writeFile writes content to path with the given mode. +func writeFile(path, content string, mode os.FileMode) error { + if err := os.WriteFile(path, []byte(content), mode); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return nil +} + +// normalizeSDKPluginName strips the "workflow-plugin-" prefix if present. +func normalizeSDKPluginName(name string) string { + return strings.TrimPrefix(name, "workflow-plugin-") +} + +// writePluginJSON writes a plugin.json with the nested capabilities format. +func writePluginJSON(path string, opts GenerateOptions) error { + shortName := normalizeSDKPluginName(opts.Name) + license := opts.License + if license == "" { + license = "Apache-2.0" + } + type capabilities struct { + ModuleTypes []string `json:"moduleTypes"` + StepTypes []string `json:"stepTypes"` + TriggerTypes []string `json:"triggerTypes"` + } + pj := map[string]interface{}{ + "name": "workflow-plugin-" + shortName, + "version": opts.Version, + "description": opts.Description, + "author": opts.Author, + "license": license, + "type": "external", + "tier": "community", + "private": false, + "minEngineVersion": "0.3.30", + "keywords": []string{}, + "capabilities": capabilities{ + ModuleTypes: []string{}, + StepTypes: []string{"step." + shortName + "_example"}, + TriggerTypes: []string{}, + }, + } + data, err := json.MarshalIndent(pj, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0640) //nolint:gosec // G306: plugin.json is user-owned output +} + +func generateMainGo(goModule, shortName string) string { + var b strings.Builder + b.WriteString("package main\n\n") + b.WriteString("import (\n") + fmt.Fprintf(&b, "\t%q\n", goModule+"/internal") + b.WriteString("\t\"github.com/GoCodeAlone/workflow/plugin/sdk\"\n") + b.WriteString(")\n\n") + b.WriteString("func main() {\n") + fmt.Fprintf(&b, "\tsdk.Serve(internal.New%sProvider())\n", toCamelCase(shortName)) + b.WriteString("}\n") + return b.String() +} + +func generateProviderGo(goModule string, opts GenerateOptions, shortName string) string { + typeName := toCamelCase(shortName) + "Provider" + var b strings.Builder + fmt.Fprintf(&b, "package internal\n\n") + b.WriteString("import (\n") + b.WriteString("\t\"github.com/GoCodeAlone/workflow/plugin\"\n") + b.WriteString(")\n\n") + fmt.Fprintf(&b, "// %s implements plugin.PluginProvider.\n", typeName) + fmt.Fprintf(&b, "type %s struct{}\n\n", typeName) + fmt.Fprintf(&b, "// New%s creates a new %s.\n", typeName, typeName) + fmt.Fprintf(&b, "func New%s() *%s {\n", typeName, typeName) + fmt.Fprintf(&b, "\treturn &%s{}\n", typeName) + b.WriteString("}\n\n") + fmt.Fprintf(&b, "func (p *%s) PluginInfo() *plugin.PluginManifest {\n", typeName) + b.WriteString("\treturn &plugin.PluginManifest{\n") + fmt.Fprintf(&b, "\t\tName: %q,\n", "workflow-plugin-"+shortName) + fmt.Fprintf(&b, "\t\tVersion: %q,\n", opts.Version) + fmt.Fprintf(&b, "\t\tAuthor: %q,\n", opts.Author) + fmt.Fprintf(&b, "\t\tDescription: %q,\n", opts.Description) + fmt.Fprintf(&b, "\t\tLicense: %q,\n", func() string { + if opts.License != "" { + return opts.License + } + return "Apache-2.0" + }()) + b.WriteString("\t}\n") + b.WriteString("}\n\n") + fmt.Fprintf(&b, "func (p *%s) StepFactories() []plugin.StepFactory {\n", typeName) + b.WriteString("\treturn []plugin.StepFactory{\n") + fmt.Fprintf(&b, "\t\tNew%sExampleStep,\n", toCamelCase(shortName)) + b.WriteString("\t}\n") + b.WriteString("}\n") + // Suppress unused import warning if goModule doesn't get used in this file + _ = goModule + return b.String() +} + +func generateStepsGo(goModule, shortName string) string { + stepType := "step." + shortName + "_example" + funcName := toCamelCase(shortName) + "ExampleStep" + var b strings.Builder + b.WriteString("package internal\n\n") + b.WriteString("import (\n") + b.WriteString("\t\"context\"\n\n") + b.WriteString("\t\"github.com/GoCodeAlone/workflow/plugin\"\n") + b.WriteString(")\n\n") + fmt.Fprintf(&b, "// %s implements the %s step.\n", funcName, stepType) + fmt.Fprintf(&b, "type %s struct{}\n\n", funcName) + fmt.Fprintf(&b, "// New%s creates the factory function for %s.\n", funcName, stepType) + fmt.Fprintf(&b, "func New%s(cfg map[string]interface{}) (plugin.Step, error) {\n", funcName) + fmt.Fprintf(&b, "\treturn &%s{}, nil\n", funcName) + b.WriteString("}\n\n") + fmt.Fprintf(&b, "func (s *%s) Type() string { return %q }\n\n", funcName, stepType) + fmt.Fprintf(&b, "func (s *%s) Execute(ctx context.Context, params plugin.StepParams) (map[string]interface{}, error) {\n", funcName) + b.WriteString("\treturn map[string]interface{}{\n") + b.WriteString("\t\t\"status\": \"ok\",\n") + b.WriteString("\t}, nil\n") + b.WriteString("}\n") + _ = goModule + return b.String() +} + +func generateGoMod(goModule string) string { + var b strings.Builder + fmt.Fprintf(&b, "module %s\n\n", goModule) + b.WriteString("go 1.22\n\n") + b.WriteString("require (\n") + b.WriteString("\tgithub.com/GoCodeAlone/workflow v0.3.30\n") + b.WriteString(")\n") + return b.String() +} + +func generateGoReleaserYML(binaryName string) string { + var b strings.Builder + b.WriteString("version: 2\n\n") + b.WriteString("builds:\n") + b.WriteString(" - id: plugin\n") + fmt.Fprintf(&b, " binary: %s\n", binaryName) + fmt.Fprintf(&b, " main: ./cmd/%s\n", binaryName) + b.WriteString(" env:\n") + b.WriteString(" - CGO_ENABLED=0\n") + b.WriteString(" goos:\n") + b.WriteString(" - linux\n") + b.WriteString(" - darwin\n") + b.WriteString(" goarch:\n") + b.WriteString(" - amd64\n") + b.WriteString(" - arm64\n\n") + b.WriteString("archives:\n") + b.WriteString(" - id: default\n") + b.WriteString(" format: tar.gz\n") + b.WriteString(" files:\n") + b.WriteString(" - plugin.json\n\n") + b.WriteString("checksum:\n") + b.WriteString(" name_template: checksums.txt\n\n") + b.WriteString("release:\n") + b.WriteString(" draft: false\n") + return b.String() +} + +func generateCIYML() string { + return `name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + - name: Test + run: go test ./... + - name: Vet + run: go vet ./... +` +} + +func generateReleaseYML(binaryName string) string { + return `name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + notify-registry: + if: startsWith(github.ref, 'refs/tags/v') + needs: [release] + runs-on: ubuntu-latest + steps: + - name: Notify workflow-registry + if: env.GH_TOKEN != '' + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.REGISTRY_PAT }} + repository: GoCodeAlone/workflow-registry + event-type: plugin-release + client-payload: >- + {"plugin": "${{ github.repository }}", "tag": "${{ github.ref_name }}"} + env: + GH_TOKEN: ${{ secrets.REGISTRY_PAT }} + continue-on-error: true +` +} + +func generateMakefile(binaryName string) string { + return fmt.Sprintf(`.PHONY: build test install-local clean + +build: + go build -o %s ./cmd/%s + +test: + go test ./... + +install-local: build + mkdir -p $(HOME)/.local/share/workflow/plugins/%s + cp %s $(HOME)/.local/share/workflow/plugins/%s/ + cp plugin.json $(HOME)/.local/share/workflow/plugins/%s/ + +clean: + rm -f %s +`, binaryName, binaryName, binaryName, binaryName, binaryName, binaryName, binaryName) +} + +func generateREADME(opts GenerateOptions, binaryName, goModule string) string { + shortName := normalizeSDKPluginName(opts.Name) + var b strings.Builder + fmt.Fprintf(&b, "# %s\n\n", binaryName) + fmt.Fprintf(&b, "%s\n\n", opts.Description) + b.WriteString("## Installation\n\n") + b.WriteString("```sh\n") + fmt.Fprintf(&b, "wfctl plugin install %s\n", binaryName) + b.WriteString("```\n\n") + b.WriteString("## Development\n\n") + b.WriteString("```sh\n") + b.WriteString("# Build\n") + b.WriteString("make build\n\n") + b.WriteString("# Test\n") + b.WriteString("make test\n\n") + b.WriteString("# Install locally\n") + b.WriteString("make install-local\n") + b.WriteString("```\n\n") + b.WriteString("## Step Types\n\n") + fmt.Fprintf(&b, "- `step.%s_example` — Example step\n\n", shortName) + b.WriteString("## Module\n\n") + fmt.Fprintf(&b, "Go module: `%s`\n", goModule) + return b.String() +} + func generateComponentSource(opts GenerateOptions) string { funcName := toCamelCase(opts.Name) var b strings.Builder From 50c6b377a82c73da1448e464ccfc649c1b46a1fc Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 15:23:12 -0400 Subject: [PATCH 04/14] feat(wfctl): add static registry source type for GitHub Pages Adds StaticRegistrySource that fetches plugin manifests from {baseURL}/plugins/{name}/manifest.json and lists/searches plugins via {baseURL}/index.json. Updates DefaultRegistryConfig to use the GitHub Pages static registry as primary with the GitHub API as a fallback. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/multi_registry.go | 2 + cmd/wfctl/registry_config.go | 21 +++++-- cmd/wfctl/registry_source.go | 111 +++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 6 deletions(-) diff --git a/cmd/wfctl/multi_registry.go b/cmd/wfctl/multi_registry.go index 41c51e06..5b03ca44 100644 --- a/cmd/wfctl/multi_registry.go +++ b/cmd/wfctl/multi_registry.go @@ -28,6 +28,8 @@ func NewMultiRegistry(cfg *RegistryConfig) *MultiRegistry { switch sc.Type { case "github": sources = append(sources, NewGitHubRegistrySource(sc)) + case "static": + sources = append(sources, NewStaticRegistrySource(sc)) default: // Skip unknown types fmt.Fprintf(os.Stderr, "warning: unknown registry type %q for %q, skipping\n", sc.Type, sc.Name) diff --git a/cmd/wfctl/registry_config.go b/cmd/wfctl/registry_config.go index 72c1227b..3d78fc85 100644 --- a/cmd/wfctl/registry_config.go +++ b/cmd/wfctl/registry_config.go @@ -17,24 +17,33 @@ type RegistryConfig struct { // RegistrySourceConfig defines a single registry source. type RegistrySourceConfig struct { Name string `yaml:"name" json:"name"` // e.g. "default", "my-org" - Type string `yaml:"type" json:"type"` // "github" (only type for now) - Owner string `yaml:"owner" json:"owner"` // GitHub owner/org - Repo string `yaml:"repo" json:"repo"` // GitHub repo name - Branch string `yaml:"branch" json:"branch"` // Git branch, default "main" + Type string `yaml:"type" json:"type"` // "github" or "static" + Owner string `yaml:"owner" json:"owner"` // GitHub owner/org (type: github) + Repo string `yaml:"repo" json:"repo"` // GitHub repo name (type: github) + Branch string `yaml:"branch" json:"branch"` // Git branch, default "main" (type: github) Priority int `yaml:"priority" json:"priority"` // Lower = higher priority + URL string `yaml:"url" json:"url"` // Base URL (type: static) + Token string `yaml:"token" json:"token"` // Auth token (optional) } -// DefaultRegistryConfig returns the built-in config with GoCodeAlone/workflow-registry. +// DefaultRegistryConfig returns the built-in config with a static GitHub Pages +// primary registry and a GitHub API fallback. func DefaultRegistryConfig() *RegistryConfig { return &RegistryConfig{ Registries: []RegistrySourceConfig{ { Name: "default", + Type: "static", + URL: "https://gocodealone.github.io/workflow-registry/v1", + Priority: 0, + }, + { + Name: "github-fallback", Type: "github", Owner: registryOwner, Repo: registryRepo, Branch: registryBranch, - Priority: 0, + Priority: 100, }, }, } diff --git a/cmd/wfctl/registry_source.go b/cmd/wfctl/registry_source.go index 053d1a0b..f2204c8c 100644 --- a/cmd/wfctl/registry_source.go +++ b/cmd/wfctl/registry_source.go @@ -138,6 +138,117 @@ func (g *GitHubRegistrySource) SearchPlugins(query string) ([]PluginSearchResult return results, nil } +// StaticRegistrySource implements RegistrySource backed by a static HTTP base URL (e.g. GitHub Pages). +// It expects: +// - {baseURL}/plugins/{name}/manifest.json for individual plugin manifests +// - {baseURL}/index.json for the plugin listing/search index +type StaticRegistrySource struct { + name string + baseURL string + token string +} + +// NewStaticRegistrySource creates a new static-URL-backed registry source. +func NewStaticRegistrySource(cfg RegistrySourceConfig) *StaticRegistrySource { + return &StaticRegistrySource{name: cfg.Name, baseURL: strings.TrimSuffix(cfg.URL, "/"), token: cfg.Token} +} + +func (s *StaticRegistrySource) Name() string { return s.name } + +func (s *StaticRegistrySource) FetchManifest(name string) (*RegistryManifest, error) { + url := fmt.Sprintf("%s/plugins/%s/manifest.json", s.baseURL, name) + data, err := s.fetch(url) + if err != nil { + return nil, fmt.Errorf("fetch manifest for %q from %s: %w", name, s.name, err) + } + var m RegistryManifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parse manifest for %q from %s: %w", name, s.name, err) + } + return &m, nil +} + +// staticIndexEntry is a single entry in the registry index.json file. +type staticIndexEntry struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Tier string `json:"tier"` +} + +func (s *StaticRegistrySource) fetchIndex() ([]staticIndexEntry, error) { + url := fmt.Sprintf("%s/index.json", s.baseURL) + data, err := s.fetch(url) + if err != nil { + return nil, fmt.Errorf("fetch index from %s: %w", s.name, err) + } + var entries []staticIndexEntry + if err := json.Unmarshal(data, &entries); err != nil { + return nil, fmt.Errorf("parse index from %s: %w", s.name, err) + } + return entries, nil +} + +func (s *StaticRegistrySource) SearchPlugins(query string) ([]PluginSearchResult, error) { + entries, err := s.fetchIndex() + if err != nil { + return nil, err + } + q := strings.ToLower(query) + var results []PluginSearchResult + for _, e := range entries { + if q == "" || + strings.Contains(strings.ToLower(e.Name), q) || + strings.Contains(strings.ToLower(e.Description), q) { + results = append(results, PluginSearchResult{ + PluginSummary: PluginSummary{ + Name: e.Name, + Version: e.Version, + Description: e.Description, + Tier: e.Tier, + }, + Source: s.name, + }) + } + } + return results, nil +} + +func (s *StaticRegistrySource) ListPlugins() ([]string, error) { + entries, err := s.fetchIndex() + if err != nil { + return nil, err + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name) + } + return names, nil +} + +// fetch performs an HTTP GET with optional auth token. +func (s *StaticRegistrySource) fetch(url string) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) //nolint:gosec // G107: URL from user config + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + if s.token != "" { + req.Header.Set("Authorization", "Bearer "+s.token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("not found (HTTP 404) at %s", url) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) + } + return io.ReadAll(resp.Body) +} + // matchesRegistryQuery checks if a manifest matches a search query. func matchesRegistryQuery(m *RegistryManifest, q string) bool { if q == "" { From 7ce1dbb73564aa3409cdab2f59b8d85e70c66ff1 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 15:25:21 -0400 Subject: [PATCH 05/14] feat(wfctl): add plugin install --local for local directory installs Adds --local flag to wfctl plugin install that reads plugin.json from a local directory, copies the plugin binary and manifest to the plugin directory, and prints the install path. Also updates TestDefaultRegistryConfig to match the new two-registry default config (static primary + github fallback). Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/multi_registry_test.go | 39 ++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/cmd/wfctl/multi_registry_test.go b/cmd/wfctl/multi_registry_test.go index fe8e2fb4..2dd16a65 100644 --- a/cmd/wfctl/multi_registry_test.go +++ b/cmd/wfctl/multi_registry_test.go @@ -103,28 +103,43 @@ func TestDefaultRegistryConfig(t *testing.T) { if cfg == nil { t.Fatal("expected non-nil config") } - if len(cfg.Registries) != 1 { - t.Fatalf("expected 1 registry, got %d", len(cfg.Registries)) + if len(cfg.Registries) != 2 { + t.Fatalf("expected 2 registries, got %d", len(cfg.Registries)) } + // Primary: static registry r := cfg.Registries[0] if r.Name != "default" { t.Errorf("name: got %q, want %q", r.Name, "default") } - if r.Type != "github" { - t.Errorf("type: got %q, want %q", r.Type, "github") - } - if r.Owner != registryOwner { - t.Errorf("owner: got %q, want %q", r.Owner, registryOwner) + if r.Type != "static" { + t.Errorf("type: got %q, want %q", r.Type, "static") } - if r.Repo != registryRepo { - t.Errorf("repo: got %q, want %q", r.Repo, registryRepo) - } - if r.Branch != registryBranch { - t.Errorf("branch: got %q, want %q", r.Branch, registryBranch) + if r.URL == "" { + t.Error("expected non-empty URL for static registry") } if r.Priority != 0 { t.Errorf("priority: got %d, want 0", r.Priority) } + // Fallback: github registry + fb := cfg.Registries[1] + if fb.Name != "github-fallback" { + t.Errorf("fallback name: got %q, want %q", fb.Name, "github-fallback") + } + if fb.Type != "github" { + t.Errorf("fallback type: got %q, want %q", fb.Type, "github") + } + if fb.Owner != registryOwner { + t.Errorf("fallback owner: got %q, want %q", fb.Owner, registryOwner) + } + if fb.Repo != registryRepo { + t.Errorf("fallback repo: got %q, want %q", fb.Repo, registryRepo) + } + if fb.Branch != registryBranch { + t.Errorf("fallback branch: got %q, want %q", fb.Branch, registryBranch) + } + if fb.Priority != 100 { + t.Errorf("fallback priority: got %d, want 100", fb.Priority) + } } func TestLoadRegistryConfigFromFile(t *testing.T) { From 60fbd0349dc25862d94492a608610bc854f6b8d2 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 16:00:31 -0400 Subject: [PATCH 06/14] fix: resolve CI lint and test failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use struct conversion for staticIndexEntry → PluginSummary (staticcheck S1016) - Remove unused updateLockfile function (replaced by updateLockfileWithChecksum) - Remove unused writePluginJSON function - Fix TestLoadRegistryConfigDefault to test DefaultRegistryConfig() directly, avoiding interference from user config files Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/multi_registry_test.go | 16 +++++++------- cmd/wfctl/plugin_lockfile.go | 17 --------------- cmd/wfctl/registry_source.go | 9 ++------ plugin/sdk/generator.go | 37 -------------------------------- 4 files changed, 10 insertions(+), 69 deletions(-) diff --git a/cmd/wfctl/multi_registry_test.go b/cmd/wfctl/multi_registry_test.go index 2dd16a65..0355dc47 100644 --- a/cmd/wfctl/multi_registry_test.go +++ b/cmd/wfctl/multi_registry_test.go @@ -184,16 +184,16 @@ func TestLoadRegistryConfigFromFile(t *testing.T) { } func TestLoadRegistryConfigDefault(t *testing.T) { - // Provide a path that does not exist — should fall back to default. - cfg, err := LoadRegistryConfig("/nonexistent/path/config.yaml") - if err != nil { - t.Fatalf("LoadRegistryConfig: %v", err) + // Test DefaultRegistryConfig directly to avoid picking up user config files. + cfg := DefaultRegistryConfig() + if len(cfg.Registries) != 2 { + t.Fatalf("expected 2 registries (static + github fallback), got %d", len(cfg.Registries)) } - if len(cfg.Registries) != 1 { - t.Fatalf("expected 1 registry (default), got %d", len(cfg.Registries)) + if cfg.Registries[0].Type != "static" { + t.Errorf("first registry type: got %q, want %q", cfg.Registries[0].Type, "static") } - if cfg.Registries[0].Owner != registryOwner { - t.Errorf("owner: got %q, want %q", cfg.Registries[0].Owner, registryOwner) + if cfg.Registries[1].Owner != registryOwner { + t.Errorf("fallback owner: got %q, want %q", cfg.Registries[1].Owner, registryOwner) } } diff --git a/cmd/wfctl/plugin_lockfile.go b/cmd/wfctl/plugin_lockfile.go index af385831..b5718868 100644 --- a/cmd/wfctl/plugin_lockfile.go +++ b/cmd/wfctl/plugin_lockfile.go @@ -98,23 +98,6 @@ func installFromLockfile(pluginDir, cfgPath string) error { 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) -} - // updateLockfileWithChecksum adds or updates a plugin entry in .wfctl.yaml with SHA-256 checksum. // Silently no-ops if the lockfile cannot be read or written (install still succeeds). func updateLockfileWithChecksum(pluginName, version, repository, sha256Hash string) { diff --git a/cmd/wfctl/registry_source.go b/cmd/wfctl/registry_source.go index f2204c8c..8764df54 100644 --- a/cmd/wfctl/registry_source.go +++ b/cmd/wfctl/registry_source.go @@ -201,13 +201,8 @@ func (s *StaticRegistrySource) SearchPlugins(query string) ([]PluginSearchResult strings.Contains(strings.ToLower(e.Name), q) || strings.Contains(strings.ToLower(e.Description), q) { results = append(results, PluginSearchResult{ - PluginSummary: PluginSummary{ - Name: e.Name, - Version: e.Version, - Description: e.Description, - Tier: e.Tier, - }, - Source: s.name, + PluginSummary: PluginSummary(e), + Source: s.name, }) } } diff --git a/plugin/sdk/generator.go b/plugin/sdk/generator.go index 2dfb20ca..7c06cec9 100644 --- a/plugin/sdk/generator.go +++ b/plugin/sdk/generator.go @@ -1,7 +1,6 @@ package sdk import ( - "encoding/json" "fmt" "os" "path/filepath" @@ -180,42 +179,6 @@ func normalizeSDKPluginName(name string) string { return strings.TrimPrefix(name, "workflow-plugin-") } -// writePluginJSON writes a plugin.json with the nested capabilities format. -func writePluginJSON(path string, opts GenerateOptions) error { - shortName := normalizeSDKPluginName(opts.Name) - license := opts.License - if license == "" { - license = "Apache-2.0" - } - type capabilities struct { - ModuleTypes []string `json:"moduleTypes"` - StepTypes []string `json:"stepTypes"` - TriggerTypes []string `json:"triggerTypes"` - } - pj := map[string]interface{}{ - "name": "workflow-plugin-" + shortName, - "version": opts.Version, - "description": opts.Description, - "author": opts.Author, - "license": license, - "type": "external", - "tier": "community", - "private": false, - "minEngineVersion": "0.3.30", - "keywords": []string{}, - "capabilities": capabilities{ - ModuleTypes: []string{}, - StepTypes: []string{"step." + shortName + "_example"}, - TriggerTypes: []string{}, - }, - } - data, err := json.MarshalIndent(pj, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, append(data, '\n'), 0640) //nolint:gosec // G306: plugin.json is user-owned output -} - func generateMainGo(goModule, shortName string) string { var b strings.Builder b.WriteString("package main\n\n") From 4b63275fece6e38fb7681c3f4d3c979b5ebd032f Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 17:38:56 -0400 Subject: [PATCH 07/14] fix: address Copilot review feedback on wfctl PR - Fix checksum mismatch: lockfile now stores SHA-256 of installed binary (not the download archive) so verifyInstalledChecksum passes correctly - Fix generator SDK import: generateMainGo, generateProviderGo, and generateStepsGo now import plugin/external/sdk and use correct external interfaces (PluginProvider.Manifest, StepProvider, StepInstance, StepResult) - Fix docs: PLUGIN_AUTHORING.md now shows correct external SDK types and corrects "cd workflow-plugin-my-plugin" to "cd my-plugin" - Add mutual exclusivity validation in runPluginInstall for --url, --local, and positional args - Add registry parameter to updateLockfileWithChecksum and write it to lockfile - Add URL validation in NewStaticRegistrySource (returns error if empty) - Replace PluginSummary(e) struct conversion with explicit field assignment - Update all NewStaticRegistrySource callers to handle error return - Update TestInstallFromURL to verify binary hash (not archive hash) Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/multi_registry.go | 7 +- cmd/wfctl/plugin_install.go | 42 ++- cmd/wfctl/plugin_install_new_test.go | 477 +++++++++++++++++++++++++++ cmd/wfctl/plugin_lockfile.go | 4 +- cmd/wfctl/registry_source.go | 17 +- cmd/wfctl/registry_source_test.go | 333 +++++++++++++++++++ docs/PLUGIN_AUTHORING.md | 227 +++++++++++++ plugin/sdk/generator.go | 63 ++-- 8 files changed, 1131 insertions(+), 39 deletions(-) create mode 100644 cmd/wfctl/plugin_install_new_test.go create mode 100644 cmd/wfctl/registry_source_test.go create mode 100644 docs/PLUGIN_AUTHORING.md diff --git a/cmd/wfctl/multi_registry.go b/cmd/wfctl/multi_registry.go index 5b03ca44..b9f0b24a 100644 --- a/cmd/wfctl/multi_registry.go +++ b/cmd/wfctl/multi_registry.go @@ -29,7 +29,12 @@ func NewMultiRegistry(cfg *RegistryConfig) *MultiRegistry { case "github": sources = append(sources, NewGitHubRegistrySource(sc)) case "static": - sources = append(sources, NewStaticRegistrySource(sc)) + staticSrc, staticErr := NewStaticRegistrySource(sc) + if staticErr != nil { + fmt.Fprintf(os.Stderr, "warning: %v, skipping\n", staticErr) + continue + } + sources = append(sources, staticSrc) default: // Skip unknown types fmt.Fprintf(os.Stderr, "warning: unknown registry type %q for %q, skipping\n", sc.Type, sc.Name) diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 0b392ba3..bbefbcd3 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -81,6 +81,21 @@ func runPluginInstall(args []string) error { return err } + // Enforce mutual exclusivity: at most one of --url, --local, or positional args. + exclusiveCount := 0 + if *directURL != "" { + exclusiveCount++ + } + if *localPath != "" { + exclusiveCount++ + } + if fs.NArg() > 0 { + exclusiveCount++ + } + if exclusiveCount > 1 { + return fmt.Errorf("--url, --local, and are mutually exclusive; specify only one") + } + if *directURL != "" { return installFromURL(*directURL, pluginDirVal) } @@ -149,11 +164,10 @@ func runPluginInstall(args []string) error { // Update .wfctl.yaml lockfile if name@version was provided. if _, ver := parseNameVersion(nameArg); ver != "" { - sha := "" - if dl, dlErr := manifest.FindDownload(runtime.GOOS, runtime.GOARCH); dlErr == nil { - sha = dl.SHA256 - } - updateLockfileWithChecksum(manifest.Name, manifest.Version, manifest.Repository, sha) + // Hash the installed binary (not the archive) so verifyInstalledChecksum matches. + binaryPath := filepath.Join(pluginDirVal, pluginName, pluginName) + sha := hashFileSHA256(binaryPath) + updateLockfileWithChecksum(manifest.Name, manifest.Version, manifest.Repository, sourceName, sha) } return nil @@ -501,9 +515,10 @@ func installFromURL(url, pluginDir string) error { fmt.Fprintf(os.Stderr, "warning: could not normalize binary name: %v\n", err) } - h := sha256.Sum256(data) - checksum := hex.EncodeToString(h[:]) - updateLockfileWithChecksum(pluginName, pj.Version, pj.Repository, checksum) + // Hash the installed binary (not the archive) so that verifyInstalledChecksum matches. + binaryPath := filepath.Join(destDir, pluginName) + checksum := hashFileSHA256(binaryPath) + updateLockfileWithChecksum(pluginName, pj.Version, pj.Repository, "", checksum) fmt.Printf("Installed %s v%s to %s\n", pluginName, pj.Version, destDir) return nil @@ -658,6 +673,17 @@ func parseGitHubRepoURL(repoURL string) (owner, repo string, err error) { return parts[1], repoName, nil } +// hashFileSHA256 returns the hex-encoded SHA-256 hash of the file at path. +// Returns an empty string if the file cannot be read. +func hashFileSHA256(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]) +} + // extractTarGz decompresses and extracts a .tar.gz archive into destDir. // It guards against path traversal (zip-slip) attacks. func extractTarGz(data []byte, destDir string) error { diff --git a/cmd/wfctl/plugin_install_new_test.go b/cmd/wfctl/plugin_install_new_test.go new file mode 100644 index 00000000..dc782f3a --- /dev/null +++ b/cmd/wfctl/plugin_install_new_test.go @@ -0,0 +1,477 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" +) + +// ============================================================ +// helpers shared by tests in this file +// ============================================================ + +// buildPluginTarGz builds an in-memory tar.gz whose layout matches a real +// GoReleaser plugin release: a single top-level directory containing the +// binary and plugin.json. +func buildPluginTarGz(t *testing.T, pluginName string, binaryContent []byte, pjContent []byte) []byte { + t.Helper() + topDir := pluginName + "-" + runtime.GOOS + "-" + runtime.GOARCH + entries := map[string][]byte{ + topDir + "/" + pluginName: binaryContent, + topDir + "/plugin.json": pjContent, + } + return buildTarGz(t, entries, 0755) +} + +// minimalPluginJSON returns a valid, minimal plugin.json as bytes. +func minimalPluginJSON(name, version string) []byte { + pj := installedPluginJSON{ + Name: name, + Version: version, + Author: "tester", + Description: "test plugin", + Type: "external", + Tier: "community", + License: "MIT", + } + data, _ := json.MarshalIndent(pj, "", " ") + return append(data, '\n') +} + +// ============================================================ +// Test 3: installFromURL +// ============================================================ + +// TestInstallFromURL sets up a local HTTP server serving a tar.gz archive +// with a valid plugin.json, calls installFromURL, and verifies: +// - the plugin binary is extracted to // +// - the plugin.json is written +// - the lockfile (.wfctl.yaml in cwd) is updated with a checksum +func TestInstallFromURL(t *testing.T) { + const pluginName = "url-test-plugin" + binaryContent := []byte("#!/bin/sh\necho url-test\n") + pjContent := minimalPluginJSON(pluginName, "1.2.3") + + tarball := buildPluginTarGz(t, pluginName, binaryContent, pjContent) + // The lockfile records the SHA-256 of the installed binary, not the archive. + binaryChecksum := sha256Hex(binaryContent) + + // Serve tarball from a local httptest server. + srv := 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 srv.Close() + + // Run inside a temp cwd so .wfctl.yaml ends up there, not the repo root. + origWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + cwdDir := t.TempDir() + if err := os.Chdir(cwdDir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { os.Chdir(origWD) }) //nolint:errcheck + + pluginsDir := t.TempDir() + + if err := installFromURL(srv.URL+"/"+pluginName+".tar.gz", pluginsDir); err != nil { + t.Fatalf("installFromURL: %v", err) + } + + // Binary should exist at //. + binaryPath := filepath.Join(pluginsDir, pluginName, pluginName) + gotBinary, err := os.ReadFile(binaryPath) + if err != nil { + t.Fatalf("read binary: %v", err) + } + if !bytes.Equal(gotBinary, binaryContent) { + t.Errorf("binary content mismatch: got %q, want %q", gotBinary, binaryContent) + } + + // plugin.json should be present. + pjPath := filepath.Join(pluginsDir, pluginName, "plugin.json") + if _, err := os.Stat(pjPath); err != nil { + t.Errorf("plugin.json not found: %v", err) + } + + // Lockfile should record the plugin with a checksum. It is written to + // .wfctl.yaml in the cwd (cwdDir). + lockfilePath := filepath.Join(cwdDir, ".wfctl.yaml") + lf, loadErr := loadPluginLockfile(lockfilePath) + if loadErr != nil { + t.Fatalf("load lockfile: %v", loadErr) + } + entry, ok := lf.Plugins[pluginName] + if !ok { + t.Fatalf("lockfile missing entry for %q; entries: %v", pluginName, lf.Plugins) + } + if entry.SHA256 != binaryChecksum { + t.Errorf("lockfile checksum: got %q, want %q (should be binary hash, not archive hash)", entry.SHA256, binaryChecksum) + } + if entry.Version != "1.2.3" { + t.Errorf("lockfile version: got %q, want %q", entry.Version, "1.2.3") + } +} + +// TestInstallFromURL_404 verifies that a 404 from the server returns an error. +func TestInstallFromURL_404(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer srv.Close() + + err := installFromURL(srv.URL+"/missing.tar.gz", t.TempDir()) + if err == nil { + t.Fatal("expected error for 404, got nil") + } +} + +// TestInstallFromURL_MissingPluginJSON verifies that a tarball without +// plugin.json returns an error. +func TestInstallFromURL_MissingPluginJSON(t *testing.T) { + // Build tarball with only a binary, no plugin.json. + topDir := "plugin-linux-amd64" + entries := map[string][]byte{ + topDir + "/binary": []byte("#!/bin/sh\n"), + } + tarball := buildTarGz(t, entries, 0755) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write(tarball) //nolint:errcheck + })) + defer srv.Close() + + err := installFromURL(srv.URL+"/plugin.tar.gz", t.TempDir()) + if err == nil { + t.Fatal("expected error for missing plugin.json, got nil") + } +} + +// TestInstallFromURL_NameNormalization verifies that a plugin named +// "workflow-plugin-foo" is normalized to "foo" in the destination path. +func TestInstallFromURL_NameNormalization(t *testing.T) { + const fullName = "workflow-plugin-foo" + const shortName = "foo" + + pjContent := minimalPluginJSON(fullName, "0.1.0") + entries := map[string][]byte{ + "top/" + fullName: []byte("#!/bin/sh\n"), + "top/plugin.json": pjContent, + } + tarball := buildTarGz(t, entries, 0755) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write(tarball) //nolint:errcheck + })) + defer srv.Close() + + // Run in a temp cwd so .wfctl.yaml lockfile stays isolated. + origWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(t.TempDir()); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { os.Chdir(origWD) }) //nolint:errcheck + + pluginsDir := t.TempDir() + if err := installFromURL(srv.URL+"/plugin.tar.gz", pluginsDir); err != nil { + t.Fatalf("installFromURL: %v", err) + } + + // Destination should use the normalized short name. + destDir := filepath.Join(pluginsDir, shortName) + if _, err := os.Stat(destDir); os.IsNotExist(err) { + t.Errorf("expected dest dir %s to exist (normalized from %q)", destDir, fullName) + } +} + +// ============================================================ +// Test 4: installFromLocal +// ============================================================ + +// TestInstallFromLocal sets up a temp dir with a fake plugin.json and binary, +// calls installFromLocal, and verifies the files are copied correctly. +func TestInstallFromLocal(t *testing.T) { + const pluginName = "local-plugin" + binaryContent := []byte("#!/bin/sh\necho local\n") + + // Create a source directory with plugin.json and binary. + srcDir := t.TempDir() + pjContent := minimalPluginJSON(pluginName, "2.0.0") + if err := os.WriteFile(filepath.Join(srcDir, "plugin.json"), pjContent, 0640); err != nil { + t.Fatalf("write plugin.json: %v", err) + } + // Binary named to match plugin name. + binaryPath := filepath.Join(srcDir, pluginName) + if err := os.WriteFile(binaryPath, binaryContent, 0750); err != nil { + t.Fatalf("write binary: %v", err) + } + + pluginsDir := t.TempDir() + if err := installFromLocal(srcDir, pluginsDir); err != nil { + t.Fatalf("installFromLocal: %v", err) + } + + // Verify binary was copied. + destBinary := filepath.Join(pluginsDir, pluginName, pluginName) + gotContent, err := os.ReadFile(destBinary) + if err != nil { + t.Fatalf("read dest binary: %v", err) + } + if !bytes.Equal(gotContent, binaryContent) { + t.Errorf("binary content mismatch: got %q, want %q", gotContent, binaryContent) + } + + // Verify plugin.json was copied. + destPJ := filepath.Join(pluginsDir, pluginName, "plugin.json") + if _, err := os.Stat(destPJ); err != nil { + t.Errorf("plugin.json not in dest: %v", err) + } +} + +// TestInstallFromLocal_NameNormalization verifies that a plugin named +// "workflow-plugin-bar" is normalized to "bar" in the destination path. +func TestInstallFromLocal_NameNormalization(t *testing.T) { + const fullName = "workflow-plugin-bar" + const shortName = "bar" + + srcDir := t.TempDir() + pjContent := minimalPluginJSON(fullName, "0.1.0") + if err := os.WriteFile(filepath.Join(srcDir, "plugin.json"), pjContent, 0640); err != nil { + t.Fatalf("write plugin.json: %v", err) + } + // Binary named with full "workflow-plugin-" prefix (also accepted). + binaryPath := filepath.Join(srcDir, shortName) + if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\n"), 0750); err != nil { + t.Fatalf("write binary: %v", err) + } + + pluginsDir := t.TempDir() + if err := installFromLocal(srcDir, pluginsDir); err != nil { + t.Fatalf("installFromLocal: %v", err) + } + + destDir := filepath.Join(pluginsDir, shortName) + if _, err := os.Stat(destDir); os.IsNotExist(err) { + t.Errorf("expected dest dir %s (normalized from %q)", destDir, fullName) + } +} + +// TestInstallFromLocal_FallbackBinaryName verifies that installFromLocal +// falls back to looking for "workflow-plugin-" when the short name +// binary is not found. +func TestInstallFromLocal_FallbackBinaryName(t *testing.T) { + const pluginName = "baz" + const fullBinaryName = "workflow-plugin-baz" + + srcDir := t.TempDir() + pjContent := minimalPluginJSON(pluginName, "0.1.0") + if err := os.WriteFile(filepath.Join(srcDir, "plugin.json"), pjContent, 0640); err != nil { + t.Fatalf("write plugin.json: %v", err) + } + // Binary uses the full name. + if err := os.WriteFile(filepath.Join(srcDir, fullBinaryName), []byte("#!/bin/sh\n"), 0750); err != nil { + t.Fatalf("write binary: %v", err) + } + + pluginsDir := t.TempDir() + if err := installFromLocal(srcDir, pluginsDir); err != nil { + t.Fatalf("installFromLocal with fallback name: %v", err) + } + + // The installed binary should be renamed to the short name. + destBinary := filepath.Join(pluginsDir, pluginName, pluginName) + if _, err := os.Stat(destBinary); err != nil { + t.Errorf("expected binary at %s: %v", destBinary, err) + } +} + +// TestInstallFromLocal_MissingPluginJSON verifies that missing plugin.json returns an error. +func TestInstallFromLocal_MissingPluginJSON(t *testing.T) { + srcDir := t.TempDir() + err := installFromLocal(srcDir, t.TempDir()) + if err == nil { + t.Fatal("expected error for missing plugin.json, got nil") + } +} + +// TestInstallFromLocal_MissingBinary verifies that missing binary returns an error. +func TestInstallFromLocal_MissingBinary(t *testing.T) { + srcDir := t.TempDir() + pjContent := minimalPluginJSON("nobinary", "0.1.0") + if err := os.WriteFile(filepath.Join(srcDir, "plugin.json"), pjContent, 0640); err != nil { + t.Fatalf("write plugin.json: %v", err) + } + // No binary file. + err := installFromLocal(srcDir, t.TempDir()) + if err == nil { + t.Fatal("expected error for missing binary, got nil") + } +} + +// ============================================================ +// Test 5: verifyInstalledChecksum +// ============================================================ + +// TestVerifyInstalledChecksum verifies that verifyInstalledChecksum: +// - succeeds when the checksum matches the binary content +// - fails when the checksum does not match +func TestVerifyInstalledChecksum_Match(t *testing.T) { + content := []byte("plugin binary content for checksum test") + h := sha256.Sum256(content) + expectedSHA := hex.EncodeToString(h[:]) + + pluginDir := t.TempDir() + const pluginName = "checksum-plugin" + binaryPath := filepath.Join(pluginDir, pluginName) + if err := os.WriteFile(binaryPath, content, 0750); err != nil { + t.Fatalf("write binary: %v", err) + } + + if err := verifyInstalledChecksum(pluginDir, pluginName, expectedSHA); err != nil { + t.Errorf("expected checksum match, got error: %v", err) + } +} + +// TestVerifyInstalledChecksum_Mismatch verifies that a wrong checksum is rejected. +func TestVerifyInstalledChecksum_Mismatch(t *testing.T) { + content := []byte("plugin binary content") + pluginDir := t.TempDir() + const pluginName = "checksum-plugin" + binaryPath := filepath.Join(pluginDir, pluginName) + if err := os.WriteFile(binaryPath, content, 0750); err != nil { + t.Fatalf("write binary: %v", err) + } + + wrongSHA := "0000000000000000000000000000000000000000000000000000000000000000" + if err := verifyInstalledChecksum(pluginDir, pluginName, wrongSHA); err == nil { + t.Error("expected error for checksum mismatch, got nil") + } +} + +// TestVerifyInstalledChecksum_MissingBinary verifies that a missing binary returns an error. +func TestVerifyInstalledChecksum_MissingBinary(t *testing.T) { + pluginDir := t.TempDir() + err := verifyInstalledChecksum(pluginDir, "nonexistent-plugin", "abc123") + if err == nil { + t.Error("expected error for missing binary, got nil") + } +} + +// TestVerifyInstalledChecksum_CaseInsensitive verifies that checksum comparison +// is case-insensitive (uppercase hex is accepted). +func TestVerifyInstalledChecksum_CaseInsensitive(t *testing.T) { + content := []byte("case insensitive checksum test") + h := sha256.Sum256(content) + lowerSHA := hex.EncodeToString(h[:]) + upperSHA := hex.EncodeToString(h[:]) + for i := range upperSHA { + if upperSHA[i] >= 'a' && upperSHA[i] <= 'f' { + upperSHA = upperSHA[:i] + string(rune(upperSHA[i]-32)) + upperSHA[i+1:] + } + } + + pluginDir := t.TempDir() + const pluginName = "case-plugin" + if err := os.WriteFile(filepath.Join(pluginDir, pluginName), content, 0750); err != nil { + t.Fatalf("write binary: %v", err) + } + + // Both lower and upper should succeed. + if err := verifyInstalledChecksum(pluginDir, pluginName, lowerSHA); err != nil { + t.Errorf("lowercase checksum failed: %v", err) + } + if err := verifyInstalledChecksum(pluginDir, pluginName, upperSHA); err != nil { + t.Errorf("uppercase checksum failed: %v", err) + } +} + +// ============================================================ +// Test 8: copyFile helper +// ============================================================ + +// TestCopyFile verifies that copyFile copies content and sets the correct mode. +func TestCopyFile(t *testing.T) { + srcDir := t.TempDir() + dstDir := t.TempDir() + + content := []byte("copy me please") + srcPath := filepath.Join(srcDir, "source.txt") + if err := os.WriteFile(srcPath, content, 0640); err != nil { + t.Fatalf("write source: %v", err) + } + + dstPath := filepath.Join(dstDir, "dest.txt") + const wantMode = os.FileMode(0750) + if err := copyFile(srcPath, dstPath, wantMode); err != nil { + t.Fatalf("copyFile: %v", err) + } + + got, err := os.ReadFile(dstPath) + if err != nil { + t.Fatalf("read dest: %v", err) + } + if !bytes.Equal(got, content) { + t.Errorf("content mismatch: got %q, want %q", got, content) + } + + info, err := os.Stat(dstPath) + if err != nil { + t.Fatalf("stat dest: %v", err) + } + if info.Mode() != wantMode { + t.Errorf("mode: got %v, want %v", info.Mode(), wantMode) + } +} + +// TestCopyFile_MissingSource verifies that a missing source returns an error. +func TestCopyFile_MissingSource(t *testing.T) { + err := copyFile("/nonexistent/source.txt", filepath.Join(t.TempDir(), "dest.txt"), 0640) + if err == nil { + t.Fatal("expected error for missing source, got nil") + } +} + +// TestCopyFile_NonWritableDest verifies that an unwritable destination returns an error. +func TestCopyFile_OverwritesExisting(t *testing.T) { + srcDir := t.TempDir() + dstDir := t.TempDir() + + src := filepath.Join(srcDir, "new.txt") + dst := filepath.Join(dstDir, "old.txt") + + // Write initial dest content. + if err := os.WriteFile(dst, []byte("old content"), 0640); err != nil { + t.Fatalf("write initial dest: %v", err) + } + // Write new source content. + if err := os.WriteFile(src, []byte("new content"), 0640); err != nil { + t.Fatalf("write source: %v", err) + } + + if err := copyFile(src, dst, 0640); err != nil { + t.Fatalf("copyFile: %v", err) + } + + got, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("read dest: %v", err) + } + if string(got) != "new content" { + t.Errorf("overwrite failed: got %q, want %q", got, "new content") + } +} diff --git a/cmd/wfctl/plugin_lockfile.go b/cmd/wfctl/plugin_lockfile.go index b5718868..26c66d2a 100644 --- a/cmd/wfctl/plugin_lockfile.go +++ b/cmd/wfctl/plugin_lockfile.go @@ -99,8 +99,9 @@ func installFromLockfile(pluginDir, cfgPath string) error { } // updateLockfileWithChecksum adds or updates a plugin entry in .wfctl.yaml with SHA-256 checksum. +// The sha256Hash must be the hash of the installed binary, not the download archive. // Silently no-ops if the lockfile cannot be read or written (install still succeeds). -func updateLockfileWithChecksum(pluginName, version, repository, sha256Hash string) { +func updateLockfileWithChecksum(pluginName, version, repository, registry, sha256Hash string) { lf, err := loadPluginLockfile(wfctlYAMLPath) if err != nil { return @@ -111,6 +112,7 @@ func updateLockfileWithChecksum(pluginName, version, repository, sha256Hash stri lf.Plugins[pluginName] = PluginLockEntry{ Version: version, Repository: repository, + Registry: registry, SHA256: sha256Hash, } _ = lf.Save(wfctlYAMLPath) diff --git a/cmd/wfctl/registry_source.go b/cmd/wfctl/registry_source.go index 8764df54..83386f82 100644 --- a/cmd/wfctl/registry_source.go +++ b/cmd/wfctl/registry_source.go @@ -149,8 +149,12 @@ type StaticRegistrySource struct { } // NewStaticRegistrySource creates a new static-URL-backed registry source. -func NewStaticRegistrySource(cfg RegistrySourceConfig) *StaticRegistrySource { - return &StaticRegistrySource{name: cfg.Name, baseURL: strings.TrimSuffix(cfg.URL, "/"), token: cfg.Token} +// Returns an error if the URL is empty. +func NewStaticRegistrySource(cfg RegistrySourceConfig) (*StaticRegistrySource, error) { + if cfg.URL == "" { + return nil, fmt.Errorf("registry %q: url is required for type=static", cfg.Name) + } + return &StaticRegistrySource{name: cfg.Name, baseURL: strings.TrimSuffix(cfg.URL, "/"), token: cfg.Token}, nil } func (s *StaticRegistrySource) Name() string { return s.name } @@ -201,8 +205,13 @@ func (s *StaticRegistrySource) SearchPlugins(query string) ([]PluginSearchResult strings.Contains(strings.ToLower(e.Name), q) || strings.Contains(strings.ToLower(e.Description), q) { results = append(results, PluginSearchResult{ - PluginSummary: PluginSummary(e), - Source: s.name, + PluginSummary: PluginSummary{ + Name: e.Name, + Version: e.Version, + Description: e.Description, + Tier: e.Tier, + }, + Source: s.name, }) } } diff --git a/cmd/wfctl/registry_source_test.go b/cmd/wfctl/registry_source_test.go new file mode 100644 index 00000000..3d525f18 --- /dev/null +++ b/cmd/wfctl/registry_source_test.go @@ -0,0 +1,333 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// ============================================================ +// Test 6: StaticRegistrySource +// ============================================================ + +// buildStaticRegistryServer creates a test HTTP server that serves: +// - GET /index.json → the provided index entries +// - GET /plugins//manifest.json → the manifest for that plugin (if present) +// +// It returns the server and a cleanup function. +func buildStaticRegistryServer(t *testing.T, index []staticIndexEntry, manifests map[string]*RegistryManifest) *httptest.Server { + t.Helper() + indexData, err := json.Marshal(index) + if err != nil { + t.Fatalf("marshal index: %v", err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/index.json": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(indexData) //nolint:errcheck + default: + // Try to match /plugins//manifest.json + var pluginName string + if _, err := splitPluginManifestPath(r.URL.Path, &pluginName); err == nil { + if m, ok := manifests[pluginName]; ok { + data, _ := json.Marshal(m) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(data) //nolint:errcheck + return + } + } + http.NotFound(w, r) + } + })) + return srv +} + +// splitPluginManifestPath parses /plugins//manifest.json and extracts +// the plugin name. Returns an error if the path does not match. +func splitPluginManifestPath(path string, name *string) (string, error) { + // path: /plugins//manifest.json + const prefix = "/plugins/" + const suffix = "/manifest.json" + if len(path) <= len(prefix)+len(suffix) { + return "", errNotPluginPath + } + if path[:len(prefix)] != prefix || path[len(path)-len(suffix):] != suffix { + return "", errNotPluginPath + } + *name = path[len(prefix) : len(path)-len(suffix)] + if *name == "" { + return "", errNotPluginPath + } + return *name, nil +} + +// errNotPluginPath is a sentinel used by splitPluginManifestPath. +var errNotPluginPath = errSentinel("not a plugin manifest path") + +type errSentinel string + +func (e errSentinel) Error() string { return string(e) } + +// mustNewStaticRegistrySource is a test helper that calls NewStaticRegistrySource +// and fails the test if an error is returned. +func mustNewStaticRegistrySource(t *testing.T, cfg RegistrySourceConfig) *StaticRegistrySource { + t.Helper() + src, err := NewStaticRegistrySource(cfg) + if err != nil { + t.Fatalf("NewStaticRegistrySource: %v", err) + } + return src +} + +// --------------------------------------------------------------------------- + +// TestStaticRegistrySource_FetchManifest verifies that FetchManifest fetches +// the correct manifest from the static server. +func TestStaticRegistrySource_FetchManifest(t *testing.T) { + manifests := map[string]*RegistryManifest{ + "alpha": { + Name: "alpha", + Version: "1.0.0", + Author: "tester", + Description: "Alpha plugin", + Type: "external", + Tier: "community", + License: "MIT", + }, + } + + srv := buildStaticRegistryServer(t, nil, manifests) + defer srv.Close() + + src := mustNewStaticRegistrySource(t, RegistrySourceConfig{ + Name: "test-static", + URL: srv.URL, + }) + + m, err := src.FetchManifest("alpha") + if err != nil { + t.Fatalf("FetchManifest: %v", err) + } + if m.Name != "alpha" { + t.Errorf("name: got %q, want %q", m.Name, "alpha") + } + if m.Version != "1.0.0" { + t.Errorf("version: got %q, want %q", m.Version, "1.0.0") + } +} + +// TestStaticRegistrySource_FetchManifest_NotFound verifies that fetching a +// non-existent plugin returns an error. +func TestStaticRegistrySource_FetchManifest_NotFound(t *testing.T) { + srv := buildStaticRegistryServer(t, nil, map[string]*RegistryManifest{}) + defer srv.Close() + + src := mustNewStaticRegistrySource(t, RegistrySourceConfig{ + Name: "test-static", + URL: srv.URL, + }) + + _, err := src.FetchManifest("nonexistent") + if err == nil { + t.Fatal("expected error for missing plugin, got nil") + } +} + +// TestStaticRegistrySource_ListPlugins verifies that ListPlugins returns +// all plugin names from the index. +func TestStaticRegistrySource_ListPlugins(t *testing.T) { + index := []staticIndexEntry{ + {Name: "alpha", Version: "1.0.0", Description: "Alpha", Tier: "core"}, + {Name: "beta", Version: "2.0.0", Description: "Beta", Tier: "community"}, + {Name: "gamma", Version: "3.0.0", Description: "Gamma", Tier: "enterprise"}, + } + + srv := buildStaticRegistryServer(t, index, nil) + defer srv.Close() + + src := mustNewStaticRegistrySource(t, RegistrySourceConfig{ + Name: "test-static", + URL: srv.URL, + }) + + names, err := src.ListPlugins() + if err != nil { + t.Fatalf("ListPlugins: %v", err) + } + if len(names) != 3 { + t.Fatalf("expected 3 plugins, got %d: %v", len(names), names) + } + + nameSet := map[string]bool{} + for _, n := range names { + nameSet[n] = true + } + for _, want := range []string{"alpha", "beta", "gamma"} { + if !nameSet[want] { + t.Errorf("expected %q in list, not found", want) + } + } +} + +// TestStaticRegistrySource_SearchPlugins_AllWithEmptyQuery verifies that an +// empty query returns all index entries. +func TestStaticRegistrySource_SearchPlugins_AllWithEmptyQuery(t *testing.T) { + index := []staticIndexEntry{ + {Name: "alpha", Version: "1.0.0", Description: "Alpha plugin", Tier: "core"}, + {Name: "beta", Version: "2.0.0", Description: "Beta plugin", Tier: "community"}, + } + + srv := buildStaticRegistryServer(t, index, nil) + defer srv.Close() + + src := mustNewStaticRegistrySource(t, RegistrySourceConfig{ + Name: "test-static", + URL: srv.URL, + }) + + results, err := src.SearchPlugins("") + if err != nil { + t.Fatalf("SearchPlugins: %v", err) + } + if len(results) != 2 { + t.Fatalf("expected 2 results for empty query, got %d", len(results)) + } +} + +// TestStaticRegistrySource_SearchPlugins_Filtering verifies that search +// filtering by name and description works correctly. +func TestStaticRegistrySource_SearchPlugins_Filtering(t *testing.T) { + index := []staticIndexEntry{ + {Name: "cache-plugin", Version: "1.0.0", Description: "Redis cache integration", Tier: "community"}, + {Name: "auth-plugin", Version: "2.0.0", Description: "Authentication and authorization", Tier: "core"}, + {Name: "logger", Version: "1.0.0", Description: "Log aggregation plugin", Tier: "community"}, + } + + srv := buildStaticRegistryServer(t, index, nil) + defer srv.Close() + + src := mustNewStaticRegistrySource(t, RegistrySourceConfig{ + Name: "test-static", + URL: srv.URL, + }) + + tests := []struct { + query string + wantCount int + wantPlugins []string + }{ + {query: "cache", wantCount: 1, wantPlugins: []string{"cache-plugin"}}, + {query: "auth", wantCount: 1, wantPlugins: []string{"auth-plugin"}}, + // "logger" has description "Log aggregation plugin" so it also matches "plugin". + {query: "plugin", wantCount: 3, wantPlugins: []string{"cache-plugin", "auth-plugin", "logger"}}, + {query: "log", wantCount: 1, wantPlugins: []string{"logger"}}, + {query: "CACHE", wantCount: 1, wantPlugins: []string{"cache-plugin"}}, // case-insensitive + {query: "nonexistent", wantCount: 0}, + } + + for _, tt := range tests { + t.Run("query="+tt.query, func(t *testing.T) { + results, err := src.SearchPlugins(tt.query) + if err != nil { + t.Fatalf("SearchPlugins(%q): %v", tt.query, err) + } + if len(results) != tt.wantCount { + t.Errorf("SearchPlugins(%q): got %d results, want %d: %v", + tt.query, len(results), tt.wantCount, results) + return + } + if len(tt.wantPlugins) > 0 { + resultNames := map[string]bool{} + for _, r := range results { + resultNames[r.Name] = true + } + for _, want := range tt.wantPlugins { + if !resultNames[want] { + t.Errorf("SearchPlugins(%q): expected %q in results", tt.query, want) + } + } + } + }) + } +} + +// TestStaticRegistrySource_SearchPlugins_SourceName verifies that search results +// include the correct Source name. +func TestStaticRegistrySource_SearchPlugins_SourceName(t *testing.T) { + index := []staticIndexEntry{ + {Name: "myplugin", Version: "1.0.0", Description: "My plugin"}, + } + + srv := buildStaticRegistryServer(t, index, nil) + defer srv.Close() + + const registryName = "my-static-registry" + src := mustNewStaticRegistrySource(t, RegistrySourceConfig{ + Name: registryName, + URL: srv.URL, + }) + + results, err := src.SearchPlugins("") + if err != nil { + t.Fatalf("SearchPlugins: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Source != registryName { + t.Errorf("source: got %q, want %q", results[0].Source, registryName) + } +} + +// TestStaticRegistrySource_TrailingSlashStripped verifies that a trailing slash +// in the base URL is stripped and doesn't cause double-slash in URLs. +func TestStaticRegistrySource_TrailingSlashStripped(t *testing.T) { + index := []staticIndexEntry{ + {Name: "slash-plugin", Version: "1.0.0"}, + } + + srv := buildStaticRegistryServer(t, index, nil) + defer srv.Close() + + // Pass URL with trailing slash. + src := mustNewStaticRegistrySource(t, RegistrySourceConfig{ + Name: "test", + URL: srv.URL + "/", + }) + + names, err := src.ListPlugins() + if err != nil { + t.Fatalf("ListPlugins with trailing-slash URL: %v", err) + } + if len(names) != 1 || names[0] != "slash-plugin" { + t.Errorf("expected [slash-plugin], got %v", names) + } +} + +// TestStaticRegistrySource_Name verifies that the registry name is returned correctly. +func TestStaticRegistrySource_Name(t *testing.T) { + src := mustNewStaticRegistrySource(t, RegistrySourceConfig{ + Name: "my-registry", + URL: "https://example.com", + }) + if src.Name() != "my-registry" { + t.Errorf("Name: got %q, want %q", src.Name(), "my-registry") + } +} + +// TestStaticRegistrySource_EmptyURL verifies that NewStaticRegistrySource returns +// an error when the URL is empty. +func TestStaticRegistrySource_EmptyURL(t *testing.T) { + _, err := NewStaticRegistrySource(RegistrySourceConfig{ + Name: "no-url-registry", + URL: "", + }) + if err == nil { + t.Fatal("expected error for empty URL, got nil") + } +} diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md new file mode 100644 index 00000000..78fccd57 --- /dev/null +++ b/docs/PLUGIN_AUTHORING.md @@ -0,0 +1,227 @@ +# Plugin Authoring Guide + +This guide covers creating, testing, publishing, and registering workflow plugins. + +## Quick Start + +```bash +# Scaffold a new plugin +wfctl plugin init my-plugin -author MyOrg -description "My custom plugin" + +# Build and test +cd my-plugin +go mod tidy +make build +make test + +# Install locally for development +make install-local +``` + +## Project Structure + +`wfctl plugin init` generates a complete project: + +``` +workflow-plugin-my-plugin/ +├── cmd/workflow-plugin-my-plugin/main.go # gRPC entrypoint +├── internal/ +│ ├── provider.go # Plugin provider (registers steps/modules) +│ └── steps.go # Step implementations +├── plugin.json # Plugin manifest +├── go.mod +├── .goreleaser.yml # Cross-platform release builds +├── .github/workflows/ +│ ├── ci.yml # Test + lint on PR +│ └── release.yml # GoReleaser + registry notification +├── Makefile +└── README.md +``` + +## Implementing Steps + +Step types are the primary extension point. Each step implements the `sdk.StepInstance` interface from `github.com/GoCodeAlone/workflow/plugin/external/sdk`: + +```go +// MyStep implements sdk.StepInstance. +type MyStep struct { + config map[string]any +} + +func (s *MyStep) Execute( + ctx context.Context, + triggerData map[string]any, + stepOutputs map[string]map[string]any, + current map[string]any, + metadata map[string]any, + config map[string]any, +) (*sdk.StepResult, error) { + // Access step config: config["key"] or s.config["key"] + // Access pipeline context: current["key"] + // Access previous step output: stepOutputs["step-name"]["key"] + return &sdk.StepResult{ + Output: map[string]any{"result": "value"}, + }, nil +} +``` + +Register in `internal/provider.go` by implementing `sdk.StepProvider`: + +```go +// StepTypes implements sdk.StepProvider. +func (p *Provider) StepTypes() []string { + return []string{"step.my_action"} +} + +// CreateStep implements sdk.StepProvider. +func (p *Provider) CreateStep(typeName, name string, config map[string]any) (sdk.StepInstance, error) { + switch typeName { + case "step.my_action": + return &MyStep{config: config}, nil + } + return nil, nil +} +``` + +## Implementing Modules + +Modules provide runtime services (database connections, API clients, etc.) by implementing `sdk.ModuleProvider`: + +```go +// ModuleTypes implements sdk.ModuleProvider. +func (p *Provider) ModuleTypes() []string { + return []string{"my.provider"} +} + +// CreateModule implements sdk.ModuleProvider. +func (p *Provider) CreateModule(typeName, name string, config map[string]any) (sdk.ModuleInstance, error) { + return &MyModule{config: config}, nil +} +``` + +## Plugin Manifest + +The `plugin.json` declares what your plugin provides: + +```json +{ + "name": "workflow-plugin-my-plugin", + "version": "0.1.0", + "description": "My custom plugin", + "author": "MyOrg", + "license": "MIT", + "type": "external", + "tier": "community", + "minEngineVersion": "0.3.30", + "capabilities": { + "moduleTypes": ["my.provider"], + "stepTypes": ["step.my_action", "step.my_query"], + "triggerTypes": [] + } +} +``` + +## Testing + +```bash +# Unit tests +make test + +# Install to local engine +make install-local + +# Validate manifest format (from registry by name) +wfctl plugin validate my-plugin + +# Validate a local manifest file +wfctl plugin validate --file plugin.json + +# Full lifecycle test (start/stop/execute) +wfctl plugin test . +``` + +## Publishing a Release + +1. Tag your version: + ```bash + git tag v0.1.0 + git push origin v0.1.0 + ``` + +2. GoReleaser builds cross-platform binaries and creates a GitHub Release automatically. + +3. If `REGISTRY_PAT` secret is configured, the registry is notified of the new version. + +## Registering in the Public Registry + +1. Fork [GoCodeAlone/workflow-registry](https://github.com/GoCodeAlone/workflow-registry) +2. Create `plugins//manifest.json` conforming to the [schema](https://github.com/GoCodeAlone/workflow-registry/blob/main/schema/registry-schema.json) +3. Open a PR — CI validates your manifest automatically +4. After maintainer review and merge, your plugin appears in `wfctl plugin search` + +### Manifest Example + +```json +{ + "name": "workflow-plugin-my-plugin", + "version": "0.1.0", + "description": "My custom plugin", + "author": "MyOrg", + "type": "external", + "tier": "community", + "license": "MIT", + "repository": "https://github.com/MyOrg/workflow-plugin-my-plugin", + "keywords": ["example"], + "capabilities": { + "moduleTypes": [], + "stepTypes": ["step.my_action"], + "triggerTypes": [] + }, + "downloads": [ + {"os": "linux", "arch": "amd64", "url": "https://github.com/MyOrg/workflow-plugin-my-plugin/releases/download/v0.1.0/workflow-plugin-my-plugin-linux-amd64.tar.gz"}, + {"os": "linux", "arch": "arm64", "url": "https://github.com/MyOrg/workflow-plugin-my-plugin/releases/download/v0.1.0/workflow-plugin-my-plugin-linux-arm64.tar.gz"}, + {"os": "darwin", "arch": "amd64", "url": "https://github.com/MyOrg/workflow-plugin-my-plugin/releases/download/v0.1.0/workflow-plugin-my-plugin-darwin-amd64.tar.gz"}, + {"os": "darwin", "arch": "arm64", "url": "https://github.com/MyOrg/workflow-plugin-my-plugin/releases/download/v0.1.0/workflow-plugin-my-plugin-darwin-arm64.tar.gz"} + ] +} +``` + +## Private Plugins + +No registry needed — install directly: + +```bash +# From a GitHub Release URL +wfctl plugin install --url https://github.com/MyOrg/my-plugin/releases/download/v0.1.0/my-plugin-darwin-arm64.tar.gz + +# From a local build +wfctl plugin install --local ./path/to/build/ + +# The lockfile (.wfctl.yaml) is updated automatically +``` + +## Engine Auto-Fetch + +Declare plugins in your workflow config for automatic download on engine startup: + +```yaml +plugins: + external: + - name: my-plugin + autoFetch: true + version: ">=0.1.0" +``` + +The engine calls `wfctl plugin install` if the plugin isn't found locally. + +## Trust Tiers + +| Tier | Requirements | +|------|-------------| +| **community** | Valid manifest, PR reviewed, SHA-256 checksums via GoReleaser | +| **verified** | + cosign-signed releases, public key in manifest | +| **official** | GoCodeAlone-maintained, signed with org key | + +## Registry Notification + +Add the [notify-registry Action template](https://github.com/GoCodeAlone/workflow-registry/blob/main/templates/notify-registry.yml) to your release workflow for automatic version tracking. diff --git a/plugin/sdk/generator.go b/plugin/sdk/generator.go index 7c06cec9..d012bb07 100644 --- a/plugin/sdk/generator.go +++ b/plugin/sdk/generator.go @@ -184,7 +184,7 @@ func generateMainGo(goModule, shortName string) string { b.WriteString("package main\n\n") b.WriteString("import (\n") fmt.Fprintf(&b, "\t%q\n", goModule+"/internal") - b.WriteString("\t\"github.com/GoCodeAlone/workflow/plugin/sdk\"\n") + b.WriteString("\t\"github.com/GoCodeAlone/workflow/plugin/external/sdk\"\n") b.WriteString(")\n\n") b.WriteString("func main() {\n") fmt.Fprintf(&b, "\tsdk.Serve(internal.New%sProvider())\n", toCamelCase(shortName)) @@ -194,38 +194,44 @@ func generateMainGo(goModule, shortName string) string { func generateProviderGo(goModule string, opts GenerateOptions, shortName string) string { typeName := toCamelCase(shortName) + "Provider" + license := opts.License + if license == "" { + license = "Apache-2.0" + } var b strings.Builder fmt.Fprintf(&b, "package internal\n\n") b.WriteString("import (\n") - b.WriteString("\t\"github.com/GoCodeAlone/workflow/plugin\"\n") + b.WriteString("\t\"github.com/GoCodeAlone/workflow/plugin/external/sdk\"\n") b.WriteString(")\n\n") - fmt.Fprintf(&b, "// %s implements plugin.PluginProvider.\n", typeName) + fmt.Fprintf(&b, "// %s implements sdk.PluginProvider and sdk.StepProvider.\n", typeName) fmt.Fprintf(&b, "type %s struct{}\n\n", typeName) fmt.Fprintf(&b, "// New%s creates a new %s.\n", typeName, typeName) fmt.Fprintf(&b, "func New%s() *%s {\n", typeName, typeName) fmt.Fprintf(&b, "\treturn &%s{}\n", typeName) b.WriteString("}\n\n") - fmt.Fprintf(&b, "func (p *%s) PluginInfo() *plugin.PluginManifest {\n", typeName) - b.WriteString("\treturn &plugin.PluginManifest{\n") + fmt.Fprintf(&b, "// Manifest implements sdk.PluginProvider.\n") + fmt.Fprintf(&b, "func (p *%s) Manifest() sdk.PluginManifest {\n", typeName) + b.WriteString("\treturn sdk.PluginManifest{\n") fmt.Fprintf(&b, "\t\tName: %q,\n", "workflow-plugin-"+shortName) fmt.Fprintf(&b, "\t\tVersion: %q,\n", opts.Version) fmt.Fprintf(&b, "\t\tAuthor: %q,\n", opts.Author) fmt.Fprintf(&b, "\t\tDescription: %q,\n", opts.Description) - fmt.Fprintf(&b, "\t\tLicense: %q,\n", func() string { - if opts.License != "" { - return opts.License - } - return "Apache-2.0" - }()) b.WriteString("\t}\n") b.WriteString("}\n\n") - fmt.Fprintf(&b, "func (p *%s) StepFactories() []plugin.StepFactory {\n", typeName) - b.WriteString("\treturn []plugin.StepFactory{\n") - fmt.Fprintf(&b, "\t\tNew%sExampleStep,\n", toCamelCase(shortName)) + fmt.Fprintf(&b, "// StepTypes implements sdk.StepProvider.\n") + fmt.Fprintf(&b, "func (p *%s) StepTypes() []string {\n", typeName) + fmt.Fprintf(&b, "\treturn []string{%q}\n", "step."+shortName+"_example") + b.WriteString("}\n\n") + fmt.Fprintf(&b, "// CreateStep implements sdk.StepProvider.\n") + fmt.Fprintf(&b, "func (p *%s) CreateStep(typeName, name string, config map[string]any) (sdk.StepInstance, error) {\n", typeName) + b.WriteString("\tswitch typeName {\n") + fmt.Fprintf(&b, "\tcase %q:\n", "step."+shortName+"_example") + fmt.Fprintf(&b, "\t\treturn &%sExampleStep{config: config}, nil\n", toCamelCase(shortName)) b.WriteString("\t}\n") + b.WriteString("\treturn nil, nil\n") b.WriteString("}\n") - // Suppress unused import warning if goModule doesn't get used in this file _ = goModule + _ = license return b.String() } @@ -236,18 +242,25 @@ func generateStepsGo(goModule, shortName string) string { b.WriteString("package internal\n\n") b.WriteString("import (\n") b.WriteString("\t\"context\"\n\n") - b.WriteString("\t\"github.com/GoCodeAlone/workflow/plugin\"\n") + b.WriteString("\t\"github.com/GoCodeAlone/workflow/plugin/external/sdk\"\n") b.WriteString(")\n\n") - fmt.Fprintf(&b, "// %s implements the %s step.\n", funcName, stepType) - fmt.Fprintf(&b, "type %s struct{}\n\n", funcName) - fmt.Fprintf(&b, "// New%s creates the factory function for %s.\n", funcName, stepType) - fmt.Fprintf(&b, "func New%s(cfg map[string]interface{}) (plugin.Step, error) {\n", funcName) - fmt.Fprintf(&b, "\treturn &%s{}, nil\n", funcName) + fmt.Fprintf(&b, "// %s implements the %s step (sdk.StepInstance).\n", funcName, stepType) + fmt.Fprintf(&b, "type %s struct {\n", funcName) + b.WriteString("\tconfig map[string]any\n") b.WriteString("}\n\n") - fmt.Fprintf(&b, "func (s *%s) Type() string { return %q }\n\n", funcName, stepType) - fmt.Fprintf(&b, "func (s *%s) Execute(ctx context.Context, params plugin.StepParams) (map[string]interface{}, error) {\n", funcName) - b.WriteString("\treturn map[string]interface{}{\n") - b.WriteString("\t\t\"status\": \"ok\",\n") + fmt.Fprintf(&b, "// Execute implements sdk.StepInstance.\n") + fmt.Fprintf(&b, "func (s *%s) Execute(\n", funcName) + b.WriteString("\tctx context.Context,\n") + b.WriteString("\ttriggerData map[string]any,\n") + b.WriteString("\tstepOutputs map[string]map[string]any,\n") + b.WriteString("\tcurrent map[string]any,\n") + b.WriteString("\tmetadata map[string]any,\n") + b.WriteString("\tconfig map[string]any,\n") + fmt.Fprintf(&b, ") (*sdk.StepResult, error) {\n") + b.WriteString("\treturn &sdk.StepResult{\n") + b.WriteString("\t\tOutput: map[string]any{\n") + b.WriteString("\t\t\t\"status\": \"ok\",\n") + b.WriteString("\t\t},\n") b.WriteString("\t}, nil\n") b.WriteString("}\n") _ = goModule From b0992905e241d63718e8c2bcab4ee45f0f5a1769 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 17:43:15 -0400 Subject: [PATCH 08/14] fix: use struct conversion for staticIndexEntry to PluginSummary (staticcheck S1016) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go struct conversion is valid even when tags differ — the fields have identical names and types. Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/registry_source.go | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/cmd/wfctl/registry_source.go b/cmd/wfctl/registry_source.go index 83386f82..2b48827c 100644 --- a/cmd/wfctl/registry_source.go +++ b/cmd/wfctl/registry_source.go @@ -205,13 +205,8 @@ func (s *StaticRegistrySource) SearchPlugins(query string) ([]PluginSearchResult strings.Contains(strings.ToLower(e.Name), q) || strings.Contains(strings.ToLower(e.Description), q) { results = append(results, PluginSearchResult{ - PluginSummary: PluginSummary{ - Name: e.Name, - Version: e.Version, - Description: e.Description, - Tier: e.Tier, - }, - Source: s.name, + PluginSummary: PluginSummary(e), + Source: s.name, }) } } From ceb13b4e3e22150d5088a0155426d925b2ab7ab8 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 17:43:23 -0400 Subject: [PATCH 09/14] docs: add messaging plugins design (Discord, Slack, Teams) --- .../2026-03-14-messaging-plugins-design.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/plans/2026-03-14-messaging-plugins-design.md diff --git a/docs/plans/2026-03-14-messaging-plugins-design.md b/docs/plans/2026-03-14-messaging-plugins-design.md new file mode 100644 index 00000000..7ebffe9d --- /dev/null +++ b/docs/plans/2026-03-14-messaging-plugins-design.md @@ -0,0 +1,46 @@ +# Messaging Platform Plugins Design + +## Overview + +Three external workflow plugins for Discord, Slack, and Microsoft Teams, sharing a common messaging interface. Each uses official/standard Go SDKs. + +## Architecture + +``` +workflow-plugin-messaging-core/ ← shared Go module, interfaces only +workflow-plugin-discord/ ← bwmarrin/discordgo +workflow-plugin-slack/ ← slack-go/slack +workflow-plugin-teams/ ← microsoftgraph/msgraph-sdk-go +``` + +## Common Interface + +```go +type MessagingProvider interface { + SendMessage(ctx, channelID, content string, opts MessageOpts) (string, error) + EditMessage(ctx, channelID, messageID, content string) error + DeleteMessage(ctx, channelID, messageID string) error + SendReply(ctx, channelID, parentID, content string) (string, error) + React(ctx, channelID, messageID, emoji string) error + UploadFile(ctx, channelID string, file io.Reader, filename string) (string, error) +} + +type EventListener interface { + Listen(ctx context.Context) (<-chan Event, error) + Close() error +} +``` + +## Per-Plugin Step Types + +Discord: send_message, send_embed, edit_message, delete_message, add_reaction, upload_file, create_thread, voice_join, voice_leave, voice_play +Slack: send_message, send_blocks, edit_message, delete_message, add_reaction, upload_file, send_thread_reply, set_topic +Teams: send_message, send_card, reply_message, delete_message, upload_file, create_channel, add_member + +## Triggers + +trigger.discord (WebSocket Gateway), trigger.slack (Socket Mode), trigger.teams (Graph change notifications) + +## Out of Scope + +Screen sharing, video conferencing, Slack/Teams voice (no Go APIs available) From 0219ede1b4ed6667da8bab16ebfe552d0a6710bd Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 17:45:09 -0400 Subject: [PATCH 10/14] docs: add messaging plugins implementation plan --- .../2026-03-14-messaging-plugins-plan.md | 493 ++++++++++++++++++ 1 file changed, 493 insertions(+) create mode 100644 docs/plans/2026-03-14-messaging-plugins-plan.md diff --git a/docs/plans/2026-03-14-messaging-plugins-plan.md b/docs/plans/2026-03-14-messaging-plugins-plan.md new file mode 100644 index 00000000..db74e816 --- /dev/null +++ b/docs/plans/2026-03-14-messaging-plugins-plan.md @@ -0,0 +1,493 @@ +# Messaging Plugins Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Create four new repos — a shared messaging core interface module and three external workflow plugins for Discord, Slack, and Microsoft Teams. + +**Architecture:** Each plugin is a standalone gRPC binary using the workflow external plugin SDK. They share a common `messaging-core` Go module defining the `MessagingProvider` and `EventListener` interfaces. Each plugin provides a `.provider` module (holding credentials/config), step types for platform actions, and a trigger type for real-time events. + +**Tech Stack:** Go 1.26, workflow plugin SDK, `bwmarrin/discordgo`, `slack-go/slack`, `microsoftgraph/msgraph-sdk-go`, GoReleaser v2 + +--- + +## Task 1: Create workflow-plugin-messaging-core repo + +**What:** Shared Go module with interfaces — no binary, just types. + +**Step 1:** Create repo on GitHub: +```bash +gh repo create GoCodeAlone/workflow-plugin-messaging-core --public --description "Shared messaging interfaces for workflow platform plugins" --clone +cd /Users/jon/workspace/workflow-plugin-messaging-core +go mod init github.com/GoCodeAlone/workflow-plugin-messaging-core +``` + +**Step 2:** Create `messaging.go`: +```go +package messaging + +import ( + "context" + "io" + "time" +) + +// MessageOpts configures optional message parameters. +type MessageOpts struct { + Embeds []Embed // Rich embeds (Discord), blocks (Slack), cards (Teams) + Files []FileAttachment // Inline file attachments + ThreadID string // Reply to thread/parent message + Ephemeral bool // Only visible to one user (where supported) + Components []Component // Interactive components (buttons, menus) +} + +// Embed represents a rich message attachment (platform-specific rendering). +type Embed struct { + Title string + Description string + URL string + Color int + Fields []EmbedField + ImageURL string + FooterText string + Timestamp time.Time +} + +type EmbedField struct { + Name string + Value string + Inline bool +} + +type FileAttachment struct { + Name string + Reader io.Reader +} + +type Component struct { + Type string // "button", "select", "action_row" + Data map[string]any // Platform-specific component data +} + +// Message represents a sent or received message. +type Message struct { + ID string + ChannelID string + AuthorID string + Content string + Timestamp time.Time + ThreadID string + Embeds []Embed +} + +// Event represents a real-time platform event. +type Event struct { + Type string // "message_create", "message_update", "reaction_add", "member_join", etc. + ChannelID string + UserID string + MessageID string + Content string + Data map[string]any // Platform-specific event data + Timestamp time.Time +} + +// Provider is the common messaging interface implemented by each platform plugin. +type Provider interface { + // Name returns the platform identifier ("discord", "slack", "teams"). + Name() string + + // SendMessage sends a message to a channel and returns the message ID. + SendMessage(ctx context.Context, channelID, content string, opts *MessageOpts) (string, error) + + // EditMessage updates an existing message. + EditMessage(ctx context.Context, channelID, messageID, content string) error + + // DeleteMessage removes a message. + DeleteMessage(ctx context.Context, channelID, messageID string) error + + // SendReply sends a threaded reply and returns the message ID. + SendReply(ctx context.Context, channelID, parentID, content string, opts *MessageOpts) (string, error) + + // React adds a reaction to a message. + React(ctx context.Context, channelID, messageID, emoji string) error + + // UploadFile sends a file to a channel and returns the message/file ID. + UploadFile(ctx context.Context, channelID string, file io.Reader, filename string) (string, error) +} + +// EventListener receives real-time events from the platform. +type EventListener interface { + // Listen starts receiving events. The returned channel is closed when + // the context is cancelled or Close is called. + Listen(ctx context.Context) (<-chan Event, error) + + // Close stops the event listener and releases resources. + Close() error +} + +// VoiceProvider is optionally implemented by platforms with voice support (Discord). +type VoiceProvider interface { + JoinVoice(ctx context.Context, guildID, channelID string) error + LeaveVoice(ctx context.Context, guildID string) error + PlayAudio(ctx context.Context, guildID string, audio io.Reader) error +} +``` + +**Step 3:** Create `messaging_test.go` — compile check: +```go +package messaging + +// Compile-time interface satisfaction checks are done per-plugin. +``` + +**Step 4:** +```bash +go mod tidy +git add -A && git commit -m "feat: shared messaging interfaces (Provider, EventListener, VoiceProvider)" +git push origin main +git tag -a v0.1.0 -m "v0.1.0: initial messaging interfaces" && git push origin v0.1.0 +``` + +--- + +## Task 2: Create workflow-plugin-discord repo + +**What:** External gRPC plugin using `bwmarrin/discordgo`. + +**Step 1:** Create repo and scaffold: +```bash +gh repo create GoCodeAlone/workflow-plugin-discord --public --description "Workflow plugin for Discord messaging, bots, and voice" --clone +cd /Users/jon/workspace/workflow-plugin-discord +go mod init github.com/GoCodeAlone/workflow-plugin-discord +``` + +**Step 2:** Add dependencies: +```bash +go get github.com/GoCodeAlone/workflow@v0.3.40 +go get github.com/GoCodeAlone/workflow-plugin-messaging-core@v0.1.0 +go get github.com/bwmarrin/discordgo +go mod tidy +``` + +**Step 3:** Create the plugin structure: + +``` +cmd/workflow-plugin-discord/main.go ← one-liner: sdk.Serve(internal.New()) +internal/ + plugin.go ← PluginProvider + ModuleProvider + StepProvider + TriggerProvider + provider.go ← discord.provider module (holds discordgo.Session) + step_send.go ← step.discord_send_message + step_embed.go ← step.discord_send_embed + step_edit.go ← step.discord_edit_message + step_delete.go ← step.discord_delete_message + step_react.go ← step.discord_add_reaction + step_upload.go ← step.discord_upload_file + step_thread.go ← step.discord_create_thread + step_voice.go ← step.discord_voice_join / leave / play + trigger.go ← trigger.discord (WebSocket Gateway listener) + convert.go ← messaging.Message ↔ discordgo type conversions +plugin.json ← manifest +.goreleaser.yaml +.github/workflows/release.yml +``` + +**Step 4:** Implement `internal/plugin.go`: +```go +package internal + +import "github.com/GoCodeAlone/workflow/plugin/external/sdk" + +type discordPlugin struct{} + +func New() *discordPlugin { return &discordPlugin{} } + +func (p *discordPlugin) Manifest() sdk.PluginManifest { + return sdk.PluginManifest{ + Name: "discord", Version: "0.1.0", Author: "GoCodeAlone", + Description: "Discord messaging, bots, and voice", + } +} + +func (p *discordPlugin) ModuleTypes() []string { return []string{"discord.provider"} } +func (p *discordPlugin) StepTypes() []string { + return []string{ + "step.discord_send_message", "step.discord_send_embed", + "step.discord_edit_message", "step.discord_delete_message", + "step.discord_add_reaction", "step.discord_upload_file", + "step.discord_create_thread", + "step.discord_voice_join", "step.discord_voice_leave", + } +} +func (p *discordPlugin) TriggerTypes() []string { return []string{"trigger.discord"} } + +// CreateModule, CreateStep, CreateTrigger dispatch to constructors... +``` + +**Step 5:** Implement `internal/provider.go` — the module that holds the discordgo session: +```go +type discordProvider struct { + name string + token string + session *discordgo.Session +} + +func (m *discordProvider) Init() error { + dg, err := discordgo.New("Bot " + m.token) + if err != nil { + return fmt.Errorf("discord session: %w", err) + } + dg.Identify.Intents = discordgo.IntentsGuildMessages | discordgo.IntentsDirectMessages | + discordgo.IntentsGuildVoiceStates | discordgo.IntentsGuildMessageReactions + m.session = dg + return nil +} + +func (m *discordProvider) Start(ctx context.Context) error { + return m.session.Open() +} + +func (m *discordProvider) Stop(ctx context.Context) error { + return m.session.Close() +} +``` + +Provider also implements `messaging.Provider` and `messaging.VoiceProvider`. + +**Step 6:** Implement each step type. Each step reads config (channel_id, content, etc.) from the `current` map (NOT from `config` — external plugin gotcha), looks up the discord.provider module by name, and calls the appropriate discordgo method. + +Example `step_send.go`: +```go +func (s *sendMessageStep) Execute(ctx context.Context, triggerData, stepOutputs, current, metadata, cfg map[string]any) (*sdk.StepResult, error) { + channelID, _ := current["channel_id"].(string) + content, _ := current["content"].(string) + if channelID == "" || content == "" { + return nil, fmt.Errorf("discord_send_message: channel_id and content required") + } + msg, err := s.session.ChannelMessageSend(channelID, content) + if err != nil { + return nil, fmt.Errorf("discord_send_message: %w", err) + } + return &sdk.StepResult{Output: map[string]any{ + "message_id": msg.ID, + "channel_id": msg.ChannelID, + }}, nil +} +``` + +**Step 7:** Implement `internal/trigger.go` — WebSocket Gateway event listener: +```go +type discordTrigger struct { + session *discordgo.Session + callback sdk.TriggerCallback + cancel context.CancelFunc +} + +func (t *discordTrigger) Start(ctx context.Context) error { + ctx, t.cancel = context.WithCancel(ctx) + t.session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { + t.callback.Fire(map[string]any{ + "type": "message_create", + "channel_id": m.ChannelID, + "message_id": m.ID, + "content": m.Content, + "author_id": m.Author.ID, + "guild_id": m.GuildID, + }) + }) + // Add handlers for other event types... + return nil +} +``` + +**Step 8:** Write tests. Use discordgo's test helpers or mock HTTP for API calls. Test each step's Execute with expected inputs/outputs. + +**Step 9:** Create `plugin.json`, `.goreleaser.yaml`, `.github/workflows/release.yml` — copy patterns from workflow-plugin-admin. See the plugin pattern exploration for exact templates. + +**Step 10:** Build, test, commit, tag: +```bash +go build ./... && go test ./... -v -count=1 +git add -A && git commit -m "feat: Discord plugin with messaging, embeds, voice, and event trigger" +git tag -a v0.1.0 -m "v0.1.0: initial Discord plugin" +git push origin main --tags +``` + +--- + +## Task 3: Create workflow-plugin-slack repo + +**What:** External gRPC plugin using `slack-go/slack`. + +Same structure as Discord. Key differences: + +**Dependencies:** +```bash +go get github.com/slack-go/slack +``` + +**Module:** `slack.provider` holds `*slack.Client` + Socket Mode client: +```go +type slackProvider struct { + name string + botToken string // xoxb-... + appToken string // xapp-... (for Socket Mode) + client *slack.Client + socketClient *socketmode.Client +} + +func (m *slackProvider) Init() error { + m.client = slack.New(m.botToken, slack.OptionAppLevelToken(m.appToken)) + m.socketClient = socketmode.New(m.client) + return nil +} +``` + +**Step types:** +- `step.slack_send_message` — `client.PostMessage(channelID, slack.MsgOptionText(content, false))` +- `step.slack_send_blocks` — `client.PostMessage(channelID, slack.MsgOptionBlocks(blocks...))` +- `step.slack_edit_message` — `client.UpdateMessage(channelID, timestamp, slack.MsgOptionText(...))` +- `step.slack_delete_message` — `client.DeleteMessage(channelID, timestamp)` +- `step.slack_add_reaction` — `client.AddReaction(emoji, slack.ItemRef{Channel, Timestamp})` +- `step.slack_upload_file` — `client.UploadFile(slack.FileUploadParameters{...})` +- `step.slack_send_thread_reply` — `client.PostMessage(channelID, slack.MsgOptionTS(threadTS), ...)` +- `step.slack_set_topic` — `client.SetTopicOfConversation(channelID, topic)` + +**Trigger:** `trigger.slack` uses Socket Mode: +```go +func (t *slackTrigger) Start(ctx context.Context) error { + go t.socketClient.Run() + go func() { + for evt := range t.socketClient.Events { + switch evt.Type { + case socketmode.EventTypeEventsAPI: + // Extract message event, fire callback + case socketmode.EventTypeSlashCommand: + // Extract command, fire callback + } + } + }() + return nil +} +``` + +**Rate limits:** Wrap API calls with retry on `slack.RateLimitedError`: +```go +if rateLimitErr, ok := err.(*slack.RateLimitedError); ok { + time.Sleep(rateLimitErr.RetryAfter) + // retry +} +``` + +**Build, test, tag v0.1.0.** + +--- + +## Task 4: Create workflow-plugin-teams repo + +**What:** External gRPC plugin using `microsoftgraph/msgraph-sdk-go`. + +**Dependencies:** +```bash +go get github.com/microsoftgraph/msgraph-sdk-go +go get github.com/Azure/azure-sdk-for-go/sdk/azidentity +``` + +**Module:** `teams.provider` holds Graph client with Azure AD auth: +```go +type teamsProvider struct { + name string + tenantID string + clientID string + secret string + client *msgraphsdk.GraphServiceClient +} + +func (m *teamsProvider) Init() error { + cred, err := azidentity.NewClientSecretCredential(m.tenantID, m.clientID, m.secret, nil) + if err != nil { + return fmt.Errorf("teams auth: %w", err) + } + m.client, err = msgraphsdk.NewGraphServiceClientWithCredentials(cred, []string{"https://graph.microsoft.com/.default"}) + return err +} +``` + +**Step types:** All use Graph API via the SDK: +- `step.teams_send_message` — `client.Teams().ByTeamId().Channels().ByChannelId().Messages().Post(ctx, body)` +- `step.teams_send_card` — same but with Adaptive Card in body content type +- `step.teams_reply_message` — `.Messages().ByMessageId().Replies().Post(ctx, body)` +- `step.teams_delete_message` — `.Messages().ByMessageId().Delete(ctx)` +- `step.teams_upload_file` — SharePoint/OneDrive via `client.Drives().ByDriveId().Items()...Upload()` +- `step.teams_create_channel` — `client.Teams().ByTeamId().Channels().Post(ctx, body)` +- `step.teams_add_member` — `client.Teams().ByTeamId().Members().Post(ctx, body)` + +**Trigger:** `trigger.teams` uses Graph Change Notifications (HTTP webhook subscriptions): +```go +type teamsTrigger struct { + client *msgraphsdk.GraphServiceClient + callback sdk.TriggerCallback + callbackURL string // public HTTPS URL for Graph to POST to + subscription string // subscription ID for cleanup +} + +func (t *teamsTrigger) Start(ctx context.Context) error { + // Create subscription on /teams/{id}/channels/{id}/messages + // Graph POSTs events to callbackURL + // Start HTTP server to receive notifications + // Parse notification, fire callback +} +``` + +Note: Teams trigger requires a public HTTPS endpoint for Graph change notifications. Document this requirement clearly. + +**Rate limits:** The Graph SDK includes automatic retry middleware for 429/503 — no manual handling needed. + +**Build, test, tag v0.1.0.** + +--- + +## Task 5: Register all plugins in workflow-registry + +**What:** Add manifest files for each plugin. + +Create in `/Users/jon/workspace/workflow-registry/plugins/`: +- `discord/manifest.json` +- `slack/manifest.json` +- `teams/manifest.json` + +Each follows the registry schema with `capabilities`, `keywords`, download URLs from GitHub releases. + +```bash +cd /Users/jon/workspace/workflow-registry +# Add the three manifest files +git add plugins/discord plugins/slack plugins/teams +git commit -m "feat: add Discord, Slack, Teams plugin manifests" +git push origin main +``` + +--- + +## Task 6: Integration tests + QA + +**For each plugin:** +1. Build the binary: `go build -o /tmp/test-plugin ./cmd/workflow-plugin-` +2. Run with mock credentials and verify: + - Plugin loads and responds to health checks + - Module creates with valid config + - Steps return expected errors for missing credentials + - Step types and module types match plugin.json +3. If real tokens available, test live send/receive + +--- + +## Execution Order + +``` +Task 1 (messaging-core) → Tasks 2, 3, 4 (plugins, parallel) + → Task 5 (registry) + → Task 6 (QA) +``` + +**Parallel groups:** +- Group A: Task 2 (Discord) +- Group B: Task 3 (Slack) +- Group C: Task 4 (Teams) +- All three depend only on Task 1 (messaging-core v0.1.0 tag) From b390e139e0812eae606173bef1c1effa46a1a447 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 18:22:09 -0400 Subject: [PATCH 11/14] fix: address second round of Copilot review feedback - CreateStep returns error for unknown step types instead of (nil, nil) - hashFileSHA256 returns (string, error) instead of silently returning "" - installFromLocal now updates lockfile with binary checksum - installFromLockfile passes pinned version and registry to install - Remove plugin directory on checksum mismatch (fail closed) - Use pluginName (not manifest.Name) as lockfile key for consistency - File mode test compares permission bits only (umask-safe) - Makefile install-local uses wfctl plugin install --local Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/plugin_install.go | 27 ++++++++++++++++++++------- cmd/wfctl/plugin_install_new_test.go | 4 ++-- cmd/wfctl/plugin_lockfile.go | 15 +++++++++++---- plugin/sdk/generator.go | 9 ++++----- 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index bbefbcd3..3471d0f8 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -166,8 +166,11 @@ func runPluginInstall(args []string) error { if _, ver := parseNameVersion(nameArg); ver != "" { // Hash the installed binary (not the archive) so verifyInstalledChecksum matches. binaryPath := filepath.Join(pluginDirVal, pluginName, pluginName) - sha := hashFileSHA256(binaryPath) - updateLockfileWithChecksum(manifest.Name, manifest.Version, manifest.Repository, sourceName, sha) + sha, hashErr := hashFileSHA256(binaryPath) + if hashErr != nil { + fmt.Fprintf(os.Stderr, "warning: could not hash installed binary: %v\n", hashErr) + } + updateLockfileWithChecksum(pluginName, manifest.Version, manifest.Repository, sourceName, sha) } return nil @@ -517,7 +520,10 @@ func installFromURL(url, pluginDir string) error { // Hash the installed binary (not the archive) so that verifyInstalledChecksum matches. binaryPath := filepath.Join(destDir, pluginName) - checksum := hashFileSHA256(binaryPath) + checksum, hashErr := hashFileSHA256(binaryPath) + if hashErr != nil { + fmt.Fprintf(os.Stderr, "warning: could not hash installed binary: %v\n", hashErr) + } updateLockfileWithChecksum(pluginName, pj.Version, pj.Repository, "", checksum) fmt.Printf("Installed %s v%s to %s\n", pluginName, pj.Version, destDir) @@ -578,6 +584,14 @@ func installFromLocal(srcDir, pluginDir string) error { return err } + // Update lockfile with binary checksum for consistency with other install paths. + installedBinary := filepath.Join(destDir, pluginName) + sha, hashErr := hashFileSHA256(installedBinary) + if hashErr != nil { + fmt.Fprintf(os.Stderr, "warning: could not hash installed binary: %v\n", hashErr) + } + updateLockfileWithChecksum(pluginName, pj.Version, "", "local", sha) + fmt.Printf("Installed %s v%s from %s to %s\n", pluginName, pj.Version, srcDir, destDir) return nil } @@ -674,14 +688,13 @@ func parseGitHubRepoURL(repoURL string) (owner, repo string, err error) { } // hashFileSHA256 returns the hex-encoded SHA-256 hash of the file at path. -// Returns an empty string if the file cannot be read. -func hashFileSHA256(path string) string { +func hashFileSHA256(path string) (string, error) { data, err := os.ReadFile(path) if err != nil { - return "" + return "", fmt.Errorf("hash file %s: %w", path, err) } h := sha256.Sum256(data) - return hex.EncodeToString(h[:]) + return hex.EncodeToString(h[:]), nil } // extractTarGz decompresses and extracts a .tar.gz archive into destDir. diff --git a/cmd/wfctl/plugin_install_new_test.go b/cmd/wfctl/plugin_install_new_test.go index dc782f3a..f291f580 100644 --- a/cmd/wfctl/plugin_install_new_test.go +++ b/cmd/wfctl/plugin_install_new_test.go @@ -433,8 +433,8 @@ func TestCopyFile(t *testing.T) { if err != nil { t.Fatalf("stat dest: %v", err) } - if info.Mode() != wantMode { - t.Errorf("mode: got %v, want %v", info.Mode(), wantMode) + if info.Mode().Perm()&wantMode != wantMode { + t.Errorf("mode: got %v, want at least %v", info.Mode().Perm(), wantMode) } } diff --git a/cmd/wfctl/plugin_lockfile.go b/cmd/wfctl/plugin_lockfile.go index 26c66d2a..b22a7a22 100644 --- a/cmd/wfctl/plugin_lockfile.go +++ b/cmd/wfctl/plugin_lockfile.go @@ -75,9 +75,15 @@ func installFromLockfile(pluginDir, cfgPath string) error { 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 entry.Registry != "" { + installArgs = append(installArgs, "--registry", entry.Registry) + } + // Pass name@version to install the pinned version from the lockfile. + installArg := name + if entry.Version != "" { + installArg = name + "@" + entry.Version + } + installArgs = append(installArgs, installArg) if err := runPluginInstall(installArgs); err != nil { fmt.Fprintf(os.Stderr, "error installing %s: %v\n", name, err) failed = append(failed, name) @@ -86,7 +92,8 @@ func installFromLockfile(pluginDir, cfgPath string) error { if entry.SHA256 != "" { pluginInstallDir := filepath.Join(pluginDir, name) if verifyErr := verifyInstalledChecksum(pluginInstallDir, name, entry.SHA256); verifyErr != nil { - fmt.Fprintf(os.Stderr, "CHECKSUM MISMATCH for %s: %v\n", name, verifyErr) + fmt.Fprintf(os.Stderr, "CHECKSUM MISMATCH for %s: %v — removing plugin\n", name, verifyErr) + _ = os.RemoveAll(pluginInstallDir) failed = append(failed, name) continue } diff --git a/plugin/sdk/generator.go b/plugin/sdk/generator.go index d012bb07..03c3f2cb 100644 --- a/plugin/sdk/generator.go +++ b/plugin/sdk/generator.go @@ -201,6 +201,7 @@ func generateProviderGo(goModule string, opts GenerateOptions, shortName string) var b strings.Builder fmt.Fprintf(&b, "package internal\n\n") b.WriteString("import (\n") + b.WriteString("\t\"fmt\"\n\n") b.WriteString("\t\"github.com/GoCodeAlone/workflow/plugin/external/sdk\"\n") b.WriteString(")\n\n") fmt.Fprintf(&b, "// %s implements sdk.PluginProvider and sdk.StepProvider.\n", typeName) @@ -228,7 +229,7 @@ func generateProviderGo(goModule string, opts GenerateOptions, shortName string) fmt.Fprintf(&b, "\tcase %q:\n", "step."+shortName+"_example") fmt.Fprintf(&b, "\t\treturn &%sExampleStep{config: config}, nil\n", toCamelCase(shortName)) b.WriteString("\t}\n") - b.WriteString("\treturn nil, nil\n") + b.WriteString("\treturn nil, fmt.Errorf(\"unknown step type: %s\", typeName)\n") b.WriteString("}\n") _ = goModule _ = license @@ -384,13 +385,11 @@ test: go test ./... install-local: build - mkdir -p $(HOME)/.local/share/workflow/plugins/%s - cp %s $(HOME)/.local/share/workflow/plugins/%s/ - cp plugin.json $(HOME)/.local/share/workflow/plugins/%s/ + wfctl plugin install --local . clean: rm -f %s -`, binaryName, binaryName, binaryName, binaryName, binaryName, binaryName, binaryName) +`, binaryName, binaryName, binaryName) } func generateREADME(opts GenerateOptions, binaryName, goModule string) string { From 8ad4d33c22eab70815fbf5f911868609b4c2da38 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 18:50:53 -0400 Subject: [PATCH 12/14] fix: address third round of Copilot review feedback - Update plugin install usage text to document --url, --local, lockfile modes - Don't write registry="local" to lockfile (prevents --registry local error) - Fail installFromURL on ensurePluginBinary/hash errors instead of warning - Normalize pluginName before lockfile write for consistent keys - Fix CreateStep example in docs to return error for unknown types - Fix project structure dir name in docs (my-plugin/ not workflow-plugin-my-plugin/) - Remove dead code: unused goModule/license params in generator functions - Add TestLoadRegistryConfigFallback to verify LoadRegistryConfig default path Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/multi_registry_test.go | 21 ++++++++++++++++++++- cmd/wfctl/plugin_install.go | 11 ++++++----- docs/PLUGIN_AUTHORING.md | 4 ++-- plugin/sdk/generator.go | 11 ++++------- 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/cmd/wfctl/multi_registry_test.go b/cmd/wfctl/multi_registry_test.go index 0355dc47..ec1a7e7e 100644 --- a/cmd/wfctl/multi_registry_test.go +++ b/cmd/wfctl/multi_registry_test.go @@ -184,7 +184,7 @@ func TestLoadRegistryConfigFromFile(t *testing.T) { } func TestLoadRegistryConfigDefault(t *testing.T) { - // Test DefaultRegistryConfig directly to avoid picking up user config files. + // Test DefaultRegistryConfig directly. cfg := DefaultRegistryConfig() if len(cfg.Registries) != 2 { t.Fatalf("expected 2 registries (static + github fallback), got %d", len(cfg.Registries)) @@ -197,6 +197,25 @@ func TestLoadRegistryConfigDefault(t *testing.T) { } } +func TestLoadRegistryConfigFallback(t *testing.T) { + // LoadRegistryConfig with no valid config files should fall back to + // DefaultRegistryConfig. Isolate from real config by changing both + // CWD and HOME to a temp dir. + origDir, _ := os.Getwd() + tmpDir := t.TempDir() + _ = os.Chdir(tmpDir) + t.Setenv("HOME", tmpDir) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + cfg, err := LoadRegistryConfig(filepath.Join(tmpDir, "nonexistent.yaml")) + if err != nil { + t.Fatalf("LoadRegistryConfig: %v", err) + } + if len(cfg.Registries) != 2 { + t.Fatalf("expected 2 registries (default fallback), got %d", len(cfg.Registries)) + } +} + func TestSaveAndLoadRegistryConfig(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "wfctl", "config.yaml") diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 3471d0f8..65ff85b7 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -74,7 +74,7 @@ func runPluginInstall(args []string) error { directURL := fs.String("url", "", "Install from a direct download URL (tar.gz archive)") localPath := fs.String("local", "", "Install from a local plugin directory") fs.Usage = func() { - fmt.Fprintf(fs.Output(), "Usage: wfctl plugin install [options] [@]\n\nDownload and install a plugin from the registry.\n\nOptions:\n") + fmt.Fprintf(fs.Output(), "Usage: wfctl plugin install [options] [[@]]\n\nInstall a plugin from the registry, a URL, a local directory, or from the lockfile.\n\n wfctl plugin install Install latest from registry\n wfctl plugin install @v1.0.0 Install specific version\n wfctl plugin install --url Install from a direct download URL\n wfctl plugin install --local Install from a local build directory\n wfctl plugin install Install all plugins from .wfctl.yaml\n\nOptions:\n") fs.PrintDefaults() } if err := fs.Parse(args); err != nil { @@ -110,7 +110,8 @@ func runPluginInstall(args []string) error { } nameArg := fs.Arg(0) - pluginName, _ := parseNameVersion(nameArg) + rawName, _ := parseNameVersion(nameArg) + pluginName := normalizePluginName(rawName) cfg, err := LoadRegistryConfig(*cfgPath) if err != nil { @@ -515,14 +516,14 @@ func installFromURL(url, pluginDir string) error { } if err := ensurePluginBinary(destDir, pluginName); err != nil { - fmt.Fprintf(os.Stderr, "warning: could not normalize binary name: %v\n", err) + return fmt.Errorf("normalize binary name: %w", err) } // Hash the installed binary (not the archive) so that verifyInstalledChecksum matches. binaryPath := filepath.Join(destDir, pluginName) checksum, hashErr := hashFileSHA256(binaryPath) if hashErr != nil { - fmt.Fprintf(os.Stderr, "warning: could not hash installed binary: %v\n", hashErr) + return fmt.Errorf("hash installed binary for lockfile: %w", hashErr) } updateLockfileWithChecksum(pluginName, pj.Version, pj.Repository, "", checksum) @@ -590,7 +591,7 @@ func installFromLocal(srcDir, pluginDir string) error { if hashErr != nil { fmt.Fprintf(os.Stderr, "warning: could not hash installed binary: %v\n", hashErr) } - updateLockfileWithChecksum(pluginName, pj.Version, "", "local", sha) + updateLockfileWithChecksum(pluginName, pj.Version, "", "", sha) fmt.Printf("Installed %s v%s from %s to %s\n", pluginName, pj.Version, srcDir, destDir) return nil diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 78fccd57..24ba976c 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -23,7 +23,7 @@ make install-local `wfctl plugin init` generates a complete project: ``` -workflow-plugin-my-plugin/ +my-plugin/ ├── cmd/workflow-plugin-my-plugin/main.go # gRPC entrypoint ├── internal/ │ ├── provider.go # Plugin provider (registers steps/modules) @@ -79,7 +79,7 @@ func (p *Provider) CreateStep(typeName, name string, config map[string]any) (sdk case "step.my_action": return &MyStep{config: config}, nil } - return nil, nil + return nil, fmt.Errorf("unknown step type: %s", typeName) } ``` diff --git a/plugin/sdk/generator.go b/plugin/sdk/generator.go index 03c3f2cb..533a8af0 100644 --- a/plugin/sdk/generator.go +++ b/plugin/sdk/generator.go @@ -124,10 +124,10 @@ func generateProjectStructure(opts GenerateOptions) error { if err := os.MkdirAll(internalDir, 0750); err != nil { return fmt.Errorf("create internal dir: %w", err) } - if err := writeFile(filepath.Join(internalDir, "provider.go"), generateProviderGo(goModule, opts, shortName), 0600); err != nil { + if err := writeFile(filepath.Join(internalDir, "provider.go"), generateProviderGo(opts, shortName), 0600); err != nil { return err } - if err := writeFile(filepath.Join(internalDir, "steps.go"), generateStepsGo(goModule, shortName), 0600); err != nil { + if err := writeFile(filepath.Join(internalDir, "steps.go"), generateStepsGo(shortName), 0600); err != nil { return err } @@ -192,7 +192,7 @@ func generateMainGo(goModule, shortName string) string { return b.String() } -func generateProviderGo(goModule string, opts GenerateOptions, shortName string) string { +func generateProviderGo(opts GenerateOptions, shortName string) string { typeName := toCamelCase(shortName) + "Provider" license := opts.License if license == "" { @@ -231,12 +231,10 @@ func generateProviderGo(goModule string, opts GenerateOptions, shortName string) b.WriteString("\t}\n") b.WriteString("\treturn nil, fmt.Errorf(\"unknown step type: %s\", typeName)\n") b.WriteString("}\n") - _ = goModule - _ = license return b.String() } -func generateStepsGo(goModule, shortName string) string { +func generateStepsGo(shortName string) string { stepType := "step." + shortName + "_example" funcName := toCamelCase(shortName) + "ExampleStep" var b strings.Builder @@ -264,7 +262,6 @@ func generateStepsGo(goModule, shortName string) string { b.WriteString("\t\t},\n") b.WriteString("\t}, nil\n") b.WriteString("}\n") - _ = goModule return b.String() } From b1a2f2a32da709c511d90976ee411a2060eaae82 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 19:11:09 -0400 Subject: [PATCH 13/14] fix: remove unused license variable in generator (lint) Co-Authored-By: Claude Opus 4.6 --- plugin/sdk/generator.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/plugin/sdk/generator.go b/plugin/sdk/generator.go index 533a8af0..84f4334e 100644 --- a/plugin/sdk/generator.go +++ b/plugin/sdk/generator.go @@ -194,10 +194,6 @@ func generateMainGo(goModule, shortName string) string { func generateProviderGo(opts GenerateOptions, shortName string) string { typeName := toCamelCase(shortName) + "Provider" - license := opts.License - if license == "" { - license = "Apache-2.0" - } var b strings.Builder fmt.Fprintf(&b, "package internal\n\n") b.WriteString("import (\n") From 9db5009badf57b413d412a2b03549394d38c8ce0 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 20:43:34 -0400 Subject: [PATCH 14/14] fix: add post-install validation for URL installs, prevent lockfile overwrite - installFromURL now calls verifyInstalledPlugin() for parity with registry installs (addresses Copilot review comment) - installFromLockfile passes name without @version to prevent runPluginInstall from overwriting the pinned lockfile entry before checksum verification Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/plugin_install.go | 5 +++++ cmd/wfctl/plugin_lockfile.go | 10 ++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 65ff85b7..3c5f016d 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -519,6 +519,11 @@ func installFromURL(url, pluginDir string) error { return fmt.Errorf("normalize binary name: %w", err) } + // Validate the installed plugin (same checks as registry installs). + if verifyErr := verifyInstalledPlugin(destDir, pluginName); verifyErr != nil { + return fmt.Errorf("post-install verification failed: %w", verifyErr) + } + // Hash the installed binary (not the archive) so that verifyInstalledChecksum matches. binaryPath := filepath.Join(destDir, pluginName) checksum, hashErr := hashFileSHA256(binaryPath) diff --git a/cmd/wfctl/plugin_lockfile.go b/cmd/wfctl/plugin_lockfile.go index b22a7a22..95d28682 100644 --- a/cmd/wfctl/plugin_lockfile.go +++ b/cmd/wfctl/plugin_lockfile.go @@ -78,12 +78,10 @@ func installFromLockfile(pluginDir, cfgPath string) error { if entry.Registry != "" { installArgs = append(installArgs, "--registry", entry.Registry) } - // Pass name@version to install the pinned version from the lockfile. - installArg := name - if entry.Version != "" { - installArg = name + "@" + entry.Version - } - installArgs = append(installArgs, installArg) + // Pass just the name (no @version) so runPluginInstall does not + // trigger lockfile updates that would overwrite the pinned entry + // before we verify the checksum. + 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)