diff --git a/cmd/wfctl/plugin.go b/cmd/wfctl/plugin.go index 653fb615..9f5c0990 100644 --- a/cmd/wfctl/plugin.go +++ b/cmd/wfctl/plugin.go @@ -31,6 +31,8 @@ func runPlugin(args []string) error { return runPluginRemove(args[1:]) case "validate": return runPluginValidate(args[1:]) + case "info": + return runPluginInfo(args[1:]) default: return pluginUsage() } @@ -49,6 +51,7 @@ Subcommands: update Update an installed plugin to its latest version remove Uninstall a plugin validate Validate a plugin manifest from the registry or a local file + info Show details about an installed plugin `) return fmt.Errorf("plugin subcommand is required") } diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 9b9059c9..812cd6df 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -15,6 +15,8 @@ import ( "path/filepath" "runtime" "strings" + + engineplugin "github.com/GoCodeAlone/workflow/plugin" ) // defaultDataDir is the default location for installed plugin binaries. @@ -154,6 +156,12 @@ func runPluginInstall(args []string) error { } } + // Verify the installed plugin.json is valid for ExternalPluginManager. + fmt.Fprintf(os.Stderr, "Verifying plugin manifest...\n") + if verifyErr := verifyInstalledPlugin(destDir, pluginName); verifyErr != nil { + return fmt.Errorf("post-install verification failed: %w", verifyErr) + } + fmt.Printf("Installed %s v%s to %s\n", manifest.Name, manifest.Version, destDir) return nil } @@ -179,16 +187,18 @@ func runPluginList(args []string) error { } type installed struct { - name string - version string + name string + version string + pluginType string + description string } var plugins []installed for _, e := range entries { if !e.IsDir() { continue } - ver := readInstalledVersion(filepath.Join(*dataDir, e.Name())) - plugins = append(plugins, installed{name: e.Name(), version: ver}) + ver, pType, desc := readInstalledInfo(filepath.Join(*dataDir, e.Name())) + plugins = append(plugins, installed{name: e.Name(), version: ver, pluginType: pType, description: desc}) } if len(plugins) == 0 { @@ -196,10 +206,14 @@ func runPluginList(args []string) error { return nil } - fmt.Printf("%-20s %s\n", "NAME", "VERSION") - fmt.Printf("%-20s %s\n", "----", "-------") + fmt.Printf("%-20s %-10s %-10s %s\n", "NAME", "VERSION", "TYPE", "DESCRIPTION") + fmt.Printf("%-20s %-10s %-10s %s\n", "----", "-------", "----", "-----------") for _, p := range plugins { - fmt.Printf("%-20s %s\n", p.name, p.version) + desc := p.description + if len(desc) > 40 { + desc = desc[:37] + "..." + } + fmt.Printf("%-20s %-10s %-10s %s\n", p.name, p.version, p.pluginType, desc) } return nil } @@ -256,6 +270,83 @@ func runPluginRemove(args []string) error { return nil } +func runPluginInfo(args []string) error { + fs := flag.NewFlagSet("plugin info", flag.ContinueOnError) + dataDir := fs.String("data-dir", defaultDataDir, "Plugin data directory") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), "Usage: wfctl plugin info [options] \n\nShow details about an installed plugin.\n\nOptions:\n") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() < 1 { + fs.Usage() + return fmt.Errorf("plugin name is required") + } + + pluginName := fs.Arg(0) + pluginDir := filepath.Join(*dataDir, pluginName) + manifestPath := filepath.Join(pluginDir, "plugin.json") + + data, err := os.ReadFile(manifestPath) + if os.IsNotExist(err) { + return fmt.Errorf("plugin %q is not installed", pluginName) + } + if err != nil { + return fmt.Errorf("read manifest: %w", err) + } + + var m installedPluginJSON + if err := json.Unmarshal(data, &m); err != nil { + return fmt.Errorf("parse manifest: %w", err) + } + + fmt.Printf("Name: %s\n", m.Name) + fmt.Printf("Version: %s\n", m.Version) + fmt.Printf("Author: %s\n", m.Author) + fmt.Printf("Description: %s\n", m.Description) + if m.License != "" { + fmt.Printf("License: %s\n", m.License) + } + if m.Type != "" { + fmt.Printf("Type: %s\n", m.Type) + } + if m.Tier != "" { + fmt.Printf("Tier: %s\n", m.Tier) + } + if m.Repository != "" { + fmt.Printf("Repository: %s\n", m.Repository) + } + if len(m.ModuleTypes) > 0 { + fmt.Printf("Module Types: %s\n", strings.Join(m.ModuleTypes, ", ")) + } + if len(m.StepTypes) > 0 { + fmt.Printf("Step Types: %s\n", strings.Join(m.StepTypes, ", ")) + } + if len(m.TriggerTypes) > 0 { + fmt.Printf("Trigger Types: %s\n", strings.Join(m.TriggerTypes, ", ")) + } + if len(m.Tags) > 0 { + fmt.Printf("Tags: %s\n", strings.Join(m.Tags, ", ")) + } + + // Check binary status. + binaryPath := filepath.Join(pluginDir, pluginName) + if info, statErr := os.Stat(binaryPath); statErr == nil { + fmt.Printf("Binary: %s (%d bytes)\n", binaryPath, info.Size()) + if info.Mode()&0111 != 0 { + fmt.Printf("Executable: yes\n") + } else { + fmt.Printf("Executable: no (WARNING: not executable)\n") + } + } else { + fmt.Printf("Binary: NOT FOUND (WARNING)\n") + } + + 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 { @@ -362,15 +453,44 @@ func safeJoin(base, name string) (string, error) { return dest, nil } -// installedPluginJSON is the minimal JSON written to plugin.json after install. +// installedPluginJSON is the JSON format for plugin.json written after install. +// This must be compatible with plugin.PluginManifest so that +// ExternalPluginManager.LoadPlugin() can validate it. type installedPluginJSON struct { - Name string `json:"name"` - Version string `json:"version"` + Name string `json:"name"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + License string `json:"license,omitempty"` + Repository string `json:"repository,omitempty"` + Tier string `json:"tier,omitempty"` + Tags []string `json:"tags,omitempty"` + Type string `json:"type,omitempty"` + ModuleTypes []string `json:"moduleTypes,omitempty"` + StepTypes []string `json:"stepTypes,omitempty"` + TriggerTypes []string `json:"triggerTypes,omitempty"` } -// writeInstalledManifest writes a minimal plugin.json to record the installed version. +// writeInstalledManifest writes a full plugin.json compatible with the engine's +// plugin.PluginManifest so that ExternalPluginManager.LoadPlugin() can validate it. func writeInstalledManifest(path string, m *RegistryManifest) error { - data, err := json.MarshalIndent(installedPluginJSON{Name: m.Name, Version: m.Version}, "", " ") + pj := installedPluginJSON{ + Name: m.Name, + Version: m.Version, + Author: m.Author, + Description: m.Description, + License: m.License, + Repository: m.Repository, + Tier: m.Tier, + Tags: m.Keywords, + Type: m.Type, + } + if m.Capabilities != nil { + pj.ModuleTypes = m.Capabilities.ModuleTypes + pj.StepTypes = m.Capabilities.StepTypes + pj.TriggerTypes = m.Capabilities.TriggerTypes + } + data, err := json.MarshalIndent(pj, "", " ") if err != nil { return err } @@ -417,18 +537,57 @@ func ensurePluginBinary(destDir, pluginName string) error { return os.Rename(filepath.Join(destDir, bestName), expectedPath) } -// readInstalledVersion reads the version from a plugin.json in the given directory. -func readInstalledVersion(dir string) string { +// verifyInstalledPlugin validates the installed plugin.json using the engine's +// manifest loader and checks that the binary exists and is executable. +func verifyInstalledPlugin(destDir, pluginName string) error { + manifestPath := filepath.Join(destDir, "plugin.json") + binaryPath := filepath.Join(destDir, pluginName) + + // Check manifest exists and is valid for the engine. + manifest, err := engineplugin.LoadManifest(manifestPath) + if err != nil { + return fmt.Errorf("load manifest: %w", err) + } + if err := manifest.Validate(); err != nil { + return fmt.Errorf("manifest validation: %w", err) + } + + // Check binary exists and is executable. + info, err := os.Stat(binaryPath) + if err != nil { + return fmt.Errorf("binary not found at %s: %w", binaryPath, err) + } + if info.IsDir() { + return fmt.Errorf("binary path %s is a directory", binaryPath) + } + if info.Mode()&0111 == 0 { + return fmt.Errorf("binary %s is not executable", binaryPath) + } + + return nil +} + +// readInstalledInfo reads version, type, and description from a plugin.json in the given directory. +func readInstalledInfo(dir string) (version, pluginType, description string) { data, err := os.ReadFile(filepath.Join(dir, "plugin.json")) if err != nil { - return "unknown" + return "unknown", "", "" } var m installedPluginJSON if err := json.Unmarshal(data, &m); err != nil { - return "unknown" + return "unknown", "", "" } - if m.Version == "" { - return "unknown" + version = m.Version + if version == "" { + version = "unknown" } - return m.Version + pluginType = m.Type + description = m.Description + return +} + +// readInstalledVersion reads the version from a plugin.json in the given directory. +func readInstalledVersion(dir string) string { + v, _, _ := readInstalledInfo(dir) + return v } diff --git a/cmd/wfctl/plugin_install_e2e_test.go b/cmd/wfctl/plugin_install_e2e_test.go index 8255bc8b..efd9efa1 100644 --- a/cmd/wfctl/plugin_install_e2e_test.go +++ b/cmd/wfctl/plugin_install_e2e_test.go @@ -15,6 +15,7 @@ import ( "runtime" "testing" + engineplugin "github.com/GoCodeAlone/workflow/plugin" "github.com/GoCodeAlone/workflow/plugin/external" ) @@ -90,6 +91,10 @@ func TestPluginInstallE2E(t *testing.T) { Type: "external", Tier: "community", License: "MIT", + Capabilities: &RegistryCapabilities{ + ModuleTypes: []string{"test.module"}, + StepTypes: []string{"step.test_action"}, + }, Downloads: []PluginDownload{ { OS: runtime.GOOS, @@ -167,7 +172,7 @@ func TestPluginInstallE2E(t *testing.T) { t.Fatalf("writeInstalledManifest: %v", err) } - // Verify plugin.json content. + // Verify plugin.json content — all fields should be populated. raw, err := os.ReadFile(pluginJSONPath) if err != nil { t.Fatalf("read plugin.json: %v", err) @@ -182,6 +187,21 @@ func TestPluginInstallE2E(t *testing.T) { if pj.Version != "1.0.0" { t.Errorf("plugin.json version: got %q, want %q", pj.Version, "1.0.0") } + if pj.Author != "tester" { + t.Errorf("plugin.json author: got %q, want %q", pj.Author, "tester") + } + if pj.Description != "e2e test plugin" { + t.Errorf("plugin.json description: got %q, want %q", pj.Description, "e2e test plugin") + } + if pj.Type != "external" { + t.Errorf("plugin.json type: got %q, want %q", pj.Type, "external") + } + if len(pj.ModuleTypes) != 1 || pj.ModuleTypes[0] != "test.module" { + t.Errorf("plugin.json moduleTypes: got %v, want [test.module]", pj.ModuleTypes) + } + if len(pj.StepTypes) != 1 || pj.StepTypes[0] != "step.test_action" { + t.Errorf("plugin.json stepTypes: got %v, want [step.test_action]", pj.StepTypes) + } // --- Step 7: ExternalPluginManager.DiscoverPlugins --- mgr := external.NewExternalPluginManager(pluginsDir, nil) @@ -321,6 +341,117 @@ func TestSafeJoin(t *testing.T) { } } +// TestInstalledManifestEngineValidation verifies that the plugin.json written by +// writeInstalledManifest passes the engine's plugin.LoadManifest and Validate. +func TestInstalledManifestEngineValidation(t *testing.T) { + rm := &RegistryManifest{ + Name: "test-plugin", + Version: "1.0.0", + Author: "tester", + Description: "test plugin for engine validation", + Type: "external", + Tier: "community", + License: "MIT", + Capabilities: &RegistryCapabilities{ + ModuleTypes: []string{"test.module"}, + StepTypes: []string{"step.test"}, + }, + } + + dir := t.TempDir() + path := filepath.Join(dir, "plugin.json") + if err := writeInstalledManifest(path, rm); err != nil { + t.Fatalf("writeInstalledManifest: %v", err) + } + + // Load with engine manifest loader and validate. + manifest, err := engineplugin.LoadManifest(path) + if err != nil { + t.Fatalf("engine LoadManifest: %v", err) + } + if err := manifest.Validate(); err != nil { + t.Fatalf("engine Validate: %v", err) + } + if manifest.Author != "tester" { + t.Errorf("author: got %q, want %q", manifest.Author, "tester") + } + if manifest.Description != "test plugin for engine validation" { + t.Errorf("description mismatch") + } +} + +// TestPluginInstallFlatTarball verifies that flat tarballs (no top-level directory) +// like authz releases produce are handled correctly, including binary rename and +// ExternalPluginManager discovery. +func TestPluginInstallFlatTarball(t *testing.T) { + const pluginName = "authz" + binaryContent := []byte("#!/bin/sh\necho authz\n") + + // Build a flat tarball (single binary, no top-level directory) like authz release produces. + binaryName := fmt.Sprintf("workflow-plugin-%s-%s-%s", pluginName, runtime.GOOS, runtime.GOARCH) + tarEntries := map[string][]byte{ + binaryName: binaryContent, + } + tarball := buildTarGz(t, tarEntries, 0755) + + pluginsDir := t.TempDir() + destDir := filepath.Join(pluginsDir, pluginName) + if err := os.MkdirAll(destDir, 0750); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + if err := extractTarGz(tarball, destDir); err != nil { + t.Fatalf("extractTarGz: %v", err) + } + + // Binary should be extracted with its original name. + extractedPath := filepath.Join(destDir, binaryName) + if _, err := os.Stat(extractedPath); err != nil { + t.Fatalf("expected binary at %s: %v", extractedPath, err) + } + + // ensurePluginBinary should rename it to match the plugin name. + if err := ensurePluginBinary(destDir, pluginName); err != nil { + t.Fatalf("ensurePluginBinary: %v", err) + } + + expectedPath := filepath.Join(destDir, pluginName) + content, err := os.ReadFile(expectedPath) + if err != nil { + t.Fatalf("read renamed binary: %v", err) + } + if !bytes.Equal(content, binaryContent) { + t.Errorf("binary content mismatch after rename") + } + + // Write plugin.json and verify ExternalPluginManager can discover it. + rm := &RegistryManifest{ + Name: pluginName, + Version: "0.1.0", + Author: "GoCodeAlone", + Description: "RBAC authorization plugin using Casbin", + Type: "external", + Tier: "core", + License: "MIT", + Capabilities: &RegistryCapabilities{ + ModuleTypes: []string{"authz.casbin"}, + StepTypes: []string{"step.authz_check"}, + }, + } + if err := writeInstalledManifest(filepath.Join(destDir, "plugin.json"), rm); err != nil { + t.Fatalf("writeInstalledManifest: %v", err) + } + + mgr := external.NewExternalPluginManager(pluginsDir, nil) + discovered, err := mgr.DiscoverPlugins() + if err != nil { + t.Fatalf("DiscoverPlugins: %v", err) + } + if len(discovered) != 1 || discovered[0] != pluginName { + t.Fatalf("expected [%q] discovered, got %v", pluginName, discovered) + } +} + // TestDownloadURL tests the downloadURL helper using an httptest server. func TestDownloadURL(t *testing.T) { t.Run("success", func(t *testing.T) { diff --git a/cmd/wfctl/registry.go b/cmd/wfctl/registry.go index f05f31b4..0ca8bec3 100644 --- a/cmd/wfctl/registry.go +++ b/cmd/wfctl/registry.go @@ -27,9 +27,20 @@ type RegistryManifest struct { License string `json:"license"` MinEngineVersion string `json:"minEngineVersion,omitempty"` Repository string `json:"repository,omitempty"` - Keywords []string `json:"keywords,omitempty"` - Downloads []PluginDownload `json:"downloads,omitempty"` - Assets *PluginAssets `json:"assets,omitempty"` + Keywords []string `json:"keywords,omitempty"` + Homepage string `json:"homepage,omitempty"` + Capabilities *RegistryCapabilities `json:"capabilities,omitempty"` + Downloads []PluginDownload `json:"downloads,omitempty"` + Assets *PluginAssets `json:"assets,omitempty"` +} + +// RegistryCapabilities describes what module/step/trigger types a plugin provides. +type RegistryCapabilities struct { + ConfigProvider bool `json:"configProvider,omitempty"` + ModuleTypes []string `json:"moduleTypes,omitempty"` + StepTypes []string `json:"stepTypes,omitempty"` + TriggerTypes []string `json:"triggerTypes,omitempty"` + WorkflowHandlers []string `json:"workflowHandlers,omitempty"` } // PluginDownload describes a platform-specific binary download for a plugin.