Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 47 additions & 17 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
51 changes: 43 additions & 8 deletions internal/cmd/variable/variable.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,49 +121,82 @@ func newGetCmd() *cobra.Command {
}

func newCreateCmd() *cobra.Command {
var valueFlag string

cmd := &cobra.Command{
Use: "create <key> <value>",
Use: "create <key> [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 <key> <value>",
Use: "update <key> [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)
Expand All @@ -175,6 +208,8 @@ func newUpdateCmd() *cobra.Command {
},
}

cmd.Flags().StringVar(&valueFlag, "value", "", "Variable value (alternative to positional argument)")

return cmd
}

Expand Down
47 changes: 44 additions & 3 deletions internal/cmd/workflow/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <workflow-id>",
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 {
Expand All @@ -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 <webhook-path>\n", args[0])
}
return fmt.Errorf("failed to execute workflow: %w", err)
}

Expand All @@ -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
}
Expand Down