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
99 changes: 98 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ A command-line interface for managing [n8n](https://n8n.io) workflows. Perfect f
- **Workflow Management**: List, view, create, update, and delete workflows
- **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
- **Execution Control**: Run workflows, view execution history, and stop running executions
- **Source Control**: Preview, push, and pull changes through the instance's Git connection
- **Versions & Publishing**: Inspect history, publish or roll back versions, archive and delete
- **Data Tables**: Query, insert, update, upsert, and delete rows
- **Admin**: Credentials, tags, folders, project members, users, and the security audit
- **Multi-Instance**: Configure and switch between multiple n8n instances
- **LLM-Friendly**: JSON output for seamless AI integration

Expand Down Expand Up @@ -69,13 +72,35 @@ n8nctl workflow activate <id> # Activate workflow
n8nctl workflow deactivate <id> # Deactivate workflow
```

#### Versions, publishing, and deletion

n8n keeps a draft and a published version of each workflow. `workflow view` shows
both version IDs and whether the draft has unpublished changes.

```bash
n8nctl workflow history <id> # Saved versions, newest first
n8nctl workflow version <id> <version-id> # One saved version as JSON
n8nctl workflow publish <id> # Publish the current draft
n8nctl workflow publish <id> --version <version-id> # Publish (roll back to) a specific version
n8nctl workflow unpublish <id> # Stop running the workflow
n8nctl workflow archive <id> # Soft delete (unpublishes it)
n8nctl workflow unarchive <id> # Restore an archived workflow
n8nctl workflow delete <id> # Permanently delete (archive or unpublish first)
```

Pushing a draft to a published workflow with `workflow push` publishes the new version
right away; it does not leave an unpublished draft.

### Executions

```bash
n8nctl execution list [--workflow <id>] # List executions
n8nctl execution view <id> # View execution details
n8nctl execution retry <id> # Retry a failed execution
n8nctl execution delete <id> # Delete execution
n8nctl execution stop <id> # Stop a running or waiting execution
n8nctl execution stop --status running,waiting [--workflow <id>] # Bulk stop
n8nctl execution stop --status queued --started-before 2026-10-13T08:00:00Z
```

### Source Control
Expand Down Expand Up @@ -113,6 +138,77 @@ Things to know before using it:
`sourceControl:pull` scope, and the instance needs source control connected. A missing
scope returns 403.

### Data Tables

```bash
n8nctl datatable list # List data tables (alias: dt)
n8nctl datatable view <table-id> # Columns and size
n8nctl dt rows <table-id> [--limit 100] # Rows as a JSON array
n8nctl dt rows <table-id> --where 'status=pending' --where 'count>=10'
n8nctl dt rows <table-id> --where 'name~%invoice%' --where 'name~%rechnung%' --or
n8nctl dt rows <table-id> --search foo --sort count:desc
n8nctl dt insert <table-id> --data '{"name":"a","count":1}' # Object or array of objects
cat rows.json | n8nctl dt insert <table-id> --data - # Read stdin
n8nctl dt update <table-id> --where 'status=pending' --data '{"status":"done"}' --dry-run
n8nctl dt upsert <table-id> --where 'key=x' --data '{"key":"x","value":"y"}'
n8nctl dt delete-rows <table-id> --where 'status=obsolete' --dry-run
```

`--where <column><op><value>` supports `=`, `!=`, `>`, `>=`, `<`, `<=`, and `~`
(case-insensitive LIKE, `%` as wildcard). Conditions are ANDed unless `--or` is set.
Values are sent as strings and n8n converts them to the column type; for anything else,
pass the API filter object with `--filter '{"type":"and","filters":[...]}'`. `update`,
`upsert`, and `delete-rows` require a filter and accept `--dry-run`, which prints the
rows before and after without writing. n8n's comparisons on date columns do not line up
exactly with the stored UTC timestamps, so check a date filter with `rows` or `--dry-run`
before writing with it.

### Credentials and Tags

```bash
n8nctl credential list # ID, type, owning project (alias: cred)
n8nctl credential test <id> # Exits non-zero when the test fails
n8nctl credential schema <type> # JSON schema of a credential type
n8nctl credential delete <id>
n8nctl tag list
n8nctl tag create <name>
n8nctl tag rename <tag-id> <new-name>
n8nctl tag delete <tag-id> # Removes it from every workflow
```

### Projects, Folders, and Users

```bash
n8nctl project list
n8nctl project member list <project-id>
n8nctl project member add <project-id> <user-id>... --role project:editor
n8nctl project member set-role <project-id> <user-id> project:viewer
n8nctl project member remove <project-id> <user-id>
n8nctl folder list --project <project-id>
n8nctl folder view <folder-id> --project <project-id>
n8nctl folder create <name> --project <project-id> [--parent <folder-id>]
n8nctl folder update <folder-id> --project <project-id> [--name <name>] [--parent <folder-id>]
n8nctl folder delete <folder-id> --project <project-id> [--transfer-to <target-folder-id>]
n8nctl user list [--project <project-id>] # Users with their global role
n8nctl user view <user-id-or-email>
```

Deleting a folder without `--transfer-to` moves its workflows to the project root and
archives them, and deletes its sub-folders.

### Security Audit

```bash
n8nctl audit # Full report as JSON
n8nctl audit --categories credentials,nodes --days-abandoned 30
```

### API Key Scopes

Each command needs the matching scope on the API key (for example `dataTableRow:read`
for `dt rows`, `folder:list` for `folder list`). A missing scope returns
`API error (403): Forbidden`.

## Recursive Pull & Push

The killer feature: pull a workflow and all its sub-workflows at once.
Expand Down Expand Up @@ -158,6 +254,7 @@ n8nctl workflow list --json
n8nctl workflow view abc123 --json
n8nctl execution list --json
n8nctl source-control status --json
n8nctl datatable rows <table-id> --json # Includes nextCursor
```

Typical workflow for LLM-assisted development:
Expand Down
134 changes: 134 additions & 0 deletions internal/api/datatables.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package api

import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"time"
)

