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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ A command-line interface for managing [n8n](https://n8n.io) workflows. Perfect f
- **Recursive Pull**: Download a workflow and all its sub-workflows with a single command
- **Smart Push**: Push workflows in dependency order with automatic ID remapping
- **Execution Control**: Run workflows, view execution history, and manage running executions
- **Source Control**: Preview, push, and pull changes through the instance's Git connection
- **Multi-Instance**: Configure and switch between multiple n8n instances
- **LLM-Friendly**: JSON output for seamless AI integration

Expand Down Expand Up @@ -77,6 +78,41 @@ n8nctl execution retry <id> # Retry a failed execution
n8nctl execution delete <id> # Delete execution
```

### Source Control

Drives n8n's built-in source control (Environments): the Git branch connected to the
instance, not local files.

```bash
n8nctl source-control status # Pending changes a push would commit
n8nctl source-control status --direction pull # Changes a pull would import
n8nctl source-control push -m "msg" --workflow <id> # Commit and push one workflow
n8nctl source-control push -m "msg" --workflow <id> --workflow <id2> --item credential:<id>
n8nctl source-control push -m "msg" --workflow <id> --force # Push despite conflicts
n8nctl source-control pull # Import the connected branch
n8nctl source-control pull --auto-publish published # Republish workflows that were published
n8nctl source-control pull --force # Discard conflicting local changes
```

`sc` is an alias for `source-control`. `--item <type>:<id>` accepts the types `credential`,
`workflow`, `tags`, `variables`, `file`, `folders`, `project`, and `datatable`; take the IDs
from `status`. Push has no push-everything option, so pending changes from other people on
a shared instance are only committed when you name them.

Things to know before using it:

- A push commits to the connected branch. Every instance connected to the same branch
receives the change on its next pull.
- A pull overwrites the drafts of every changed item with the Git version. `--auto-publish`
controls publishing after import: `none` (server default) keeps each workflow's published
state, `all` publishes everything imported, `published` republishes only workflows that
were published before the pull.
- On a 409 the CLI lists the conflicting items and exits non-zero; rerun with `--force` only
after checking them. With `--json`, stdout carries `{"error": ..., "conflicts": [...]}`.
- The API key needs the `sourceControl:read` (status), `sourceControl:push`, or
`sourceControl:pull` scope, and the instance needs source control connected. A missing
scope returns 403.

## Recursive Pull & Push

The killer feature: pull a workflow and all its sub-workflows at once.
Expand Down Expand Up @@ -121,6 +157,7 @@ Use `--json` flag for structured output:
n8nctl workflow list --json
n8nctl workflow view abc123 --json
n8nctl execution list --json
n8nctl source-control status --json
```

Typical workflow for LLM-assisted development:
Expand Down
24 changes: 20 additions & 4 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,18 +161,34 @@ func (c *Client) request(method, path string, body interface{}) ([]byte, error)
}

if resp.StatusCode >= 400 {
var apiErr struct {
apiErr := &APIError{StatusCode: resp.StatusCode, Body: respBody}
var msg struct {
Message string `json:"message"`
}
if json.Unmarshal(respBody, &apiErr) == nil && apiErr.Message != "" {
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, apiErr.Message)
if json.Unmarshal(respBody, &msg) == nil {
apiErr.Message = msg.Message
}
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
return nil, apiErr
}

return respBody, nil
}

// APIError is returned for responses with status >= 400. Body keeps the raw
// response for endpoints that put structured data in error responses.
type APIError struct {
StatusCode int
Message string
Body []byte
}

func (e *APIError) Error() string {
if e.Message != "" {
return fmt.Sprintf("API error (%d): %s", e.StatusCode, e.Message)
}
return fmt.Sprintf("API error (%d): %s", e.StatusCode, string(e.Body))
}

// listWorkflowsPage fetches a single page of workflows.
func (c *Client) listWorkflowsPage(opts ListWorkflowsOptions) (*ListResult[Workflow], error) {
params := url.Values{}
Expand Down
162 changes: 162 additions & 0 deletions internal/api/sourcecontrol.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package api

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
)

