From f0f10eaa1e223425b01bc0ce3622941b4a4f5deb Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Wed, 18 Feb 2026 08:07:47 +0100 Subject: [PATCH] feat: add webhook trigger support and --value flag for variables Add --webhook and --method flags to `wf run` to trigger workflows via webhook URL, working around the missing /execute endpoint (405). When the execute API returns 405, a helpful hint is printed. Add --value flag to `var create` and `var update` as an alternative to positional arguments, avoiding bash history expansion of `!` in values like SharePoint drive IDs. --- internal/api/client.go | 64 +++++++++++++++++++++++-------- internal/cmd/variable/variable.go | 51 ++++++++++++++++++++---- internal/cmd/workflow/workflow.go | 47 +++++++++++++++++++++-- 3 files changed, 134 insertions(+), 28 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index fa679ed..5dbb6b0 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -31,17 +31,17 @@ func NewClient(baseURL, apiKey string) *Client { // Workflow represents an n8n workflow type Workflow struct { - ID string `json:"id,omitempty"` - Name string `json:"name"` - Active bool `json:"active"` - Nodes []map[string]interface{} `json:"nodes"` - Connections map[string]interface{} `json:"connections"` - Settings map[string]interface{} `json:"settings,omitempty"` - StaticData interface{} `json:"staticData,omitempty"` - Tags []Tag `json:"tags,omitempty"` - Shared []WorkflowShared `json:"shared,omitempty"` - CreatedAt *time.Time `json:"createdAt,omitempty"` - UpdatedAt *time.Time `json:"updatedAt,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name"` + Active bool `json:"active"` + Nodes []map[string]interface{} `json:"nodes"` + Connections map[string]interface{} `json:"connections"` + Settings map[string]interface{} `json:"settings,omitempty"` + StaticData interface{} `json:"staticData,omitempty"` + Tags []Tag `json:"tags,omitempty"` + Shared []WorkflowShared `json:"shared,omitempty"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` } // Tag represents a workflow tag @@ -82,13 +82,13 @@ type Execution struct { // ListWorkflowsOptions contains options for listing workflows type ListWorkflowsOptions struct { - Active *bool - Tags []string - Name string - ProjectID string + Active *bool + Tags []string + Name string + ProjectID string ExcludePinnedData bool - Limit int - Cursor string + Limit int + Cursor string } // ListExecutionsOptions contains options for listing executions @@ -616,6 +616,36 @@ func (c *Client) TransferCredential(id, destinationProjectID string) error { return err } +// TriggerWebhook triggers a workflow via its webhook URL. +// The path is the webhook path (e.g. a UUID), and method is the HTTP method (GET, POST, etc.). +// Webhooks are public endpoints, so no API key is sent. +func (c *Client) TriggerWebhook(path, method string) ([]byte, error) { + reqURL := c.baseURL + "/webhook/" + path + req, err := http.NewRequest(method, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("webhook error (%d): %s", resp.StatusCode, string(respBody)) + } + + return respBody, nil +} + // --- Variables --- // ListVariables returns all variables diff --git a/internal/cmd/variable/variable.go b/internal/cmd/variable/variable.go index da7fab1..e4d26c9 100644 --- a/internal/cmd/variable/variable.go +++ b/internal/cmd/variable/variable.go @@ -121,49 +121,82 @@ func newGetCmd() *cobra.Command { } func newCreateCmd() *cobra.Command { + var valueFlag string + cmd := &cobra.Command{ - Use: "create ", + Use: "create [value]", Short: "Create a new variable", - Args: cobra.ExactArgs(2), + Long: `Create a new variable. The value can be passed as a positional argument +or via the --value flag. The flag form is useful for values containing +special shell characters (e.g. n8nctl var create key --value 'b!xyz').`, + Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { client, err := getClient() if err != nil { return err } - if err := client.CreateVariable(args[0], args[1]); err != nil { + key := args[0] + var value string + switch { + case len(args) == 2: + value = args[1] + case valueFlag != "": + value = valueFlag + default: + return fmt.Errorf("value is required: pass as second argument or use --value") + } + + if err := client.CreateVariable(key, value); err != nil { return fmt.Errorf("failed to create variable: %w", err) } - fmt.Printf("Created variable: %s\n", args[0]) + fmt.Printf("Created variable: %s\n", key) return nil }, } + cmd.Flags().StringVar(&valueFlag, "value", "", "Variable value (alternative to positional argument)") + return cmd } func newUpdateCmd() *cobra.Command { + var valueFlag string + cmd := &cobra.Command{ - Use: "update ", + Use: "update [value]", Short: "Update a variable by key", - Args: cobra.ExactArgs(2), + Long: `Update a variable. The value can be passed as a positional argument +or via the --value flag. The flag form is useful for values containing +special shell characters (e.g. n8nctl var update key --value 'b!xyz').`, + Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { client, err := getClient() if err != nil { return err } + key := args[0] + var value string + switch { + case len(args) == 2: + value = args[1] + case valueFlag != "": + value = valueFlag + default: + return fmt.Errorf("value is required: pass as second argument or use --value") + } + // Resolve key to ID vars, err := client.ListVariables(0, "") if err != nil { return fmt.Errorf("failed to list variables: %w", err) } - key := args[0] for _, v := range vars { if v.Key == key { - if err := client.UpdateVariable(v.ID, key, args[1]); err != nil { + if err := client.UpdateVariable(v.ID, key, value); err != nil { return fmt.Errorf("failed to update variable: %w", err) } fmt.Printf("Updated variable: %s\n", key) @@ -175,6 +208,8 @@ func newUpdateCmd() *cobra.Command { }, } + cmd.Flags().StringVar(&valueFlag, "value", "", "Variable value (alternative to positional argument)") + return cmd } diff --git a/internal/cmd/workflow/workflow.go b/internal/cmd/workflow/workflow.go index ed9d940..b776d5e 100644 --- a/internal/cmd/workflow/workflow.go +++ b/internal/cmd/workflow/workflow.go @@ -407,20 +407,54 @@ func pushDirectory(client *api.Client, dir string, create bool) error { func newRunCmd() *cobra.Command { var ( - inputJSON string - wait bool + inputJSON string + wait bool + webhookPath string + method string ) cmd := &cobra.Command{ Use: "run ", Short: "Execute a workflow", - Args: cobra.ExactArgs(1), + Long: `Execute a workflow via the API or via a webhook trigger. + +By default, uses the /execute API endpoint. If your n8n instance doesn't +support this endpoint (returns 405), use --webhook to trigger via webhook instead. + +Examples: + n8nctl wf run abc123 # Execute via API + n8nctl wf run abc123 --webhook my-hook-path # Trigger via webhook (GET) + n8nctl wf run abc123 --webhook my-hook-path --method POST`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client, err := getClient() if err != nil { return err } + // Webhook mode: trigger via webhook URL instead of execute API + if webhookPath != "" { + respBody, err := client.TriggerWebhook(webhookPath, method) + if err != nil { + return fmt.Errorf("failed to trigger webhook: %w", err) + } + + jsonFlag, _ := cmd.Flags().GetBool("json") + if jsonFlag { + // Try to pretty-print if valid JSON, otherwise print raw + var parsed interface{} + if json.Unmarshal(respBody, &parsed) == nil { + return printJSON(parsed) + } + } + + fmt.Printf("Webhook triggered successfully.\n") + if len(respBody) > 0 { + fmt.Printf("Response: %s\n", string(respBody)) + } + return nil + } + var inputData map[string]interface{} if inputJSON != "" { if err := json.Unmarshal([]byte(inputJSON), &inputData); err != nil { @@ -430,6 +464,11 @@ func newRunCmd() *cobra.Command { execution, err := client.ExecuteWorkflow(args[0], inputData, wait) if err != nil { + if strings.Contains(err.Error(), "405") { + fmt.Fprintf(os.Stderr, "Hint: The /execute API endpoint returned 405. This endpoint may not be available on your n8n instance.\n") + fmt.Fprintf(os.Stderr, "Use --webhook to trigger the workflow via its webhook URL instead:\n") + fmt.Fprintf(os.Stderr, " n8nctl wf run %s --webhook \n", args[0]) + } return fmt.Errorf("failed to execute workflow: %w", err) } @@ -450,6 +489,8 @@ func newRunCmd() *cobra.Command { cmd.Flags().StringVarP(&inputJSON, "input", "i", "", "Input data as JSON") cmd.Flags().BoolVarP(&wait, "wait", "w", false, "Wait for execution to complete") + cmd.Flags().StringVar(&webhookPath, "webhook", "", "Trigger via webhook path instead of execute API") + cmd.Flags().StringVar(&method, "method", "GET", "HTTP method for webhook trigger") return cmd }