// DataTable is an n8n data table.
type DataTable struct {
ID string `json:"id"`
Name string `json:"name"`
ProjectID string `json:"projectId"`
Columns []DataTableColumn `json:"columns"`
SizeBytes int64 `json:"sizeBytes"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}

// DataTableColumn is a column of a data table.
type DataTableColumn struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Type string `json:"type"`
Index int `json:"index"`
}

// DataTableFilter matches rows. Type is "and" (server default) or "or".
type DataTableFilter struct {
Type string `json:"type,omitempty"`
Filters []DataTableFilterCondition `json:"filters"`
}

// DataTableFilterCondition compares a column to a value. Condition is one of
// eq, neq, like, ilike, gt, gte, lt, lte.
type DataTableFilterCondition struct {
ColumnName string `json:"columnName"`
Condition string `json:"condition"`
Value interface{} `json:"value"`
}

func dataTablePath(id string) string {
return "/data-tables/" + url.PathEscape(id)
}

// ListDataTables returns one page of data tables.
func (c *Client) ListDataTables(limit int, cursor string) (*ListResult[DataTable], error) {
return getJSON[ListResult[DataTable]](c, pagedPath("/data-tables", limit, cursor, nil))
}

// GetDataTable returns a data table with its columns.
func (c *Client) GetDataTable(id string) (*DataTable, error) {
return getJSON[DataTable](c, dataTablePath(id))
}

// ListRowsOptions selects rows of a data table.
type ListRowsOptions struct {
Filter *DataTableFilter
Search string
SortBy string // column:asc or column:desc
Limit int
Cursor string
}

// ListDataTableRows returns one page of rows.
func (c *Client) ListDataTableRows(id string, opts ListRowsOptions) (*ListResult[map[string]interface{}], error) {
extra := url.Values{}
if opts.Filter != nil {
f, err := json.Marshal(opts.Filter)
if err != nil {
return nil, err
}
extra.Set("filter", string(f))
}
if opts.Search != "" {
extra.Set("search", opts.Search)
}
if opts.SortBy != "" {
extra.Set("sortBy", opts.SortBy)
}
return getJSON[ListResult[map[string]interface{}]](c, pagedPath(dataTablePath(id)+"/rows", opts.Limit, opts.Cursor, extra))
}

// InsertDataTableRows inserts rows and returns the inserted rows.
func (c *Client) InsertDataTableRows(id string, rows []map[string]interface{}) (json.RawMessage, error) {
return c.rawJSON(http.MethodPost, dataTablePath(id)+"/rows", map[string]interface{}{"data": rows, "returnType": "all"})
}

// RowWriteOptions controls an update, upsert, or delete of rows matching Filter.
type RowWriteOptions struct {
Filter DataTableFilter
Data map[string]interface{}
DryRun bool
}

// UpdateDataTableRows updates every row matching opts.Filter and returns the affected rows.
func (c *Client) UpdateDataTableRows(id string, opts RowWriteOptions) (json.RawMessage, error) {
return c.rawJSON(http.MethodPatch, dataTablePath(id)+"/rows/update", map[string]interface{}{
"filter": opts.Filter, "data": opts.Data, "returnData": true, "dryRun": opts.DryRun,
})
}

// UpsertDataTableRow updates the rows matching opts.Filter, or inserts opts.Data
// when none match, and returns the affected rows.
func (c *Client) UpsertDataTableRow(id string, opts RowWriteOptions) (json.RawMessage, error) {
return c.rawJSON(http.MethodPost, dataTablePath(id)+"/rows/upsert", map[string]interface{}{
"filter": opts.Filter, "data": opts.Data, "returnData": true, "dryRun": opts.DryRun,
})
}

// DeleteDataTableRows deletes the rows matching opts.Filter and returns them.
// The server requires a filter, so this cannot clear a whole table.
func (c *Client) DeleteDataTableRows(id string, opts RowWriteOptions) (json.RawMessage, error) {
f, err := json.Marshal(opts.Filter)
if err != nil {
return nil, err
}
params := url.Values{
"filter": {string(f)},
"returnData": {"true"},
"dryRun": {strconv.FormatBool(opts.DryRun)},
}
return c.rawJSON(http.MethodDelete, dataTablePath(id)+"/rows/delete?"+params.Encode(), nil)
}

func (c *Client) rawJSON(method, path string, body interface{}) (json.RawMessage, error) {
respBody, err := c.request(method, path, body)
if err != nil {
return nil, err
}
return json.RawMessage(respBody), nil
}
92 changes: 92 additions & 0 deletions internal/api/datatables_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package api

import (
"net/http"
"testing"
)

var testFilter = DataTableFilter{Type: "and", Filters: []DataTableFilterCondition{{ColumnName: "status", Condition: "eq", Value: "pending"}}}

const testFilterJSON = `{"type":"and","filters":[{"columnName":"status","condition":"eq","value":"pending"}]}`

func TestListDataTableRows(t *testing.T) {
client, rec := newRecordingServer(t, http.StatusOK, `{"data":[{"id":1,"status":"pending"}],"nextCursor":"c2"}`)

result, err := client.ListDataTableRows("dt1", ListRowsOptions{Filter: &testFilter, Search: "foo", SortBy: "id:desc", Limit: 5})
if err != nil {
t.Fatalf("ListDataTableRows: %v", err)
}
if rec.Path != "/api/v1/data-tables/dt1/rows" {
t.Errorf("path = %s", rec.Path)
}
assertJSONEqual(t, rec.Query.Get("filter"), testFilterJSON)
if rec.Query.Get("search") != "foo" || rec.Query.Get("sortBy") != "id:desc" || rec.Query.Get("limit") != "5" {
t.Errorf("query = %s", rec.Query.Encode())
}
if len(result.Data) != 1 || result.NextCursor != "c2" {
t.Errorf("result = %+v", result)
}
}

func TestListDataTableRowsWithoutFilter(t *testing.T) {
client, rec := newRecordingServer(t, http.StatusOK, `{"data":[],"nextCursor":null}`)

if _, err := client.ListDataTableRows("dt1", ListRowsOptions{}); err != nil {
t.Fatalf("ListDataTableRows: %v", err)
}
if len(rec.Query) != 0 {
t.Errorf("query = %s, want none", rec.Query.Encode())
}
}

func TestInsertDataTableRows(t *testing.T) {
client, rec := newRecordingServer(t, http.StatusOK, `[{"id":7,"name":"a"}]`)

out, err := client.InsertDataTableRows("dt1", []map[string]interface{}{{"name": "a"}})
if err != nil {
t.Fatalf("InsertDataTableRows: %v", err)
}
if rec.Method != http.MethodPost || rec.Path != "/api/v1/data-tables/dt1/rows" {
t.Errorf("request = %s %s", rec.Method, rec.Path)
}
assertJSONEqual(t, rec.Body, `{"data":[{"name":"a"}],"returnType":"all"}`)
assertJSONEqual(t, string(out), `[{"id":7,"name":"a"}]`)
}

func TestUpdateAndUpsertDataTableRows(t *testing.T) {
opts := RowWriteOptions{Filter: testFilter, Data: map[string]interface{}{"status": "done"}, DryRun: true}
want := `{"filter":` + testFilterJSON + `,"data":{"status":"done"},"returnData":true,"dryRun":true}`

client, rec := newRecordingServer(t, http.StatusOK, `[]`)
if _, err := client.UpdateDataTableRows("dt1", opts); err != nil {
t.Fatalf("UpdateDataTableRows: %v", err)
}
if rec.Method != http.MethodPatch || rec.Path != "/api/v1/data-tables/dt1/rows/update" {
t.Errorf("update request = %s %s", rec.Method, rec.Path)
}
assertJSONEqual(t, rec.Body, want)

client, rec = newRecordingServer(t, http.StatusOK, `{"id":1}`)
if _, err := client.UpsertDataTableRow("dt1", opts); err != nil {
t.Fatalf("UpsertDataTableRow: %v", err)
}
if rec.Method != http.MethodPost || rec.Path != "/api/v1/data-tables/dt1/rows/upsert" {
t.Errorf("upsert request = %s %s", rec.Method, rec.Path)
}
assertJSONEqual(t, rec.Body, want)
}

func TestDeleteDataTableRows(t *testing.T) {
client, rec := newRecordingServer(t, http.StatusOK, `[{"id":1}]`)

if _, err := client.DeleteDataTableRows("dt1", RowWriteOptions{Filter: testFilter}); err != nil {
t.Fatalf("DeleteDataTableRows: %v", err)
}
if rec.Method != http.MethodDelete || rec.Path != "/api/v1/data-tables/dt1/rows/delete" {
t.Errorf("request = %s %s", rec.Method, rec.Path)
}
assertJSONEqual(t, rec.Query.Get("filter"), testFilterJSON)
if rec.Query.Get("returnData") != "true" || rec.Query.Get("dryRun") != "false" {
t.Errorf("query = %s", rec.Query.Encode())
}
}
Loading
Loading