diff --git a/cmd/wfctl/main.go b/cmd/wfctl/main.go index 4067f3eb..1a0e89a8 100644 --- a/cmd/wfctl/main.go +++ b/cmd/wfctl/main.go @@ -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, @@ -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 [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 -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. @@ -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) } diff --git a/cmd/wfctl/type_registry.go b/cmd/wfctl/type_registry.go index a6849df4..3f540734 100644 --- a/cmd/wfctl/type_registry.go +++ b/cmd/wfctl/type_registry.go @@ -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", diff --git a/cmd/wfctl/wfctl.yaml b/cmd/wfctl/wfctl.yaml new file mode 100644 index 00000000..5274be80 --- /dev/null +++ b/cmd/wfctl/wfctl.yaml @@ -0,0 +1,324 @@ +workflows: + cli: + name: wfctl + description: Workflow Engine CLI + commands: + - name: init + description: Scaffold a new workflow project from a template + - name: validate + description: Validate a workflow configuration file + - name: inspect + description: Inspect modules, workflows, and triggers in a config + - name: run + description: Run a workflow engine from a config file + - name: plugin + description: "Plugin management (init, docs, search, install, list, update, remove)" + - name: pipeline + description: "Pipeline management (list, run)" + - name: schema + description: Generate JSON Schema for workflow configs + - name: snippets + description: "Export IDE snippets (--format vscode|jetbrains|json)" + - name: manifest + description: Analyze config and report infrastructure requirements + - name: migrate + description: Manage database schema migrations + - name: build-ui + description: "Build the application UI (npm install + npm run build + validate)" + - name: ui + description: "UI tooling (scaffold: generate Vite+React+TypeScript SPA from OpenAPI spec)" + - name: publish + description: Prepare and publish a plugin manifest to the workflow-registry + - name: deploy + description: "Deploy the workflow application (docker, kubernetes, cloud)" + - name: api + description: "API tooling (extract: generate OpenAPI 3.0 spec from config)" + - name: diff + description: Compare two workflow config files and show what changed + - name: template + description: "Template management (validate: check templates against known types)" + - name: contract + description: "Contract testing (test: generate/compare API contracts)" + - name: compat + description: "Compatibility checking (check: verify config works with current engine)" + - name: generate + description: "Code generation (github-actions: generate CI/CD workflows from config)" + - name: git + description: "Git integration (connect: link to GitHub repo, push: commit and push)" + - name: registry + description: "Registry management (list, add, remove plugin registry sources)" + - name: update + description: "Update wfctl to the latest version (use --check to only check)" + - name: mcp + description: Start the MCP server over stdio for AI assistant integration + +# Each command is expressed as a workflow pipeline triggered by the CLI. +# The pipeline delegates to the registered Go implementation via step.cli_invoke, +# which looks up the command function from the CLICommandRegistry service. +# Pre/post steps can be added here to intercept or augment any command without +# changing Go code — e.g. audit logging, feature flags, telemetry. +pipelines: + cmd-init: + trigger: + type: cli + config: + command: init + steps: + - name: run + type: step.cli_invoke + config: + command: init + + cmd-validate: + trigger: + type: cli + config: + command: validate + steps: + - name: run + type: step.cli_invoke + config: + command: validate + + cmd-inspect: + trigger: + type: cli + config: + command: inspect + steps: + - name: run + type: step.cli_invoke + config: + command: inspect + + cmd-run: + trigger: + type: cli + config: + command: run + steps: + - name: run + type: step.cli_invoke + config: + command: run + + cmd-plugin: + trigger: + type: cli + config: + command: plugin + steps: + - name: run + type: step.cli_invoke + config: + command: plugin + + cmd-pipeline: + trigger: + type: cli + config: + command: pipeline + steps: + - name: run + type: step.cli_invoke + config: + command: pipeline + + cmd-schema: + trigger: + type: cli + config: + command: schema + steps: + - name: run + type: step.cli_invoke + config: + command: schema + + cmd-snippets: + trigger: + type: cli + config: + command: snippets + steps: + - name: run + type: step.cli_invoke + config: + command: snippets + + cmd-manifest: + trigger: + type: cli + config: + command: manifest + steps: + - name: run + type: step.cli_invoke + config: + command: manifest + + cmd-migrate: + trigger: + type: cli + config: + command: migrate + steps: + - name: run + type: step.cli_invoke + config: + command: migrate + + cmd-build-ui: + trigger: + type: cli + config: + command: build-ui + steps: + - name: run + type: step.cli_invoke + config: + command: build-ui + + cmd-ui: + trigger: + type: cli + config: + command: ui + steps: + - name: run + type: step.cli_invoke + config: + command: ui + + cmd-publish: + trigger: + type: cli + config: + command: publish + steps: + - name: run + type: step.cli_invoke + config: + command: publish + + cmd-deploy: + trigger: + type: cli + config: + command: deploy + steps: + - name: run + type: step.cli_invoke + config: + command: deploy + + cmd-api: + trigger: + type: cli + config: + command: api + steps: + - name: run + type: step.cli_invoke + config: + command: api + + cmd-diff: + trigger: + type: cli + config: + command: diff + steps: + - name: run + type: step.cli_invoke + config: + command: diff + + cmd-template: + trigger: + type: cli + config: + command: template + steps: + - name: run + type: step.cli_invoke + config: + command: template + + cmd-contract: + trigger: + type: cli + config: + command: contract + steps: + - name: run + type: step.cli_invoke + config: + command: contract + + cmd-compat: + trigger: + type: cli + config: + command: compat + steps: + - name: run + type: step.cli_invoke + config: + command: compat + + cmd-generate: + trigger: + type: cli + config: + command: generate + steps: + - name: run + type: step.cli_invoke + config: + command: generate + + cmd-git: + trigger: + type: cli + config: + command: git + steps: + - name: run + type: step.cli_invoke + config: + command: git + + cmd-registry: + trigger: + type: cli + config: + command: registry + steps: + - name: run + type: step.cli_invoke + config: + command: registry + + cmd-update: + trigger: + type: cli + config: + command: update + steps: + - name: run + type: step.cli_invoke + config: + command: update + + cmd-mcp: + trigger: + type: cli + config: + command: mcp + steps: + - name: run + type: step.cli_invoke + config: + command: mcp + diff --git a/config/config.go b/config/config.go index 1cc33414..ce50dbcc 100644 --- a/config/config.go +++ b/config/config.go @@ -262,6 +262,18 @@ func (cfg *WorkflowConfig) processImports(seen map[string]bool) error { return nil } +// LoadFromBytes loads a workflow configuration from a YAML byte slice. +// This is useful for loading embedded configs (e.g. via //go:embed). +// Note: imports are NOT processed because there is no file path context +// to resolve relative import paths against. +func LoadFromBytes(data []byte) (*WorkflowConfig, error) { + var cfg WorkflowConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config bytes: %w", err) + } + return &cfg, nil +} + // LoadFromString loads a workflow configuration from a YAML string. // Note: imports are NOT processed when loading from a string because there is // no file path context to resolve relative import paths against. diff --git a/engine.go b/engine.go index 51fd0647..63c1d63d 100644 --- a/engine.go +++ b/engine.go @@ -759,6 +759,15 @@ func (e *StdEngine) configurePipelines(pipelineCfg map[string]any) error { Compensation: compSteps, } + // Propagate the engine's logger to the pipeline so that execution logs + // (Pipeline started, Step completed, etc.) use the same logger instance + // as the rest of the engine rather than falling back to slog.Default(). + // This ensures that callers who pass a discard logger via WithLogger get + // full suppression without needing to mutate the global slog default. + if sl, ok := e.logger.(*slog.Logger); ok { + pipeline.Logger = sl + } + // Set RoutePattern from inline HTTP trigger path so that step.request_parse // can extract path parameters via _route_pattern in the pipeline context. if pipeCfg.Trigger.Type == "http" { diff --git a/handlers/cli.go b/handlers/cli.go new file mode 100644 index 00000000..94c74131 --- /dev/null +++ b/handlers/cli.go @@ -0,0 +1,309 @@ +package handlers + +import ( + "context" + "fmt" + "io" + "os" + "sort" + "strings" + + "github.com/CrisisTextLine/modular" +) + +// CLICommandDef defines a single CLI command in a workflow config. +type CLICommandDef struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + // Handler is the name of the registered Go function handler for this command. + // If empty the command name itself is used as the handler key. + Handler string `json:"handler,omitempty" yaml:"handler,omitempty"` +} + +// CLIWorkflowConfig is the configuration structure for a "cli" workflow type. +type CLIWorkflowConfig struct { + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Version string `json:"version,omitempty" yaml:"version,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Commands []CLICommandDef `json:"commands,omitempty" yaml:"commands,omitempty"` +} + +// CLICommandFunc is the signature for CLI command handler functions. +type CLICommandFunc func(args []string) error + +// CLIPipelineDispatcher is implemented by module.CLITrigger. It allows +// CLIWorkflowHandler to fall back to pipeline-based command execution when no +// direct Go runner is registered for a command. +type CLIPipelineDispatcher interface { + DispatchCommand(ctx context.Context, cmd string, args []string) error +} + +// CLIWorkflowHandlerServiceName is the well-known app service name under which +// CLIWorkflowHandler registers itself during ConfigureWorkflow. External callers +// (e.g. cmd/wfctl/main.go) can retrieve the handler with: +// +// var h *handlers.CLIWorkflowHandler +// app.GetService(handlers.CLIWorkflowHandlerServiceName, &h) +const CLIWorkflowHandlerServiceName = "cliWorkflowHandler" + +// CLIWorkflowHandler handles "cli" workflow types. It registers Go function +// handlers for CLI commands, configures them from a YAML workflow config, and +// dispatches os.Args to the correct handler at runtime. +// +// Commands can be backed by either a directly-registered Go function +// (RegisterCommand) or a pipeline defined in the workflow config with a "cli" +// trigger type. Pipeline dispatch is handled by the CLITrigger (module.CLITrigger), +// which CLIWorkflowHandler discovers lazily from the app service registry. +type CLIWorkflowHandler struct { + config *CLIWorkflowConfig + commands map[string]*CLICommandDef // keyed by command name + runners map[string]CLICommandFunc // keyed by handler name (or command name) + output io.Writer // for usage output; defaults to os.Stderr + app modular.Application // stored in ConfigureWorkflow; used for lazy service lookup +} + +// NewCLIWorkflowHandler creates a new CLIWorkflowHandler with no registered commands. +func NewCLIWorkflowHandler() *CLIWorkflowHandler { + return &CLIWorkflowHandler{ + commands: make(map[string]*CLICommandDef), + runners: make(map[string]CLICommandFunc), + output: os.Stderr, + } +} + +// SetOutput overrides the writer used for usage/error messages (default os.Stderr). +// Useful in tests to capture output. +func (h *CLIWorkflowHandler) SetOutput(w io.Writer) { + h.output = w +} + +// RegisterCommand registers a Go function as the handler for a CLI command. +// The key must match either the command's Handler field (if set) or its Name. +// +// This is the simple/standalone path. When the full workflow engine is used, +// register functions in a module.CLICommandRegistry service instead so that +// step.cli_invoke can call them from within a pipeline. +func (h *CLIWorkflowHandler) RegisterCommand(key string, fn CLICommandFunc) { + h.runners[key] = fn +} + +// CanHandle returns true for the "cli" workflow type. +func (h *CLIWorkflowHandler) CanHandle(workflowType string) bool { + return workflowType == "cli" +} + +// ConfigureWorkflow stores the CLI workflow config and indexes commands by name. +// It also registers the handler as a service so that callers can retrieve it +// from the app service registry via CLIWorkflowHandlerServiceName. +// +// Calling ConfigureWorkflow more than once (e.g. during a hot-reload) is safe: +// the command index is fully rebuilt from the new config so that removed or +// renamed commands do not persist from a previous configuration. +func (h *CLIWorkflowHandler) ConfigureWorkflow(app modular.Application, workflowConfig any) error { + cfg, err := parseCLIWorkflowConfig(workflowConfig) + if err != nil { + return fmt.Errorf("cli workflow: %w", err) + } + h.config = cfg + + // Reinitialize the command index on every call so that removed/renamed + // commands from a previous configuration do not linger. + h.commands = make(map[string]*CLICommandDef) + for i := range cfg.Commands { + cmd := &cfg.Commands[i] + h.commands[cmd.Name] = cmd + } + + // Store app for lazy pipeline-dispatcher lookup in runCommand. + h.app = app + + // Register self so engine consumers can retrieve this handler by name. + // If a service under the same name already exists, only tolerate it when + // it is this same instance (idempotent re-registration); otherwise surface + // the error so misconfiguration is not silently hidden. + if app != nil { + if err := app.RegisterService(CLIWorkflowHandlerServiceName, h); err != nil { + var existing *CLIWorkflowHandler + if getErr := app.GetService(CLIWorkflowHandlerServiceName, &existing); getErr == nil && existing == h { + // Same instance already registered; idempotent, continue. + } else { + return fmt.Errorf("cli workflow: register service %q: %w", CLIWorkflowHandlerServiceName, err) + } + } + } + return nil +} + +// ExecuteWorkflow implements WorkflowHandler. The action is the command name; +// data["args"] may hold a []string of additional arguments. +// The provided context is threaded through to the pipeline dispatcher so that +// cancellation and tracing signals are preserved for programmatic invocations. +func (h *CLIWorkflowHandler) ExecuteWorkflow(ctx context.Context, _ string, action string, data map[string]any) (map[string]any, error) { + args, _ := data["args"].([]string) + if err := h.runCommand(ctx, action, args); err != nil { + return nil, err + } + return map[string]any{"success": true}, nil +} + +// Dispatch inspects args (typically os.Args[1:]) to choose and run a command +// using context.Background(). For cancellable dispatches (e.g., with +// os/signal), use DispatchContext instead. +func (h *CLIWorkflowHandler) Dispatch(args []string) error { + return h.DispatchContext(context.Background(), args) +} + +// DispatchContext is like Dispatch but accepts an explicit context, enabling +// cancellation (e.g. Ctrl+C via signal.NotifyContext) and tracing propagation. +func (h *CLIWorkflowHandler) DispatchContext(ctx context.Context, args []string) error { + if len(args) == 0 { + h.printUsage() + return fmt.Errorf("no command specified") + } + + cmd := args[0] + switch cmd { + case "-h", "--help", "help": + h.printUsage() + return nil + case "-v", "--version", "version": + version := "dev" + if h.config != nil && h.config.Version != "" { + version = h.config.Version + } + fmt.Fprintln(h.output, version) + return nil + } + + return h.runCommand(ctx, cmd, args[1:]) +} + +// runCommand looks up and calls the registered runner or pipeline dispatcher +// for the named command. ctx is threaded through to the pipeline dispatcher. +// +// Priority: +// 1. Directly registered Go runner (RegisterCommand). +// 2. Pipeline dispatch via CLIPipelineDispatcher (module.CLITrigger) found in +// the app service registry — used when commands are defined as pipelines in +// the workflow config. +func (h *CLIWorkflowHandler) runCommand(ctx context.Context, name string, args []string) error { + def, known := h.commands[name] + if !known { + fmt.Fprintf(h.output, "unknown command: %s\n\n", name) //nolint:gosec // G705 + h.printUsage() + return fmt.Errorf("unknown command: %s", name) + } + + handlerKey := def.Handler + if handlerKey == "" { + handlerKey = def.Name + } + + // Fast path: directly registered Go runner. + if fn, ok := h.runners[handlerKey]; ok { + return fn(args) + } + + // Fallback: pipeline dispatch via CLITrigger found in app services. + // Pass the caller's ctx so that cancellation and tracing are preserved. + if h.app != nil { + for _, svc := range h.app.SvcRegistry() { + if d, ok := svc.(CLIPipelineDispatcher); ok { + return d.DispatchCommand(ctx, name, args) + } + } + } + + return fmt.Errorf("no runner registered for command %q (handler key: %q)", name, handlerKey) +} + +// printUsage writes the CLI usage message to the configured output writer. +func (h *CLIWorkflowHandler) printUsage() { + appName := "app" + description := "" + version := "dev" + if h.config != nil { + if h.config.Name != "" { + appName = h.config.Name + } + if h.config.Description != "" { + description = h.config.Description + } + if h.config.Version != "" { + version = h.config.Version + } + } + + fmt.Fprintf(h.output, "%s - %s (version %s)\n\nUsage:\n %s [options]\n\nCommands:\n", + appName, description, version, appName) + + // Print commands in sorted order for deterministic output. + names := make([]string, 0, len(h.commands)) + for n := range h.commands { + names = append(names, n) + } + sort.Strings(names) + + // Calculate max name width for alignment. + maxWidth := 0 + for _, n := range names { + if len(n) > maxWidth { + maxWidth = len(n) + } + } + + for _, n := range names { + def := h.commands[n] + padding := strings.Repeat(" ", maxWidth-len(n)) + fmt.Fprintf(h.output, " %s%s %s\n", n, padding, def.Description) + } + + fmt.Fprintf(h.output, "\nRun '%s -h' for command-specific help.\n", appName) +} + +// parseCLIWorkflowConfig converts the raw workflow config (map[string]any) to +// a CLIWorkflowConfig. It accepts either the map representation produced by +// YAML unmarshalling or a pre-typed *CLIWorkflowConfig. +func parseCLIWorkflowConfig(raw any) (*CLIWorkflowConfig, error) { + if raw == nil { + return &CLIWorkflowConfig{}, nil + } + + if cfg, ok := raw.(*CLIWorkflowConfig); ok { + return cfg, nil + } + + cfgMap, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid cli workflow configuration type: %T", raw) + } + + cfg := &CLIWorkflowConfig{} + cfg.Name, _ = cfgMap["name"].(string) + cfg.Version, _ = cfgMap["version"].(string) + cfg.Description, _ = cfgMap["description"].(string) + + if rawCmds, ok := cfgMap["commands"].([]any); ok { + seen := make(map[string]struct{}, len(rawCmds)) + for i, rc := range rawCmds { + cmdMap, ok := rc.(map[string]any) + if !ok { + return nil, fmt.Errorf("command at index %d is not a map", i) + } + def := CLICommandDef{} + def.Name, _ = cmdMap["name"].(string) + def.Description, _ = cmdMap["description"].(string) + def.Handler, _ = cmdMap["handler"].(string) + if def.Name == "" { + return nil, fmt.Errorf("command at index %d has an empty name", i) + } + if _, dup := seen[def.Name]; dup { + return nil, fmt.Errorf("duplicate command name %q at index %d", def.Name, i) + } + seen[def.Name] = struct{}{} + cfg.Commands = append(cfg.Commands, def) + } + } + + return cfg, nil +} diff --git a/handlers/cli_test.go b/handlers/cli_test.go new file mode 100644 index 00000000..37f54a5f --- /dev/null +++ b/handlers/cli_test.go @@ -0,0 +1,434 @@ +package handlers + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/CrisisTextLine/modular" +) + +func TestCLIWorkflowHandler_CanHandle(t *testing.T) { + h := NewCLIWorkflowHandler() + if !h.CanHandle("cli") { + t.Error("expected CanHandle(\"cli\") to return true") + } + if h.CanHandle("http") { + t.Error("expected CanHandle(\"http\") to return false") + } + if h.CanHandle("") { + t.Error("expected CanHandle(\"\") to return false") + } +} + +func TestCLIWorkflowHandler_ConfigureAndDispatch(t *testing.T) { + h := NewCLIWorkflowHandler() + + var buf bytes.Buffer + h.SetOutput(&buf) + + called := "" + h.RegisterCommand("validate", func(args []string) error { + called = "validate" + return nil + }) + h.RegisterCommand("inspect", func(args []string) error { + called = "inspect" + return nil + }) + + cfg := map[string]any{ + "name": "wfctl", + "version": "1.0.0", + "description": "Workflow CLI", + "commands": []any{ + map[string]any{"name": "validate", "description": "Validate a config"}, + map[string]any{"name": "inspect", "description": "Inspect a config"}, + }, + } + + if err := h.ConfigureWorkflow(nil, cfg); err != nil { + t.Fatalf("ConfigureWorkflow failed: %v", err) + } + + if err := h.Dispatch([]string{"validate", "myfile.yaml"}); err != nil { + t.Fatalf("Dispatch(validate) failed: %v", err) + } + if called != "validate" { + t.Errorf("expected validate handler to be called, got %q", called) + } + + if err := h.Dispatch([]string{"inspect"}); err != nil { + t.Fatalf("Dispatch(inspect) failed: %v", err) + } + if called != "inspect" { + t.Errorf("expected inspect handler to be called, got %q", called) + } +} + +func TestCLIWorkflowHandler_UnknownCommand(t *testing.T) { + h := NewCLIWorkflowHandler() + var buf bytes.Buffer + h.SetOutput(&buf) + + if err := h.ConfigureWorkflow(nil, map[string]any{ + "name": "wfctl", + "commands": []any{}, + }); err != nil { + t.Fatalf("ConfigureWorkflow failed: %v", err) + } + + err := h.Dispatch([]string{"noexist"}) + if err == nil { + t.Fatal("expected error for unknown command") + } + if !strings.Contains(err.Error(), "unknown command") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestCLIWorkflowHandler_NoCommand(t *testing.T) { + h := NewCLIWorkflowHandler() + var buf bytes.Buffer + h.SetOutput(&buf) + + if err := h.ConfigureWorkflow(nil, nil); err != nil { + t.Fatalf("ConfigureWorkflow failed: %v", err) + } + + err := h.Dispatch([]string{}) + if err == nil { + t.Fatal("expected error when no command given") + } +} + +func TestCLIWorkflowHandler_HelpFlag(t *testing.T) { + h := NewCLIWorkflowHandler() + var buf bytes.Buffer + h.SetOutput(&buf) + + cfg := map[string]any{ + "name": "wfctl", + "version": "1.2.3", + "commands": []any{ + map[string]any{"name": "validate", "description": "Validate config"}, + }, + } + if err := h.ConfigureWorkflow(nil, cfg); err != nil { + t.Fatalf("ConfigureWorkflow failed: %v", err) + } + + for _, flag := range []string{"-h", "--help", "help"} { + buf.Reset() + if err := h.Dispatch([]string{flag}); err != nil { + t.Errorf("Dispatch(%q) returned error: %v", flag, err) + } + if !strings.Contains(buf.String(), "wfctl") { + t.Errorf("usage output missing app name for flag %q: %s", flag, buf.String()) + } + } +} + +func TestCLIWorkflowHandler_VersionFlag(t *testing.T) { + h := NewCLIWorkflowHandler() + var buf bytes.Buffer + h.SetOutput(&buf) + + if err := h.ConfigureWorkflow(nil, map[string]any{ + "name": "wfctl", + "version": "2.0.0", + }); err != nil { + t.Fatalf("ConfigureWorkflow failed: %v", err) + } + + for _, flag := range []string{"-v", "--version", "version"} { + buf.Reset() + if err := h.Dispatch([]string{flag}); err != nil { + t.Errorf("Dispatch(%q) returned error: %v", flag, err) + } + if !strings.Contains(buf.String(), "2.0.0") { + t.Errorf("version output missing version for flag %q: %s", flag, buf.String()) + } + } +} + +func TestCLIWorkflowHandler_HandlerKey(t *testing.T) { + h := NewCLIWorkflowHandler() + var buf bytes.Buffer + h.SetOutput(&buf) + + // Register runner under the explicit handler key, not the command name. + called := false + h.RegisterCommand("my-runner", func(args []string) error { + called = true + return nil + }) + + if err := h.ConfigureWorkflow(nil, map[string]any{ + "name": "app", + "commands": []any{ + map[string]any{"name": "do-thing", "handler": "my-runner"}, + }, + }); err != nil { + t.Fatalf("ConfigureWorkflow failed: %v", err) + } + + if err := h.Dispatch([]string{"do-thing"}); err != nil { + t.Fatalf("Dispatch failed: %v", err) + } + if !called { + t.Error("expected my-runner to be called") + } +} + +func TestCLIWorkflowHandler_CommandReturnsError(t *testing.T) { + h := NewCLIWorkflowHandler() + var buf bytes.Buffer + h.SetOutput(&buf) + + sentinel := errors.New("command failed") + h.RegisterCommand("fail", func(args []string) error { return sentinel }) + + if err := h.ConfigureWorkflow(nil, map[string]any{ + "name": "app", + "commands": []any{ + map[string]any{"name": "fail"}, + }, + }); err != nil { + t.Fatalf("ConfigureWorkflow failed: %v", err) + } + + if err := h.Dispatch([]string{"fail"}); !errors.Is(err, sentinel) { + t.Errorf("expected sentinel error, got: %v", err) + } +} + +func TestCLIWorkflowHandler_ExecuteWorkflow(t *testing.T) { + h := NewCLIWorkflowHandler() + var buf bytes.Buffer + h.SetOutput(&buf) + + called := false + h.RegisterCommand("ping", func(args []string) error { + called = true + return nil + }) + + if err := h.ConfigureWorkflow(nil, map[string]any{ + "name": "app", + "commands": []any{ + map[string]any{"name": "ping"}, + }, + }); err != nil { + t.Fatalf("ConfigureWorkflow failed: %v", err) + } + + result, err := h.ExecuteWorkflow(context.Background(), "cli", "ping", map[string]any{ + "args": []string{}, + }) + if err != nil { + t.Fatalf("ExecuteWorkflow failed: %v", err) + } + if !called { + t.Error("expected ping runner to be called") + } + if result["success"] != true { + t.Errorf("expected success=true, got %v", result["success"]) + } +} + +func TestCLIWorkflowHandler_InvalidConfig(t *testing.T) { + h := NewCLIWorkflowHandler() + err := h.ConfigureWorkflow(nil, 42) // invalid type + if err == nil { + t.Fatal("expected error for invalid config type") + } +} + +// TestCLIWorkflowHandler_PipelineDispatch verifies that CLIWorkflowHandler falls +// back to CLIPipelineDispatcher when no direct Go runner is registered. +func TestCLIWorkflowHandler_PipelineDispatch(t *testing.T) { + h := NewCLIWorkflowHandler() + var buf bytes.Buffer + h.SetOutput(&buf) + + app := modular.NewStdApplication(nil, nil) + + // Configure the handler with a command that has no direct runner. + if err := h.ConfigureWorkflow(app, map[string]any{ + "name": "app", + "commands": []any{ + map[string]any{"name": "deploy", "description": "Deploy something"}, + }, + }); err != nil { + t.Fatalf("ConfigureWorkflow failed: %v", err) + } + + // Register a CLIPipelineDispatcher service in the app. + dispatched := "" + dispatcher := &mockCLIPipelineDispatcher{ + dispatch: func(ctx context.Context, cmd string, args []string) error { + dispatched = cmd + return nil + }, + } + if err := app.RegisterService("cliTrigger", dispatcher); err != nil { + t.Fatalf("RegisterService failed: %v", err) + } + + if err := h.Dispatch([]string{"deploy", "--env", "prod"}); err != nil { + t.Fatalf("Dispatch failed: %v", err) + } + if dispatched != "deploy" { + t.Errorf("expected deploy to be dispatched, got %q", dispatched) + } +} + +// TestCLIWorkflowHandler_RegisterServiceOnConfigure verifies the handler +// registers itself as CLIWorkflowHandlerServiceName in ConfigureWorkflow. +func TestCLIWorkflowHandler_RegisterServiceOnConfigure(t *testing.T) { + h := NewCLIWorkflowHandler() + app := modular.NewStdApplication(nil, nil) + + if err := h.ConfigureWorkflow(app, map[string]any{"name": "app"}); err != nil { + t.Fatalf("ConfigureWorkflow failed: %v", err) + } + var registered *CLIWorkflowHandler + if err := app.GetService(CLIWorkflowHandlerServiceName, ®istered); err != nil { + t.Errorf("expected handler registered as service, got error: %v", err) + } + if registered != h { + t.Error("expected registered service to be the handler itself") + } +} + +// mockCLIPipelineDispatcher is a test double for CLIPipelineDispatcher. +type mockCLIPipelineDispatcher struct { + dispatch func(ctx context.Context, cmd string, args []string) error +} + +func (m *mockCLIPipelineDispatcher) DispatchCommand(ctx context.Context, cmd string, args []string) error { + return m.dispatch(ctx, cmd, args) +} + +// TestCLIWorkflowHandler_CtxThreaded verifies that the context passed to +// ExecuteWorkflow and DispatchContext reaches the CLIPipelineDispatcher. +func TestCLIWorkflowHandler_CtxThreaded(t *testing.T) { + h := NewCLIWorkflowHandler() + var buf bytes.Buffer + h.SetOutput(&buf) + + app := modular.NewStdApplication(nil, nil) + if err := h.ConfigureWorkflow(app, map[string]any{ + "name": "app", + "commands": []any{ + map[string]any{"name": "run"}, + }, + }); err != nil { + t.Fatalf("ConfigureWorkflow failed: %v", err) + } + + type ctxKey struct{} + sentinelCtx := context.WithValue(context.Background(), ctxKey{}, "sentinel") + var receivedCtx context.Context + dispatcher := &mockCLIPipelineDispatcher{ + dispatch: func(ctx context.Context, cmd string, args []string) error { + receivedCtx = ctx + return nil + }, + } + if err := app.RegisterService("cliTrigger", dispatcher); err != nil { + t.Fatalf("RegisterService failed: %v", err) + } + + // Via DispatchContext — ctx should be threaded through. + if err := h.DispatchContext(sentinelCtx, []string{"run"}); err != nil { + t.Fatalf("DispatchContext failed: %v", err) + } + if receivedCtx.Value(ctxKey{}) != "sentinel" { + t.Error("expected sentinel context to be threaded through DispatchContext") + } + + // Via ExecuteWorkflow — ctx should also be threaded through. + receivedCtx = nil + if _, err := h.ExecuteWorkflow(sentinelCtx, "cli", "run", map[string]any{}); err != nil { + t.Fatalf("ExecuteWorkflow failed: %v", err) + } + if receivedCtx.Value(ctxKey{}) != "sentinel" { + t.Error("expected sentinel context to be threaded through ExecuteWorkflow") + } +} + +// TestCLIWorkflowHandler_ConfigureWorkflow_Reconfigure verifies that calling +// ConfigureWorkflow a second time replaces the command index cleanly. +func TestCLIWorkflowHandler_ConfigureWorkflow_Reconfigure(t *testing.T) { + h := NewCLIWorkflowHandler() + called := false + h.RegisterCommand("new-cmd", func(args []string) error { called = true; return nil }) + + // First configure: adds "old-cmd" + if err := h.ConfigureWorkflow(nil, map[string]any{ + "name": "app", + "commands": []any{map[string]any{"name": "old-cmd"}}, + }); err != nil { + t.Fatalf("first ConfigureWorkflow failed: %v", err) + } + + // Second configure: only has "new-cmd" — "old-cmd" should disappear. + if err := h.ConfigureWorkflow(nil, map[string]any{ + "name": "app", + "commands": []any{map[string]any{"name": "new-cmd"}}, + }); err != nil { + t.Fatalf("second ConfigureWorkflow failed: %v", err) + } + + var errBuf bytes.Buffer + h.SetOutput(&errBuf) + // "old-cmd" must no longer be reachable. + if err := h.Dispatch([]string{"old-cmd"}); err == nil { + t.Error("expected error for old-cmd after reconfigure") + } + // "new-cmd" must work. + if err := h.Dispatch([]string{"new-cmd"}); err != nil { + t.Fatalf("Dispatch(new-cmd) failed: %v", err) + } + if !called { + t.Error("expected new-cmd runner to be called") + } +} + +// TestCLIWorkflowHandler_DuplicateCommandName verifies that parseCLIWorkflowConfig +// returns an error when two commands share the same name. +func TestCLIWorkflowHandler_DuplicateCommandName(t *testing.T) { + h := NewCLIWorkflowHandler() + err := h.ConfigureWorkflow(nil, map[string]any{ + "name": "app", + "commands": []any{ + map[string]any{"name": "validate"}, + map[string]any{"name": "validate"}, // duplicate + }, + }) + if err == nil { + t.Fatal("expected error for duplicate command name") + } + if !strings.Contains(err.Error(), "duplicate") { + t.Errorf("expected 'duplicate' in error message, got: %v", err) + } +} + +// TestCLIWorkflowHandler_EmptyCommandName verifies that parseCLIWorkflowConfig +// rejects commands with an empty name. +func TestCLIWorkflowHandler_EmptyCommandName(t *testing.T) { + h := NewCLIWorkflowHandler() + err := h.ConfigureWorkflow(nil, map[string]any{ + "name": "app", + "commands": []any{ + map[string]any{"name": ""}, // empty name + }, + }) + if err == nil { + t.Fatal("expected error for empty command name") + } +} diff --git a/module/cli_command_registry.go b/module/cli_command_registry.go new file mode 100644 index 00000000..de72ac47 --- /dev/null +++ b/module/cli_command_registry.go @@ -0,0 +1,42 @@ +package module + +// CLICommandFunc is the type for CLI command implementations. It matches the +// signature used by wfctl command handlers (func(args []string) error). +type CLICommandFunc func(args []string) error + +// CLICommandRegistryServiceName is the well-known service name used to register +// CLICommandRegistry in the application service registry so that step.cli_invoke +// can locate it at execution time. +const CLICommandRegistryServiceName = "cliCommandRegistry" + +// CLICommandRegistry is a shared service that maps CLI command names to their +// Go function implementations. It is registered in the app under the name +// CLICommandRegistryServiceName before BuildFromConfig is called so that +// step.cli_invoke can look up and invoke the correct function at execution time. +// +// Usage in cmd/wfctl/main.go: +// +// registry := module.NewCLICommandRegistry() +// for name, fn := range commands { +// registry.Register(name, module.CLICommandFunc(fn)) +// } +// engineInst.App().RegisterService(module.CLICommandRegistryServiceName, registry) +type CLICommandRegistry struct { + runners map[string]CLICommandFunc +} + +// NewCLICommandRegistry creates a new empty CLICommandRegistry. +func NewCLICommandRegistry() *CLICommandRegistry { + return &CLICommandRegistry{runners: make(map[string]CLICommandFunc)} +} + +// Register adds or replaces the function for the named command. +func (r *CLICommandRegistry) Register(name string, fn CLICommandFunc) { + r.runners[name] = fn +} + +// Get returns the function registered for name, if any. +func (r *CLICommandRegistry) Get(name string) (CLICommandFunc, bool) { + fn, ok := r.runners[name] + return fn, ok +} diff --git a/module/cli_trigger.go b/module/cli_trigger.go new file mode 100644 index 00000000..6a5936f3 --- /dev/null +++ b/module/cli_trigger.go @@ -0,0 +1,154 @@ +package module + +import ( + "context" + "fmt" + + "github.com/CrisisTextLine/modular" +) + +// CLITriggerName is the canonical name / service key for the CLI trigger. +const CLITriggerName = "trigger.cli" + +// CLITrigger maps CLI command names to workflow pipelines. It implements the +// interfaces.Trigger contract so it can be registered with the engine via +// engine.RegisterTrigger() or DefaultTriggers(). +// +// In a workflow config file, CLI commands are wired to pipelines via the "cli" +// pipeline trigger type: +// +// pipelines: +// cmd-validate: +// trigger: +// type: cli +// config: +// command: validate +// steps: +// - name: run +// type: step.cli_invoke +// config: +// command: validate +// +// The engine's wrapPipelineTriggerConfig helper enriches the flat config with a +// "workflowType" key before calling Configure, so the trigger receives: +// +// {command: "validate", workflowType: "pipeline:cmd-validate"} +type CLITrigger struct { + name string + commands map[string]string // command name → "pipeline:" + engine WorkflowEngine +} + +// NewCLITrigger creates a new CLITrigger. +func NewCLITrigger() *CLITrigger { + return &CLITrigger{ + name: CLITriggerName, + commands: make(map[string]string), + } +} + +// Name returns the trigger's canonical name ("trigger.cli"). +func (t *CLITrigger) Name() string { return t.name } + +// Dependencies returns nil — the CLI trigger has no module dependencies. +func (t *CLITrigger) Dependencies() []string { return nil } + +// Init is a no-op; triggers registered via engine.RegisterTrigger are not +// Init-ed through the module system. Registration as a service happens in +// Configure, which IS called during engine configuration. +func (t *CLITrigger) Init(_ modular.Application) error { return nil } + +// Configure processes a single command→pipeline mapping. It is called once per +// pipeline that carries a "cli" inline trigger config. The enriched config map +// must contain: +// +// command — the CLI command name (e.g. "validate") +// workflowType — the pipeline workflow identifier (e.g. "pipeline:cmd-validate") +// +// Configure also registers the trigger as the "trigger.cli" service so that +// CLIWorkflowHandler can discover it via the application service registry. +func (t *CLITrigger) Configure(app modular.Application, triggerConfig any) error { + cfg, ok := triggerConfig.(map[string]any) + if !ok { + return fmt.Errorf("cli trigger: invalid config type %T (expected map[string]any)", triggerConfig) + } + + // Register as a service so CLIWorkflowHandler can find us lazily. + // If a service under the same name already exists, tolerate it only when + // the existing entry is this same *CLITrigger instance (idempotent + // re-registration across multiple pipeline Configure calls). A different + // instance occupying the slot is a configuration error. + if err := app.RegisterService(t.name, t); err != nil { + sameInstance := false + for _, svc := range app.SvcRegistry() { + if existing, ok := svc.(*CLITrigger); ok && existing == t { + sameInstance = true + break + } + } + if !sameInstance { + return fmt.Errorf("cli trigger: registering service %q: %w", t.name, err) + } + } + + // Find the workflow engine in app services (engine registers itself as + // "workflowEngine" during configureTriggers, before configurePipelines runs). + if t.engine == nil { + for _, svc := range app.SvcRegistry() { + if e, ok := svc.(WorkflowEngine); ok { + t.engine = e + break + } + } + } + + command, _ := cfg["command"].(string) + workflowType, _ := cfg["workflowType"].(string) + + if command == "" { + return fmt.Errorf("cli trigger: 'command' is required in trigger config") + } + if workflowType == "" { + return fmt.Errorf("cli trigger: 'workflowType' is required in trigger config (injected by the engine)") + } + + // Prevent ambiguous CLI routing: reject a different workflowType for a + // command that is already registered. Re-registering the exact same + // mapping (idempotent from hot-reload) is allowed. + if existing, ok := t.commands[command]; ok && existing != workflowType { + return fmt.Errorf("cli trigger: command %q already registered for workflow %q (cannot re-register for %q)", command, existing, workflowType) + } + t.commands[command] = workflowType + return nil +} + +// Start is a no-op for the CLI trigger — CLI commands are dispatched +// synchronously by the application, not by a background goroutine. +func (t *CLITrigger) Start(_ context.Context) error { return nil } + +// Stop is a no-op for the CLI trigger. +func (t *CLITrigger) Stop(_ context.Context) error { return nil } + +// DispatchCommand invokes TriggerWorkflow for the named CLI command, passing +// the original command name and its arguments as trigger data. The pipeline +// context will expose these values as pc.Current["command"] and +// pc.Current["args"] respectively. +func (t *CLITrigger) DispatchCommand(ctx context.Context, cmd string, args []string) error { + workflowType, ok := t.commands[cmd] + if !ok { + return fmt.Errorf("cli trigger: no pipeline registered for command %q", cmd) + } + if t.engine == nil { + return fmt.Errorf("cli trigger: workflow engine not available (engine was not registered as a service)") + } + return t.engine.TriggerWorkflow(ctx, workflowType, "", map[string]any{ + "command": cmd, + "args": args, + }) +} + +// HasCommand returns true if a pipeline mapping is registered for cmd. +func (t *CLITrigger) HasCommand(cmd string) bool { + _, ok := t.commands[cmd] + return ok +} diff --git a/module/cli_trigger_test.go b/module/cli_trigger_test.go new file mode 100644 index 00000000..7d9c8caf --- /dev/null +++ b/module/cli_trigger_test.go @@ -0,0 +1,183 @@ +package module + +import ( + "context" + "testing" +) + +// mockCLIEngine is a minimal WorkflowEngine for testing CLITrigger. +type mockCLIEngine struct { + calls []cliTriggerCall +} + +type cliTriggerCall struct { + workflowType string + action string + data map[string]any +} + +func (m *mockCLIEngine) TriggerWorkflow(_ context.Context, workflowType, action string, data map[string]any) error { + m.calls = append(m.calls, cliTriggerCall{workflowType: workflowType, action: action, data: data}) + return nil +} + +func TestCLITrigger_Name(t *testing.T) { + tr := NewCLITrigger() + if tr.Name() != CLITriggerName { + t.Errorf("expected %q, got %q", CLITriggerName, tr.Name()) + } +} + +func TestCLITrigger_StartStop(t *testing.T) { + tr := NewCLITrigger() + if err := tr.Start(context.Background()); err != nil { + t.Errorf("Start should be no-op, got error: %v", err) + } + if err := tr.Stop(context.Background()); err != nil { + t.Errorf("Stop should be no-op, got error: %v", err) + } +} + +func TestCLITrigger_Configure_AndDispatch(t *testing.T) { + engine := &mockCLIEngine{} + app := NewMockApplication() + app.Services["workflowEngine"] = engine + + tr := NewCLITrigger() + + cfg := map[string]any{ + "command": "validate", + "workflowType": "pipeline:cmd-validate", + } + if err := tr.Configure(app, cfg); err != nil { + t.Fatalf("Configure failed: %v", err) + } + + if _, ok := app.Services[CLITriggerName]; !ok { + t.Error("expected CLITrigger to register itself as service") + } + if !tr.HasCommand("validate") { + t.Error("expected HasCommand(\"validate\") == true") + } + if err := tr.DispatchCommand(context.Background(), "validate", []string{"cfg.yaml"}); err != nil { + t.Fatalf("DispatchCommand failed: %v", err) + } + if len(engine.calls) != 1 { + t.Fatalf("expected 1 engine call, got %d", len(engine.calls)) + } + c := engine.calls[0] + if c.workflowType != "pipeline:cmd-validate" { + t.Errorf("unexpected workflowType %q", c.workflowType) + } + if c.data["command"] != "validate" { + t.Errorf("expected data.command=validate, got %v", c.data["command"]) + } + if args, ok := c.data["args"].([]string); !ok || len(args) != 1 || args[0] != "cfg.yaml" { + t.Errorf("unexpected data.args: %v", c.data["args"]) + } +} + +func TestCLITrigger_Configure_MultipleCalls(t *testing.T) { + engine := &mockCLIEngine{} + app := NewMockApplication() + app.Services["workflowEngine"] = engine + tr := NewCLITrigger() + + for _, cmd := range []string{"validate", "inspect", "run"} { + cfg := map[string]any{ + "command": cmd, + "workflowType": "pipeline:cmd-" + cmd, + } + if err := tr.Configure(app, cfg); err != nil { + t.Fatalf("Configure(%s) failed: %v", cmd, err) + } + } + for _, cmd := range []string{"validate", "inspect", "run"} { + if !tr.HasCommand(cmd) { + t.Errorf("expected HasCommand(%q) == true", cmd) + } + } +} + +func TestCLITrigger_DispatchUnknownCommand(t *testing.T) { + engine := &mockCLIEngine{} + tr := NewCLITrigger() + tr.engine = engine + err := tr.DispatchCommand(context.Background(), "unknowncmd", nil) + if err == nil { + t.Fatal("expected error for unknown command") + } +} + +func TestCLITrigger_Configure_InvalidConfig(t *testing.T) { + tr := NewCLITrigger() + if err := tr.Configure(NewMockApplication(), 42); err == nil { + t.Fatal("expected error for invalid config type") + } +} + +func TestCLITrigger_Configure_MissingCommand(t *testing.T) { + tr := NewCLITrigger() + err := tr.Configure(NewMockApplication(), map[string]any{ + "workflowType": "pipeline:foo", + }) + if err == nil { + t.Fatal("expected error for missing command") + } +} + +func TestCLITrigger_Configure_MissingWorkflowType(t *testing.T) { + tr := NewCLITrigger() + err := tr.Configure(NewMockApplication(), map[string]any{ + "command": "validate", + }) + if err == nil { + t.Fatal("expected error for missing workflowType") + } +} + +// TestCLITrigger_Configure_DuplicateCommandConflict verifies that registering +// two different workflow types for the same command returns an error. +func TestCLITrigger_Configure_DuplicateCommandConflict(t *testing.T) { + engine := &mockCLIEngine{} + app := NewMockApplication() + app.Services["workflowEngine"] = engine + tr := NewCLITrigger() + + if err := tr.Configure(app, map[string]any{ + "command": "validate", + "workflowType": "pipeline:cmd-validate", + }); err != nil { + t.Fatalf("first Configure failed: %v", err) + } + + // Same command, different workflowType → must error. + err := tr.Configure(app, map[string]any{ + "command": "validate", + "workflowType": "pipeline:cmd-other", + }) + if err == nil { + t.Fatal("expected error for conflicting command registration") + } +} + +// TestCLITrigger_Configure_IdempotentReregistration verifies that registering +// the same command→workflowType mapping twice is allowed (idempotent). +func TestCLITrigger_Configure_IdempotentReregistration(t *testing.T) { + engine := &mockCLIEngine{} + app := NewMockApplication() + app.Services["workflowEngine"] = engine + tr := NewCLITrigger() + + cfg := map[string]any{ + "command": "validate", + "workflowType": "pipeline:cmd-validate", + } + if err := tr.Configure(app, cfg); err != nil { + t.Fatalf("first Configure failed: %v", err) + } + // Exact same mapping again — must not error. + if err := tr.Configure(app, cfg); err != nil { + t.Fatalf("idempotent Configure failed: %v", err) + } +} diff --git a/module/pipeline_step_cli_invoke.go b/module/pipeline_step_cli_invoke.go new file mode 100644 index 00000000..0724ed20 --- /dev/null +++ b/module/pipeline_step_cli_invoke.go @@ -0,0 +1,115 @@ +package module + +import ( + "context" + "fmt" + + "github.com/CrisisTextLine/modular" +) + +// CLIInvokeStep calls a registered Go CLI command function from within a +// pipeline. It looks up the CLICommandRegistry service in the application +// and invokes the function registered under the configured command name. +// +// Step type: step.cli_invoke +// +// Config fields: +// - command (required) — the command name as registered in CLICommandRegistry +// +// Pipeline context inputs: +// - args ([]string) — forwarded as the args parameter to the Go function; +// passed from the CLI trigger data as pc.Current["args"] +// +// Step output: +// - command (string) — the command name that was executed +// - success (bool) — always true on success (error path returns a non-nil error) +type CLIInvokeStep struct { + name string + commandName string + app modular.Application +} + +// NewCLIInvokeStepFactory returns a StepFactory that creates CLIInvokeStep instances. +func NewCLIInvokeStepFactory() StepFactory { + return func(name string, config map[string]any, app modular.Application) (PipelineStep, error) { + commandName, _ := config["command"].(string) + if commandName == "" { + return nil, fmt.Errorf("cli_invoke step %q: 'command' is required", name) + } + return &CLIInvokeStep{ + name: name, + commandName: commandName, + app: app, + }, nil + } +} + +// Name returns the step name. +func (s *CLIInvokeStep) Name() string { return s.name } + +// Execute resolves the CLICommandRegistry service, looks up the configured +// command, extracts args from the pipeline context, and calls the function. +func (s *CLIInvokeStep) Execute(_ context.Context, pc *PipelineContext) (*StepResult, error) { + registry, err := s.resolveRegistry() + if err != nil { + return nil, fmt.Errorf("cli_invoke step %q: %w", s.name, err) + } + + fn, ok := registry.Get(s.commandName) + if !ok { + return nil, fmt.Errorf("cli_invoke step %q: no runner registered for command %q", s.name, s.commandName) + } + + // Extract args from the pipeline context (injected by CLITrigger.DispatchCommand). + // The value may be []string (from a direct Go call) or []any (when trigger + // data was round-tripped through JSON/YAML decoding). Both are accepted. + var args []string + switch v := pc.Current["args"].(type) { + case []string: + args = v + case []any: + args = make([]string, 0, len(v)) + for i, a := range v { + str, ok := a.(string) + if !ok { + return nil, fmt.Errorf("cli_invoke step %q: args[%d] is not a string (got %T)", s.name, i, a) + } + args = append(args, str) + } + } + + if err := fn(args); err != nil { + return nil, fmt.Errorf("cli_invoke step %q (command %q): %w", s.name, s.commandName, err) + } + + return &StepResult{Output: map[string]any{ + "command": s.commandName, + "success": true, + }}, nil +} + +// resolveRegistry returns the CLICommandRegistry from the app service registry. +// It first tries the well-known service name, then falls back to scanning all +// services for a *CLICommandRegistry value. +func (s *CLIInvokeStep) resolveRegistry() (*CLICommandRegistry, error) { + if s.app == nil { + return nil, fmt.Errorf("application is nil; CLICommandRegistry cannot be resolved") + } + + var registry *CLICommandRegistry + if err := s.app.GetService(CLICommandRegistryServiceName, ®istry); err == nil && registry != nil { + return registry, nil + } + + // Fallback: scan all services. + for _, svc := range s.app.SvcRegistry() { + if r, ok := svc.(*CLICommandRegistry); ok { + return r, nil + } + } + + return nil, fmt.Errorf("CLICommandRegistry service %q not found in app — "+ + "register one via app.RegisterService(%q, module.NewCLICommandRegistry()) "+ + "before calling engine.BuildFromConfig", + CLICommandRegistryServiceName, CLICommandRegistryServiceName) +} diff --git a/module/pipeline_step_cli_invoke_test.go b/module/pipeline_step_cli_invoke_test.go new file mode 100644 index 00000000..50f78e48 --- /dev/null +++ b/module/pipeline_step_cli_invoke_test.go @@ -0,0 +1,204 @@ +package module + +import ( + "context" + "errors" + "testing" +) + +func TestCLIInvokeStep_Basic(t *testing.T) { + registry := NewCLICommandRegistry() + called := "" + registry.Register("validate", func(args []string) error { + called = "validate" + return nil + }) + + app := NewMockApplication() + app.Services[CLICommandRegistryServiceName] = registry + + factory := NewCLIInvokeStepFactory() + step, err := factory("invoke", map[string]any{"command": "validate"}, app) + if err != nil { + t.Fatalf("factory failed: %v", err) + } + + pc := NewPipelineContext(map[string]any{"args": []string{"cfg.yaml"}}, nil) + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + if called != "validate" { + t.Error("expected validate runner to be called") + } + if result.Output["success"] != true { + t.Errorf("expected success=true, got %v", result.Output["success"]) + } + if result.Output["command"] != "validate" { + t.Errorf("expected command=validate, got %v", result.Output["command"]) + } +} + +func TestCLIInvokeStep_CommandError(t *testing.T) { + sentinel := errors.New("validate failed") + registry := NewCLICommandRegistry() + registry.Register("validate", func(args []string) error { return sentinel }) + + app := NewMockApplication() + app.Services[CLICommandRegistryServiceName] = registry + + factory := NewCLIInvokeStepFactory() + step, err := factory("invoke", map[string]any{"command": "validate"}, app) + if err != nil { + t.Fatalf("factory failed: %v", err) + } + + _, err = step.Execute(context.Background(), NewPipelineContext(nil, nil)) + if !errors.Is(err, sentinel) { + t.Errorf("expected sentinel error, got: %v", err) + } +} + +func TestCLIInvokeStep_UnknownCommand(t *testing.T) { + registry := NewCLICommandRegistry() + app := NewMockApplication() + app.Services[CLICommandRegistryServiceName] = registry + + factory := NewCLIInvokeStepFactory() + step, err := factory("invoke", map[string]any{"command": "nonexistent"}, app) + if err != nil { + t.Fatalf("factory failed: %v", err) + } + + _, err = step.Execute(context.Background(), NewPipelineContext(nil, nil)) + if err == nil { + t.Fatal("expected error for unknown command") + } +} + +func TestCLIInvokeStep_NoRegistry(t *testing.T) { + app := NewMockApplication() // registry not registered + + factory := NewCLIInvokeStepFactory() + step, err := factory("invoke", map[string]any{"command": "validate"}, app) + if err != nil { + t.Fatalf("factory failed: %v", err) + } + + _, err = step.Execute(context.Background(), NewPipelineContext(nil, nil)) + if err == nil { + t.Fatal("expected error when registry not found") + } +} + +func TestCLIInvokeStep_RegistryFallbackScan(t *testing.T) { + registry := NewCLICommandRegistry() + called := false + registry.Register("validate", func(args []string) error { + called = true + return nil + }) + + app := NewMockApplication() + // Register under a non-standard name to test fallback scan. + app.Services["customRegistryKey"] = registry + + factory := NewCLIInvokeStepFactory() + step, err := factory("invoke", map[string]any{"command": "validate"}, app) + if err != nil { + t.Fatalf("factory failed: %v", err) + } + + _, err = step.Execute(context.Background(), NewPipelineContext(nil, nil)) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + if !called { + t.Error("expected validate to be called via fallback scan") + } +} + +func TestCLIInvokeStep_MissingCommand(t *testing.T) { + factory := NewCLIInvokeStepFactory() + _, err := factory("invoke", map[string]any{}, nil) + if err == nil { + t.Fatal("expected error for missing command config") + } +} + +func TestCLIInvokeStep_ArgsPassthrough(t *testing.T) { + registry := NewCLICommandRegistry() + var receivedArgs []string + registry.Register("deploy", func(args []string) error { + receivedArgs = args + return nil + }) + + app := NewMockApplication() + app.Services[CLICommandRegistryServiceName] = registry + + factory := NewCLIInvokeStepFactory() + step, err := factory("invoke", map[string]any{"command": "deploy"}, app) + if err != nil { + t.Fatalf("factory failed: %v", err) + } + + pc := NewPipelineContext(map[string]any{"args": []string{"--env", "prod", "myapp"}}, nil) + if _, err := step.Execute(context.Background(), pc); err != nil { + t.Fatalf("Execute failed: %v", err) + } + if len(receivedArgs) != 3 || receivedArgs[0] != "--env" { + t.Errorf("unexpected args: %v", receivedArgs) + } +} + +// TestCLIInvokeStep_ArgsSliceAny verifies that args arriving as []any +// (e.g. after JSON/YAML round-trip) are correctly coerced to []string. +func TestCLIInvokeStep_ArgsSliceAny(t *testing.T) { + registry := NewCLICommandRegistry() + var receivedArgs []string + registry.Register("deploy", func(args []string) error { + receivedArgs = args + return nil + }) + + app := NewMockApplication() + app.Services[CLICommandRegistryServiceName] = registry + + factory := NewCLIInvokeStepFactory() + step, err := factory("invoke", map[string]any{"command": "deploy"}, app) + if err != nil { + t.Fatalf("factory failed: %v", err) + } + + // Simulate JSON-decoded args ([]any instead of []string). + pc := NewPipelineContext(map[string]any{"args": []any{"--env", "prod"}}, nil) + if _, err := step.Execute(context.Background(), pc); err != nil { + t.Fatalf("Execute with []any args failed: %v", err) + } + if len(receivedArgs) != 2 || receivedArgs[0] != "--env" || receivedArgs[1] != "prod" { + t.Errorf("unexpected receivedArgs: %v", receivedArgs) + } +} + +// TestCLIInvokeStep_ArgsSliceAnyNonString verifies that []any with a non-string +// element returns a clear error. +func TestCLIInvokeStep_ArgsSliceAnyNonString(t *testing.T) { + registry := NewCLICommandRegistry() + registry.Register("deploy", func(args []string) error { return nil }) + + app := NewMockApplication() + app.Services[CLICommandRegistryServiceName] = registry + + factory := NewCLIInvokeStepFactory() + step, err := factory("invoke", map[string]any{"command": "deploy"}, app) + if err != nil { + t.Fatalf("factory failed: %v", err) + } + + pc := NewPipelineContext(map[string]any{"args": []any{"ok", 42}}, nil) + _, err = step.Execute(context.Background(), pc) + if err == nil { + t.Fatal("expected error for non-string element in args") + } +} diff --git a/module/pipeline_step_cli_print.go b/module/pipeline_step_cli_print.go new file mode 100644 index 00000000..7ac0b2bb --- /dev/null +++ b/module/pipeline_step_cli_print.go @@ -0,0 +1,90 @@ +package module + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/CrisisTextLine/modular" +) + +// CLIPrintStep writes a template-resolved message to stdout or stderr. +// Use this step to produce output in workflow-powered CLI applications. +// +// Step type: step.cli_print +// +// Config fields: +// - message (required) — Go template expression resolved against the pipeline +// context (e.g. "PASS {{.config_file}}") +// - newline (optional, default true) — append a trailing newline +// - target (optional, "stdout" | "stderr", default "stdout") +type CLIPrintStep struct { + name string + message string + newline bool + target io.Writer + tmpl *TemplateEngine +} + +// NewCLIPrintStepFactory returns a StepFactory that creates CLIPrintStep instances. +func NewCLIPrintStepFactory() StepFactory { + return newCLIPrintStepFactoryWithWriters(os.Stdout, os.Stderr) +} + +// newCLIPrintStepFactoryWithWriters is the testable variant that accepts explicit writers. +func newCLIPrintStepFactoryWithWriters(stdout, stderr io.Writer) StepFactory { + return func(name string, config map[string]any, _ modular.Application) (PipelineStep, error) { + message, _ := config["message"].(string) + if message == "" { + return nil, fmt.Errorf("cli_print step %q: 'message' is required", name) + } + + newline := true + if nl, ok := config["newline"].(bool); ok { + newline = nl + } + + target := stdout + if rawTarget, ok := config["target"]; ok { + if t, ok := rawTarget.(string); ok && t != "" { + switch t { + case "stdout": + target = stdout + case "stderr": + target = stderr + default: + return nil, fmt.Errorf("cli_print step %q: invalid 'target' %q; must be \"stdout\" or \"stderr\"", name, t) + } + } + } + + return &CLIPrintStep{ + name: name, + message: message, + newline: newline, + target: target, + tmpl: NewTemplateEngine(), + }, nil + } +} + +// Name returns the step name. +func (s *CLIPrintStep) Name() string { return s.name } + +// Execute resolves the message template and writes it to the configured target. +// It sets "printed" in the step output to the resolved message string. +func (s *CLIPrintStep) Execute(_ context.Context, pc *PipelineContext) (*StepResult, error) { + resolved, err := s.tmpl.Resolve(s.message, pc) + if err != nil { + return nil, fmt.Errorf("cli_print step %q: failed to resolve message: %w", s.name, err) + } + + if s.newline { + fmt.Fprintln(s.target, resolved) + } else { + fmt.Fprint(s.target, resolved) + } + + return &StepResult{Output: map[string]any{"printed": resolved}}, nil +} diff --git a/module/pipeline_step_cli_print_test.go b/module/pipeline_step_cli_print_test.go new file mode 100644 index 00000000..215d898e --- /dev/null +++ b/module/pipeline_step_cli_print_test.go @@ -0,0 +1,100 @@ +package module + +import ( + "bytes" + "context" + "strings" + "testing" +) + +func TestCLIPrintStep_Basic(t *testing.T) { + var buf bytes.Buffer + factory := newCLIPrintStepFactoryWithWriters(&buf, &buf) + step, err := factory("print", map[string]any{"message": "hello world"}, nil) + if err != nil { + t.Fatalf("factory failed: %v", err) + } + + pc := NewPipelineContext(nil, nil) + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + if buf.String() != "hello world\n" { + t.Errorf("unexpected output: %q", buf.String()) + } + if result.Output["printed"] != "hello world" { + t.Errorf("expected printed=hello world, got %v", result.Output["printed"]) + } +} + +func TestCLIPrintStep_NoNewline(t *testing.T) { + var buf bytes.Buffer + factory := newCLIPrintStepFactoryWithWriters(&buf, &buf) + step, err := factory("print", map[string]any{"message": "hi", "newline": false}, nil) + if err != nil { + t.Fatalf("factory failed: %v", err) + } + _, err = step.Execute(context.Background(), NewPipelineContext(nil, nil)) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + if buf.String() != "hi" { + t.Errorf("unexpected output: %q", buf.String()) + } +} + +func TestCLIPrintStep_TemplateResolution(t *testing.T) { + var buf bytes.Buffer + factory := newCLIPrintStepFactoryWithWriters(&buf, &buf) + step, err := factory("print", map[string]any{"message": "cmd: {{.command}}"}, nil) + if err != nil { + t.Fatalf("factory failed: %v", err) + } + pc := NewPipelineContext(map[string]any{"command": "validate"}, nil) + _, err = step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + if buf.String() != "cmd: validate\n" { + t.Errorf("unexpected output: %q", buf.String()) + } +} + +func TestCLIPrintStep_StderrTarget(t *testing.T) { + var stdout, stderr bytes.Buffer + factory := newCLIPrintStepFactoryWithWriters(&stdout, &stderr) + step, err := factory("print", map[string]any{"message": "err msg", "target": "stderr"}, nil) + if err != nil { + t.Fatalf("factory failed: %v", err) + } + _, err = step.Execute(context.Background(), NewPipelineContext(nil, nil)) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + if stdout.Len() != 0 { + t.Errorf("expected nothing on stdout, got %q", stdout.String()) + } + if stderr.String() != "err msg\n" { + t.Errorf("unexpected stderr: %q", stderr.String()) + } +} + +func TestCLIPrintStep_MissingMessage(t *testing.T) { + factory := newCLIPrintStepFactoryWithWriters(nil, nil) + _, err := factory("print", map[string]any{}, nil) + if err == nil { + t.Fatal("expected error for missing message") + } +} + +func TestCLIPrintStep_InvalidTarget(t *testing.T) { + factory := newCLIPrintStepFactoryWithWriters(nil, nil) + _, err := factory("print", map[string]any{"message": "hi", "target": "invalid"}, nil) + if err == nil { + t.Fatal("expected error for invalid target") + } + if !strings.Contains(err.Error(), "invalid 'target'") { + t.Errorf("unexpected error message: %v", err) + } +} diff --git a/plugins/pipelinesteps/plugin.go b/plugins/pipelinesteps/plugin.go index 011c469f..6df0648f 100644 --- a/plugins/pipelinesteps/plugin.go +++ b/plugins/pipelinesteps/plugin.go @@ -96,6 +96,8 @@ func New() *Plugin { "step.http_proxy", "step.hash", "step.regex_match", + "step.cli_print", + "step.cli_invoke", "step.parallel", }, WorkflowTypes: []string{"pipeline"}, @@ -173,6 +175,9 @@ func (p *Plugin) StepFactories() map[string]plugin.StepFactory { "step.http_proxy": wrapStepFactory(module.NewHTTPProxyStepFactory()), "step.hash": wrapStepFactory(module.NewHashStepFactory()), "step.regex_match": wrapStepFactory(module.NewRegexMatchStepFactory()), + // CLI steps for workflow-powered CLI applications. + "step.cli_print": wrapStepFactory(module.NewCLIPrintStepFactory()), + "step.cli_invoke": wrapStepFactory(module.NewCLIInvokeStepFactory()), // step.parallel uses a lazy registry getter so sub-steps can reference any registered type. "step.parallel": wrapStepFactory(module.NewParallelStepFactory(func() *module.StepRegistry { return p.concreteStepRegistry diff --git a/plugins/pipelinesteps/plugin_test.go b/plugins/pipelinesteps/plugin_test.go index 2e9ec5d0..052462e2 100644 --- a/plugins/pipelinesteps/plugin_test.go +++ b/plugins/pipelinesteps/plugin_test.go @@ -74,6 +74,8 @@ func TestStepFactories(t *testing.T) { "step.http_proxy", "step.hash", "step.regex_match", + "step.cli_print", + "step.cli_invoke", "step.parallel", } diff --git a/setup/setup.go b/setup/setup.go index 175f867f..62fc7752 100644 --- a/setup/setup.go +++ b/setup/setup.go @@ -40,6 +40,7 @@ func DefaultHandlers() []workflow.WorkflowHandler { handlers.NewPipelineWorkflowHandler(), handlers.NewEventWorkflowHandler(), handlers.NewPlatformWorkflowHandler(), + handlers.NewCLIWorkflowHandler(), } } @@ -51,5 +52,6 @@ func DefaultTriggers() []interfaces.Trigger { module.NewScheduleTrigger(), module.NewEventBusTrigger(), module.NewReconciliationTrigger(), + module.NewCLITrigger(), } } diff --git a/setup/setup_test.go b/setup/setup_test.go index 6c35a122..16f6d414 100644 --- a/setup/setup_test.go +++ b/setup/setup_test.go @@ -9,9 +9,10 @@ func TestDefaultHandlers(t *testing.T) { if len(handlers) == 0 { t.Fatal("expected at least one default handler") } - // Should have 8 built-in handlers - if len(handlers) != 8 { - t.Errorf("expected 8 default handlers, got %d", len(handlers)) + // Should have 9 built-in handlers (HTTP, Messaging, StateMachine, Scheduler, + // Integration, Pipeline, Event, Platform, CLI) + if len(handlers) != 9 { + t.Errorf("expected 9 default handlers, got %d", len(handlers)) } } @@ -20,8 +21,8 @@ func TestDefaultTriggers(t *testing.T) { if len(triggers) == 0 { t.Fatal("expected at least one default trigger") } - // Should have 5 built-in triggers - if len(triggers) != 5 { - t.Errorf("expected 5 default triggers, got %d", len(triggers)) + // Should have 6 built-in triggers + if len(triggers) != 6 { + t.Errorf("expected 6 default triggers, got %d", len(triggers)) } }