Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0c6c890
docs: add wfctl audit and plugin ecosystem design
intel352 Mar 13, 2026
ad199a5
feat: add wave 2 integration plugins design + release/validation plan
intel352 Mar 13, 2026
cf02c94
docs: add wfctl audit implementation plan (20 tasks)
intel352 Mar 13, 2026
83fc44e
docs: add B5 schema validation gap to Task 14
intel352 Mar 13, 2026
4dbd603
fix: move Permit.io into workflow-plugin-authz as provider
intel352 Mar 13, 2026
6942e83
feat: rename -data-dir to -plugin-dir in plugin subcommands
intel352 Mar 13, 2026
d369b77
feat(wfctl): add trailing flag detection helper
intel352 Mar 13, 2026
c114e1f
fix: --help exits 0 and suppresses engine error leakage
intel352 Mar 13, 2026
4265f10
feat(wfctl): add plugin name normalization to multi-registry
intel352 Mar 13, 2026
fb6ca6b
feat: validate --dir skips non-workflow YAML files
intel352 Mar 13, 2026
b5a6f53
feat: add version check to plugin update command
intel352 Mar 13, 2026
aa961eb
fix: infra commands show actionable error when no config found
intel352 Mar 13, 2026
88a5090
test(wfctl): add TestPluginInstallRespectsPluginDir
intel352 Mar 13, 2026
d1a6da3
fix: handle missing go.sum in init Dockerfile templates
intel352 Mar 13, 2026
aaf07cb
feat: log resolved imports during validate
intel352 Mar 13, 2026
5ebc60e
feat(wfctl): accept positional config arg in deploy subcommands
intel352 Mar 13, 2026
319c279
feat: PluginManifest UnmarshalJSON handles legacy capabilities object…
intel352 Mar 13, 2026
b7e6eda
fix: plugin info shows absolute binary path
intel352 Mar 13, 2026
e5d0619
feat: engine warns when plugin minEngineVersion exceeds current version
intel352 Mar 13, 2026
dada6bc
feat: add GitHub URL install support to plugin install
intel352 Mar 13, 2026
1ab9616
feat: plugin lockfile support via .wfctl.yaml plugins section
intel352 Mar 13, 2026
77ae86f
docs: add plugin goreleaser reference config
intel352 Mar 13, 2026
f480d38
fix: correct FetchManifest arg and destDir for GitHub installs
intel352 Mar 13, 2026
4598bc1
fix: lockfile install doesn't re-pin; engine compat uses slog
intel352 Mar 13, 2026
6fb3b4f
fix: use published @gocodealone/workflow-editor from GitHub Packages
intel352 Mar 13, 2026
bc14b21
fix: remove trailing punctuation from infra error string (ST1005)
intel352 Mar 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions cmd/wfctl/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ Options:
if err := fs.Parse(args); err != nil {
return err
}
if *config == "" && fs.NArg() > 0 {
*config = fs.Arg(0)
}