// SourceControlFile is one entry of a source control status, push or pull result.
type SourceControlFile struct {
File string `json:"file"`
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Location string `json:"location"`
Conflict bool `json:"conflict"`
UpdatedAt string `json:"updatedAt"`
Pushed *bool `json:"pushed,omitempty"`
IsLocalPublished *bool `json:"isLocalPublished,omitempty"`
IsRemoteArchived *bool `json:"isRemoteArchived,omitempty"`
ParentFolderID *string `json:"parentFolderId,omitempty"`
FolderPath []string `json:"folderPath,omitempty"`
RemoteFolderPath []string `json:"remoteFolderPath,omitempty"`
Owner *SourceControlOwner `json:"owner,omitempty"`
PublishingError string `json:"publishingError,omitempty"`
PublishingErrorDetails *PublishingErrorDetails `json:"publishingErrorDetails,omitempty"`
ContentImportPolicy json.RawMessage `json:"contentImportPolicy,omitempty"`
}

// SourceControlOwner is the project that owns a source control item.
type SourceControlOwner struct {
Type string `json:"type"`
ProjectID string `json:"projectId"`
ProjectName string `json:"projectName"`
}

// PublishingErrorDetails explains why an imported workflow was not published.
type PublishingErrorDetails struct {
Reason string `json:"reason"`
WorkflowReviewRequestID string `json:"workflowReviewRequestId,omitempty"`
VersionID string `json:"versionId,omitempty"`
}

// SourceControlItem identifies one item to push.
type SourceControlItem struct {
ID string `json:"id"`
Type string `json:"type"`
}

// SourceControlTypes are the item types the source control API accepts.
var SourceControlTypes = []string{"credential", "workflow", "tags", "variables", "file", "folders", "project", "datatable"}

// SourceControlConflictError is returned when a push or pull is rejected with 409.
type SourceControlConflictError struct {
Message string
Conflicts []SourceControlFile
}

func (e *SourceControlConflictError) Error() string {
msg := e.Message
if msg == "" {
msg = "conflicting changes"
}
return fmt.Sprintf("source control conflict (%d files): %s", len(e.Conflicts), msg)
}

func withScopeHint(err error, scope string) error {
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden {
return fmt.Errorf("%w (the API key needs the %s scope, and source control must be connected on the instance)", err, scope)
}
return err
}

// SourceControlStatus previews pending changes in the given direction ("push" or "pull").
func (c *Client) SourceControlStatus(direction string) ([]SourceControlFile, error) {
respBody, err := c.request(http.MethodGet, "/source-control/status?direction="+url.QueryEscape(direction), nil)
if err != nil {
return nil, withScopeHint(err, "sourceControl:read")
}

var resp struct {
Data []SourceControlFile `json:"data"`
}
if err := json.Unmarshal(respBody, &resp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}

return resp.Data, nil
}

// SourceControlPushOptions contains the parameters of a source control push.
type SourceControlPushOptions struct {
CommitMessage string `json:"commitMessage"`
FileNames []SourceControlItem `json:"fileNames"`
Force bool `json:"force,omitempty"`
}

// SourceControlPush commits the selected items and pushes them to the connected branch.
func (c *Client) SourceControlPush(opts SourceControlPushOptions) ([]SourceControlFile, error) {
respBody, err := c.request(http.MethodPost, "/source-control/push", opts)
if err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict {
var conflict struct {
Message string `json:"message"`
Conflicts []SourceControlFile `json:"conflicts"`
}
if json.Unmarshal(apiErr.Body, &conflict) == nil {
return nil, &SourceControlConflictError{Message: conflict.Message, Conflicts: conflict.Conflicts}
}
}
return nil, withScopeHint(err, "sourceControl:push")
}

var resp struct {
Data []SourceControlFile `json:"data"`
}
if err := json.Unmarshal(respBody, &resp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}

return resp.Data, nil
}

// SourceControlPullOptions contains the parameters of a source control pull.
type SourceControlPullOptions struct {
Force bool `json:"force,omitempty"`
AutoPublish string `json:"autoPublish,omitempty"` // none (server default), all, published
}

// SourceControlPull imports the connected branch into the instance.
// Unlike status and push, the pull endpoint returns a bare array.
func (c *Client) SourceControlPull(opts SourceControlPullOptions) ([]SourceControlFile, error) {
respBody, err := c.request(http.MethodPost, "/source-control/pull", opts)
if err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict {
var files []SourceControlFile
if json.Unmarshal(apiErr.Body, &files) == nil {
var conflicts []SourceControlFile
for _, f := range files {
if f.Conflict || f.Status == "modified" {
conflicts = append(conflicts, f)
}
}
return nil, &SourceControlConflictError{Message: "local changes would be overwritten", Conflicts: conflicts}
}
}
return nil, withScopeHint(err, "sourceControl:pull")
}

var files []SourceControlFile
if err := json.Unmarshal(respBody, &files); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}

return files, nil
}
Loading
Loading