Skip to content

Commit 738a631

Browse files
authored
feat: add webhook trigger and --value flag for variables (#11)
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.
1 parent 5e42851 commit 738a631

File tree

3 files changed

+134
-28
lines changed

3 files changed

+134
-28
lines changed

internal/api/client.go

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,17 @@ func NewClient(baseURL, apiKey string) *Client {
3131

3232
// Workflow represents an n8n workflow
3333
type Workflow struct {
34-
ID string `json:"id,omitempty"`
35-
Name string `json:"name"`
36-
Active bool `json:"active"`
37-
Nodes []map[string]interface{} `json:"nodes"`
38-
Connections map[string]interface{} `json:"connections"`
39-
Settings map[string]interface{} `json:"settings,omitempty"`
40-
StaticData interface{} `json:"staticData,omitempty"`
41-
Tags []Tag `json:"tags,omitempty"`
42-
Shared []WorkflowShared `json:"shared,omitempty"`
43-
CreatedAt *time.Time `json:"createdAt,omitempty"`
44-
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
34+
ID string `json:"id,omitempty"`
35+
Name string `json:"name"`
36+
Active bool `json:"active"`
37+
Nodes []map[string]interface{} `json:"nodes"`
38+
Connections map[string]interface{} `json:"connections"`
39+
Settings map[string]interface{} `json:"settings,omitempty"`
40+
StaticData interface{} `json:"staticData,omitempty"`
41+
Tags []Tag `json:"tags,omitempty"`
42+
Shared []WorkflowShared `json:"shared,omitempty"`
43+
CreatedAt *time.Time `json:"createdAt,omitempty"`
44+
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
4545
}
4646

4747
// Tag represents a workflow tag
@@ -82,13 +82,13 @@ type Execution struct {
8282

8383
// ListWorkflowsOptions contains options for listing workflows
8484
type ListWorkflowsOptions struct {
85-
Active *bool
86-
Tags []string
87-
Name string
88-
ProjectID string
85+
Active *bool
86+
Tags []string
87+
Name string
88+
ProjectID string
8989
ExcludePinnedData bool
90-
Limit int
91-
Cursor string
90+
Limit int
91+
Cursor string
9292
}
9393

9494
// ListExecutionsOptions contains options for listing executions
@@ -616,6 +616,36 @@ func (c *Client) TransferCredential(id, destinationProjectID string) error {
616616
return err
617617
}
618618

619+
// TriggerWebhook triggers a workflow via its webhook URL.
620+
// The path is the webhook path (e.g. a UUID), and method is the HTTP method (GET, POST, etc.).
621+
// Webhooks are public endpoints, so no API key is sent.
622+
func (c *Client) TriggerWebhook(path, method string) ([]byte, error) {
623+
reqURL := c.baseURL + "/webhook/" + path
624+
req, err := http.NewRequest(method, reqURL, nil)
625+
if err != nil {
626+
return nil, fmt.Errorf("failed to create request: %w", err)
627+
}
628+
629+
req.Header.Set("Accept", "application/json")
630+
631+
resp, err := c.httpClient.Do(req)
632+
if err != nil {
633+
return nil, fmt.Errorf("request failed: %w", err)
634+
}
635+
defer func() { _ = resp.Body.Close() }()
636+
637+
respBody, err := io.ReadAll(resp.Body)
638+
if err != nil {
639+
return nil, fmt.Errorf("failed to read response: %w", err)
640+
}
641+
642+
if resp.StatusCode >= 400 {
643+
return nil, fmt.Errorf("webhook error (%d): %s", resp.StatusCode, string(respBody))
644+
}
645+
646+
return respBody, nil
647+
}
648+
619649
// --- Variables ---
620650

621651
// ListVariables returns all variables

internal/cmd/variable/variable.go

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -121,49 +121,82 @@ func newGetCmd() *cobra.Command {
121121
}
122122

123123
func newCreateCmd() *cobra.Command {
124+
var valueFlag string
125+
124126
cmd := &cobra.Command{
125-
Use: "create <key> <value>",
127+
Use: "create <key> [value]",
126128
Short: "Create a new variable",
127-
Args: cobra.ExactArgs(2),
129+
Long: `Create a new variable. The value can be passed as a positional argument
130+
or via the --value flag. The flag form is useful for values containing
131+
special shell characters (e.g. n8nctl var create key --value 'b!xyz').`,
132+
Args: cobra.RangeArgs(1, 2),
128133
RunE: func(cmd *cobra.Command, args []string) error {
129134
client, err := getClient()
130135
if err != nil {
131136
return err
132137
}
133138

134-
if err := client.CreateVariable(args[0], args[1]); err != nil {
139+
key := args[0]
140+
var value string
141+
switch {
142+
case len(args) == 2:
143+
value = args[1]
144+
case valueFlag != "":
145+
value = valueFlag
146+
default:
147+
return fmt.Errorf("value is required: pass as second argument or use --value")
148+
}
149+
150+
if err := client.CreateVariable(key, value); err != nil {
135151
return fmt.Errorf("failed to create variable: %w", err)
136152
}
137153

138-
fmt.Printf("Created variable: %s\n", args[0])
154+
fmt.Printf("Created variable: %s\n", key)
139155
return nil
140156
},
141157
}
142158

159+
cmd.Flags().StringVar(&valueFlag, "value", "", "Variable value (alternative to positional argument)")
160+
143161
return cmd
144162
}
145163

146164
func newUpdateCmd() *cobra.Command {
165+
var valueFlag string
166+
147167
cmd := &cobra.Command{
148-
Use: "update <key> <value>",
168+
Use: "update <key> [value]",
149169
Short: "Update a variable by key",
150-
Args: cobra.ExactArgs(2),
170+
Long: `Update a variable. The value can be passed as a positional argument
171+
or via the --value flag. The flag form is useful for values containing
172+
special shell characters (e.g. n8nctl var update key --value 'b!xyz').`,
173+
Args: cobra.RangeArgs(1, 2),
151174
RunE: func(cmd *cobra.Command, args []string) error {
152175
client, err := getClient()
153176
if err != nil {
154177
return err
155178
}
156179

180+
key := args[0]
181+
var value string
182+
switch {
183+
case len(args) == 2:
184+
value = args[1]
185+
case valueFlag != "":
186+
value = valueFlag
187+
default:
188+
return fmt.Errorf("value is required: pass as second argument or use --value")
189+
}
190+
157191
// Resolve key to ID
158192
vars, err := client.ListVariables(0, "")
159193
if err != nil {
160194
return fmt.Errorf("failed to list variables: %w", err)
161195
}
162196

163-
key := args[0]
164197
for _, v := range vars {
165198
if v.Key == key {
166-
if err := client.UpdateVariable(v.ID, key, args[1]); err != nil {
199+
if err := client.UpdateVariable(v.ID, key, value); err != nil {
167200
return fmt.Errorf("failed to update variable: %w", err)
168201
}
169202
fmt.Printf("Updated variable: %s\n", key)
@@ -175,6 +208,8 @@ func newUpdateCmd() *cobra.Command {
175208
},
176209
}
177210

211+
cmd.Flags().StringVar(&valueFlag, "value", "", "Variable value (alternative to positional argument)")
212+
178213
return cmd
179214
}
180215

internal/cmd/workflow/workflow.go

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -407,20 +407,54 @@ func pushDirectory(client *api.Client, dir string, create bool) error {
407407

408408
func newRunCmd() *cobra.Command {
409409
var (
410-
inputJSON string
411-
wait bool
410+
inputJSON string
411+
wait bool
412+
webhookPath string
413+
method string
412414
)
413415

414416
cmd := &cobra.Command{
415417
Use: "run <workflow-id>",
416418
Short: "Execute a workflow",
417-
Args: cobra.ExactArgs(1),
419+
Long: `Execute a workflow via the API or via a webhook trigger.
420+
421+
By default, uses the /execute API endpoint. If your n8n instance doesn't
422+
support this endpoint (returns 405), use --webhook to trigger via webhook instead.
423+
424+
Examples:
425+
n8nctl wf run abc123 # Execute via API
426+
n8nctl wf run abc123 --webhook my-hook-path # Trigger via webhook (GET)
427+
n8nctl wf run abc123 --webhook my-hook-path --method POST`,
428+
Args: cobra.ExactArgs(1),
418429
RunE: func(cmd *cobra.Command, args []string) error {
419430
client, err := getClient()
420431
if err != nil {
421432
return err
422433
}
423434

435+
// Webhook mode: trigger via webhook URL instead of execute API
436+
if webhookPath != "" {
437+
respBody, err := client.TriggerWebhook(webhookPath, method)
438+
if err != nil {
439+
return fmt.Errorf("failed to trigger webhook: %w", err)
440+
}
441+
442+
jsonFlag, _ := cmd.Flags().GetBool("json")
443+
if jsonFlag {
444+
// Try to pretty-print if valid JSON, otherwise print raw
445+
var parsed interface{}
446+
if json.Unmarshal(respBody, &parsed) == nil {
447+
return printJSON(parsed)
448+
}
449+
}
450+
451+
fmt.Printf("Webhook triggered successfully.\n")
452+
if len(respBody) > 0 {
453+
fmt.Printf("Response: %s\n", string(respBody))
454+
}
455+
return nil
456+
}
457+
424458
var inputData map[string]interface{}
425459
if inputJSON != "" {
426460
if err := json.Unmarshal([]byte(inputJSON), &inputData); err != nil {
@@ -430,6 +464,11 @@ func newRunCmd() *cobra.Command {
430464

431465
execution, err := client.ExecuteWorkflow(args[0], inputData, wait)
432466
if err != nil {
467+
if strings.Contains(err.Error(), "405") {
468+
fmt.Fprintf(os.Stderr, "Hint: The /execute API endpoint returned 405. This endpoint may not be available on your n8n instance.\n")
469+
fmt.Fprintf(os.Stderr, "Use --webhook to trigger the workflow via its webhook URL instead:\n")
470+
fmt.Fprintf(os.Stderr, " n8nctl wf run %s --webhook <webhook-path>\n", args[0])
471+
}
433472
return fmt.Errorf("failed to execute workflow: %w", err)
434473
}
435474

@@ -450,6 +489,8 @@ func newRunCmd() *cobra.Command {
450489

451490
cmd.Flags().StringVarP(&inputJSON, "input", "i", "", "Input data as JSON")
452491
cmd.Flags().BoolVarP(&wait, "wait", "w", false, "Wait for execution to complete")
492+
cmd.Flags().StringVar(&webhookPath, "webhook", "", "Trigger via webhook path instead of execute API")
493+
cmd.Flags().StringVar(&method, "method", "GET", "HTTP method for webhook trigger")
453494

454495
return cmd
455496
}

0 commit comments

Comments
 (0)