cwd, err := os.Getwd()
if err != nil {
Expand Down Expand Up @@ -417,6 +420,9 @@ func runK8sGenerate(args []string) error {
if err := fs.Parse(args); err != nil {
return err
}
if f.configFile == "" && fs.NArg() > 0 {
f.configFile = fs.Arg(0)
}
if f.image == "" {
return fmt.Errorf("-image is required")
}
Expand Down Expand Up @@ -470,6 +476,9 @@ func runK8sApply(args []string) error {
if err := fs.Parse(args); err != nil {
return err
}
if f.configFile == "" && fs.NArg() > 0 {
f.configFile = fs.Arg(0)
}

// Load .wfctl.yaml defaults for build settings
if wfcfg, loadErr := loadWfctlConfig(); loadErr == nil {
Expand Down Expand Up @@ -779,6 +788,9 @@ Options:
if err := fs.Parse(args); err != nil {
return err
}
if *configFile == "" && fs.NArg() > 0 {
*configFile = fs.Arg(0)
}

if *target != "" && *target != "staging" && *target != "production" {
return fmt.Errorf("invalid target %q: must be staging or production", *target)
Expand Down
29 changes: 29 additions & 0 deletions cmd/wfctl/flag_helpers.go
Original file line number Diff line number Diff line change
@@ -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
}
43 changes: 43 additions & 0 deletions cmd/wfctl/flag_helpers_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
92 changes: 92 additions & 0 deletions cmd/wfctl/github_install.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion cmd/wfctl/infra.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func resolveInfraConfig(fs *flag.FlagSet) (string, error) {
return arg, nil
}
}
return "", fmt.Errorf("no config file found (tried infra.yaml, config/infra.yaml)")
return "", fmt.Errorf("no infrastructure config found (tried infra.yaml, config/infra.yaml)\n\nCreate an infra config with cloud.account and platform.* modules.\nRun 'wfctl init --template full-stack' for a starter config with infrastructure")
}

// infraModuleEntry is a minimal struct for parsing modules from YAML.
Expand Down
20 changes: 18 additions & 2 deletions cmd/wfctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"time"

Expand All @@ -27,6 +28,17 @@ var wfctlConfigBytes []byte

var version = "dev"

// isHelpRequested reports whether the error originated from the user
// requesting help (--help / -h). flag.ErrHelp propagates through the
// pipeline engine as a step failure; catching it here lets us exit 0
// instead of printing a confusing "error: flag: help requested" message.
func isHelpRequested(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "flag: help requested")
}

// commands maps each CLI command name to its Go implementation. The command
// metadata (name, description) is declared in wfctl.yaml; this map provides
// the runtime functions that are registered in the CLICommandRegistry service
Expand Down Expand Up @@ -131,9 +143,9 @@ func main() {
cliHandler.SetOutput(os.Stderr)

if len(os.Args) < 2 {
// No subcommand — print usage and exit non-zero.
// No subcommand — print usage and exit 0 (help is not an error).
_ = cliHandler.Dispatch([]string{"-h"})
os.Exit(1)
os.Exit(0)
}

cmd := os.Args[1]
Expand All @@ -155,6 +167,10 @@ func main() {
stop()

if dispatchErr != nil {
// If the user requested help, exit cleanly without printing the engine error.
if isHelpRequested(dispatchErr) {
os.Exit(0)
}
// The handler already printed routing errors (unknown/missing command).
// Only emit the "error:" prefix for actual command execution failures.
if _, isKnown := commands[cmd]; isKnown {
Expand Down
20 changes: 20 additions & 0 deletions cmd/wfctl/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package main

import (
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
Expand All @@ -10,6 +13,23 @@ import (
"github.com/GoCodeAlone/workflow/schema"
)

func TestHelpFlagDoesNotLeakEngineError(t *testing.T) {
if !isHelpRequested(flag.ErrHelp) {
t.Error("isHelpRequested should return true for flag.ErrHelp")
}
if isHelpRequested(nil) {
t.Error("isHelpRequested should return false for nil")
}
if isHelpRequested(errors.New("some other error")) {
t.Error("isHelpRequested should return false for unrelated errors")
}
// Wrapped error should also be detected
wrapped := fmt.Errorf("pipeline failed: %w", flag.ErrHelp)
if !isHelpRequested(wrapped) {
t.Error("isHelpRequested should return true for wrapped flag.ErrHelp")
}
}

func writeTestConfig(t *testing.T, dir, name, content string) string {
t.Helper()
path := filepath.Join(dir, name)
Expand Down
31 changes: 29 additions & 2 deletions cmd/wfctl/multi_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"sort"
"strings"
)

// MultiRegistry aggregates multiple RegistrySource instances and resolves
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions cmd/wfctl/multi_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,30 @@ func (m *mockRegistrySource) SearchPlugins(query string) ([]PluginSearchResult,
return results, nil
}

// ---------------------------------------------------------------------------
// normalizePluginName tests
// ---------------------------------------------------------------------------

func TestNormalizePluginName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"authz", "authz"},
{"workflow-plugin-authz", "authz"},
{"workflow-plugin-payments", "payments"},
{"custom-plugin", "custom-plugin"},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
got := normalizePluginName(tc.input)
if got != tc.want {
t.Errorf("normalizePluginName(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}

// ---------------------------------------------------------------------------
// Registry config tests
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading