diff --git a/README.md b/README.md index 45572f3..a088729 100644 --- a/README.md +++ b/README.md @@ -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 @@ -69,6 +72,25 @@ n8nctl workflow activate # Activate workflow n8nctl workflow deactivate # 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 # Saved versions, newest first +n8nctl workflow version # One saved version as JSON +n8nctl workflow publish # Publish the current draft +n8nctl workflow publish --version # Publish (roll back to) a specific version +n8nctl workflow unpublish # Stop running the workflow +n8nctl workflow archive # Soft delete (unpublishes it) +n8nctl workflow unarchive # Restore an archived workflow +n8nctl workflow delete # 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 @@ -76,6 +98,9 @@ n8nctl execution list [--workflow ] # List executions n8nctl execution view # View execution details n8nctl execution retry # Retry a failed execution n8nctl execution delete # Delete execution +n8nctl execution stop # Stop a running or waiting execution +n8nctl execution stop --status running,waiting [--workflow ] # Bulk stop +n8nctl execution stop --status queued --started-before 2026-10-13T08:00:00Z ``` ### Source Control @@ -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 # Columns and size +n8nctl dt rows [--limit 100] # Rows as a JSON array +n8nctl dt rows --where 'status=pending' --where 'count>=10' +n8nctl dt rows --where 'name~%invoice%' --where 'name~%rechnung%' --or +n8nctl dt rows --search foo --sort count:desc +n8nctl dt insert --data '{"name":"a","count":1}' # Object or array of objects +cat rows.json | n8nctl dt insert --data - # Read stdin +n8nctl dt update --where 'status=pending' --data '{"status":"done"}' --dry-run +n8nctl dt upsert --where 'key=x' --data '{"key":"x","value":"y"}' +n8nctl dt delete-rows --where 'status=obsolete' --dry-run +``` + +`--where ` 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 # Exits non-zero when the test fails +n8nctl credential schema # JSON schema of a credential type +n8nctl credential delete +n8nctl tag list +n8nctl tag create +n8nctl tag rename +n8nctl tag delete # Removes it from every workflow +``` + +### Projects, Folders, and Users + +```bash +n8nctl project list +n8nctl project member list +n8nctl project member add ... --role project:editor +n8nctl project member set-role project:viewer +n8nctl project member remove +n8nctl folder list --project +n8nctl folder view --project +n8nctl folder create --project [--parent ] +n8nctl folder update --project [--name ] [--parent ] +n8nctl folder delete --project [--transfer-to ] +n8nctl user list [--project ] # Users with their global role +n8nctl user view +``` + +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. @@ -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 --json # Includes nextCursor ``` Typical workflow for LLM-assisted development: diff --git a/internal/api/datatables.go b/internal/api/datatables.go new file mode 100644 index 0000000..931bc1c --- /dev/null +++ b/internal/api/datatables.go @@ -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 +} diff --git a/internal/api/datatables_test.go b/internal/api/datatables_test.go new file mode 100644 index 0000000..d23eaa8 --- /dev/null +++ b/internal/api/datatables_test.go @@ -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()) + } +} diff --git a/internal/api/resources.go b/internal/api/resources.go new file mode 100644 index 0000000..c60127a --- /dev/null +++ b/internal/api/resources.go @@ -0,0 +1,261 @@ +package api + +import ( + "net/http" + "net/url" + "strconv" + "time" +) + +func pagedPath(path string, limit int, cursor string, extra url.Values) string { + params := url.Values{} + for k, v := range extra { + params[k] = v + } + if limit > 0 { + params.Set("limit", strconv.Itoa(limit)) + } + if cursor != "" { + params.Set("cursor", cursor) + } + if len(params) > 0 { + path += "?" + params.Encode() + } + return path +} + +// --- Executions --- + +// StopExecution stops a running or waiting execution. +func (c *Client) StopExecution(id string) (*Execution, error) { + return doJSON[Execution](c, http.MethodPost, "/executions/"+url.PathEscape(id)+"/stop", nil) +} + +// StopExecutionsOptions selects the executions to stop. Status is required. +type StopExecutionsOptions struct { + Status []string `json:"status"` + WorkflowID string `json:"workflowId,omitempty"` + StartedAfter string `json:"startedAfter,omitempty"` + StartedBefore string `json:"startedBefore,omitempty"` +} + +// StopExecutions stops every execution matching opts and returns how many were stopped. +func (c *Client) StopExecutions(opts StopExecutionsOptions) (int, error) { + resp, err := doJSON[struct { + Stopped int `json:"stopped"` + }](c, http.MethodPost, "/executions/stop", opts) + if err != nil { + return 0, err + } + return resp.Stopped, nil +} + +// --- Credentials --- + +// CredentialSummary is a credential as returned by the list endpoint (without secrets). +type CredentialSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + Shared []SharedProject `json:"shared,omitempty"` +} + +// SharedProject is a project a credential is shared with. +type SharedProject struct { + ID string `json:"id"` + Name string `json:"name"` + Role string `json:"role"` +} + +// ListCredentials returns one page of credentials. +func (c *Client) ListCredentials(limit int, cursor string) (*ListResult[CredentialSummary], error) { + return getJSON[ListResult[CredentialSummary]](c, pagedPath("/credentials", limit, cursor, nil)) +} + +// CredentialTestResult is the outcome of a credential test. +type CredentialTestResult struct { + Status string `json:"status"` + Message string `json:"message"` +} + +// TestCredential checks a credential against its service. +func (c *Client) TestCredential(id string) (*CredentialTestResult, error) { + return doJSON[CredentialTestResult](c, http.MethodPost, "/credentials/"+url.PathEscape(id)+"/test", nil) +} + +// ListTagsPage returns one page of tags with its pagination cursor. +func (c *Client) ListTagsPage(limit int, cursor string) (*ListResult[Tag], error) { + return getJSON[ListResult[Tag]](c, pagedPath("/tags", limit, cursor, nil)) +} + +// --- Folders --- + +// Folder is a workflow folder inside a project. +type Folder struct { + ID string `json:"id"` + Name string `json:"name"` + ParentFolderID *string `json:"parentFolderId"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + TotalSubFolders *int `json:"totalSubFolders,omitempty"` + TotalWorkflows *int `json:"totalWorkflows,omitempty"` +} + +func folderPath(projectID string) string { + return "/projects/" + url.PathEscape(projectID) + "/folders" +} + +// ListFolders returns the folders of a project. Folders use skip/take instead of cursors. +func (c *Client) ListFolders(projectID string, skip, take int) ([]Folder, int, error) { + params := url.Values{} + if skip > 0 { + params.Set("skip", strconv.Itoa(skip)) + } + if take > 0 { + params.Set("take", strconv.Itoa(take)) + } + path := folderPath(projectID) + if len(params) > 0 { + path += "?" + params.Encode() + } + + resp, err := getJSON[struct { + Count int `json:"count"` + Data []Folder `json:"data"` + }](c, path) + if err != nil { + return nil, 0, err + } + return resp.Data, resp.Count, nil +} + +// GetFolder returns a folder with its sub-folder and workflow counts. +func (c *Client) GetFolder(projectID, folderID string) (*Folder, error) { + return getJSON[Folder](c, folderPath(projectID)+"/"+url.PathEscape(folderID)) +} + +// CreateFolder creates a folder; an empty parentFolderID creates it at the project root. +func (c *Client) CreateFolder(projectID, name, parentFolderID string) (*Folder, error) { + body := map[string]string{"name": name} + if parentFolderID != "" { + body["parentFolderId"] = parentFolderID + } + return doJSON[Folder](c, http.MethodPost, folderPath(projectID), body) +} + +// UpdateFolder renames and/or moves a folder. Empty arguments are left unchanged. +func (c *Client) UpdateFolder(projectID, folderID, name, parentFolderID string) (*Folder, error) { + body := map[string]string{} + if name != "" { + body["name"] = name + } + if parentFolderID != "" { + body["parentFolderId"] = parentFolderID + } + return doJSON[Folder](c, http.MethodPatch, folderPath(projectID)+"/"+url.PathEscape(folderID), body) +} + +// DeleteFolder deletes a folder. With transferToFolderID set, its workflows and +// sub-folders move there; otherwise its workflows move to the project root and +// are archived, and its sub-folders are deleted. +func (c *Client) DeleteFolder(projectID, folderID, transferToFolderID string) error { + path := folderPath(projectID) + "/" + url.PathEscape(folderID) + if transferToFolderID != "" { + path += "?transferToFolderId=" + url.QueryEscape(transferToFolderID) + } + _, err := c.request(http.MethodDelete, path, nil) + return err +} + +// --- Users and project members --- + +// User is an n8n user. +type User struct { + ID string `json:"id"` + Email string `json:"email"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + IsPending bool `json:"isPending"` + Role string `json:"role,omitempty"` + MFAEnabled bool `json:"mfaEnabled,omitempty"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +// ListUsers returns one page of users, optionally limited to a project's members. +func (c *Client) ListUsers(limit int, cursor, projectID string) (*ListResult[User], error) { + extra := url.Values{"includeRole": {"true"}} + if projectID != "" { + extra.Set("projectId", projectID) + } + return getJSON[ListResult[User]](c, pagedPath("/users", limit, cursor, extra)) +} + +// GetUser returns a user by ID or email address. +func (c *Client) GetUser(idOrEmail string) (*User, error) { + return getJSON[User](c, "/users/"+url.PathEscape(idOrEmail)+"?includeRole=true") +} + +func projectUsersPath(projectID string) string { + return "/projects/" + url.PathEscape(projectID) + "/users" +} + +// ListProjectMembers returns one page of a project's members with their project role. +func (c *Client) ListProjectMembers(projectID string, limit int, cursor string) (*ListResult[User], error) { + return getJSON[ListResult[User]](c, pagedPath(projectUsersPath(projectID), limit, cursor, nil)) +} + +// ProjectRelation assigns a project role to a user. +type ProjectRelation struct { + UserID string `json:"userId"` + Role string `json:"role"` +} + +// AddProjectMembers adds users to a project with the given roles. +func (c *Client) AddProjectMembers(projectID string, relations []ProjectRelation) error { + _, err := c.request(http.MethodPost, projectUsersPath(projectID), map[string]interface{}{"relations": relations}) + return err +} + +// SetProjectMemberRole changes a member's role in a project. +func (c *Client) SetProjectMemberRole(projectID, userID, role string) error { + _, err := c.request(http.MethodPatch, projectUsersPath(projectID)+"/"+url.PathEscape(userID), map[string]string{"role": role}) + return err +} + +// RemoveProjectMember removes a user from a project. +func (c *Client) RemoveProjectMember(projectID, userID string) error { + _, err := c.request(http.MethodDelete, projectUsersPath(projectID)+"/"+url.PathEscape(userID), nil) + return err +} + +// --- Audit --- + +// AuditOptions narrows a security audit. Zero values use the server defaults. +type AuditOptions struct { + Categories []string + DaysAbandonedWorkflow int +} + +// GenerateAudit runs a security audit and returns the report keyed by risk category. +func (c *Client) GenerateAudit(opts AuditOptions) (map[string]interface{}, error) { + extra := map[string]interface{}{} + if len(opts.Categories) > 0 { + extra["categories"] = opts.Categories + } + if opts.DaysAbandonedWorkflow > 0 { + extra["daysAbandonedWorkflow"] = opts.DaysAbandonedWorkflow + } + body := map[string]interface{}{} + if len(extra) > 0 { + body["additionalOptions"] = extra + } + + resp, err := doJSON[map[string]interface{}](c, http.MethodPost, "/audit", body) + if err != nil { + return nil, err + } + return *resp, nil +} diff --git a/internal/api/resources_test.go b/internal/api/resources_test.go new file mode 100644 index 0000000..dba01c9 --- /dev/null +++ b/internal/api/resources_test.go @@ -0,0 +1,332 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "testing" +) + +type capturedRequest struct { + Method string + Path string + Query url.Values + Body string +} + +// newRecordingServer serves body with status for every request and records the last request. +func newRecordingServer(t *testing.T, status int, body string) (*Client, *capturedRequest) { + t.Helper() + rec := &capturedRequest{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + *rec = capturedRequest{Method: r.Method, Path: r.URL.Path, Query: r.URL.Query(), Body: string(b)} + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return NewClient(srv.URL, "test-key"), rec +} + +func assertJSONEqual(t *testing.T, got, want string) { + t.Helper() + var g, w interface{} + if err := json.Unmarshal([]byte(got), &g); err != nil { + t.Fatalf("body %q is not JSON: %v", got, err) + } + if err := json.Unmarshal([]byte(want), &w); err != nil { + t.Fatalf("want %q is not JSON: %v", want, err) + } + if !reflect.DeepEqual(g, w) { + t.Errorf("body = %s, want %s", got, want) + } +} + +func TestGetWorkflowWithState(t *testing.T) { + client, rec := newRecordingServer(t, http.StatusOK, + `{"id":"wf1","name":"Router","active":true,"nodes":[],"connections":{},"versionId":"v2","activeVersionId":"v1","isArchived":false}`) + + wf, state, err := client.GetWorkflowWithState("wf1") + if err != nil { + t.Fatalf("GetWorkflowWithState: %v", err) + } + if rec.Path != "/api/v1/workflows/wf1" { + t.Errorf("path = %s", rec.Path) + } + if wf.Name != "Router" || state.VersionID != "v2" || state.ActiveVersionID == nil || *state.ActiveVersionID != "v1" { + t.Errorf("wf = %+v, state = %+v", wf, state) + } + if !state.HasUnpublishedChanges() { + t.Error("HasUnpublishedChanges = false, want true") + } +} + +func TestHasUnpublishedChanges(t *testing.T) { + v1 := "v1" + tests := []struct { + name string + state WorkflowState + want bool + }{ + {"never published", WorkflowState{VersionID: "v1"}, false}, + {"published draft", WorkflowState{VersionID: "v1", ActiveVersionID: &v1}, false}, + {"draft ahead", WorkflowState{VersionID: "v2", ActiveVersionID: &v1}, true}, + } + for _, tt := range tests { + if got := tt.state.HasUnpublishedChanges(); got != tt.want { + t.Errorf("%s: got %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestPublishWorkflowBody(t *testing.T) { + tests := []struct { + versionID string + wantBody string + }{ + {"", `{}`}, + {"v7", `{"versionId":"v7"}`}, + } + for _, tt := range tests { + client, rec := newRecordingServer(t, http.StatusOK, `{"versionId":"v7","activeVersionId":"v7"}`) + state, err := client.PublishWorkflow("wf1", tt.versionID) + if err != nil { + t.Fatalf("PublishWorkflow: %v", err) + } + if rec.Method != http.MethodPost || rec.Path != "/api/v1/workflows/wf1/publish" { + t.Errorf("request = %s %s", rec.Method, rec.Path) + } + assertJSONEqual(t, rec.Body, tt.wantBody) + if state.ActiveVersionID == nil || *state.ActiveVersionID != "v7" { + t.Errorf("state = %+v", state) + } + } +} + +func TestWorkflowLifecyclePaths(t *testing.T) { + tests := []struct { + call func(*Client) (*WorkflowState, error) + path string + }{ + {func(c *Client) (*WorkflowState, error) { return c.ArchiveWorkflow("wf1") }, "/api/v1/workflows/wf1/archive"}, + {func(c *Client) (*WorkflowState, error) { return c.UnarchiveWorkflow("wf1") }, "/api/v1/workflows/wf1/unarchive"}, + {func(c *Client) (*WorkflowState, error) { return c.UnpublishWorkflow("wf1") }, "/api/v1/workflows/wf1/unpublish"}, + } + for _, tt := range tests { + client, rec := newRecordingServer(t, http.StatusOK, `{"versionId":"v1","activeVersionId":null,"isArchived":true}`) + if _, err := tt.call(client); err != nil { + t.Fatalf("%s: %v", tt.path, err) + } + if rec.Method != http.MethodPost || rec.Path != tt.path { + t.Errorf("request = %s %s, want POST %s", rec.Method, rec.Path, tt.path) + } + } +} + +func TestListWorkflowHistory(t *testing.T) { + client, rec := newRecordingServer(t, http.StatusOK, + `{"data":[{"versionId":"v2","workflowId":"wf1","authors":"Jane","name":null,"description":null,"createdAt":"2026-10-13T10:00:00Z","updatedAt":"2026-10-13T10:00:00Z"}],"nextCursor":"c2"}`) + + result, err := client.ListWorkflowHistory("wf1", 20, "c1") + if err != nil { + t.Fatalf("ListWorkflowHistory: %v", err) + } + if rec.Path != "/api/v1/workflows/wf1/history" || rec.Query.Get("limit") != "20" || rec.Query.Get("cursor") != "c1" { + t.Errorf("request = %s?%s", rec.Path, rec.Query.Encode()) + } + if len(result.Data) != 1 || result.Data[0].VersionID != "v2" || result.NextCursor != "c2" { + t.Errorf("result = %+v", result) + } +} + +func TestGetWorkflowVersionPath(t *testing.T) { + client, rec := newRecordingServer(t, http.StatusOK, `{"versionId":"v2","workflowId":"wf1","authors":"Jane","nodes":[{"name":"Start"}],"connections":{}}`) + + v, err := client.GetWorkflowVersion("wf1", "v2") + if err != nil { + t.Fatalf("GetWorkflowVersion: %v", err) + } + if rec.Path != "/api/v1/workflows/wf1/versions/v2" { + t.Errorf("path = %s", rec.Path) + } + if len(v.Nodes) != 1 { + t.Errorf("nodes = %+v", v.Nodes) + } +} + +func TestStopExecutions(t *testing.T) { + client, rec := newRecordingServer(t, http.StatusOK, `{"stopped":3}`) + + n, err := client.StopExecutions(StopExecutionsOptions{Status: []string{"running", "waiting"}, WorkflowID: "wf1"}) + if err != nil { + t.Fatalf("StopExecutions: %v", err) + } + if rec.Path != "/api/v1/executions/stop" || n != 3 { + t.Errorf("path = %s, stopped = %d", rec.Path, n) + } + assertJSONEqual(t, rec.Body, `{"status":["running","waiting"],"workflowId":"wf1"}`) +} + +func TestStopExecution(t *testing.T) { + client, rec := newRecordingServer(t, http.StatusOK, `{"mode":"trigger","status":"canceled","finished":false,"startedAt":"2026-10-13T10:00:00Z"}`) + + exec, err := client.StopExecution("42") + if err != nil { + t.Fatalf("StopExecution: %v", err) + } + if rec.Method != http.MethodPost || rec.Path != "/api/v1/executions/42/stop" || exec.Status != "canceled" { + t.Errorf("request = %s %s, exec = %+v", rec.Method, rec.Path, exec) + } +} + +func TestTestCredential(t *testing.T) { + client, rec := newRecordingServer(t, http.StatusOK, `{"status":"Error","message":"401 Unauthorized"}`) + + result, err := client.TestCredential("c1") + if err != nil { + t.Fatalf("TestCredential: %v", err) + } + if rec.Method != http.MethodPost || rec.Path != "/api/v1/credentials/c1/test" { + t.Errorf("request = %s %s", rec.Method, rec.Path) + } + if result.Status != "Error" || result.Message != "401 Unauthorized" { + t.Errorf("result = %+v", result) + } +} + +func TestListCredentials(t *testing.T) { + client, rec := newRecordingServer(t, http.StatusOK, + `{"data":[{"id":"c1","name":"SharePoint","type":"oAuth2Api","shared":[{"id":"p1","name":"DMS","role":"credential:owner"}]}],"nextCursor":null}`) + + result, err := client.ListCredentials(50, "") + if err != nil { + t.Fatalf("ListCredentials: %v", err) + } + if rec.Path != "/api/v1/credentials" || rec.Query.Get("limit") != "50" || rec.Query.Has("cursor") { + t.Errorf("request = %s?%s", rec.Path, rec.Query.Encode()) + } + if len(result.Data) != 1 || result.Data[0].Shared[0].Name != "DMS" || result.NextCursor != "" { + t.Errorf("result = %+v", result) + } +} + +func TestListFolders(t *testing.T) { + client, rec := newRecordingServer(t, http.StatusOK, `{"count":5,"data":[{"id":"f1","name":"Invoices","parentFolderId":null}]}`) + + folders, count, err := client.ListFolders("p1", 10, 1) + if err != nil { + t.Fatalf("ListFolders: %v", err) + } + if rec.Path != "/api/v1/projects/p1/folders" || rec.Query.Get("skip") != "10" || rec.Query.Get("take") != "1" { + t.Errorf("request = %s?%s", rec.Path, rec.Query.Encode()) + } + if count != 5 || len(folders) != 1 || folders[0].ParentFolderID != nil { + t.Errorf("count = %d, folders = %+v", count, folders) + } +} + +func TestUpdateFolderSendsOnlySetFields(t *testing.T) { + client, rec := newRecordingServer(t, http.StatusOK, `{"id":"f1","name":"New"}`) + + if _, err := client.UpdateFolder("p1", "f1", "New", ""); err != nil { + t.Fatalf("UpdateFolder: %v", err) + } + if rec.Method != http.MethodPatch || rec.Path != "/api/v1/projects/p1/folders/f1" { + t.Errorf("request = %s %s", rec.Method, rec.Path) + } + assertJSONEqual(t, rec.Body, `{"name":"New"}`) +} + +func TestDeleteFolderTransfer(t *testing.T) { + client, rec := newRecordingServer(t, http.StatusNoContent, ``) + + if err := client.DeleteFolder("p1", "f1", "f2"); err != nil { + t.Fatalf("DeleteFolder: %v", err) + } + if rec.Method != http.MethodDelete || rec.Path != "/api/v1/projects/p1/folders/f1" || rec.Query.Get("transferToFolderId") != "f2" { + t.Errorf("request = %s %s?%s", rec.Method, rec.Path, rec.Query.Encode()) + } +} + +func TestListUsers(t *testing.T) { + client, rec := newRecordingServer(t, http.StatusOK, `{"data":[{"id":"u1","email":"a@example.com","role":"global:member"}],"nextCursor":null}`) + + result, err := client.ListUsers(10, "", "p1") + if err != nil { + t.Fatalf("ListUsers: %v", err) + } + if rec.Query.Get("includeRole") != "true" || rec.Query.Get("projectId") != "p1" || rec.Query.Get("limit") != "10" { + t.Errorf("query = %s", rec.Query.Encode()) + } + if result.Data[0].Role != "global:member" { + t.Errorf("result = %+v", result) + } +} + +func TestGetUserByEmail(t *testing.T) { + client, rec := newRecordingServer(t, http.StatusOK, `{"id":"u1","email":"a@example.com"}`) + + if _, err := client.GetUser("a@example.com"); err != nil { + t.Fatalf("GetUser: %v", err) + } + if rec.Path != "/api/v1/users/a@example.com" || rec.Query.Get("includeRole") != "true" { + t.Errorf("request = %s?%s", rec.Path, rec.Query.Encode()) + } +} + +func TestProjectMembers(t *testing.T) { + client, rec := newRecordingServer(t, http.StatusCreated, ``) + if err := client.AddProjectMembers("p1", []ProjectRelation{{UserID: "u1", Role: "project:editor"}}); err != nil { + t.Fatalf("AddProjectMembers: %v", err) + } + if rec.Method != http.MethodPost || rec.Path != "/api/v1/projects/p1/users" { + t.Errorf("request = %s %s", rec.Method, rec.Path) + } + assertJSONEqual(t, rec.Body, `{"relations":[{"userId":"u1","role":"project:editor"}]}`) + + client, rec = newRecordingServer(t, http.StatusNoContent, ``) + if err := client.SetProjectMemberRole("p1", "u1", "project:viewer"); err != nil { + t.Fatalf("SetProjectMemberRole: %v", err) + } + if rec.Method != http.MethodPatch || rec.Path != "/api/v1/projects/p1/users/u1" { + t.Errorf("request = %s %s", rec.Method, rec.Path) + } + assertJSONEqual(t, rec.Body, `{"role":"project:viewer"}`) + + client, rec = newRecordingServer(t, http.StatusNoContent, ``) + if err := client.RemoveProjectMember("p1", "u1"); err != nil { + t.Fatalf("RemoveProjectMember: %v", err) + } + if rec.Method != http.MethodDelete || rec.Path != "/api/v1/projects/p1/users/u1" { + t.Errorf("request = %s %s", rec.Method, rec.Path) + } +} + +func TestGenerateAuditBody(t *testing.T) { + tests := []struct { + opts AuditOptions + want string + }{ + {AuditOptions{}, `{}`}, + {AuditOptions{Categories: []string{"credentials"}, DaysAbandonedWorkflow: 30}, + `{"additionalOptions":{"categories":["credentials"],"daysAbandonedWorkflow":30}}`}, + } + for _, tt := range tests { + client, rec := newRecordingServer(t, http.StatusOK, `{"Credentials Risk Report":{"risk":"credentials"}}`) + report, err := client.GenerateAudit(tt.opts) + if err != nil { + t.Fatalf("GenerateAudit: %v", err) + } + if rec.Path != "/api/v1/audit" { + t.Errorf("path = %s", rec.Path) + } + assertJSONEqual(t, rec.Body, tt.want) + if _, ok := report["Credentials Risk Report"]; !ok { + t.Errorf("report = %+v", report) + } + } +} diff --git a/internal/api/workflow_versions.go b/internal/api/workflow_versions.go new file mode 100644 index 0000000..e92fb2a --- /dev/null +++ b/internal/api/workflow_versions.go @@ -0,0 +1,134 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "time" +) + +// WorkflowState holds the draft/published version fields of a workflow. It is +// kept out of Workflow so pulled workflow files do not change on every save. +type WorkflowState struct { + VersionID string `json:"versionId,omitempty"` + ActiveVersionID *string `json:"activeVersionId"` + IsArchived bool `json:"isArchived"` +} + +// HasUnpublishedChanges reports whether the draft differs from the published version. +func (s *WorkflowState) HasUnpublishedChanges() bool { + return s.ActiveVersionID != nil && *s.ActiveVersionID != s.VersionID +} + +// GetWorkflowWithState returns a workflow together with its version state. +func (c *Client) GetWorkflowWithState(id string) (*Workflow, *WorkflowState, error) { + respBody, err := c.request(http.MethodGet, "/workflows/"+url.PathEscape(id), nil) + if err != nil { + return nil, nil, err + } + + var wf Workflow + var state WorkflowState + if err := json.Unmarshal(respBody, &wf); err != nil { + return nil, nil, fmt.Errorf("failed to parse response: %w", err) + } + if err := json.Unmarshal(respBody, &state); err != nil { + return nil, nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &wf, &state, nil +} + +func (c *Client) workflowAction(id, action string, body interface{}) (*WorkflowState, error) { + respBody, err := c.request(http.MethodPost, "/workflows/"+url.PathEscape(id)+"/"+action, body) + if err != nil { + return nil, err + } + + var state WorkflowState + if err := json.Unmarshal(respBody, &state); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &state, nil +} + +// ArchiveWorkflow archives a workflow. Archived workflows are deactivated and hidden from lists. +func (c *Client) ArchiveWorkflow(id string) (*WorkflowState, error) { + return c.workflowAction(id, "archive", nil) +} + +// UnarchiveWorkflow restores an archived workflow. +func (c *Client) UnarchiveWorkflow(id string) (*WorkflowState, error) { + return c.workflowAction(id, "unarchive", nil) +} + +// PublishWorkflow publishes a workflow version; an empty versionID publishes the current draft. +func (c *Client) PublishWorkflow(id, versionID string) (*WorkflowState, error) { + body := map[string]string{} + if versionID != "" { + body["versionId"] = versionID + } + return c.workflowAction(id, "publish", body) +} + +// UnpublishWorkflow removes the published version so the workflow no longer runs. +func (c *Client) UnpublishWorkflow(id string) (*WorkflowState, error) { + return c.workflowAction(id, "unpublish", nil) +} + +// WorkflowVersion is an entry in a workflow's version history. +type WorkflowVersion struct { + VersionID string `json:"versionId"` + WorkflowID string `json:"workflowId"` + Authors string `json:"authors"` + Name *string `json:"name"` + Description *string `json:"description"` + Nodes []map[string]interface{} `json:"nodes,omitempty"` + Connections map[string]interface{} `json:"connections,omitempty"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +// ListWorkflowHistory returns one page of a workflow's version history, newest first. +func (c *Client) ListWorkflowHistory(id string, limit int, cursor string) (*ListResult[WorkflowVersion], error) { + params := url.Values{} + if limit > 0 { + params.Set("limit", strconv.Itoa(limit)) + } + if cursor != "" { + params.Set("cursor", cursor) + } + + path := "/workflows/" + url.PathEscape(id) + "/history" + if len(params) > 0 { + path += "?" + params.Encode() + } + + return getJSON[ListResult[WorkflowVersion]](c, path) +} + +// GetWorkflowVersion returns one version of a workflow, including its nodes and connections. +func (c *Client) GetWorkflowVersion(id, versionID string) (*WorkflowVersion, error) { + return getJSON[WorkflowVersion](c, "/workflows/"+url.PathEscape(id)+"/versions/"+url.PathEscape(versionID)) +} + +func getJSON[T any](c *Client, path string) (*T, error) { + return doJSON[T](c, http.MethodGet, path, nil) +} + +func doJSON[T any](c *Client, method, path string, body interface{}) (*T, error) { + respBody, err := c.request(method, path, body) + if err != nil { + return nil, err + } + + var out T + if err := json.Unmarshal(respBody, &out); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &out, nil +} diff --git a/internal/cmd/audit/audit.go b/internal/cmd/audit/audit.go new file mode 100644 index 0000000..6554b6e --- /dev/null +++ b/internal/cmd/audit/audit.go @@ -0,0 +1,53 @@ +package audit + +import ( + "fmt" + "slices" + "strings" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/n8n-cli/internal/api" + "github.com/enthus-appdev/n8n-cli/internal/cmd/cmdutil" +) + +var categories = []string{"credentials", "database", "nodes", "filesystem", "instance"} + +func NewAuditCmd() *cobra.Command { + var ( + cats []string + daysAbandoned int + ) + + cmd := &cobra.Command{ + Use: "audit", + Short: "Run n8n's security audit and print the report as JSON", + Example: ` n8nctl audit + n8nctl audit --categories credentials,nodes --days-abandoned 30`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + for _, c := range cats { + if !slices.Contains(categories, c) { + return fmt.Errorf("invalid --categories value %q (must be one of: %s)", c, strings.Join(categories, ", ")) + } + } + + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + report, err := client.GenerateAudit(api.AuditOptions{Categories: cats, DaysAbandonedWorkflow: daysAbandoned}) + if err != nil { + return fmt.Errorf("failed to generate audit: %w", err) + } + + return cmdutil.PrintJSON(report) + }, + } + + cmd.Flags().StringSliceVar(&cats, "categories", nil, "Risk categories to audit: "+strings.Join(categories, ", ")+" (default: all)") + cmd.Flags().IntVar(&daysAbandoned, "days-abandoned", 0, "Days without executions before a workflow counts as abandoned (default: server default)") + + return cmd +} diff --git a/internal/cmd/cmdutil/cmdutil.go b/internal/cmd/cmdutil/cmdutil.go new file mode 100644 index 0000000..607ac25 --- /dev/null +++ b/internal/cmd/cmdutil/cmdutil.go @@ -0,0 +1,57 @@ +// Package cmdutil holds helpers shared by the command packages. +package cmdutil + +import ( + "encoding/json" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/n8n-cli/internal/api" + "github.com/enthus-appdev/n8n-cli/internal/config" +) + +// GetClient returns an API client for the active instance. +func GetClient() (*api.Client, error) { + cfg, err := config.Load() + if err != nil { + return nil, fmt.Errorf("failed to load config (run 'n8nctl config init' to create one): %w", err) + } + + instance, err := cfg.GetCurrentInstance() + if err != nil { + return nil, err + } + + return api.NewClient(instance.URL, instance.APIKey), nil +} + +// JSONOutput reports whether the global --json flag is set. +func JSONOutput(cmd *cobra.Command) bool { + v, _ := cmd.Flags().GetBool("json") + return v +} + +// PrintJSON writes v as indented JSON to stdout. +func PrintJSON(v interface{}) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +// PrintNextCursor tells the user how to fetch the next page. +func PrintNextCursor(cursor string) { + if cursor != "" { + fmt.Printf("\nMore results available. Use --cursor %s to continue.\n", cursor) + } +} + +// FormatTime formats a timestamp in local time, or "" when unset. +func FormatTime(t *time.Time) string { + if t == nil { + return "" + } + return t.Local().Format("2006-01-02 15:04:05") +} diff --git a/internal/cmd/credential/credential.go b/internal/cmd/credential/credential.go new file mode 100644 index 0000000..e08cc0d --- /dev/null +++ b/internal/cmd/credential/credential.go @@ -0,0 +1,148 @@ +package credential + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/n8n-cli/internal/cmd/cmdutil" +) + +func NewCredentialCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "credential", + Aliases: []string{"cred"}, + Short: "Manage n8n credentials", + Long: `List, test, and delete credentials. Secrets are never returned by the API.`, + } + + cmd.AddCommand(newListCmd()) + cmd.AddCommand(newTestCmd()) + cmd.AddCommand(newSchemaCmd()) + cmd.AddCommand(newDeleteCmd()) + + return cmd +} + +func newListCmd() *cobra.Command { + var ( + limit int + cursor string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List credentials", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + result, err := client.ListCredentials(limit, cursor) + if err != nil { + return fmt.Errorf("failed to list credentials: %w", err) + } + + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(result) + } + + if len(result.Data) == 0 { + fmt.Println("No credentials found.") + return nil + } + + fmt.Printf("%-18s %-28s %-30s %s\n", "ID", "TYPE", "PROJECT", "NAME") + for _, c := range result.Data { + var projects []string + for _, s := range c.Shared { + projects = append(projects, s.Name) + } + fmt.Printf("%-18s %-28s %-30s %s\n", c.ID, c.Type, strings.Join(projects, ", "), c.Name) + } + cmdutil.PrintNextCursor(result.NextCursor) + return nil + }, + } + + cmd.Flags().IntVar(&limit, "limit", 100, "Maximum number of credentials to return (max 250)") + cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor for next page") + + return cmd +} + +func newTestCmd() *cobra.Command { + return &cobra.Command{ + Use: "test ", + Short: "Test a credential against its service", + Long: `Test a credential. Exits non-zero when the test fails.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + result, err := client.TestCredential(args[0]) + if err != nil { + return fmt.Errorf("failed to test credential: %w", err) + } + + if cmdutil.JSONOutput(cmd) { + if err := cmdutil.PrintJSON(result); err != nil { + return err + } + } else { + fmt.Printf("%s: %s\n", result.Status, result.Message) + } + if result.Status != "OK" { + return fmt.Errorf("credential test failed") + } + return nil + }, + } +} + +func newSchemaCmd() *cobra.Command { + return &cobra.Command{ + Use: "schema ", + Short: "Show the JSON schema of a credential type's data", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + schema, err := client.GetCredentialSchema(args[0]) + if err != nil { + return fmt.Errorf("failed to get credential schema: %w", err) + } + + return cmdutil.PrintJSON(schema) + }, + } +} + +func newDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Delete a credential", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + if err := client.DeleteCredential(args[0]); err != nil { + return fmt.Errorf("failed to delete credential: %w", err) + } + + fmt.Println("Credential deleted.") + return nil + }, + } +} diff --git a/internal/cmd/datatable/datatable.go b/internal/cmd/datatable/datatable.go new file mode 100644 index 0000000..5e24c37 --- /dev/null +++ b/internal/cmd/datatable/datatable.go @@ -0,0 +1,335 @@ +package datatable + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/n8n-cli/internal/api" + "github.com/enthus-appdev/n8n-cli/internal/cmd/cmdutil" +) + +func NewDataTableCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "datatable", + Aliases: []string{"dt"}, + Short: "Read and write n8n data tables", + Long: `List data tables and read, insert, update, upsert, and delete rows. + +Select rows with repeatable --where conditions, where op is +one of = != > >= < <= ~ (~ is a case-insensitive LIKE; use % as wildcard). +Conditions are ANDed unless --or is set. For typed values or other shapes, +pass the API filter object with --filter instead.`, + } + + cmd.AddCommand(newListCmd()) + cmd.AddCommand(newViewCmd()) + cmd.AddCommand(newRowsCmd()) + cmd.AddCommand(newInsertCmd()) + cmd.AddCommand(newUpdateCmd()) + cmd.AddCommand(newUpsertCmd()) + cmd.AddCommand(newDeleteRowsCmd()) + + return cmd +} + +type filterFlags struct { + wheres []string + filter string + or bool +} + +func (f *filterFlags) register(cmd *cobra.Command) { + cmd.Flags().StringArrayVar(&f.wheres, "where", nil, "Row condition (repeatable)") + cmd.Flags().StringVar(&f.filter, "filter", "", `Raw filter JSON, e.g. '{"type":"and","filters":[{"columnName":"x","condition":"eq","value":1}]}'`) + cmd.Flags().BoolVar(&f.or, "or", false, "Match rows meeting any --where condition instead of all") +} + +func (f *filterFlags) build(required bool) (*api.DataTableFilter, error) { + filter, err := buildFilter(f.wheres, f.filter, f.or) + if err != nil { + return nil, err + } + if filter == nil && required { + return nil, fmt.Errorf("select rows with --where or --filter") + } + return filter, nil +} + +func printRaw(raw json.RawMessage) error { + var v interface{} + if err := json.Unmarshal(raw, &v); err != nil { + fmt.Println(string(raw)) + return nil + } + return cmdutil.PrintJSON(v) +} + +func newListCmd() *cobra.Command { + var ( + limit int + cursor string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List data tables", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + result, err := client.ListDataTables(limit, cursor) + if err != nil { + return fmt.Errorf("failed to list data tables: %w", err) + } + + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(result) + } + + if len(result.Data) == 0 { + fmt.Println("No data tables found.") + return nil + } + + fmt.Printf("%-18s %-18s %-8s %s\n", "ID", "PROJECT", "COLUMNS", "NAME") + for _, t := range result.Data { + fmt.Printf("%-18s %-18s %-8d %s\n", t.ID, t.ProjectID, len(t.Columns), t.Name) + } + cmdutil.PrintNextCursor(result.NextCursor) + return nil + }, + } + + cmd.Flags().IntVar(&limit, "limit", 100, "Maximum number of tables to return (max 250)") + cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor for next page") + + return cmd +} + +func newViewCmd() *cobra.Command { + return &cobra.Command{ + Use: "view ", + Short: "Show a data table and its columns", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + t, err := client.GetDataTable(args[0]) + if err != nil { + return fmt.Errorf("failed to get data table: %w", err) + } + + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(t) + } + + fmt.Printf("Data table: %s (%s)\n", t.Name, t.ID) + fmt.Printf("Project: %s\n", t.ProjectID) + fmt.Printf("Size: %d bytes\n", t.SizeBytes) + fmt.Println("Columns:") + for _, c := range t.Columns { + fmt.Printf(" %-30s %s\n", c.Name, c.Type) + } + return nil + }, + } +} + +func newRowsCmd() *cobra.Command { + var ( + ff filterFlags + search string + sortBy string + limit int + cursor string + ) + + cmd := &cobra.Command{ + Use: "rows ", + Short: "List rows as a JSON array (--json adds the pagination cursor)", + Example: ` n8nctl dt rows m7h0yVfJWk2hr9Mv --where 'displayname=Jane Doe' --limit 10 + n8nctl dt rows m7h0yVfJWk2hr9Mv --where 'requestduration>1000' --sort requestduration:desc + n8nctl dt rows m7h0yVfJWk2hr9Mv --where 'input~%invoice%' --where 'input~%rechnung%' --or`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + filter, err := ff.build(false) + if err != nil { + return err + } + + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + result, err := client.ListDataTableRows(args[0], api.ListRowsOptions{ + Filter: filter, Search: search, SortBy: sortBy, Limit: limit, Cursor: cursor, + }) + if err != nil { + return fmt.Errorf("failed to list rows: %w", err) + } + + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(result) + } + if err := cmdutil.PrintJSON(result.Data); err != nil { + return err + } + if result.NextCursor != "" { + fmt.Fprintf(os.Stderr, "More rows available. Use --cursor %s to continue.\n", result.NextCursor) + } + return nil + }, + } + + ff.register(cmd) + cmd.Flags().StringVar(&search, "search", "", "Text to search for in all string columns") + cmd.Flags().StringVar(&sortBy, "sort", "", "Sort as :asc or :desc") + cmd.Flags().IntVar(&limit, "limit", 100, "Maximum number of rows to return (max 250)") + cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor for next page") + + return cmd +} + +func newInsertCmd() *cobra.Command { + var data string + + cmd := &cobra.Command{ + Use: "insert ", + Short: "Insert one or more rows", + Example: ` n8nctl dt insert --data '{"name":"a","count":1}' + n8nctl dt insert --data '[{"name":"a"},{"name":"b"}]' + jq -c '.' rows.json | n8nctl dt insert --data -`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + raw, err := readData(data, stdin) + if err != nil { + return err + } + rows, err := parseRows(raw) + if err != nil { + return err + } + + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + inserted, err := client.InsertDataTableRows(args[0], rows) + if err != nil { + return fmt.Errorf("failed to insert rows: %w", err) + } + return printRaw(inserted) + }, + } + + cmd.Flags().StringVar(&data, "data", "", "Row object or array of row objects as JSON, or - to read stdin") + + return cmd +} + +func newWriteCmd(use, short, example string, call func(*api.Client, string, api.RowWriteOptions) (json.RawMessage, error)) *cobra.Command { + var ( + ff filterFlags + data string + dryRun bool + ) + + cmd := &cobra.Command{ + Use: use + " ", + Short: short, + Example: example, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + filter, err := ff.build(true) + if err != nil { + return err + } + raw, err := readData(data, stdin) + if err != nil { + return err + } + row, err := parseRow(raw) + if err != nil { + return err + } + + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + result, err := call(client, args[0], api.RowWriteOptions{Filter: *filter, Data: row, DryRun: dryRun}) + if err != nil { + return fmt.Errorf("failed to %s rows: %w", use, err) + } + return printRaw(result) + }, + } + + ff.register(cmd) + cmd.Flags().StringVar(&data, "data", "", "Column values as a JSON object, or - to read stdin") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show the rows that would change without writing") + + return cmd +} + +func newUpdateCmd() *cobra.Command { + return newWriteCmd("update", "Update every row matching the filter", + ` n8nctl dt update --where 'status=pending' --data '{"status":"done"}' --dry-run`, + (*api.Client).UpdateDataTableRows) +} + +func newUpsertCmd() *cobra.Command { + return newWriteCmd("upsert", "Update the rows matching the filter, or insert one if none match", + ` n8nctl dt upsert --where 'cache_key=dashboard' --data '{"cache_key":"dashboard","payload":"..."}'`, + (*api.Client).UpsertDataTableRow) +} + +func newDeleteRowsCmd() *cobra.Command { + var ( + ff filterFlags + dryRun bool + ) + + cmd := &cobra.Command{ + Use: "delete-rows ", + Short: "Delete the rows matching the filter", + Long: `Delete the rows matching --where or --filter and print them. + +A filter is required; this command cannot clear a whole table.`, + Example: ` n8nctl dt delete-rows --where 'status=obsolete' --dry-run`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + filter, err := ff.build(true) + if err != nil { + return err + } + + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + deleted, err := client.DeleteDataTableRows(args[0], api.RowWriteOptions{Filter: *filter, DryRun: dryRun}) + if err != nil { + return fmt.Errorf("failed to delete rows: %w", err) + } + return printRaw(deleted) + }, + } + + ff.register(cmd) + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show the rows that would be deleted without deleting") + + return cmd +} diff --git a/internal/cmd/datatable/filter.go b/internal/cmd/datatable/filter.go new file mode 100644 index 0000000..913cccd --- /dev/null +++ b/internal/cmd/datatable/filter.go @@ -0,0 +1,136 @@ +package datatable + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/enthus-appdev/n8n-cli/internal/api" +) + +// whereOperators maps --where operators to API conditions. Two-character +// operators come first so ">=" is not read as ">" followed by "=value". +var whereOperators = []struct{ op, condition string }{ + {">=", "gte"}, {"<=", "lte"}, {"!=", "neq"}, {"~", "ilike"}, + {"=", "eq"}, {">", "gt"}, {"<", "lt"}, +} + +// parseWhere turns "columnvalue" into a filter condition. Column names +// may only contain letters, digits and underscores, so the operator starts at +// the first other character. Values are sent as strings; n8n converts them to +// the column type. +func parseWhere(expr string) (api.DataTableFilterCondition, error) { + i := strings.IndexFunc(expr, func(r rune) bool { return !isColumnChar(r) }) + if i <= 0 { + return api.DataTableFilterCondition{}, fmt.Errorf("invalid --where %q (expected , op one of = != > >= < <= ~)", expr) + } + column, rest := expr[:i], expr[i:] + for _, o := range whereOperators { + if value, ok := strings.CutPrefix(rest, o.op); ok { + return api.DataTableFilterCondition{ColumnName: column, Condition: o.condition, Value: value}, nil + } + } + return api.DataTableFilterCondition{}, fmt.Errorf("invalid operator in --where %q (use = != > >= < <= ~)", expr) +} + +func isColumnChar(r rune) bool { + return r == '_' || r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' +} + +// buildFilter combines --where conditions or a raw --filter JSON object. It +// returns nil when neither is set. +func buildFilter(wheres []string, rawFilter string, or bool) (*api.DataTableFilter, error) { + if rawFilter != "" && len(wheres) > 0 { + return nil, fmt.Errorf("use either --where or --filter, not both") + } + if rawFilter != "" { + var f api.DataTableFilter + if err := decodeJSON([]byte(rawFilter), &f); err != nil { + return nil, fmt.Errorf("invalid --filter JSON: %w", err) + } + if len(f.Filters) == 0 { + return nil, fmt.Errorf("--filter needs at least one entry in \"filters\"") + } + return &f, nil + } + if len(wheres) == 0 { + return nil, nil + } + f := &api.DataTableFilter{Type: "and"} + if or { + f.Type = "or" + } + for _, w := range wheres { + cond, err := parseWhere(w) + if err != nil { + return nil, err + } + f.Filters = append(f.Filters, cond) + } + return f, nil +} + +// readData parses --data, reading stdin when the value is "-". +func readData(data string, stdin io.Reader) ([]byte, error) { + if data == "-" { + b, err := io.ReadAll(stdin) + if err != nil { + return nil, fmt.Errorf("failed to read stdin: %w", err) + } + return b, nil + } + if data == "" { + return nil, fmt.Errorf("--data is required (JSON, or - to read stdin)") + } + return []byte(data), nil +} + +// parseRows accepts a JSON object or an array of objects. +func parseRows(raw []byte) ([]map[string]interface{}, error) { + trimmed := strings.TrimSpace(string(raw)) + if strings.HasPrefix(trimmed, "[") { + var rows []map[string]interface{} + if err := decodeJSON(raw, &rows); err != nil { + return nil, fmt.Errorf("invalid --data JSON: %w", err) + } + if len(rows) == 0 { + return nil, fmt.Errorf("--data array is empty") + } + return rows, nil + } + row, err := parseRow(raw) + if err != nil { + return nil, err + } + return []map[string]interface{}{row}, nil +} + +func parseRow(raw []byte) (map[string]interface{}, error) { + var row map[string]interface{} + if err := decodeJSON(raw, &row); err != nil { + return nil, fmt.Errorf("invalid --data JSON object: %w", err) + } + if len(row) == 0 { + return nil, fmt.Errorf("--data object is empty") + } + return row, nil +} + +// decodeJSON keeps numbers as json.Number so integers above 2^53 reach the +// API unchanged. +func decodeJSON(raw []byte, v interface{}) error { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + if err := dec.Decode(v); err != nil { + return err + } + if _, err := dec.Token(); err != io.EOF { + return fmt.Errorf("unexpected data after JSON value") + } + return nil +} + +var stdin io.Reader = os.Stdin diff --git a/internal/cmd/datatable/filter_test.go b/internal/cmd/datatable/filter_test.go new file mode 100644 index 0000000..fe3ab13 --- /dev/null +++ b/internal/cmd/datatable/filter_test.go @@ -0,0 +1,136 @@ +package datatable + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/enthus-appdev/n8n-cli/internal/api" +) + +func TestParseWhere(t *testing.T) { + tests := []struct { + expr string + want api.DataTableFilterCondition + }{ + {"status=done", api.DataTableFilterCondition{ColumnName: "status", Condition: "eq", Value: "done"}}, + {"status!=done", api.DataTableFilterCondition{ColumnName: "status", Condition: "neq", Value: "done"}}, + {"count>=10", api.DataTableFilterCondition{ColumnName: "count", Condition: "gte", Value: "10"}}, + {"count<=10", api.DataTableFilterCondition{ColumnName: "count", Condition: "lte", Value: "10"}}, + {"count>10", api.DataTableFilterCondition{ColumnName: "count", Condition: "gt", Value: "10"}}, + {"count<10", api.DataTableFilterCondition{ColumnName: "count", Condition: "lt", Value: "10"}}, + {"name~%inv%", api.DataTableFilterCondition{ColumnName: "name", Condition: "ilike", Value: "%inv%"}}, + {"a_b1=x=y", api.DataTableFilterCondition{ColumnName: "a_b1", Condition: "eq", Value: "x=y"}}, + {"note=", api.DataTableFilterCondition{ColumnName: "note", Condition: "eq", Value: ""}}, + {"cmp=>=", api.DataTableFilterCondition{ColumnName: "cmp", Condition: "eq", Value: ">="}}, + } + for _, tt := range tests { + got, err := parseWhere(tt.expr) + if err != nil { + t.Errorf("parseWhere(%q): %v", tt.expr, err) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseWhere(%q) = %+v, want %+v", tt.expr, got, tt.want) + } + } +} + +func TestParseWhereErrors(t *testing.T) { + for _, expr := range []string{"", "=x", "status", "status:done", "status!x"} { + if _, err := parseWhere(expr); err == nil { + t.Errorf("parseWhere(%q) succeeded, want error", expr) + } + } +} + +func TestBuildFilter(t *testing.T) { + f, err := buildFilter([]string{"a=1", "b=2"}, "", true) + if err != nil { + t.Fatalf("buildFilter: %v", err) + } + if f.Type != "or" || len(f.Filters) != 2 { + t.Errorf("filter = %+v", f) + } + + f, err = buildFilter(nil, `{"filters":[{"columnName":"id","condition":"eq","value":1}]}`, false) + if err != nil { + t.Fatalf("buildFilter raw: %v", err) + } + if f.Filters[0].Value != json.Number("1") { + t.Errorf("raw filter value = %#v, want number 1", f.Filters[0].Value) + } + + big := `{"filters":[{"columnName":"id","condition":"eq","value":9007199254740993}]}` + f, err = buildFilter(nil, big, false) + if err != nil { + t.Fatalf("buildFilter big: %v", err) + } + out, _ := json.Marshal(f.Filters[0]) + if !strings.Contains(string(out), `"value":9007199254740993`) { + t.Errorf("big integer re-encoded as %s", out) + } + + if f, err := buildFilter(nil, "", false); f != nil || err != nil { + t.Errorf("empty = %+v, %v; want nil, nil", f, err) + } +} + +func TestBuildFilterErrors(t *testing.T) { + tests := []struct { + wheres []string + raw string + wantErr string + }{ + {[]string{"a=1"}, `{"filters":[]}`, "either --where or --filter"}, + {nil, `{"filters":[]}`, "at least one entry"}, + {nil, `not json`, "invalid --filter JSON"}, + {[]string{"bad"}, "", "invalid --where"}, + } + for _, tt := range tests { + _, err := buildFilter(tt.wheres, tt.raw, false) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("buildFilter(%v, %q) err = %v, want %q", tt.wheres, tt.raw, err, tt.wantErr) + } + } +} + +func TestParseRows(t *testing.T) { + rows, err := parseRows([]byte(` [{"a":1},{"a":2}]`)) + if err != nil || len(rows) != 2 { + t.Errorf("array: rows = %v, err = %v", rows, err) + } + rows, err = parseRows([]byte(`{"a":1}`)) + if err != nil || len(rows) != 1 { + t.Errorf("object: rows = %v, err = %v", rows, err) + } + rows, err = parseRows([]byte(`{"n":9007199254740993}`)) + if err != nil { + t.Fatalf("big: %v", err) + } + if out, _ := json.Marshal(rows[0]); string(out) != `{"n":9007199254740993}` { + t.Errorf("big integer re-encoded as %s", out) + } + for _, bad := range []string{`[]`, `{}`, `null`, `"x"`, `[1]`, `{"a":1} {"b":2}`, `{"a":1}]`, `{"a":1}}`, `[{"a":1}]]`} { + if _, err := parseRows([]byte(bad)); err == nil { + t.Errorf("parseRows(%s) succeeded, want error", bad) + } + } +} + +func TestBuildFilterRejectsTrailingData(t *testing.T) { + if _, err := buildFilter(nil, `{"filters":[{"columnName":"a","condition":"eq","value":1}]}]`, false); err == nil { + t.Error("trailing ] accepted, want error") + } +} + +func TestReadData(t *testing.T) { + b, err := readData("-", strings.NewReader(`{"a":1}`)) + if err != nil || string(b) != `{"a":1}` { + t.Errorf("stdin: %q, %v", b, err) + } + if _, err := readData("", strings.NewReader("")); err == nil { + t.Error("empty --data succeeded, want error") + } +} diff --git a/internal/cmd/execution/execution.go b/internal/cmd/execution/execution.go index ec257e6..0ff02a0 100644 --- a/internal/cmd/execution/execution.go +++ b/internal/cmd/execution/execution.go @@ -25,6 +25,7 @@ func NewExecutionCmd() *cobra.Command { cmd.AddCommand(newViewCmd()) cmd.AddCommand(newRetryCmd()) cmd.AddCommand(newDeleteCmd()) + cmd.AddCommand(newStopCmd()) return cmd } diff --git a/internal/cmd/execution/stop.go b/internal/cmd/execution/stop.go new file mode 100644 index 0000000..1fb673b --- /dev/null +++ b/internal/cmd/execution/stop.go @@ -0,0 +1,98 @@ +package execution + +import ( + "fmt" + "slices" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/n8n-cli/internal/api" + "github.com/enthus-appdev/n8n-cli/internal/cmd/cmdutil" +) + +var stoppableStatuses = []string{"queued", "running", "waiting"} + +func newStopCmd() *cobra.Command { + var ( + statuses []string + workflowID string + startedAfter string + startedBefore string + ) + + cmd := &cobra.Command{ + Use: "stop [execution-id]", + Short: "Stop one execution, or every execution matching filters", + Long: `Stop a single execution by ID, or stop in bulk by status. + +Bulk stop needs --status and can be narrowed with --workflow and a start-time +window (RFC 3339 timestamps).`, + Example: ` n8nctl execution stop 12345 + n8nctl execution stop --status running,waiting --workflow 1QHLOQM9piQTOb9Y + n8nctl execution stop --status queued --started-before 2026-10-13T08:00:00Z`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + bulk := len(statuses) > 0 || workflowID != "" || startedAfter != "" || startedBefore != "" + if len(args) == 1 && bulk { + return fmt.Errorf("pass either an execution ID or bulk filters, not both") + } + if len(args) == 0 && len(statuses) == 0 { + return fmt.Errorf("pass an execution ID, or --status for a bulk stop") + } + for flag, v := range map[string]string{"--started-after": startedAfter, "--started-before": startedBefore} { + if v == "" { + continue + } + if _, err := time.Parse(time.RFC3339, v); err != nil { + return fmt.Errorf("invalid %s %q (use RFC 3339, e.g. 2026-10-13T08:00:00Z)", flag, v) + } + } + for _, s := range statuses { + if !slices.Contains(stoppableStatuses, s) { + return fmt.Errorf("invalid --status %q (must be one of: %s)", s, strings.Join(stoppableStatuses, ", ")) + } + } + + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + if len(args) == 1 { + exec, err := client.StopExecution(args[0]) + if err != nil { + return fmt.Errorf("failed to stop execution: %w", err) + } + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(exec) + } + fmt.Printf("Stop requested for execution %s; status is now %s.\n", args[0], exec.Status) + return nil + } + + stopped, err := client.StopExecutions(api.StopExecutionsOptions{ + Status: statuses, + WorkflowID: workflowID, + StartedAfter: startedAfter, + StartedBefore: startedBefore, + }) + if err != nil { + return fmt.Errorf("failed to stop executions: %w", err) + } + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(map[string]int{"stopped": stopped}) + } + fmt.Printf("Stopped %d executions.\n", stopped) + return nil + }, + } + + cmd.Flags().StringSliceVar(&statuses, "status", nil, "Bulk stop executions with these statuses: "+strings.Join(stoppableStatuses, ", ")) + cmd.Flags().StringVar(&workflowID, "workflow", "", "Bulk stop only executions of this workflow") + cmd.Flags().StringVar(&startedAfter, "started-after", "", "Bulk stop only executions started after this time (RFC 3339)") + cmd.Flags().StringVar(&startedBefore, "started-before", "", "Bulk stop only executions started before this time (RFC 3339)") + + return cmd +} diff --git a/internal/cmd/execution/stop_test.go b/internal/cmd/execution/stop_test.go new file mode 100644 index 0000000..17dff7d --- /dev/null +++ b/internal/cmd/execution/stop_test.go @@ -0,0 +1,30 @@ +package execution + +import ( + "strings" + "testing" +) + +func TestStopArgValidation(t *testing.T) { + tests := []struct { + args []string + wantErr string + }{ + {nil, "pass an execution ID, or --status"}, + {[]string{"42", "--status", "running"}, "either an execution ID or bulk filters"}, + {[]string{"42", "--workflow", "wf1"}, "either an execution ID or bulk filters"}, + {[]string{"--workflow", "wf1"}, "pass an execution ID, or --status"}, + {[]string{"--status", "error"}, `invalid --status "error"`}, + {[]string{"--status", "running", "--started-after", "2026-10-13"}, `invalid --started-after "2026-10-13"`}, + } + for _, tt := range tests { + cmd := newStopCmd() + cmd.SetArgs(tt.args) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("args %v: err = %v, want %q", tt.args, err, tt.wantErr) + } + } +} diff --git a/internal/cmd/folder/folder.go b/internal/cmd/folder/folder.go new file mode 100644 index 0000000..84ba5b5 --- /dev/null +++ b/internal/cmd/folder/folder.go @@ -0,0 +1,219 @@ +package folder + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/n8n-cli/internal/cmd/cmdutil" +) + +func NewFolderCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "folder", + Short: "Manage workflow folders in a project", + Long: `List, create, rename, move, and delete workflow folders. Every subcommand needs --project.`, + } + + cmd.PersistentFlags().String("project", "", "Project ID (required; see 'n8nctl project list')") + _ = cmd.MarkPersistentFlagRequired("project") + + cmd.AddCommand(newListCmd()) + cmd.AddCommand(newViewCmd()) + cmd.AddCommand(newCreateCmd()) + cmd.AddCommand(newUpdateCmd()) + cmd.AddCommand(newDeleteCmd()) + + return cmd +} + +func projectID(cmd *cobra.Command) string { + p, _ := cmd.Flags().GetString("project") + return p +} + +func parentName(id *string) string { + if id == nil { + return "-" + } + return *id +} + +func newListCmd() *cobra.Command { + var skip, take int + + cmd := &cobra.Command{ + Use: "list", + Short: "List the folders of a project", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + folders, count, err := client.ListFolders(projectID(cmd), skip, take) + if err != nil { + return fmt.Errorf("failed to list folders: %w", err) + } + + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(map[string]interface{}{"count": count, "data": folders}) + } + + if len(folders) == 0 { + fmt.Println("No folders found.") + return nil + } + + fmt.Printf("%-18s %-18s %s\n", "ID", "PARENT", "NAME") + for _, f := range folders { + fmt.Printf("%-18s %-18s %s\n", f.ID, parentName(f.ParentFolderID), f.Name) + } + if shown := skip + len(folders); shown < count { + fmt.Printf("\nShowing %d of %d. Use --skip %d to continue.\n", len(folders), count, shown) + } + return nil + }, + } + + cmd.Flags().IntVar(&skip, "skip", 0, "Number of folders to skip") + cmd.Flags().IntVar(&take, "take", 0, "Maximum number of folders to return (default: server default)") + + return cmd +} + +func newViewCmd() *cobra.Command { + return &cobra.Command{ + Use: "view ", + Short: "Show a folder with its sub-folder and workflow counts", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + f, err := client.GetFolder(projectID(cmd), args[0]) + if err != nil { + return fmt.Errorf("failed to get folder: %w", err) + } + + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(f) + } + + fmt.Printf("Folder: %s (%s)\n", f.Name, f.ID) + fmt.Printf("Parent: %s\n", parentName(f.ParentFolderID)) + if f.TotalSubFolders != nil { + fmt.Printf("Sub-folders: %d\n", *f.TotalSubFolders) + } + if f.TotalWorkflows != nil { + fmt.Printf("Workflows: %d\n", *f.TotalWorkflows) + } + return nil + }, + } +} + +func newCreateCmd() *cobra.Command { + var parent string + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a folder", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + f, err := client.CreateFolder(projectID(cmd), args[0], parent) + if err != nil { + return fmt.Errorf("failed to create folder: %w", err) + } + + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(f) + } + fmt.Printf("Created folder %s (%s).\n", f.Name, f.ID) + return nil + }, + } + + cmd.Flags().StringVar(&parent, "parent", "", "Parent folder ID (default: project root)") + + return cmd +} + +func newUpdateCmd() *cobra.Command { + var name, parent string + + cmd := &cobra.Command{ + Use: "update ", + Short: "Rename a folder or move it under another folder", + Long: `Rename a folder or move it under another folder. + +The public API has no documented way to move a folder back to the project +root; use the n8n UI for that.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" && parent == "" { + return fmt.Errorf("pass --name and/or --parent") + } + + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + f, err := client.UpdateFolder(projectID(cmd), args[0], name, parent) + if err != nil { + return fmt.Errorf("failed to update folder: %w", err) + } + + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(f) + } + fmt.Println("Folder updated.") + return nil + }, + } + + cmd.Flags().StringVar(&name, "name", "", "New folder name") + cmd.Flags().StringVar(&parent, "parent", "", "New parent folder ID") + + return cmd +} + +func newDeleteCmd() *cobra.Command { + var transferTo string + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a folder", + Long: `Delete a folder. + +With --transfer-to, its workflows and sub-folders move to that folder first. +Without it, its workflows move to the project root and are ARCHIVED, and its +sub-folders are deleted.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + if err := client.DeleteFolder(projectID(cmd), args[0], transferTo); err != nil { + return fmt.Errorf("failed to delete folder: %w", err) + } + + fmt.Println("Folder deleted.") + return nil + }, + } + + cmd.Flags().StringVar(&transferTo, "transfer-to", "", "Folder ID that receives the deleted folder's contents") + + return cmd +} diff --git a/internal/cmd/project/member.go b/internal/cmd/project/member.go new file mode 100644 index 0000000..86fa4ce --- /dev/null +++ b/internal/cmd/project/member.go @@ -0,0 +1,145 @@ +package project + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/n8n-cli/internal/api" + "github.com/enthus-appdev/n8n-cli/internal/cmd/cmdutil" + "github.com/enthus-appdev/n8n-cli/internal/cmd/user" +) + +func newMemberCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "member", + Short: "Manage project members", + Long: `List, add, and remove project members and change their project role. + +Project roles look like project:admin, project:editor, or project:viewer.`, + } + + cmd.AddCommand(newMemberListCmd()) + cmd.AddCommand(newMemberAddCmd()) + cmd.AddCommand(newMemberSetRoleCmd()) + cmd.AddCommand(newMemberRemoveCmd()) + + return cmd +} + +func newMemberListCmd() *cobra.Command { + var ( + limit int + cursor string + ) + + cmd := &cobra.Command{ + Use: "list ", + Short: "List a project's members with their project role", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + result, err := client.ListProjectMembers(args[0], limit, cursor) + if err != nil { + return fmt.Errorf("failed to list project members: %w", err) + } + + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(result) + } + + if len(result.Data) == 0 { + fmt.Println("No members found.") + return nil + } + + user.PrintUsers(result.Data) + cmdutil.PrintNextCursor(result.NextCursor) + return nil + }, + } + + cmd.Flags().IntVar(&limit, "limit", 100, "Maximum number of members to return (max 250)") + cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor for next page") + + return cmd +} + +func newMemberAddCmd() *cobra.Command { + var role string + + cmd := &cobra.Command{ + Use: "add ...", + Short: "Add users to a project", + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + relations := make([]api.ProjectRelation, 0, len(args)-1) + for _, id := range args[1:] { + relations = append(relations, api.ProjectRelation{UserID: id, Role: role}) + } + + if err := client.AddProjectMembers(args[0], relations); err != nil { + return fmt.Errorf("failed to add project members: %w", err) + } + + fmt.Printf("Added %d members as %s.\n", len(relations), role) + return nil + }, + } + + cmd.Flags().StringVar(&role, "role", "", "Project role for the added users, e.g. project:editor (required)") + _ = cmd.MarkFlagRequired("role") + + return cmd +} + +func newMemberSetRoleCmd() *cobra.Command { + return &cobra.Command{ + Use: "set-role ", + Short: "Change a member's project role", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + if err := client.SetProjectMemberRole(args[0], args[1], args[2]); err != nil { + return fmt.Errorf("failed to change project role: %w", err) + } + + fmt.Println("Project role changed.") + return nil + }, + } +} + +func newMemberRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove a user from a project", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + if err := client.RemoveProjectMember(args[0], args[1]); err != nil { + return fmt.Errorf("failed to remove project member: %w", err) + } + + fmt.Println("Project member removed.") + return nil + }, + } +} diff --git a/internal/cmd/project/project.go b/internal/cmd/project/project.go index 20ebaf4..dc4588f 100644 --- a/internal/cmd/project/project.go +++ b/internal/cmd/project/project.go @@ -16,10 +16,11 @@ func NewProjectCmd() *cobra.Command { cmd := &cobra.Command{ Use: "project", Short: "Manage n8n projects", - Long: `List and manage n8n projects.`, + Long: `List n8n projects and manage their members.`, } cmd.AddCommand(newListCmd()) + cmd.AddCommand(newMemberCmd()) return cmd } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 8721118..e418a00 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -8,10 +8,16 @@ import ( "github.com/spf13/cobra" + auditcmd "github.com/enthus-appdev/n8n-cli/internal/cmd/audit" configcmd "github.com/enthus-appdev/n8n-cli/internal/cmd/config" + credentialcmd "github.com/enthus-appdev/n8n-cli/internal/cmd/credential" + datatablecmd "github.com/enthus-appdev/n8n-cli/internal/cmd/datatable" executioncmd "github.com/enthus-appdev/n8n-cli/internal/cmd/execution" + foldercmd "github.com/enthus-appdev/n8n-cli/internal/cmd/folder" projectcmd "github.com/enthus-appdev/n8n-cli/internal/cmd/project" sourcecontrolcmd "github.com/enthus-appdev/n8n-cli/internal/cmd/sourcecontrol" + tagcmd "github.com/enthus-appdev/n8n-cli/internal/cmd/tag" + usercmd "github.com/enthus-appdev/n8n-cli/internal/cmd/user" variablecmd "github.com/enthus-appdev/n8n-cli/internal/cmd/variable" workflowcmd "github.com/enthus-appdev/n8n-cli/internal/cmd/workflow" ) @@ -46,6 +52,12 @@ func init() { rootCmd.AddCommand(projectcmd.NewProjectCmd()) rootCmd.AddCommand(variablecmd.NewVariableCmd()) rootCmd.AddCommand(sourcecontrolcmd.NewSourceControlCmd()) + rootCmd.AddCommand(credentialcmd.NewCredentialCmd()) + rootCmd.AddCommand(tagcmd.NewTagCmd()) + rootCmd.AddCommand(foldercmd.NewFolderCmd()) + rootCmd.AddCommand(datatablecmd.NewDataTableCmd()) + rootCmd.AddCommand(usercmd.NewUserCmd()) + rootCmd.AddCommand(auditcmd.NewAuditCmd()) rootCmd.AddCommand(newVersionCmd()) } diff --git a/internal/cmd/tag/tag.go b/internal/cmd/tag/tag.go new file mode 100644 index 0000000..108b488 --- /dev/null +++ b/internal/cmd/tag/tag.go @@ -0,0 +1,138 @@ +package tag + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/n8n-cli/internal/cmd/cmdutil" +) + +func NewTagCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "tag", + Short: "Manage workflow tags", + } + + cmd.AddCommand(newListCmd()) + cmd.AddCommand(newCreateCmd()) + cmd.AddCommand(newRenameCmd()) + cmd.AddCommand(newDeleteCmd()) + + return cmd +} + +func newListCmd() *cobra.Command { + var ( + limit int + cursor string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List tags", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + result, err := client.ListTagsPage(limit, cursor) + if err != nil { + return fmt.Errorf("failed to list tags: %w", err) + } + + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(result) + } + + if len(result.Data) == 0 { + fmt.Println("No tags found.") + return nil + } + + fmt.Printf("%-18s %s\n", "ID", "NAME") + for _, t := range result.Data { + fmt.Printf("%-18s %s\n", t.ID, t.Name) + } + cmdutil.PrintNextCursor(result.NextCursor) + return nil + }, + } + + cmd.Flags().IntVar(&limit, "limit", 100, "Maximum number of tags to return (max 250)") + cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor for next page") + + return cmd +} + +func newCreateCmd() *cobra.Command { + return &cobra.Command{ + Use: "create ", + Short: "Create a tag", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + tag, err := client.CreateTag(args[0]) + if err != nil { + return fmt.Errorf("failed to create tag: %w", err) + } + + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(tag) + } + fmt.Printf("Created tag %s (%s).\n", tag.Name, tag.ID) + return nil + }, + } +} + +func newRenameCmd() *cobra.Command { + return &cobra.Command{ + Use: "rename ", + Short: "Rename a tag", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + tag, err := client.UpdateTag(args[0], args[1]) + if err != nil { + return fmt.Errorf("failed to rename tag: %w", err) + } + + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(tag) + } + fmt.Printf("Renamed tag %s to %s.\n", tag.ID, tag.Name) + return nil + }, + } +} + +func newDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Delete a tag (removes it from every workflow)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + if err := client.DeleteTag(args[0]); err != nil { + return fmt.Errorf("failed to delete tag: %w", err) + } + + fmt.Println("Tag deleted.") + return nil + }, + } +} diff --git a/internal/cmd/user/user.go b/internal/cmd/user/user.go new file mode 100644 index 0000000..cae7f4d --- /dev/null +++ b/internal/cmd/user/user.go @@ -0,0 +1,109 @@ +package user + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/n8n-cli/internal/api" + "github.com/enthus-appdev/n8n-cli/internal/cmd/cmdutil" +) + +func NewUserCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "user", + Short: "Look up n8n users", + Long: `List and look up users. Project membership is managed with 'n8nctl project member'.`, + } + + cmd.AddCommand(newListCmd()) + cmd.AddCommand(newViewCmd()) + + return cmd +} + +// PrintUsers prints users as a table; role is the global or project role. +func PrintUsers(users []api.User) { + fmt.Printf("%-36s %-24s %-34s %s\n", "ID", "ROLE", "EMAIL", "NAME") + for _, u := range users { + name := strings.TrimSpace(u.FirstName + " " + u.LastName) + if u.IsPending { + name += " (pending)" + } + fmt.Printf("%-36s %-24s %-34s %s\n", u.ID, u.Role, u.Email, name) + } +} + +func newListCmd() *cobra.Command { + var ( + limit int + cursor string + projectID string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List users with their global role", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + result, err := client.ListUsers(limit, cursor, projectID) + if err != nil { + return fmt.Errorf("failed to list users: %w", err) + } + + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(result) + } + + if len(result.Data) == 0 { + fmt.Println("No users found.") + return nil + } + + PrintUsers(result.Data) + cmdutil.PrintNextCursor(result.NextCursor) + return nil + }, + } + + cmd.Flags().IntVar(&limit, "limit", 100, "Maximum number of users to return (max 250)") + cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor for next page") + cmd.Flags().StringVar(&projectID, "project", "", "Only users that are members of this project") + + return cmd +} + +func newViewCmd() *cobra.Command { + return &cobra.Command{ + Use: "view ", + Short: "Show a user", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + u, err := client.GetUser(args[0]) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(u) + } + + fmt.Printf("User: %s %s <%s>\n", u.FirstName, u.LastName, u.Email) + fmt.Printf("ID: %s\n", u.ID) + fmt.Printf("Role: %s\n", u.Role) + fmt.Printf("Pending invite: %t\n", u.IsPending) + fmt.Printf("MFA enabled: %t\n", u.MFAEnabled) + return nil + }, + } +} diff --git a/internal/cmd/workflow/versions.go b/internal/cmd/workflow/versions.go new file mode 100644 index 0000000..5cce12a --- /dev/null +++ b/internal/cmd/workflow/versions.go @@ -0,0 +1,182 @@ +package workflow + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/n8n-cli/internal/api" + "github.com/enthus-appdev/n8n-cli/internal/cmd/cmdutil" +) + +func newStateCmd(use, short, done string, call func(*api.Client, string) (*api.WorkflowState, error)) *cobra.Command { + return &cobra.Command{ + Use: use + " ", + Short: short, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + state, err := call(client, args[0]) + if err != nil { + return fmt.Errorf("failed to %s workflow: %w", use, err) + } + + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(state) + } + fmt.Println(done) + return nil + }, + } +} + +func newArchiveCmd() *cobra.Command { + return newStateCmd("archive", "Archive a workflow (soft delete; deactivates it)", "Workflow archived.", (*api.Client).ArchiveWorkflow) +} + +func newUnarchiveCmd() *cobra.Command { + return newStateCmd("unarchive", "Restore an archived workflow", "Workflow unarchived.", (*api.Client).UnarchiveWorkflow) +} + +func newUnpublishCmd() *cobra.Command { + return newStateCmd("unpublish", "Unpublish a workflow so its triggers stop", "Workflow unpublished.", (*api.Client).UnpublishWorkflow) +} + +func newPublishCmd() *cobra.Command { + var versionID string + + cmd := &cobra.Command{ + Use: "publish ", + Short: "Publish the current draft, or a specific version, of a workflow", + Long: `Publish a workflow version so it becomes the version that runs. + +Without --version the current draft is published. Use 'workflow view' to see +whether the draft differs from the published version, and 'workflow history' +to list version IDs.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + state, err := client.PublishWorkflow(args[0], versionID) + if err != nil { + return fmt.Errorf("failed to publish workflow: %w", err) + } + + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(state) + } + if state.ActiveVersionID != nil { + fmt.Printf("Workflow published (version %s).\n", *state.ActiveVersionID) + } else { + fmt.Println("Workflow published.") + } + return nil + }, + } + + cmd.Flags().StringVar(&versionID, "version", "", "Version ID to publish (default: current draft)") + + return cmd +} + +func newDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Permanently delete a workflow", + Long: `Permanently delete a workflow and its version history. + +A published workflow must be unpublished or archived first. Use +'workflow archive' alone for a reversible soft delete.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + if err := client.DeleteWorkflow(args[0]); err != nil { + return fmt.Errorf("failed to delete workflow: %w", err) + } + + fmt.Println("Workflow deleted.") + return nil + }, + } +} + +func newHistoryCmd() *cobra.Command { + var ( + limit int + cursor string + ) + + cmd := &cobra.Command{ + Use: "history ", + Short: "List a workflow's saved versions, newest first", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + result, err := client.ListWorkflowHistory(args[0], limit, cursor) + if err != nil { + return fmt.Errorf("failed to get workflow history: %w", err) + } + + if cmdutil.JSONOutput(cmd) { + return cmdutil.PrintJSON(result) + } + + if len(result.Data) == 0 { + fmt.Println("No versions found.") + return nil + } + + fmt.Printf("%-36s %-19s %s\n", "VERSION", "CREATED", "AUTHORS") + for _, v := range result.Data { + label := v.Authors + if v.Name != nil && *v.Name != "" { + label += " (" + *v.Name + ")" + } + fmt.Printf("%-36s %-19s %s\n", v.VersionID, cmdutil.FormatTime(v.CreatedAt), label) + } + cmdutil.PrintNextCursor(result.NextCursor) + return nil + }, + } + + cmd.Flags().IntVar(&limit, "limit", 20, "Maximum number of versions to return (max 250)") + cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor for next page") + + return cmd +} + +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version ", + Short: "Show one saved version of a workflow as JSON", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdutil.GetClient() + if err != nil { + return err + } + + v, err := client.GetWorkflowVersion(args[0], args[1]) + if err != nil { + return fmt.Errorf("failed to get workflow version: %w", err) + } + + return cmdutil.PrintJSON(v) + }, + } +} diff --git a/internal/cmd/workflow/workflow.go b/internal/cmd/workflow/workflow.go index 66461bc..64f0444 100644 --- a/internal/cmd/workflow/workflow.go +++ b/internal/cmd/workflow/workflow.go @@ -30,6 +30,13 @@ func NewWorkflowCmd() *cobra.Command { cmd.AddCommand(newActivateCmd()) cmd.AddCommand(newDeactivateCmd()) cmd.AddCommand(newTransferCmd()) + cmd.AddCommand(newPublishCmd()) + cmd.AddCommand(newUnpublishCmd()) + cmd.AddCommand(newArchiveCmd()) + cmd.AddCommand(newUnarchiveCmd()) + cmd.AddCommand(newDeleteCmd()) + cmd.AddCommand(newHistoryCmd()) + cmd.AddCommand(newVersionCmd()) return cmd } @@ -134,14 +141,17 @@ func newViewCmd() *cobra.Command { return err } - wf, err := client.GetWorkflow(args[0]) + wf, state, err := client.GetWorkflowWithState(args[0]) if err != nil { return fmt.Errorf("failed to get workflow: %w", err) } jsonFlag, _ := cmd.Flags().GetBool("json") if jsonFlag { - return printJSON(wf) + return printJSON(struct { + *api.Workflow + *api.WorkflowState + }{wf, state}) } // Human-readable summary @@ -151,6 +161,18 @@ func newViewCmd() *cobra.Command { activeStr = "yes" } fmt.Printf("Active: %s\n", activeStr) + fmt.Printf("Draft version: %s\n", state.VersionID) + switch { + case state.ActiveVersionID == nil: + fmt.Println("Published version: none") + case state.HasUnpublishedChanges(): + fmt.Printf("Published version: %s (draft has unpublished changes)\n", *state.ActiveVersionID) + default: + fmt.Printf("Published version: %s (matches draft)\n", *state.ActiveVersionID) + } + if state.IsArchived { + fmt.Println("Archived: yes") + } // Show project info from shared field. // n8n's API always returns exactly one owner entry per workflow.