Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
155 changes: 104 additions & 51 deletions cmd/wfctl/main.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,36 @@
package main

import (
"context"
_ "embed"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
"syscall"
"time"

workflow "github.com/GoCodeAlone/workflow"
"github.com/GoCodeAlone/workflow/config"
"github.com/GoCodeAlone/workflow/handlers"
"github.com/GoCodeAlone/workflow/module"
)

// wfctlConfigBytes is the embedded workflow config that declares wfctl's CLI
// structure and maps every command to a pipeline triggered via the "cli"
// trigger type. The engine resolves these pipelines at startup so each command
// flows through the workflow engine as a proper workflow primitive.
//
//go:embed wfctl.yaml
var wfctlConfigBytes []byte

var version = "dev"

// 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
// and invoked by step.cli_invoke from within each command's pipeline.
var commands = map[string]func([]string) error{
"init": runInit,
"validate": runValidate,
Expand Down Expand Up @@ -35,65 +58,83 @@ var commands = map[string]func([]string) error{
"mcp": runMCP,
}

func usage() {
fmt.Fprintf(os.Stderr, `wfctl - Workflow Engine CLI (version %s)

Usage:
wfctl <command> [options]

Commands:
init Scaffold a new workflow project from a template
validate Validate a workflow configuration file
inspect Inspect modules, workflows, and triggers in a config
run Run a workflow engine from a config file
plugin Plugin management (init, docs, search, install, list, update, remove)
pipeline Pipeline management (list, run)
schema Generate JSON Schema for workflow configs
snippets Export IDE snippets (--format vscode|jetbrains|json)
manifest Analyze config and report infrastructure requirements
migrate Manage database schema migrations
build-ui Build the application UI (npm install + npm run build + validate)
ui UI tooling (scaffold: generate Vite+React+TypeScript SPA from OpenAPI spec)
publish Prepare and publish a plugin manifest to the workflow-registry
deploy Deploy the workflow application (docker, kubernetes, cloud)
api API tooling (extract: generate OpenAPI 3.0 spec from config)
diff Compare two workflow config files and show what changed
template Template management (validate: check templates against known types)
contract Contract testing (test: generate/compare API contracts)
compat Compatibility checking (check: verify config works with current engine)
generate Code generation (github-actions: generate CI/CD workflows from config)
git Git integration (connect: link to GitHub repo, push: commit and push)
registry Registry management (list, add, remove plugin registry sources)
update Update wfctl to the latest version (use --check to only check)
mcp Start the MCP server over stdio for AI assistant integration

Run 'wfctl <command> -h' for command-specific help.
`, version)
}

func main() {
if len(os.Args) < 2 {
usage()
// Load the embedded config. All command definitions and pipeline wiring
// live in wfctl.yaml — no hardcoded routing in this file.
cfg, err := config.LoadFromBytes(wfctlConfigBytes)
if err != nil {
fmt.Fprintf(os.Stderr, "internal error: failed to load embedded config: %v\n", err) //nolint:gosec // G705
os.Exit(1)
}

cmd := os.Args[1]
if cmd == "-h" || cmd == "--help" || cmd == "help" {
usage()
os.Exit(0)
// Inject the build-time version into the cli workflow config map so that
// --version and the usage header display the correct release string.
if wfCfg, ok := cfg.Workflows["cli"].(map[string]any); ok {
wfCfg["version"] = version
}
if cmd == "-v" || cmd == "--version" || cmd == "version" {
fmt.Println(version)
os.Exit(0)

// Build the engine with all default handlers and triggers.
// The discard logger is propagated to all cmd-* pipelines automatically
// via configurePipelines, so internal plumbing logs do not appear in the
// terminal. Each command creates its own logger when it needs output.
engineLogger := slog.New(slog.NewTextHandler(io.Discard, nil))
engineInst, err := workflow.NewEngineBuilder().
WithLogger(engineLogger).
WithAllDefaults().
Build()
if err != nil {
fmt.Fprintf(os.Stderr, "internal error: failed to build engine: %v\n", err) //nolint:gosec // G705
os.Exit(1)
}

// Register all Go command implementations in the CLICommandRegistry service
// before BuildFromConfig so that step.cli_invoke can look them up at
// pipeline execution time (service is resolved lazily on each Execute call).
registry := module.NewCLICommandRegistry()
for name, fn := range commands {
registry.Register(name, module.CLICommandFunc(fn))
}
if err := engineInst.App().RegisterService(module.CLICommandRegistryServiceName, registry); err != nil {
fmt.Fprintf(os.Stderr, "internal error: failed to register command registry: %v\n", err) //nolint:gosec // G705
os.Exit(1)
}

// Register the CLI-specific step types on the engine's step registry.
// step.cli_invoke calls a Go function by name from CLICommandRegistry.
// step.cli_print writes a template-resolved message to stdout/stderr.
// These are registered here rather than via the pipelinesteps plugin to
// keep wfctl lean — only what the binary actually needs is loaded.
engineInst.AddStepType("step.cli_invoke", module.NewCLIInvokeStepFactory())
engineInst.AddStepType("step.cli_print", module.NewCLIPrintStepFactory())

fn, ok := commands[cmd]
if !ok {
fmt.Fprintf(os.Stderr, "unknown command: %s\n\n", cmd) //nolint:gosec // G705: CLI error output
usage()
// BuildFromConfig wires the engine from wfctl.yaml:
// 1. CLIWorkflowHandler is configured from workflows.cli (registers itself
// as "cliWorkflowHandler" in the app service registry).
// 2. Each cmd-* pipeline is created and registered.
// 3. CLITrigger is configured once per pipeline (via the "cli" inline
// trigger), accumulating command→pipeline mappings.
if err := engineInst.BuildFromConfig(cfg); err != nil {
fmt.Fprintf(os.Stderr, "internal error: failed to configure engine: %v\n", err) //nolint:gosec // G705
os.Exit(1)
}

// Retrieve the CLIWorkflowHandler that registered itself during BuildFromConfig.
var cliHandler *handlers.CLIWorkflowHandler
if err := engineInst.App().GetService(handlers.CLIWorkflowHandlerServiceName, &cliHandler); err != nil || cliHandler == nil {
fmt.Fprintf(os.Stderr, "internal error: CLIWorkflowHandler not found in service registry\n") //nolint:gosec // G705
os.Exit(1)
}
// Error/usage output goes to stderr; command output goes to stdout.
cliHandler.SetOutput(os.Stderr)

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

cmd := os.Args[1]

// Start the update check in the background before running the command so
// that it runs concurrently. For long-running commands (mcp, run) we skip
// it entirely. After the command finishes we wait briefly for the result.
Expand All @@ -102,8 +143,20 @@ func main() {
updateNoticeDone = checkForUpdateNotice()
}

if err := fn(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) //nolint:gosec // G705: CLI error output
// Set up a context that is cancelled on SIGINT/SIGTERM so that long-running
// commands (e.g. wfctl mcp, wfctl run) can be interrupted cleanly.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)

dispatchErr := cliHandler.DispatchContext(ctx, os.Args[1:])
// Release signal resources before waiting for the update notice or exiting.
stop()

if dispatchErr != nil {
// 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
}
os.Exit(1)
}

Expand Down
10 changes: 10 additions & 0 deletions cmd/wfctl/type_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,16 @@ func KnownStepTypes() map[string]StepTypeInfo {
Plugin: "pipelinesteps",
ConfigKeys: []string{"message", "level"},
},
"step.cli_print": {
Type: "step.cli_print",
Plugin: "pipelinesteps",
ConfigKeys: []string{"message", "newline", "target"},
},
"step.cli_invoke": {
Type: "step.cli_invoke",
Plugin: "pipelinesteps",
ConfigKeys: []string{"command"},
},
"step.delegate": {
Type: "step.delegate",
Plugin: "pipelinesteps",
Expand Down
Loading
Loading