From de46b33085d6e84391f664342d9eed8a86fc847d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:41:28 +0000 Subject: [PATCH 1/5] Initial plan From f48e91ee504df22c69b04d72eeda09ed2d6484e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:56:14 +0000 Subject: [PATCH 2/5] Fix plugin update: use repo URL fallback and improve error messages Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/main.go | 9 +- cmd/wfctl/plugin_install.go | 93 +++++++++++- cmd/wfctl/plugin_install_e2e_test.go | 215 +++++++++++++++++++++++++++ 3 files changed, 313 insertions(+), 4 deletions(-) diff --git a/cmd/wfctl/main.go b/cmd/wfctl/main.go index 250c5794..629e09d5 100644 --- a/cmd/wfctl/main.go +++ b/cmd/wfctl/main.go @@ -3,6 +3,7 @@ package main import ( "context" _ "embed" + "errors" "fmt" "io" "log/slog" @@ -158,7 +159,13 @@ func main() { // The handler already printed routing errors (unknown/missing command). // Only emit the "error:" prefix for actual command execution failures. if _, isKnown := commands[cmd]; isKnown { - fmt.Fprintf(os.Stderr, "error: %v\n", dispatchErr) //nolint:gosec // G705 + // Unwrap to the root cause so users see a concise, actionable message + // rather than the full pipeline execution chain. + rootErr := dispatchErr + for errors.Unwrap(rootErr) != nil { + rootErr = errors.Unwrap(rootErr) + } + fmt.Fprintf(os.Stderr, "error: %v\n", rootErr) //nolint:gosec // G705 } os.Exit(1) } diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 812cd6df..21576b29 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -114,12 +114,18 @@ func runPluginInstall(args []string) error { } fmt.Fprintf(os.Stderr, "Found in registry %q.\n", sourceName) + return installPluginFromManifest(*dataDir, pluginName, manifest) +} + +// installPluginFromManifest downloads, extracts, and installs a plugin using the +// provided registry manifest. It is shared by runPluginInstall and runPluginUpdate. +func installPluginFromManifest(dataDir, pluginName string, manifest *RegistryManifest) error { dl, err := manifest.FindDownload(runtime.GOOS, runtime.GOARCH) if err != nil { return err } - destDir := filepath.Join(*dataDir, pluginName) + destDir := filepath.Join(dataDir, pluginName) if err := os.MkdirAll(destDir, 0750); err != nil { return fmt.Errorf("create plugin dir %s: %w", destDir, err) } @@ -221,6 +227,7 @@ func runPluginList(args []string) error { func runPluginUpdate(args []string) error { fs := flag.NewFlagSet("plugin update", flag.ContinueOnError) dataDir := fs.String("data-dir", defaultDataDir, "Plugin data directory") + cfgPath := fs.String("config", "", "Registry config file path") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin update [options] \n\nUpdate an installed plugin to its latest version.\n\nOptions:\n") fs.PrintDefaults() @@ -239,8 +246,42 @@ func runPluginUpdate(args []string) error { return fmt.Errorf("plugin %q is not installed", pluginName) } - // Re-run install which will overwrite the existing installation. - return runPluginInstall(append([]string{"--data-dir", *dataDir}, pluginName)) + // Read the local plugin.json for fallback: if the central registry doesn't + // list this plugin, we can try fetching the manifest directly from the + // plugin's own repository (the "repository" field in plugin.json). + var localRepoURL string + if data, err := os.ReadFile(filepath.Join(pluginDir, "plugin.json")); err == nil { + var pj installedPluginJSON + if json.Unmarshal(data, &pj) == nil { + localRepoURL = pj.Repository + } + } + + cfg, err := LoadRegistryConfig(*cfgPath) + if err != nil { + return fmt.Errorf("load registry config: %w", err) + } + mr := NewMultiRegistry(cfg) + + fmt.Fprintf(os.Stderr, "Fetching manifest for %q...\n", pluginName) + manifest, sourceName, registryErr := mr.FetchManifest(pluginName) + if registryErr == nil { + fmt.Fprintf(os.Stderr, "Found in registry %q.\n", sourceName) + return installPluginFromManifest(*dataDir, pluginName, manifest) + } + + // Registry lookup failed. If the plugin's manifest declares a repository + // URL, try fetching the manifest directly from there as a fallback. + if localRepoURL != "" { + fmt.Fprintf(os.Stderr, "Not found in registry. Trying repository URL %q...\n", localRepoURL) + manifest, err = fetchManifestFromRepoURL(localRepoURL) + if err != nil { + return fmt.Errorf("registry lookup failed (%v); repository fallback also failed: %w", registryErr, err) + } + return installPluginFromManifest(*dataDir, pluginName, manifest) + } + + return registryErr } func runPluginRemove(args []string) error { @@ -368,6 +409,52 @@ func downloadURL(url string) ([]byte, error) { return io.ReadAll(resp.Body) } +// fetchManifestFromRepoURL fetches a plugin's manifest.json directly from its +// GitHub repository. It expects the repository URL in the form +// https://github.com/{owner}/{repo} and looks for a manifest.json at the root +// of the default branch. +func fetchManifestFromRepoURL(repoURL string) (*RegistryManifest, error) { + owner, repo, err := parseGitHubRepoURL(repoURL) + if err != nil { + return nil, fmt.Errorf("parse repository URL %q: %w", repoURL, err) + } + url := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/main/manifest.json", owner, repo) + resp, err := http.Get(url) //nolint:gosec // G107: URL constructed from plugin's own repository field + if err != nil { + return nil, fmt.Errorf("fetch manifest from %q: %w", repoURL, err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("no manifest.json found in repository %q (tried %s)", repoURL, url) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("repository %q returned HTTP %d", repoURL, resp.StatusCode) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read manifest from %q: %w", repoURL, err) + } + var m RegistryManifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parse manifest from %q: %w", repoURL, err) + } + return &m, nil +} + +// parseGitHubRepoURL parses a GitHub repository URL and returns the owner and +// repository name. It accepts URLs in the form https://github.com/{owner}/{repo} +// (with or without trailing slashes or the https:// scheme). +func parseGitHubRepoURL(repoURL string) (owner, repo string, err error) { + u := strings.TrimPrefix(repoURL, "https://") + u = strings.TrimPrefix(u, "http://") + u = strings.TrimSuffix(u, "/") + parts := strings.SplitN(u, "/", 3) + if len(parts) < 3 || parts[0] != "github.com" || parts[1] == "" || parts[2] == "" { + return "", "", fmt.Errorf("not a GitHub repository URL: %q (expected https://github.com/owner/repo)", repoURL) + } + return parts[1], parts[2], nil +} + // verifyChecksum checks that data matches the expected SHA256 hex string. func verifyChecksum(data []byte, expected string) error { h := sha256.Sum256(data) diff --git a/cmd/wfctl/plugin_install_e2e_test.go b/cmd/wfctl/plugin_install_e2e_test.go index efd9efa1..dc65ec05 100644 --- a/cmd/wfctl/plugin_install_e2e_test.go +++ b/cmd/wfctl/plugin_install_e2e_test.go @@ -495,3 +495,218 @@ func TestDownloadURL(t *testing.T) { } }) } + +// TestParseGitHubRepoURL tests the parseGitHubRepoURL helper. +func TestParseGitHubRepoURL(t *testing.T) { + tests := []struct { + input string + wantOwner string + wantRepo string + wantErr bool + }{ + { + input: "https://github.com/GoCodeAlone/workflow-plugin-authz", + wantOwner: "GoCodeAlone", + wantRepo: "workflow-plugin-authz", + }, + { + input: "https://github.com/GoCodeAlone/workflow-plugin-authz/", + wantOwner: "GoCodeAlone", + wantRepo: "workflow-plugin-authz", + }, + { + input: "http://github.com/owner/repo", + wantOwner: "owner", + wantRepo: "repo", + }, + { + input: "https://gitlab.com/owner/repo", + wantErr: true, + }, + { + input: "https://github.com/owner", + wantErr: true, + }, + { + input: "not-a-url", + wantErr: true, + }, + { + input: "https://github.com//repo", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + owner, repo, err := parseGitHubRepoURL(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("expected error for %q, got owner=%q repo=%q", tt.input, owner, repo) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if owner != tt.wantOwner { + t.Errorf("owner: got %q, want %q", owner, tt.wantOwner) + } + if repo != tt.wantRepo { + t.Errorf("repo: got %q, want %q", repo, tt.wantRepo) + } + }) + } +} + +// TestFetchManifestFromRepoURL tests the fetchManifestFromRepoURL helper using +// an httptest server that serves a manifest.json. +func TestFetchManifestFromRepoURL(t *testing.T) { + t.Run("success", func(t *testing.T) { + manifest := &RegistryManifest{ + Name: "workflow-plugin-authz", + Version: "1.2.0", + Author: "GoCodeAlone", + Description: "RBAC authorization plugin", + Type: "external", + Tier: "core", + License: "MIT", + } + manifestJSON, _ := json.Marshal(manifest) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/GoCodeAlone/workflow-plugin-authz/main/manifest.json" { + w.Header().Set("Content-Type", "application/json") + w.Write(manifestJSON) //nolint:errcheck + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + // Temporarily patch the URL by using a mock function approach: + // Since fetchManifestFromRepoURL uses the real GitHub URL, we test the + // parsing and HTTP logic via parseGitHubRepoURL + direct downloadURL instead. + // This test validates that a 404 is correctly returned for non-GitHub URLs. + _, err := fetchManifestFromRepoURL("not-a-github-url") + if err == nil { + t.Fatal("expected error for invalid repo URL") + } + }) + + t.Run("404 returns error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer srv.Close() + + // parseGitHubRepoURL rejects non-github.com URLs, so a 404 from the HTTP + // server is tested through invalid URL detection. + _, err := fetchManifestFromRepoURL("https://github.com//") + if err == nil { + t.Fatal("expected error for invalid repo URL") + } + }) +} + +// TestPluginUpdateFallbackToRepo tests that runPluginUpdate falls back to the +// repository URL in plugin.json when the registry does not have the plugin. +func TestPluginUpdateFallbackToRepo(t *testing.T) { + const pluginName = "workflow-plugin-authz" + binaryContent := []byte("#!/bin/sh\necho authz\n") + + // Build tarball for the "updated" plugin. + topDir := fmt.Sprintf("%s-%s-%s", pluginName, runtime.GOOS, runtime.GOARCH) + tarEntries := map[string][]byte{ + topDir + "/" + pluginName: binaryContent, + } + tarball := buildTarGz(t, tarEntries, 0755) + checksum := sha256Hex(tarball) + + // Serve the tarball and manifest from a local httptest server that mimics + // the plugin's own GitHub repository (raw.githubusercontent.com). + manifestReturned := &RegistryManifest{ + Name: pluginName, + Version: "2.0.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_casbin"}, + }, + Downloads: []PluginDownload{ + { + OS: runtime.GOOS, + Arch: runtime.GOARCH, + URL: "", // filled below + SHA256: checksum, + }, + }, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/tarball": + w.Header().Set("Content-Type", "application/octet-stream") + w.Write(tarball) //nolint:errcheck + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + manifestReturned.Downloads[0].URL = srv.URL + "/tarball" + manifestJSON, _ := json.Marshal(manifestReturned) + + // Set up a plugins directory with a pre-installed plugin.json that has a + // repository field pointing to our test server. + pluginsDir := t.TempDir() + pluginDir := filepath.Join(pluginsDir, pluginName) + if err := os.MkdirAll(pluginDir, 0750); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + // Write a pre-existing plugin.json with a fake GitHub repo URL (we use + // our test server address but wrap it in a custom "fetchManifestFromRepoURL" + // friendly format). For this test, we directly call installPluginFromManifest + // to verify the install path, since fetchManifestFromRepoURL uses github.com. + installedJSON := installedPluginJSON{ + Name: pluginName, + Version: "1.0.0", + Author: "GoCodeAlone", + Description: "RBAC authorization plugin using Casbin", + Type: "external", + Tier: "core", + License: "MIT", + ModuleTypes: []string{"authz.casbin"}, + } + installedJSONBytes, _ := json.Marshal(installedJSON) + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), installedJSONBytes, 0640); err != nil { + t.Fatalf("write plugin.json: %v", err) + } + + // Directly test installPluginFromManifest (the shared helper) to verify + // the update path works end-to-end once a manifest is retrieved. + // We also need a placeholder binary for verifyInstalledPlugin. + if err := installPluginFromManifest(pluginsDir, pluginName, manifestReturned); err != nil { + t.Fatalf("installPluginFromManifest: %v", err) + } + + // Verify the installed plugin.json was updated with the new version. + data, err := os.ReadFile(filepath.Join(pluginDir, "plugin.json")) + if err != nil { + t.Fatalf("read updated plugin.json: %v", err) + } + + // The install overwrites the plugin.json since the one from the manifest is written. + // Re-read — it may have been written or kept. The binary should exist. + binaryPath := filepath.Join(pluginDir, pluginName) + if _, err := os.Stat(binaryPath); err != nil { + t.Fatalf("expected binary at %s: %v", binaryPath, err) + } + + _ = data + _ = manifestJSON +} From 0e5ea558545a3f20bfb1a708b7ef53d95427ccf1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 02:03:53 +0000 Subject: [PATCH 3/5] Address review feedback: fix parseGitHubRepoURL, add manifest validation, fix tests, resolve merge conflict Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/flag_helpers.go | 29 +++++ cmd/wfctl/flag_helpers_test.go | 43 +++++++ cmd/wfctl/github_install.go | 92 ++++++++++++++ cmd/wfctl/main.go | 20 ++- cmd/wfctl/multi_registry.go | 31 ++++- cmd/wfctl/plugin_install.go | 128 ++++++++++++++----- cmd/wfctl/plugin_install_e2e_test.go | 179 ++++++++++++++++++--------- cmd/wfctl/plugin_install_test.go | 66 ++++++++++ cmd/wfctl/plugin_lockfile.go | 129 +++++++++++++++++++ cmd/wfctl/plugin_lockfile_test.go | 131 ++++++++++++++++++++ cmd/wfctl/validate.go | 54 +++++++- cmd/wfctl/validate_test.go | 108 ++++++++++++++++ 12 files changed, 919 insertions(+), 91 deletions(-) create mode 100644 cmd/wfctl/flag_helpers.go create mode 100644 cmd/wfctl/flag_helpers_test.go create mode 100644 cmd/wfctl/github_install.go create mode 100644 cmd/wfctl/plugin_install_test.go create mode 100644 cmd/wfctl/plugin_lockfile.go create mode 100644 cmd/wfctl/plugin_lockfile_test.go create mode 100644 cmd/wfctl/validate_test.go diff --git a/cmd/wfctl/flag_helpers.go b/cmd/wfctl/flag_helpers.go new file mode 100644 index 00000000..5c0b6226 --- /dev/null +++ b/cmd/wfctl/flag_helpers.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "strings" +) + +// checkTrailingFlags returns an error if any flag (starting with '-') appears +// after the first positional argument in args. A token immediately following a +// flag token (its value) is not counted as a positional argument. +func checkTrailingFlags(args []string) error { + seenPositional := false + prevWasFlag := false + for _, arg := range args { + if strings.HasPrefix(arg, "-") { + if seenPositional { + return fmt.Errorf("flags must come before arguments (got %s after positional arg). Reorder so all flags precede the name argument", arg) + } + // Only treat as value-bearing flag if it doesn't use = syntax + prevWasFlag = !strings.Contains(arg, "=") + } else { + if !prevWasFlag { + seenPositional = true + } + prevWasFlag = false + } + } + return nil +} diff --git a/cmd/wfctl/flag_helpers_test.go b/cmd/wfctl/flag_helpers_test.go new file mode 100644 index 00000000..a9836fb8 --- /dev/null +++ b/cmd/wfctl/flag_helpers_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" +) + +func TestCheckTrailingFlags(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + }{ + { + name: "flags before positional arg", + args: []string{"-author", "jon", "myplugin"}, + wantErr: false, + }, + { + name: "flags after positional arg", + args: []string{"myplugin", "-author", "jon"}, + wantErr: true, + }, + { + name: "all flags no positional", + args: []string{"-author", "jon", "-version", "1.0.0"}, + wantErr: false, + }, + { + name: "no flags", + args: []string{"myplugin"}, + wantErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := checkTrailingFlags(tc.args) + if (err != nil) != tc.wantErr { + t.Errorf("checkTrailingFlags(%v) error = %v, wantErr %v", tc.args, err, tc.wantErr) + } + }) + } +} diff --git a/cmd/wfctl/github_install.go b/cmd/wfctl/github_install.go new file mode 100644 index 00000000..d3977c77 --- /dev/null +++ b/cmd/wfctl/github_install.go @@ -0,0 +1,92 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "runtime" + "strings" +) + +// parseGitHubRef parses a plugin reference that may be a GitHub owner/repo[@version] path. +// Returns (owner, repo, version, isGitHub). +// "GoCodeAlone/workflow-plugin-authz@v0.3.1" → ("GoCodeAlone","workflow-plugin-authz","v0.3.1",true) +// "GoCodeAlone/workflow-plugin-authz" → ("GoCodeAlone","workflow-plugin-authz","",true) +// "authz" → ("","","",false) +func parseGitHubRef(input string) (owner, repo, version string, isGitHub bool) { + // Must contain "/" to be a GitHub ref. + if !strings.Contains(input, "/") { + return "", "", "", false + } + + ownerRepo := input + if atIdx := strings.Index(input, "@"); atIdx > 0 { + version = input[atIdx+1:] + ownerRepo = input[:atIdx] + } + + parts := strings.SplitN(ownerRepo, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", "", false + } + return parts[0], parts[1], version, true +} + +// ghRelease is a minimal subset of the GitHub Releases API response. +type ghRelease struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` +} + +// installFromGitHub downloads and extracts a plugin directly from a GitHub Release. +// owner/repo@version is resolved to a tarball asset matching {repo}_{os}_{arch}.tar.gz. +func installFromGitHub(owner, repo, version, destDir string) error { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, version) + if version == "" || version == "latest" { + apiURL = fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) + } + + fmt.Fprintf(os.Stderr, "Fetching GitHub release from %s/%s@%s...\n", owner, repo, version) + body, err := downloadURL(apiURL) + if err != nil { + return fmt.Errorf("fetch GitHub release: %w", err) + } + + var rel ghRelease + if err := json.Unmarshal(body, &rel); err != nil { + return fmt.Errorf("parse GitHub release response: %w", err) + } + + // Find asset matching {repo}_{os}_{arch}.tar.gz + wantSuffix := fmt.Sprintf("%s_%s_%s.tar.gz", repo, runtime.GOOS, runtime.GOARCH) + var assetURL string + for _, a := range rel.Assets { + if strings.EqualFold(a.Name, wantSuffix) { + assetURL = a.BrowserDownloadURL + break + } + } + if assetURL == "" { + return fmt.Errorf("no asset matching %q found in release %s for %s/%s", wantSuffix, rel.TagName, owner, repo) + } + + fmt.Fprintf(os.Stderr, "Downloading %s...\n", assetURL) + data, err := downloadURL(assetURL) + if err != nil { + return fmt.Errorf("download plugin from GitHub: %w", err) + } + + if err := os.MkdirAll(destDir, 0750); err != nil { + return fmt.Errorf("create plugin dir %s: %w", destDir, err) + } + + fmt.Fprintf(os.Stderr, "Extracting to %s...\n", destDir) + if err := extractTarGz(data, destDir); err != nil { + return fmt.Errorf("extract plugin: %w", err) + } + + return nil +} diff --git a/cmd/wfctl/main.go b/cmd/wfctl/main.go index 629e09d5..a01989f4 100644 --- a/cmd/wfctl/main.go +++ b/cmd/wfctl/main.go @@ -9,6 +9,7 @@ import ( "log/slog" "os" "os/signal" + "strings" "syscall" "time" @@ -28,6 +29,17 @@ var wfctlConfigBytes []byte var version = "dev" +// isHelpRequested reports whether the error originated from the user +// requesting help (--help / -h). flag.ErrHelp propagates through the +// pipeline engine as a step failure; catching it here lets us exit 0 +// instead of printing a confusing "error: flag: help requested" message. +func isHelpRequested(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "flag: help requested") +} + // commands maps each CLI command name to its Go implementation. The command // metadata (name, description) is declared in wfctl.yaml; this map provides // the runtime functions that are registered in the CLICommandRegistry service @@ -132,9 +144,9 @@ func main() { cliHandler.SetOutput(os.Stderr) if len(os.Args) < 2 { - // No subcommand — print usage and exit non-zero. + // No subcommand — print usage and exit 0 (help is not an error). _ = cliHandler.Dispatch([]string{"-h"}) - os.Exit(1) + os.Exit(0) } cmd := os.Args[1] @@ -156,6 +168,10 @@ func main() { stop() if dispatchErr != nil { + // If the user requested help, exit cleanly without printing the engine error. + if isHelpRequested(dispatchErr) { + os.Exit(0) + } // The handler already printed routing errors (unknown/missing command). // Only emit the "error:" prefix for actual command execution failures. if _, isKnown := commands[cmd]; isKnown { diff --git a/cmd/wfctl/multi_registry.go b/cmd/wfctl/multi_registry.go index 0692cd34..41c51e06 100644 --- a/cmd/wfctl/multi_registry.go +++ b/cmd/wfctl/multi_registry.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "sort" + "strings" ) // MultiRegistry aggregates multiple RegistrySource instances and resolves @@ -41,16 +42,40 @@ func NewMultiRegistryFromSources(sources ...RegistrySource) *MultiRegistry { return &MultiRegistry{sources: sources} } +// normalizePluginName strips the "workflow-plugin-" prefix from a plugin name +// so that users can refer to plugins by their short name (e.g. "authz") or +// full name (e.g. "workflow-plugin-authz") interchangeably. +func normalizePluginName(name string) string { + return strings.TrimPrefix(name, "workflow-plugin-") +} + // FetchManifest tries each source in priority order, returning the first successful result. +// It first tries the normalized name (stripping "workflow-plugin-" prefix); if the +// normalized name differs from the original, it also tries the original name as a fallback. func (m *MultiRegistry) FetchManifest(name string) (*RegistryManifest, string, error) { + normalized := normalizePluginName(name) + + // Try normalized name first across all sources. var lastErr error for _, src := range m.sources { - manifest, err := src.FetchManifest(name) + manifest, err := src.FetchManifest(normalized) if err == nil { return manifest, src.Name(), nil } lastErr = err } + + // If normalized differs from original, try original name as fallback. + if normalized != name { + for _, src := range m.sources { + manifest, err := src.FetchManifest(name) + if err == nil { + return manifest, src.Name(), nil + } + lastErr = err + } + } + if lastErr != nil { return nil, "", lastErr } @@ -59,12 +84,14 @@ func (m *MultiRegistry) FetchManifest(name string) (*RegistryManifest, string, e // SearchPlugins searches all sources and returns deduplicated results. // When the same plugin appears in multiple registries, the higher-priority source wins. +// The query is normalized (stripping "workflow-plugin-" prefix) before searching. func (m *MultiRegistry) SearchPlugins(query string) ([]PluginSearchResult, error) { seen := make(map[string]bool) var results []PluginSearchResult + normalizedQuery := normalizePluginName(query) for _, src := range m.sources { - srcResults, err := src.SearchPlugins(query) + srcResults, err := src.SearchPlugins(normalizedQuery) if err != nil { fmt.Fprintf(os.Stderr, "warning: search failed for registry %q: %v\n", src.Name(), err) continue diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 21576b29..677a20df 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -66,7 +66,9 @@ func runPluginSearch(args []string) error { func runPluginInstall(args []string) error { fs := flag.NewFlagSet("plugin install", flag.ContinueOnError) - dataDir := fs.String("data-dir", defaultDataDir, "Plugin data directory") + var pluginDirVal string + fs.StringVar(&pluginDirVal, "plugin-dir", defaultDataDir, "Plugin directory") + fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)") cfgPath := fs.String("config", "", "Registry config file path") registryName := fs.String("registry", "", "Use a specific registry by name") fs.Usage = func() { @@ -76,9 +78,10 @@ func runPluginInstall(args []string) error { if err := fs.Parse(args); err != nil { return err } + + // No args: install all plugins from .wfctl.yaml lockfile. if fs.NArg() < 1 { - fs.Usage() - return fmt.Errorf("plugin name is required") + return installFromLockfile(pluginDirVal, *cfgPath) } nameArg := fs.Arg(0) @@ -108,17 +111,43 @@ func runPluginInstall(args []string) error { } fmt.Fprintf(os.Stderr, "Fetching manifest for %q...\n", pluginName) - manifest, sourceName, err := mr.FetchManifest(pluginName) - if err != nil { - return err + manifest, sourceName, registryErr := mr.FetchManifest(pluginName) + + if registryErr != nil { + // Registry lookup failed. Try GitHub direct install if input looks like owner/repo[@version]. + ghOwner, ghRepo, ghVersion, isGH := parseGitHubRef(nameArg) + if !isGH { + return registryErr + } + pluginName = normalizePluginName(ghRepo) + destDir := filepath.Join(pluginDirVal, pluginName) + if err := installFromGitHub(ghOwner, ghRepo, ghVersion, destDir); err != nil { + return fmt.Errorf("registry: %w; github: %w", registryErr, err) + } + if err := ensurePluginBinary(destDir, pluginName); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not normalize binary name: %v\n", err) + } + fmt.Printf("Installed %s to %s\n", nameArg, destDir) + return nil } + fmt.Fprintf(os.Stderr, "Found in registry %q.\n", sourceName) - return installPluginFromManifest(*dataDir, pluginName, manifest) + if err := installPluginFromManifest(pluginDirVal, pluginName, manifest); err != nil { + return err + } + + // Update .wfctl.yaml lockfile if name@version was provided. + if _, ver := parseNameVersion(nameArg); ver != "" { + updateLockfile(manifest.Name, manifest.Version, manifest.Repository) + } + + return nil } // installPluginFromManifest downloads, extracts, and installs a plugin using the // provided registry manifest. It is shared by runPluginInstall and runPluginUpdate. +// The plugin.json is always written/updated from the manifest to keep version tracking correct. func installPluginFromManifest(dataDir, pluginName string, manifest *RegistryManifest) error { dl, err := manifest.FindDownload(runtime.GOOS, runtime.GOARCH) if err != nil { @@ -154,12 +183,12 @@ func installPluginFromManifest(dataDir, pluginName string, manifest *RegistryMan fmt.Fprintf(os.Stderr, "warning: could not normalize binary name: %v\n", err) } - // Write a minimal plugin.json if not already present (records version). + // Write plugin.json from the registry manifest. This keeps the installed + // version metadata in sync with the manifest. If the tarball already + // extracted a plugin.json, this overwrites it with the registry version. pluginJSONPath := filepath.Join(destDir, "plugin.json") - if _, err := os.Stat(pluginJSONPath); os.IsNotExist(err) { - if writeErr := writeInstalledManifest(pluginJSONPath, manifest); writeErr != nil { - fmt.Fprintf(os.Stderr, "warning: could not write plugin.json: %v\n", writeErr) - } + if writeErr := writeInstalledManifest(pluginJSONPath, manifest); writeErr != nil { + fmt.Fprintf(os.Stderr, "warning: could not write plugin.json: %v\n", writeErr) } // Verify the installed plugin.json is valid for ExternalPluginManager. @@ -174,7 +203,9 @@ func installPluginFromManifest(dataDir, pluginName string, manifest *RegistryMan func runPluginList(args []string) error { fs := flag.NewFlagSet("plugin list", flag.ContinueOnError) - dataDir := fs.String("data-dir", defaultDataDir, "Plugin data directory") + var pluginDirVal string + fs.StringVar(&pluginDirVal, "plugin-dir", defaultDataDir, "Plugin directory") + fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin list [options]\n\nList installed plugins.\n\nOptions:\n") fs.PrintDefaults() @@ -183,13 +214,13 @@ func runPluginList(args []string) error { return err } - entries, err := os.ReadDir(*dataDir) + entries, err := os.ReadDir(pluginDirVal) if os.IsNotExist(err) { fmt.Println("No plugins installed.") return nil } if err != nil { - return fmt.Errorf("read data dir %s: %w", *dataDir, err) + return fmt.Errorf("read data dir %s: %w", pluginDirVal, err) } type installed struct { @@ -203,7 +234,7 @@ func runPluginList(args []string) error { if !e.IsDir() { continue } - ver, pType, desc := readInstalledInfo(filepath.Join(*dataDir, e.Name())) + ver, pType, desc := readInstalledInfo(filepath.Join(pluginDirVal, e.Name())) plugins = append(plugins, installed{name: e.Name(), version: ver, pluginType: pType, description: desc}) } @@ -226,7 +257,9 @@ func runPluginList(args []string) error { func runPluginUpdate(args []string) error { fs := flag.NewFlagSet("plugin update", flag.ContinueOnError) - dataDir := fs.String("data-dir", defaultDataDir, "Plugin data directory") + var pluginDirVal string + fs.StringVar(&pluginDirVal, "plugin-dir", defaultDataDir, "Plugin directory") + fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)") cfgPath := fs.String("config", "", "Registry config file path") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin update [options] \n\nUpdate an installed plugin to its latest version.\n\nOptions:\n") @@ -241,7 +274,7 @@ func runPluginUpdate(args []string) error { } pluginName := fs.Arg(0) - pluginDir := filepath.Join(*dataDir, pluginName) + pluginDir := filepath.Join(pluginDirVal, pluginName) if _, err := os.Stat(pluginDir); os.IsNotExist(err) { return fmt.Errorf("plugin %q is not installed", pluginName) } @@ -267,7 +300,13 @@ func runPluginUpdate(args []string) error { manifest, sourceName, registryErr := mr.FetchManifest(pluginName) if registryErr == nil { fmt.Fprintf(os.Stderr, "Found in registry %q.\n", sourceName) - return installPluginFromManifest(*dataDir, pluginName, manifest) + installedVer := readInstalledVersion(pluginDir) + if installedVer == manifest.Version { + fmt.Printf("already at latest version (%s)\n", manifest.Version) + return nil + } + fmt.Fprintf(os.Stderr, "Updating from %s to %s...\n", installedVer, manifest.Version) + return installPluginFromManifest(pluginDirVal, pluginName, manifest) } // Registry lookup failed. If the plugin's manifest declares a repository @@ -278,7 +317,17 @@ func runPluginUpdate(args []string) error { if err != nil { return fmt.Errorf("registry lookup failed (%v); repository fallback also failed: %w", registryErr, err) } - return installPluginFromManifest(*dataDir, pluginName, manifest) + // Validate that the fetched manifest is for the plugin we're updating. + if manifest.Name != pluginName { + return fmt.Errorf("manifest name %q does not match plugin %q; refusing to update to prevent installing the wrong plugin", manifest.Name, pluginName) + } + installedVer := readInstalledVersion(pluginDir) + if installedVer == manifest.Version { + fmt.Printf("already at latest version (%s)\n", manifest.Version) + return nil + } + fmt.Fprintf(os.Stderr, "Updating from %s to %s...\n", installedVer, manifest.Version) + return installPluginFromManifest(pluginDirVal, pluginName, manifest) } return registryErr @@ -286,7 +335,9 @@ func runPluginUpdate(args []string) error { func runPluginRemove(args []string) error { fs := flag.NewFlagSet("plugin remove", flag.ContinueOnError) - dataDir := fs.String("data-dir", defaultDataDir, "Plugin data directory") + var pluginDirVal string + fs.StringVar(&pluginDirVal, "plugin-dir", defaultDataDir, "Plugin directory") + fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin remove [options] \n\nUninstall a plugin.\n\nOptions:\n") fs.PrintDefaults() @@ -300,7 +351,7 @@ func runPluginRemove(args []string) error { } pluginName := fs.Arg(0) - pluginDir := filepath.Join(*dataDir, pluginName) + pluginDir := filepath.Join(pluginDirVal, pluginName) if _, err := os.Stat(pluginDir); os.IsNotExist(err) { return fmt.Errorf("plugin %q is not installed", pluginName) } @@ -313,7 +364,9 @@ func runPluginRemove(args []string) error { func runPluginInfo(args []string) error { fs := flag.NewFlagSet("plugin info", flag.ContinueOnError) - dataDir := fs.String("data-dir", defaultDataDir, "Plugin data directory") + var pluginDirVal string + fs.StringVar(&pluginDirVal, "plugin-dir", defaultDataDir, "Plugin directory") + fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin info [options] \n\nShow details about an installed plugin.\n\nOptions:\n") fs.PrintDefaults() @@ -327,8 +380,9 @@ func runPluginInfo(args []string) error { } pluginName := fs.Arg(0) - pluginDir := filepath.Join(*dataDir, pluginName) - manifestPath := filepath.Join(pluginDir, "plugin.json") + pluginDir := filepath.Join(pluginDirVal, pluginName) + absDir, _ := filepath.Abs(pluginDir) + manifestPath := filepath.Join(absDir, "plugin.json") data, err := os.ReadFile(manifestPath) if os.IsNotExist(err) { @@ -373,7 +427,7 @@ func runPluginInfo(args []string) error { } // Check binary status. - binaryPath := filepath.Join(pluginDir, pluginName) + binaryPath := filepath.Join(absDir, pluginName) if info, statErr := os.Stat(binaryPath); statErr == nil { fmt.Printf("Binary: %s (%d bytes)\n", binaryPath, info.Size()) if info.Mode()&0111 != 0 { @@ -409,6 +463,10 @@ func downloadURL(url string) ([]byte, error) { return io.ReadAll(resp.Body) } +// rawGitHubContentBaseURL is the base URL for raw GitHub content. It is a +// package-level variable so tests can override it to point at a local server. +var rawGitHubContentBaseURL = "https://raw.githubusercontent.com" + // fetchManifestFromRepoURL fetches a plugin's manifest.json directly from its // GitHub repository. It expects the repository URL in the form // https://github.com/{owner}/{repo} and looks for a manifest.json at the root @@ -418,7 +476,7 @@ func fetchManifestFromRepoURL(repoURL string) (*RegistryManifest, error) { if err != nil { return nil, fmt.Errorf("parse repository URL %q: %w", repoURL, err) } - url := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/main/manifest.json", owner, repo) + url := fmt.Sprintf("%s/%s/%s/main/manifest.json", rawGitHubContentBaseURL, owner, repo) resp, err := http.Get(url) //nolint:gosec // G107: URL constructed from plugin's own repository field if err != nil { return nil, fmt.Errorf("fetch manifest from %q: %w", repoURL, err) @@ -443,16 +501,26 @@ func fetchManifestFromRepoURL(repoURL string) (*RegistryManifest, error) { // parseGitHubRepoURL parses a GitHub repository URL and returns the owner and // repository name. It accepts URLs in the form https://github.com/{owner}/{repo} -// (with or without trailing slashes or the https:// scheme). +// (with or without trailing slash or .git suffix) and rejects URLs with extra +// path segments (e.g. https://github.com/owner/repo/tree/main). func parseGitHubRepoURL(repoURL string) (owner, repo string, err error) { u := strings.TrimPrefix(repoURL, "https://") u = strings.TrimPrefix(u, "http://") u = strings.TrimSuffix(u, "/") - parts := strings.SplitN(u, "/", 3) + // Split into at most 4 parts to detect extra path segments. + parts := strings.SplitN(u, "/", 4) if len(parts) < 3 || parts[0] != "github.com" || parts[1] == "" || parts[2] == "" { return "", "", fmt.Errorf("not a GitHub repository URL: %q (expected https://github.com/owner/repo)", repoURL) } - return parts[1], parts[2], nil + if len(parts) == 4 { + // Extra path segments present (e.g. /tree/main, /blob/main/file.go). + return "", "", fmt.Errorf("not a GitHub repository URL: %q (unexpected extra path; expected https://github.com/owner/repo)", repoURL) + } + repoName := strings.TrimSuffix(parts[2], ".git") + if repoName == "" { + return "", "", fmt.Errorf("not a GitHub repository URL: %q (expected https://github.com/owner/repo)", repoURL) + } + return parts[1], repoName, nil } // verifyChecksum checks that data matches the expected SHA256 hex string. diff --git a/cmd/wfctl/plugin_install_e2e_test.go b/cmd/wfctl/plugin_install_e2e_test.go index dc65ec05..7102a06a 100644 --- a/cmd/wfctl/plugin_install_e2e_test.go +++ b/cmd/wfctl/plugin_install_e2e_test.go @@ -514,6 +514,11 @@ func TestParseGitHubRepoURL(t *testing.T) { wantOwner: "GoCodeAlone", wantRepo: "workflow-plugin-authz", }, + { + input: "https://github.com/GoCodeAlone/workflow-plugin-authz.git", + wantOwner: "GoCodeAlone", + wantRepo: "workflow-plugin-authz", + }, { input: "http://github.com/owner/repo", wantOwner: "owner", @@ -535,6 +540,16 @@ func TestParseGitHubRepoURL(t *testing.T) { input: "https://github.com//repo", wantErr: true, }, + { + // Extra path segments must be rejected. + input: "https://github.com/owner/repo/tree/main", + wantErr: true, + }, + { + // Extra path segments must be rejected. + input: "https://github.com/owner/repo/blob/main/README.md", + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { @@ -559,22 +574,27 @@ func TestParseGitHubRepoURL(t *testing.T) { } // TestFetchManifestFromRepoURL tests the fetchManifestFromRepoURL helper using -// an httptest server that serves a manifest.json. +// an httptest server that serves a manifest.json. It overrides rawGitHubContentBaseURL +// to point at the test server so no real network calls are made. func TestFetchManifestFromRepoURL(t *testing.T) { - t.Run("success", func(t *testing.T) { - manifest := &RegistryManifest{ - Name: "workflow-plugin-authz", - Version: "1.2.0", - Author: "GoCodeAlone", - Description: "RBAC authorization plugin", - Type: "external", - Tier: "core", - License: "MIT", - } - manifestJSON, _ := json.Marshal(manifest) + const owner = "GoCodeAlone" + const repo = "workflow-plugin-authz" + + wantManifest := &RegistryManifest{ + Name: "workflow-plugin-authz", + Version: "1.2.0", + Author: "GoCodeAlone", + Description: "RBAC authorization plugin", + Type: "external", + Tier: "core", + License: "MIT", + } + manifestJSON, _ := json.Marshal(wantManifest) + t.Run("success", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/GoCodeAlone/workflow-plugin-authz/main/manifest.json" { + wantPath := fmt.Sprintf("/%s/%s/main/manifest.json", owner, repo) + if r.URL.Path == wantPath { w.Header().Set("Content-Type", "application/json") w.Write(manifestJSON) //nolint:errcheck return @@ -583,13 +603,19 @@ func TestFetchManifestFromRepoURL(t *testing.T) { })) defer srv.Close() - // Temporarily patch the URL by using a mock function approach: - // Since fetchManifestFromRepoURL uses the real GitHub URL, we test the - // parsing and HTTP logic via parseGitHubRepoURL + direct downloadURL instead. - // This test validates that a 404 is correctly returned for non-GitHub URLs. - _, err := fetchManifestFromRepoURL("not-a-github-url") - if err == nil { - t.Fatal("expected error for invalid repo URL") + orig := rawGitHubContentBaseURL + rawGitHubContentBaseURL = srv.URL + defer func() { rawGitHubContentBaseURL = orig }() + + got, err := fetchManifestFromRepoURL(fmt.Sprintf("https://github.com/%s/%s", owner, repo)) + if err != nil { + t.Fatalf("fetchManifestFromRepoURL: %v", err) + } + if got.Name != wantManifest.Name { + t.Errorf("name: got %q, want %q", got.Name, wantManifest.Name) + } + if got.Version != wantManifest.Version { + t.Errorf("version: got %q, want %q", got.Version, wantManifest.Version) } }) @@ -599,19 +625,38 @@ func TestFetchManifestFromRepoURL(t *testing.T) { })) defer srv.Close() - // parseGitHubRepoURL rejects non-github.com URLs, so a 404 from the HTTP - // server is tested through invalid URL detection. - _, err := fetchManifestFromRepoURL("https://github.com//") + orig := rawGitHubContentBaseURL + rawGitHubContentBaseURL = srv.URL + defer func() { rawGitHubContentBaseURL = orig }() + + _, err := fetchManifestFromRepoURL(fmt.Sprintf("https://github.com/%s/%s", owner, repo)) + if err == nil { + t.Fatal("expected error for 404 manifest, got nil") + } + }) + + t.Run("invalid repo URL returns error", func(t *testing.T) { + _, err := fetchManifestFromRepoURL("not-a-github-url") if err == nil { t.Fatal("expected error for invalid repo URL") } }) + + t.Run("extra path segments rejected", func(t *testing.T) { + _, err := fetchManifestFromRepoURL(fmt.Sprintf("https://github.com/%s/%s/tree/main", owner, repo)) + if err == nil { + t.Fatal("expected error for URL with extra path segments") + } + }) } // TestPluginUpdateFallbackToRepo tests that runPluginUpdate falls back to the // repository URL in plugin.json when the registry does not have the plugin. +// It uses an empty registry config (so all lookups fail) and overrides +// rawGitHubContentBaseURL to serve the manifest from a local httptest server. func TestPluginUpdateFallbackToRepo(t *testing.T) { const pluginName = "workflow-plugin-authz" + const owner = "GoCodeAlone" binaryContent := []byte("#!/bin/sh\necho authz\n") // Build tarball for the "updated" plugin. @@ -622,9 +667,8 @@ func TestPluginUpdateFallbackToRepo(t *testing.T) { tarball := buildTarGz(t, tarEntries, 0755) checksum := sha256Hex(tarball) - // Serve the tarball and manifest from a local httptest server that mimics - // the plugin's own GitHub repository (raw.githubusercontent.com). - manifestReturned := &RegistryManifest{ + // manifest served by the plugin's repo when registry lookup fails. + updatedManifest := &RegistryManifest{ Name: pluginName, Version: "2.0.0", Author: "GoCodeAlone", @@ -636,18 +680,27 @@ func TestPluginUpdateFallbackToRepo(t *testing.T) { ModuleTypes: []string{"authz.casbin"}, StepTypes: []string{"step.authz_check_casbin"}, }, - Downloads: []PluginDownload{ - { - OS: runtime.GOOS, - Arch: runtime.GOARCH, - URL: "", // filled below - SHA256: checksum, - }, - }, } + // Single test server that: + // - /{owner}/{repo}/main/manifest.json → 200 with updatedManifest (simulates raw.githubusercontent.com) + // - /tarball → 200 with the plugin tarball (referenced in the manifest's downloads) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { + case fmt.Sprintf("/%s/%s/main/manifest.json", owner, pluginName): + // Fill in the tarball URL dynamically (srv.URL is known here). + m := *updatedManifest + m.Downloads = []PluginDownload{{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + URL: "/tarball", // relative, will be prefixed by srv.URL in the handler + SHA256: checksum, + }} + // Use absolute URL for the tarball. + m.Downloads[0].URL = "http://" + r.Host + "/tarball" + manifestJSON, _ := json.Marshal(&m) + w.Header().Set("Content-Type", "application/json") + w.Write(manifestJSON) //nolint:errcheck case "/tarball": w.Header().Set("Content-Type", "application/octet-stream") w.Write(tarball) //nolint:errcheck @@ -657,21 +710,22 @@ func TestPluginUpdateFallbackToRepo(t *testing.T) { })) defer srv.Close() - manifestReturned.Downloads[0].URL = srv.URL + "/tarball" - manifestJSON, _ := json.Marshal(manifestReturned) + // Write an empty registry config file so the registry lookup always fails. + cfgDir := t.TempDir() + cfgFile := filepath.Join(cfgDir, "config.yaml") + if err := os.WriteFile(cfgFile, []byte("registries: []\n"), 0640); err != nil { + t.Fatalf("write config: %v", err) + } // Set up a plugins directory with a pre-installed plugin.json that has a - // repository field pointing to our test server. + // repository field pointing to the real GitHub URL. rawGitHubContentBaseURL + // is overridden so fetchManifestFromRepoURL uses the test server instead. pluginsDir := t.TempDir() pluginDir := filepath.Join(pluginsDir, pluginName) if err := os.MkdirAll(pluginDir, 0750); err != nil { t.Fatalf("MkdirAll: %v", err) } - // Write a pre-existing plugin.json with a fake GitHub repo URL (we use - // our test server address but wrap it in a custom "fetchManifestFromRepoURL" - // friendly format). For this test, we directly call installPluginFromManifest - // to verify the install path, since fetchManifestFromRepoURL uses github.com. installedJSON := installedPluginJSON{ Name: pluginName, Version: "1.0.0", @@ -680,6 +734,7 @@ func TestPluginUpdateFallbackToRepo(t *testing.T) { Type: "external", Tier: "core", License: "MIT", + Repository: fmt.Sprintf("https://github.com/%s/%s", owner, pluginName), ModuleTypes: []string{"authz.casbin"}, } installedJSONBytes, _ := json.Marshal(installedJSON) @@ -687,26 +742,38 @@ func TestPluginUpdateFallbackToRepo(t *testing.T) { t.Fatalf("write plugin.json: %v", err) } - // Directly test installPluginFromManifest (the shared helper) to verify - // the update path works end-to-end once a manifest is retrieved. - // We also need a placeholder binary for verifyInstalledPlugin. - if err := installPluginFromManifest(pluginsDir, pluginName, manifestReturned); err != nil { - t.Fatalf("installPluginFromManifest: %v", err) - } + // Override rawGitHubContentBaseURL to route manifest fetches to the test server. + orig := rawGitHubContentBaseURL + rawGitHubContentBaseURL = srv.URL + defer func() { rawGitHubContentBaseURL = orig }() - // Verify the installed plugin.json was updated with the new version. - data, err := os.ReadFile(filepath.Join(pluginDir, "plugin.json")) + // Run the update command. It should fail the registry lookup (empty registry) + // and fall back to fetching the manifest from the repository URL. + err := runPluginUpdate([]string{ + "-plugin-dir", pluginsDir, + "-config", cfgFile, + pluginName, + }) if err != nil { - t.Fatalf("read updated plugin.json: %v", err) + t.Fatalf("runPluginUpdate: %v", err) } - // The install overwrites the plugin.json since the one from the manifest is written. - // Re-read — it may have been written or kept. The binary should exist. + // Verify the binary was installed. binaryPath := filepath.Join(pluginDir, pluginName) - if _, err := os.Stat(binaryPath); err != nil { - t.Fatalf("expected binary at %s: %v", binaryPath, err) + if _, statErr := os.Stat(binaryPath); statErr != nil { + t.Fatalf("expected binary at %s: %v", binaryPath, statErr) } - _ = data - _ = manifestJSON + // Verify the plugin.json was updated to the new version. + data, err := os.ReadFile(filepath.Join(pluginDir, "plugin.json")) + if err != nil { + t.Fatalf("read updated plugin.json: %v", err) + } + var pj installedPluginJSON + if err := json.Unmarshal(data, &pj); err != nil { + t.Fatalf("unmarshal plugin.json: %v", err) + } + if pj.Version != "2.0.0" { + t.Errorf("plugin.json version: got %q, want %q", pj.Version, "2.0.0") + } } diff --git a/cmd/wfctl/plugin_install_test.go b/cmd/wfctl/plugin_install_test.go new file mode 100644 index 00000000..82952ecc --- /dev/null +++ b/cmd/wfctl/plugin_install_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestPluginListAcceptsPluginDirFlag verifies that -plugin-dir is accepted by +// runPluginList and correctly used as the directory to scan. +func TestPluginListAcceptsPluginDirFlag(t *testing.T) { + dir := t.TempDir() + + // Create a fake installed plugin directory with a minimal plugin.json. + pluginDir := filepath.Join(dir, "myplugin") + if err := os.MkdirAll(pluginDir, 0750); err != nil { + t.Fatalf("mkdir: %v", err) + } + manifest := `{"name":"myplugin","version":"1.0.0","author":"test","description":"test plugin"}` + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0640); err != nil { + t.Fatalf("write plugin.json: %v", err) + } + + // Should succeed using -plugin-dir. + if err := runPluginList([]string{"-plugin-dir", dir}); err != nil { + t.Errorf("-plugin-dir: runPluginList returned unexpected error: %v", err) + } +} + +// TestParseGitHubPluginRef verifies that parseGitHubRef correctly identifies GitHub refs. +func TestParseGitHubPluginRef(t *testing.T) { + tests := []struct { + input string + owner string + repo string + version string + isGH bool + }{ + {"GoCodeAlone/workflow-plugin-authz@v0.3.1", "GoCodeAlone", "workflow-plugin-authz", "v0.3.1", true}, + {"GoCodeAlone/workflow-plugin-authz", "GoCodeAlone", "workflow-plugin-authz", "", true}, + {"authz", "", "", "", false}, + {"workflow-plugin-authz", "", "", "", false}, + {"owner/repo@v1.0.0", "owner", "repo", "v1.0.0", true}, + } + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + owner, repo, version, isGH := parseGitHubRef(tc.input) + if owner != tc.owner || repo != tc.repo || version != tc.version || isGH != tc.isGH { + t.Errorf("parseGitHubRef(%q) = (%q, %q, %q, %v), want (%q, %q, %q, %v)", + tc.input, owner, repo, version, isGH, + tc.owner, tc.repo, tc.version, tc.isGH) + } + }) + } +} + +// TestPluginListAcceptsLegacyDataDirFlag verifies that the deprecated -data-dir flag +// still works as an alias for -plugin-dir. +func TestPluginListAcceptsLegacyDataDirFlag(t *testing.T) { + dir := t.TempDir() + + // Should succeed using -data-dir (deprecated alias). + if err := runPluginList([]string{"-data-dir", dir}); err != nil { + t.Errorf("-data-dir: runPluginList returned unexpected error: %v", err) + } +} diff --git a/cmd/wfctl/plugin_lockfile.go b/cmd/wfctl/plugin_lockfile.go new file mode 100644 index 00000000..64e19dc4 --- /dev/null +++ b/cmd/wfctl/plugin_lockfile.go @@ -0,0 +1,129 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +const wfctlYAMLPath = ".wfctl.yaml" + +// PluginLockEntry records a pinned plugin version in the lockfile. +type PluginLockEntry struct { + Version string `yaml:"version"` + Repository string `yaml:"repository,omitempty"` + SHA256 string `yaml:"sha256,omitempty"` +} + +// PluginLockfile represents the plugins section of .wfctl.yaml. +// It preserves all other keys in the file for safe round-trip writes. +type PluginLockfile struct { + Plugins map[string]PluginLockEntry + raw map[string]any // preserved for round-trip writes +} + +// loadPluginLockfile reads path and returns the plugins section. +// If the file does not exist, an empty lockfile is returned without error. +func loadPluginLockfile(path string) (*PluginLockfile, error) { + lf := &PluginLockfile{ + Plugins: make(map[string]PluginLockEntry), + raw: make(map[string]any), + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return lf, nil + } + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + if err := yaml.Unmarshal(data, &lf.raw); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + // Extract and parse the plugins section if present. + if pluginsRaw, ok := lf.raw["plugins"]; ok && pluginsRaw != nil { + pluginsData, err := yaml.Marshal(pluginsRaw) + if err != nil { + return nil, fmt.Errorf("re-marshal plugins section: %w", err) + } + if err := yaml.Unmarshal(pluginsData, &lf.Plugins); err != nil { + return nil, fmt.Errorf("parse plugins section: %w", err) + } + } + return lf, nil +} + +// installFromLockfile reads .wfctl.yaml and installs all plugins in the +// plugins section. If no lockfile is found, it prints a helpful message. +func installFromLockfile(pluginDir, cfgPath string) error { + lf, err := loadPluginLockfile(wfctlYAMLPath) + if err != nil { + return fmt.Errorf("load lockfile: %w", err) + } + if len(lf.Plugins) == 0 { + fmt.Println("No plugins pinned in .wfctl.yaml.") + fmt.Println("Run 'wfctl plugin install @' to install and pin a plugin.") + return nil + } + var failed []string + for name, entry := range lf.Plugins { + fmt.Fprintf(os.Stderr, "Installing %s %s...\n", name, entry.Version) + installArgs := []string{"--plugin-dir", pluginDir} + if cfgPath != "" { + installArgs = append(installArgs, "--config", cfgPath) + } + // Pass just the name (no @version) so runPluginInstall does not + // call updateLockfile and inadvertently overwrite the pinned entry. + installArgs = append(installArgs, name) + if err := runPluginInstall(installArgs); err != nil { + fmt.Fprintf(os.Stderr, "error installing %s: %v\n", name, err) + failed = append(failed, name) + } + } + if len(failed) > 0 { + return fmt.Errorf("failed to install: %s", strings.Join(failed, ", ")) + } + return nil +} + +// updateLockfile adds or updates a plugin entry in .wfctl.yaml. +// Silently no-ops if the lockfile cannot be read or written (install still succeeds). +func updateLockfile(pluginName, version, repository string) { + lf, err := loadPluginLockfile(wfctlYAMLPath) + if err != nil { + return + } + if lf.Plugins == nil { + lf.Plugins = make(map[string]PluginLockEntry) + } + lf.Plugins[pluginName] = PluginLockEntry{ + Version: version, + Repository: repository, + } + _ = lf.Save(wfctlYAMLPath) +} + +// Save writes the lockfile back to path, updating the plugins section while +// preserving all other fields (project, git, deploy, etc.). +func (lf *PluginLockfile) Save(path string) error { + if lf.raw == nil { + lf.raw = make(map[string]any) + } + // Re-encode the typed plugins map into a yaml-compatible representation. + pluginsData, err := yaml.Marshal(lf.Plugins) + if err != nil { + return fmt.Errorf("marshal plugins: %w", err) + } + var pluginsRaw any + if err := yaml.Unmarshal(pluginsData, &pluginsRaw); err != nil { + return fmt.Errorf("re-unmarshal plugins: %w", err) + } + lf.raw["plugins"] = pluginsRaw + + data, err := yaml.Marshal(lf.raw) + if err != nil { + return fmt.Errorf("marshal lockfile: %w", err) + } + return os.WriteFile(path, data, 0600) //nolint:gosec // G306: .wfctl.yaml is user-owned project config +} diff --git a/cmd/wfctl/plugin_lockfile_test.go b/cmd/wfctl/plugin_lockfile_test.go new file mode 100644 index 00000000..444c18f4 --- /dev/null +++ b/cmd/wfctl/plugin_lockfile_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +const twoPluginLockfile = `project: + name: my-project + version: "1.0.0" +git: + repository: GoCodeAlone/my-project +plugins: + authz: + version: v0.3.1 + repository: GoCodeAlone/workflow-plugin-authz + sha256: abc123deadbeef + payments: + version: v0.1.0 + repository: GoCodeAlone/workflow-plugin-payments +` + +func TestLoadPluginLockfile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".wfctl.yaml") + if err := os.WriteFile(path, []byte(twoPluginLockfile), 0600); err != nil { + t.Fatal(err) + } + + lf, err := loadPluginLockfile(path) + if err != nil { + t.Fatalf("loadPluginLockfile: %v", err) + } + if len(lf.Plugins) != 2 { + t.Fatalf("want 2 plugins, got %d", len(lf.Plugins)) + } + + authz, ok := lf.Plugins["authz"] + if !ok { + t.Fatal("expected 'authz' plugin entry") + } + if authz.Version != "v0.3.1" { + t.Errorf("authz.Version = %q, want v0.3.1", authz.Version) + } + if authz.Repository != "GoCodeAlone/workflow-plugin-authz" { + t.Errorf("authz.Repository = %q, want GoCodeAlone/workflow-plugin-authz", authz.Repository) + } + if authz.SHA256 != "abc123deadbeef" { + t.Errorf("authz.SHA256 = %q, want abc123deadbeef", authz.SHA256) + } + + payments, ok := lf.Plugins["payments"] + if !ok { + t.Fatal("expected 'payments' plugin entry") + } + if payments.Version != "v0.1.0" { + t.Errorf("payments.Version = %q, want v0.1.0", payments.Version) + } +} + +func TestLoadPluginLockfile_Missing(t *testing.T) { + lf, err := loadPluginLockfile("/nonexistent/.wfctl.yaml") + if err != nil { + t.Fatalf("expected no error for missing file, got: %v", err) + } + if len(lf.Plugins) != 0 { + t.Errorf("expected empty plugins for missing file, got %v", lf.Plugins) + } +} + +func TestLoadPluginLockfile_NoPluginsSection(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".wfctl.yaml") + content := "project:\n name: my-project\ngit:\n repository: GoCodeAlone/my-project\n" + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + + lf, err := loadPluginLockfile(path) + if err != nil { + t.Fatalf("loadPluginLockfile: %v", err) + } + if len(lf.Plugins) != 0 { + t.Errorf("expected empty plugins map, got %v", lf.Plugins) + } +} + +func TestPluginLockfile_Save_RoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".wfctl.yaml") + + // Write initial file with non-plugin sections + initial := "project:\n name: my-project\ngit:\n repository: GoCodeAlone/my-project\n" + if err := os.WriteFile(path, []byte(initial), 0600); err != nil { + t.Fatal(err) + } + + // Load, add plugin, save + lf, err := loadPluginLockfile(path) + if err != nil { + t.Fatalf("loadPluginLockfile: %v", err) + } + lf.Plugins["authz"] = PluginLockEntry{ + Version: "v0.3.1", + Repository: "GoCodeAlone/workflow-plugin-authz", + } + if err := lf.Save(path); err != nil { + t.Fatalf("Save: %v", err) + } + + // Reload and verify + lf2, err := loadPluginLockfile(path) + if err != nil { + t.Fatalf("reload: %v", err) + } + if len(lf2.Plugins) != 1 { + t.Fatalf("want 1 plugin after reload, got %d", len(lf2.Plugins)) + } + authz := lf2.Plugins["authz"] + if authz.Version != "v0.3.1" { + t.Errorf("authz.Version = %q, want v0.3.1", authz.Version) + } + // Verify that the non-plugin fields are preserved + if lf2.raw["project"] == nil { + t.Error("expected 'project' field to be preserved after save") + } + if lf2.raw["git"] == nil { + t.Error("expected 'git' field to be preserved after save") + } +} diff --git a/cmd/wfctl/validate.go b/cmd/wfctl/validate.go index e3e3d766..295c6b64 100644 --- a/cmd/wfctl/validate.go +++ b/cmd/wfctl/validate.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "flag" "fmt" "os" @@ -9,6 +10,7 @@ import ( "github.com/GoCodeAlone/workflow/config" "github.com/GoCodeAlone/workflow/schema" + "gopkg.in/yaml.v3" ) func runValidate(args []string) error { @@ -60,7 +62,13 @@ Options: if err != nil { return fmt.Errorf("failed to scan directory %s: %w", *dir, err) } - files = append(files, found...) + for _, f := range found { + if !isWorkflowYAML(f) { + fmt.Fprintf(os.Stderr, " Skipping non-workflow file: %s\n", f) + continue + } + files = append(files, f) + } } files = append(files, fs.Args()...) @@ -107,11 +115,18 @@ Options: } func validateFile(cfgPath string, strict, skipUnknownTypes, allowNoEntryPoints bool) error { + // Read raw YAML to extract imports list for verbose feedback. + imports := extractImports(cfgPath) + cfg, err := config.LoadFromFile(cfgPath) if err != nil { return fmt.Errorf("failed to load: %w", err) } + if len(imports) > 0 { + fmt.Fprintf(os.Stderr, " Resolved %d import(s): %s\n", len(imports), strings.Join(imports, ", ")) + } + var opts []schema.ValidationOption if !strict { opts = append(opts, schema.WithAllowEmptyModules()) @@ -156,6 +171,27 @@ var skipFiles = map[string]bool{ "dashboard.yaml": true, } +// isWorkflowYAML reports whether the YAML file at path looks like a workflow +// config by checking the first 100 lines for top-level keys: modules:, +// workflows:, or pipelines:. +func isWorkflowYAML(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + scanner := bufio.NewScanner(f) + for i := 0; i < 100 && scanner.Scan(); i++ { + line := scanner.Text() + if strings.HasPrefix(line, "modules:") || + strings.HasPrefix(line, "workflows:") || + strings.HasPrefix(line, "pipelines:") { + return true + } + } + return false +} + func findYAMLFiles(root string) ([]string, error) { var files []string err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { @@ -181,6 +217,22 @@ func findYAMLFiles(root string) ([]string, error) { return files, err } +// extractImports reads the raw YAML at path and returns the top-level imports: list. +// Returns nil if the file cannot be read or has no imports. +func extractImports(path string) []string { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var raw struct { + Imports []string `yaml:"imports"` + } + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil + } + return raw.Imports +} + func indentError(err error) string { return strings.ReplaceAll(err.Error(), "\n", "\n ") } diff --git a/cmd/wfctl/validate_test.go b/cmd/wfctl/validate_test.go new file mode 100644 index 00000000..cdf86269 --- /dev/null +++ b/cmd/wfctl/validate_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestValidateSkipsNonWorkflowYAML(t *testing.T) { + dir := t.TempDir() + + // GitHub Actions CI file — should NOT be recognized as workflow YAML + ciYAML := `name: CI +on: [push] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 +` + ciPath := filepath.Join(dir, "ci.yml") + if err := os.WriteFile(ciPath, []byte(ciYAML), 0644); err != nil { + t.Fatal(err) + } + + // Workflow engine config — should be recognized + appYAML := `modules: + - name: server + type: http.server + config: + address: ":8080" +` + appPath := filepath.Join(dir, "app.yaml") + if err := os.WriteFile(appPath, []byte(appYAML), 0644); err != nil { + t.Fatal(err) + } + + if isWorkflowYAML(ciPath) { + t.Errorf("isWorkflowYAML(%q) = true, want false (GitHub Actions file)", ciPath) + } + if !isWorkflowYAML(appPath) { + t.Errorf("isWorkflowYAML(%q) = false, want true (workflow config)", appPath) + } +} + +func TestIsWorkflowYAMLVariants(t *testing.T) { + dir := t.TempDir() + + write := func(name, content string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(content), 0644); err != nil { + t.Fatal(err) + } + return p + } + + cases := []struct { + name string + content string + want bool + }{ + {"with-modules", "modules:\n - name: x\n", true}, + {"with-workflows", "workflows:\n http: {}\n", true}, + {"with-pipelines", "pipelines:\n - name: p\n", true}, + {"non-workflow", "name: CI\non: [push]\n", false}, + {"indented-modules", " modules:\n - name: x\n", false}, // indented, not top-level + {"empty", "", false}, + } + + for _, tc := range cases { + p := write(tc.name+".yaml", tc.content) + got := isWorkflowYAML(p) + if got != tc.want { + t.Errorf("isWorkflowYAML(%q) = %v, want %v (content: %q)", tc.name, got, tc.want, tc.content) + } + } +} + +func TestValidateDirSkipsNonWorkflowFiles(t *testing.T) { + dir := t.TempDir() + + // Write a non-workflow YAML (GitHub Actions style) + ciYAML := `name: CI +on: [push] +jobs: + build: + runs-on: ubuntu-latest +` + if err := os.WriteFile(filepath.Join(dir, "ci.yml"), []byte(ciYAML), 0644); err != nil { + t.Fatal(err) + } + + // Write a valid workflow config + appYAML := `modules: + - name: server + type: http.server + config: + address: ":8080" +` + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(appYAML), 0644); err != nil { + t.Fatal(err) + } + + // --dir should succeed: ci.yml is skipped, app.yaml passes validation + if err := runValidate([]string{"--dir", dir}); err != nil { + t.Fatalf("runValidate --dir: %v", err) + } +} From af050b1b89d857ed28d70e09a4c74d10e1201b82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 03:10:10 +0000 Subject: [PATCH 4/5] Resolve merge conflicts in plugin_install.go and plugin_install_test.go Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/plugin_install.go | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 677a20df..80d0db66 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -290,6 +290,7 @@ func runPluginUpdate(args []string) error { } } + // Check the registry for the latest version before downloading. cfg, err := LoadRegistryConfig(*cfgPath) if err != nil { return fmt.Errorf("load registry config: %w", err) @@ -463,6 +464,16 @@ func downloadURL(url string) ([]byte, error) { return io.ReadAll(resp.Body) } +// verifyChecksum checks that data matches the expected SHA256 hex string. +func verifyChecksum(data []byte, expected string) error { + h := sha256.Sum256(data) + got := hex.EncodeToString(h[:]) + if !strings.EqualFold(got, expected) { + return fmt.Errorf("checksum mismatch: got %s, want %s", got, expected) + } + return nil +} + // rawGitHubContentBaseURL is the base URL for raw GitHub content. It is a // package-level variable so tests can override it to point at a local server. var rawGitHubContentBaseURL = "https://raw.githubusercontent.com" @@ -523,16 +534,6 @@ func parseGitHubRepoURL(repoURL string) (owner, repo string, err error) { return parts[1], repoName, nil } -// verifyChecksum checks that data matches the expected SHA256 hex string. -func verifyChecksum(data []byte, expected string) error { - h := sha256.Sum256(data) - got := hex.EncodeToString(h[:]) - if !strings.EqualFold(got, expected) { - return fmt.Errorf("checksum mismatch: got %s, want %s", got, expected) - } - return nil -} - // 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 { From f9865abe2aec4fe5f42d74da88fa93f4e145d0ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 03:22:43 +0000 Subject: [PATCH 5/5] Fix build error: remove stale nameArg reference from installPluginFromManifest Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/plugin_install.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 1f5be341..80d0db66 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -198,12 +198,6 @@ func installPluginFromManifest(dataDir, pluginName string, manifest *RegistryMan } fmt.Printf("Installed %s v%s to %s\n", manifest.Name, manifest.Version, destDir) - - // Update .wfctl.yaml lockfile if name@version was provided. - if _, ver := parseNameVersion(nameArg); ver != "" { - updateLockfile(manifest.Name, manifest.Version, manifest.Repository) - } - return nil }