From 7662d24be2ef4cac18776be202d05f3596607507 Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Tue, 13 Oct 2026 12:26:19 +0200 Subject: [PATCH 1/4] feat: add source-control status, push and pull commands Wraps the public API's /source-control endpoints. Push takes explicit --workflow/--item targets; 409 responses list the conflicting items and 403 responses name the missing API key scope. --- README.md | 37 +++ internal/api/client.go | 24 +- internal/api/sourcecontrol.go | 166 +++++++++++ internal/api/sourcecontrol_test.go | 204 +++++++++++++ internal/cmd/root.go | 2 + internal/cmd/sourcecontrol/sourcecontrol.go | 276 ++++++++++++++++++ .../cmd/sourcecontrol/sourcecontrol_test.go | 47 +++ 7 files changed, 752 insertions(+), 4 deletions(-) create mode 100644 internal/api/sourcecontrol.go create mode 100644 internal/api/sourcecontrol_test.go create mode 100644 internal/cmd/sourcecontrol/sourcecontrol.go create mode 100644 internal/cmd/sourcecontrol/sourcecontrol_test.go diff --git a/README.md b/README.md index ad33274..d994724 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A command-line interface for managing [n8n](https://n8n.io) workflows. Perfect f - **Recursive Pull**: Download a workflow and all its sub-workflows with a single command - **Smart Push**: Push workflows in dependency order with automatic ID remapping - **Execution Control**: Run workflows, view execution history, and manage running executions +- **Source Control**: Preview, push, and pull changes through the instance's Git connection - **Multi-Instance**: Configure and switch between multiple n8n instances - **LLM-Friendly**: JSON output for seamless AI integration @@ -77,6 +78,41 @@ n8nctl execution retry # Retry a failed execution n8nctl execution delete # Delete execution ``` +### Source Control + +Drives n8n's built-in source control (Environments): the Git branch connected to the +instance, not local files. + +```bash +n8nctl source-control status # Pending changes a push would commit +n8nctl source-control status --direction pull # Changes a pull would import +n8nctl source-control push -m "msg" --workflow # Commit and push one workflow +n8nctl source-control push -m "msg" --workflow --workflow --item credential: +n8nctl source-control push -m "msg" --workflow --force # Push despite conflicts +n8nctl source-control pull # Import the connected branch +n8nctl source-control pull --auto-publish published # Republish workflows that were published +n8nctl source-control pull --force # Discard conflicting local changes +``` + +`sc` is an alias for `source-control`. `--item :` accepts the types `credential`, +`workflow`, `tags`, `variables`, `file`, `folders`, `project`, and `datatable`; take the IDs +from `status`. Push has no push-everything option, so pending changes from other people on +a shared instance are only committed when you name them. + +Things to know before using it: + +- A push commits to the connected branch. Every instance connected to the same branch + receives the change on its next pull. +- A pull overwrites the drafts of every changed item with the Git version. `--auto-publish` + controls publishing after import: `none` (server default) keeps each workflow's published + state, `all` publishes everything imported, `published` republishes only workflows that + were published before the pull. +- On a 409 the CLI lists the conflicting items and exits non-zero; rerun with `--force` only + after checking them. +- The API key needs the `sourceControl:read` (status), `sourceControl:push`, or + `sourceControl:pull` scope, and the instance needs source control connected. A missing + scope returns 403. + ## Recursive Pull & Push The killer feature: pull a workflow and all its sub-workflows at once. @@ -121,6 +157,7 @@ Use `--json` flag for structured output: n8nctl workflow list --json n8nctl workflow view abc123 --json n8nctl execution list --json +n8nctl source-control status --json ``` Typical workflow for LLM-assisted development: diff --git a/internal/api/client.go b/internal/api/client.go index dd466d3..939ee7b 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -161,18 +161,34 @@ func (c *Client) request(method, path string, body interface{}) ([]byte, error) } if resp.StatusCode >= 400 { - var apiErr struct { + apiErr := &APIError{StatusCode: resp.StatusCode, Body: respBody} + var msg struct { Message string `json:"message"` } - if json.Unmarshal(respBody, &apiErr) == nil && apiErr.Message != "" { - return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, apiErr.Message) + if json.Unmarshal(respBody, &msg) == nil { + apiErr.Message = msg.Message } - return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody)) + return nil, apiErr } return respBody, nil } +// APIError is returned for responses with status >= 400. Body keeps the raw +// response for endpoints that put structured data in error responses. +type APIError struct { + StatusCode int + Message string + Body []byte +} + +func (e *APIError) Error() string { + if e.Message != "" { + return fmt.Sprintf("API error (%d): %s", e.StatusCode, e.Message) + } + return fmt.Sprintf("API error (%d): %s", e.StatusCode, string(e.Body)) +} + // listWorkflowsPage fetches a single page of workflows. func (c *Client) listWorkflowsPage(opts ListWorkflowsOptions) (*ListResult[Workflow], error) { params := url.Values{} diff --git a/internal/api/sourcecontrol.go b/internal/api/sourcecontrol.go new file mode 100644 index 0000000..34118c2 --- /dev/null +++ b/internal/api/sourcecontrol.go @@ -0,0 +1,166 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" +) + +// SourceControlFile is one entry of a source control status, push or pull result. +type SourceControlFile struct { + File string `json:"file"` + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Location string `json:"location"` + Conflict bool `json:"conflict"` + UpdatedAt string `json:"updatedAt"` + Pushed *bool `json:"pushed,omitempty"` + IsLocalPublished *bool `json:"isLocalPublished,omitempty"` + IsRemoteArchived *bool `json:"isRemoteArchived,omitempty"` + ParentFolderID *string `json:"parentFolderId,omitempty"` + FolderPath []string `json:"folderPath,omitempty"` + RemoteFolderPath []string `json:"remoteFolderPath,omitempty"` + Owner *SourceControlOwner `json:"owner,omitempty"` + PublishingError string `json:"publishingError,omitempty"` + PublishingErrorDetails *PublishingErrorDetails `json:"publishingErrorDetails,omitempty"` + ContentImportPolicy json.RawMessage `json:"contentImportPolicy,omitempty"` +} + +// SourceControlOwner is the project that owns a source control item. +type SourceControlOwner struct { + Type string `json:"type"` + ProjectID string `json:"projectId"` + ProjectName string `json:"projectName"` +} + +// PublishingErrorDetails explains why an imported workflow was not published. +type PublishingErrorDetails struct { + Reason string `json:"reason"` + WorkflowReviewRequestID string `json:"workflowReviewRequestId,omitempty"` + VersionID string `json:"versionId,omitempty"` +} + +// SourceControlItem identifies one item to push. +type SourceControlItem struct { + ID string `json:"id"` + Type string `json:"type"` +} + +// SourceControlTypes are the item types the source control API accepts. +var SourceControlTypes = []string{"credential", "workflow", "tags", "variables", "file", "folders", "project", "datatable"} + +// SourceControlConflictError is returned when a push or pull is rejected with 409. +type SourceControlConflictError struct { + Message string + Conflicts []SourceControlFile +} + +func (e *SourceControlConflictError) Error() string { + msg := e.Message + if msg == "" { + msg = "conflicting changes" + } + return fmt.Sprintf("source control conflict (%d files): %s", len(e.Conflicts), msg) +} + +// withScopeHint adds the required API key scope to a 403 error. +func withScopeHint(err error, scope string) error { + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden { + return fmt.Errorf("%w (the API key needs the %s scope, and source control must be connected on the instance)", err, scope) + } + return err +} + +// SourceControlStatus previews pending changes in the given direction ("push" or "pull"). +func (c *Client) SourceControlStatus(direction string) ([]SourceControlFile, error) { + respBody, err := c.request(http.MethodGet, "/source-control/status?direction="+url.QueryEscape(direction), nil) + if err != nil { + return nil, withScopeHint(err, "sourceControl:read") + } + + var resp struct { + Data []SourceControlFile `json:"data"` + } + if err := json.Unmarshal(respBody, &resp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return resp.Data, nil +} + +// SourceControlPushOptions contains the parameters of a source control push. +type SourceControlPushOptions struct { + CommitMessage string `json:"commitMessage"` + FileNames []SourceControlItem `json:"fileNames"` + Force bool `json:"force,omitempty"` +} + +// SourceControlPush commits the selected items and pushes them to the connected branch. +func (c *Client) SourceControlPush(opts SourceControlPushOptions) ([]SourceControlFile, error) { + respBody, err := c.request(http.MethodPost, "/source-control/push", opts) + if err != nil { + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict { + var conflict struct { + Message string `json:"message"` + Conflicts []SourceControlFile `json:"conflicts"` + } + if json.Unmarshal(apiErr.Body, &conflict) == nil { + return nil, &SourceControlConflictError{Message: conflict.Message, Conflicts: conflict.Conflicts} + } + } + return nil, withScopeHint(err, "sourceControl:push") + } + + var resp struct { + Data []SourceControlFile `json:"data"` + } + if err := json.Unmarshal(respBody, &resp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return resp.Data, nil +} + +// SourceControlPullOptions contains the parameters of a source control pull. +type SourceControlPullOptions struct { + Force bool `json:"force,omitempty"` + AutoPublish string `json:"autoPublish,omitempty"` // none (server default), all, published +} + +// SourceControlPull imports the connected branch into the instance. +// Unlike status and push, the pull endpoint returns a bare array. +func (c *Client) SourceControlPull(opts SourceControlPullOptions) ([]SourceControlFile, error) { + // The request body is required, so an empty options struct is sent as {}. + respBody, err := c.request(http.MethodPost, "/source-control/pull", opts) + if err != nil { + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict { + // The 409 body lists every file in the pull diff; the conflicting + // ones are flagged or still modified locally. + var files []SourceControlFile + if json.Unmarshal(apiErr.Body, &files) == nil { + var conflicts []SourceControlFile + for _, f := range files { + if f.Conflict || f.Status == "modified" { + conflicts = append(conflicts, f) + } + } + return nil, &SourceControlConflictError{Message: "local changes would be overwritten", Conflicts: conflicts} + } + } + return nil, withScopeHint(err, "sourceControl:pull") + } + + var files []SourceControlFile + if err := json.Unmarshal(respBody, &files); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return files, nil +} diff --git a/internal/api/sourcecontrol_test.go b/internal/api/sourcecontrol_test.go new file mode 100644 index 0000000..41b1320 --- /dev/null +++ b/internal/api/sourcecontrol_test.go @@ -0,0 +1,204 @@ +package api + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type recordedRequest struct { + Method string + Path string + Query string + Body string + APIKey string +} + +func newTestServer(t *testing.T, status int, body string) (*Client, *recordedRequest) { + t.Helper() + rec := &recordedRequest{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + *rec = recordedRequest{Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery, Body: string(b), APIKey: r.Header.Get("X-N8N-API-KEY")} + 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 +} + +const fileJSON = `{"file":"workflows/wf1.json","id":"wf1","name":"Invoice routing","type":"workflow","status":"modified","location":"local","conflict":false,"updatedAt":"2026-10-13T10:00:00.000Z"}` + +func TestSourceControlStatus(t *testing.T) { + client, rec := newTestServer(t, http.StatusOK, `{"data":[`+fileJSON+`]}`) + + files, err := client.SourceControlStatus("push") + if err != nil { + t.Fatalf("SourceControlStatus: %v", err) + } + if rec.Method != http.MethodGet || rec.Path != "/api/v1/source-control/status" || rec.Query != "direction=push" { + t.Errorf("request = %s %s?%s", rec.Method, rec.Path, rec.Query) + } + if rec.APIKey != "test-key" { + t.Errorf("API key header = %q", rec.APIKey) + } + if len(files) != 1 || files[0].ID != "wf1" || files[0].Status != "modified" { + t.Errorf("files = %+v", files) + } +} + +func TestSourceControlPushBody(t *testing.T) { + client, rec := newTestServer(t, http.StatusOK, `{"data":[`+fileJSON+`]}`) + + pushed, err := client.SourceControlPush(SourceControlPushOptions{ + CommitMessage: "Fix routing", + FileNames: []SourceControlItem{{ID: "wf1", Type: "workflow"}, {ID: "cred1", Type: "credential"}}, + }) + if err != nil { + t.Fatalf("SourceControlPush: %v", err) + } + if rec.Method != http.MethodPost || rec.Path != "/api/v1/source-control/push" { + t.Errorf("request = %s %s", rec.Method, rec.Path) + } + want := `{"commitMessage":"Fix routing","fileNames":[{"id":"wf1","type":"workflow"},{"id":"cred1","type":"credential"}]}` + if rec.Body != want { + t.Errorf("body = %s, want %s", rec.Body, want) + } + if len(pushed) != 1 { + t.Errorf("pushed = %+v", pushed) + } +} + +func TestSourceControlPushForce(t *testing.T) { + client, rec := newTestServer(t, http.StatusOK, `{"data":[]}`) + + if _, err := client.SourceControlPush(SourceControlPushOptions{ + CommitMessage: "m", + FileNames: []SourceControlItem{{ID: "wf1", Type: "workflow"}}, + Force: true, + }); err != nil { + t.Fatalf("SourceControlPush: %v", err) + } + var body map[string]any + if err := json.Unmarshal([]byte(rec.Body), &body); err != nil { + t.Fatalf("body is not JSON: %v", err) + } + if body["force"] != true { + t.Errorf("force = %v, want true", body["force"]) + } +} + +func TestSourceControlPushConflict(t *testing.T) { + conflictFile := strings.Replace(fileJSON, `"conflict":false`, `"conflict":true`, 1) + client, _ := newTestServer(t, http.StatusConflict, `{"message":"Conflicts found","conflicts":[`+conflictFile+`]}`) + + _, err := client.SourceControlPush(SourceControlPushOptions{CommitMessage: "m", FileNames: []SourceControlItem{{ID: "wf1", Type: "workflow"}}}) + var conflict *SourceControlConflictError + if !errors.As(err, &conflict) { + t.Fatalf("err = %v, want SourceControlConflictError", err) + } + if conflict.Message != "Conflicts found" || len(conflict.Conflicts) != 1 || conflict.Conflicts[0].ID != "wf1" { + t.Errorf("conflict = %+v", conflict) + } +} + +func TestSourceControlPullSendsEmptyObject(t *testing.T) { + client, rec := newTestServer(t, http.StatusOK, `[`+fileJSON+`]`) + + files, err := client.SourceControlPull(SourceControlPullOptions{}) + if err != nil { + t.Fatalf("SourceControlPull: %v", err) + } + if rec.Method != http.MethodPost || rec.Path != "/api/v1/source-control/pull" { + t.Errorf("request = %s %s", rec.Method, rec.Path) + } + if rec.Body != `{}` { + t.Errorf("body = %q, want {}", rec.Body) + } + if len(files) != 1 || files[0].Name != "Invoice routing" { + t.Errorf("files = %+v", files) + } +} + +func TestSourceControlPullOptions(t *testing.T) { + client, rec := newTestServer(t, http.StatusOK, `[]`) + + if _, err := client.SourceControlPull(SourceControlPullOptions{Force: true, AutoPublish: "published"}); err != nil { + t.Fatalf("SourceControlPull: %v", err) + } + if want := `{"force":true,"autoPublish":"published"}`; rec.Body != want { + t.Errorf("body = %s, want %s", rec.Body, want) + } +} + +func TestSourceControlPullConflict(t *testing.T) { + unchanged := strings.Replace(fileJSON, `"status":"modified"`, `"status":"new"`, 1) + unchanged = strings.Replace(unchanged, `"id":"wf1"`, `"id":"wf2"`, 1) + flagged := strings.Replace(unchanged, `"id":"wf2"`, `"id":"wf3"`, 1) + flagged = strings.Replace(flagged, `"conflict":false`, `"conflict":true`, 1) + client, _ := newTestServer(t, http.StatusConflict, `[`+fileJSON+`,`+unchanged+`,`+flagged+`]`) + + _, err := client.SourceControlPull(SourceControlPullOptions{}) + var conflict *SourceControlConflictError + if !errors.As(err, &conflict) { + t.Fatalf("err = %v, want SourceControlConflictError", err) + } + var ids []string + for _, f := range conflict.Conflicts { + ids = append(ids, f.ID) + } + if strings.Join(ids, ",") != "wf1,wf3" { + t.Errorf("conflict IDs = %v, want [wf1 wf3]", ids) + } +} + +func TestSourceControlForbiddenNamesScope(t *testing.T) { + tests := []struct { + name string + call func(*Client) error + scope string + }{ + {"status", func(c *Client) error { _, err := c.SourceControlStatus("push"); return err }, "sourceControl:read"}, + {"push", func(c *Client) error { + _, err := c.SourceControlPush(SourceControlPushOptions{CommitMessage: "m", FileNames: []SourceControlItem{{ID: "x", Type: "workflow"}}}) + return err + }, "sourceControl:push"}, + {"pull", func(c *Client) error { _, err := c.SourceControlPull(SourceControlPullOptions{}); return err }, "sourceControl:pull"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, _ := newTestServer(t, http.StatusForbidden, `{"message":"Forbidden"}`) + err := tt.call(client) + if err == nil || !strings.Contains(err.Error(), tt.scope) || !strings.Contains(err.Error(), "API error (403): Forbidden") { + t.Errorf("err = %v, want 403 with %s hint", err, tt.scope) + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Errorf("err does not wrap *APIError") + } + }) + } +} + +func TestAPIErrorMessage(t *testing.T) { + tests := []struct { + body string + want string + }{ + {`{"message":"not found"}`, "API error (404): not found"}, + {`plain text`, "API error (404): plain text"}, + {`{"other":"x"}`, `API error (404): {"other":"x"}`}, + } + for _, tt := range tests { + client, _ := newTestServer(t, http.StatusNotFound, tt.body) + _, err := client.GetWorkflow("x") + if err == nil || err.Error() != tt.want { + t.Errorf("body %s: err = %v, want %s", tt.body, err, tt.want) + } + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index a6ab77f..8721118 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -11,6 +11,7 @@ import ( configcmd "github.com/enthus-appdev/n8n-cli/internal/cmd/config" executioncmd "github.com/enthus-appdev/n8n-cli/internal/cmd/execution" projectcmd "github.com/enthus-appdev/n8n-cli/internal/cmd/project" + sourcecontrolcmd "github.com/enthus-appdev/n8n-cli/internal/cmd/sourcecontrol" variablecmd "github.com/enthus-appdev/n8n-cli/internal/cmd/variable" workflowcmd "github.com/enthus-appdev/n8n-cli/internal/cmd/workflow" ) @@ -44,6 +45,7 @@ func init() { rootCmd.AddCommand(executioncmd.NewExecutionCmd()) rootCmd.AddCommand(projectcmd.NewProjectCmd()) rootCmd.AddCommand(variablecmd.NewVariableCmd()) + rootCmd.AddCommand(sourcecontrolcmd.NewSourceControlCmd()) rootCmd.AddCommand(newVersionCmd()) } diff --git a/internal/cmd/sourcecontrol/sourcecontrol.go b/internal/cmd/sourcecontrol/sourcecontrol.go new file mode 100644 index 0000000..09f3c04 --- /dev/null +++ b/internal/cmd/sourcecontrol/sourcecontrol.go @@ -0,0 +1,276 @@ +package sourcecontrol + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "slices" + "strings" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/n8n-cli/internal/api" + "github.com/enthus-appdev/n8n-cli/internal/config" +) + +func NewSourceControlCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "source-control", + Aliases: []string{"sc"}, + Short: "Push and pull changes through the instance's Git connection", + Long: `Use n8n's built-in source control (Environments) feature. + +push commits selected items to the Git branch connected to the instance. +pull imports that branch into the instance and overwrites local drafts. +Both act on the shared branch that other instances may pull from.`, + } + + cmd.AddCommand(newStatusCmd()) + cmd.AddCommand(newPushCmd()) + cmd.AddCommand(newPullCmd()) + + return cmd +} + +func getClient() (*api.Client, error) { + cfg, err := config.Load() + if err != nil { + return nil, fmt.Errorf("not configured. Run 'n8n config init' first") + } + + instance, err := cfg.GetCurrentInstance() + if err != nil { + return nil, err + } + + return api.NewClient(instance.URL, instance.APIKey), nil +} + +func newStatusCmd() *cobra.Command { + var direction string + + cmd := &cobra.Command{ + Use: "status", + Short: "Preview pending source control changes", + Long: `Preview the changes a push or pull would apply. + +Use the IDs from 'status --direction push' as push targets.`, + Example: ` n8nctl source-control status + n8nctl sc status --direction pull --json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if direction != "push" && direction != "pull" { + return fmt.Errorf("invalid --direction %q (must be push or pull)", direction) + } + + client, err := getClient() + if err != nil { + return err + } + + files, err := client.SourceControlStatus(direction) + if err != nil { + return fmt.Errorf("failed to get source control status: %w", err) + } + + jsonFlag, _ := cmd.Flags().GetBool("json") + if jsonFlag { + return printJSON(files) + } + + if len(files) == 0 { + fmt.Printf("No pending %s changes.\n", direction) + return nil + } + printFiles(os.Stdout, files) + return nil + }, + } + + cmd.Flags().StringVar(&direction, "direction", "push", "Direction to preview: push or pull") + + return cmd +} + +func newPushCmd() *cobra.Command { + var ( + message string + workflows []string + items []string + force bool + ) + + cmd := &cobra.Command{ + Use: "push", + Short: "Commit and push selected items to the connected Git branch", + Long: `Commit the selected items and push them to the Git branch connected to the instance. + +Select items explicitly with --workflow or --item; there is no push-everything +option, so other people's pending changes on a shared instance are not swept in. +Run 'n8nctl source-control status' to list pending items and their IDs.`, + Example: ` n8nctl source-control push -m "Fix invoice routing" --workflow 1QHLOQM9piQTOb9Y + n8nctl sc push -m "Rotate SharePoint credential" --item credential:WjE8kQkvvV0zDFrN`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(message) == "" { + return fmt.Errorf("a commit message is required (--message)") + } + + fileNames, err := buildPushItems(workflows, items) + if err != nil { + return err + } + + client, err := getClient() + if err != nil { + return err + } + + pushed, err := client.SourceControlPush(api.SourceControlPushOptions{ + CommitMessage: message, + FileNames: fileNames, + Force: force, + }) + if err != nil { + return conflictError("push", err) + } + + jsonFlag, _ := cmd.Flags().GetBool("json") + if jsonFlag { + return printJSON(pushed) + } + + fmt.Printf("Pushed %d items.\n", len(pushed)) + if len(pushed) > 0 { + printFiles(os.Stdout, pushed) + } + return nil + }, + } + + cmd.Flags().StringVarP(&message, "message", "m", "", "Commit message (required)") + cmd.Flags().StringArrayVar(&workflows, "workflow", nil, "Workflow ID to push (repeatable)") + cmd.Flags().StringArrayVar(&items, "item", nil, "Item to push as : (repeatable); types: "+strings.Join(api.SourceControlTypes, ", ")) + cmd.Flags().BoolVar(&force, "force", false, "Push even if the selected items conflict with the remote branch") + + return cmd +} + +// buildPushItems turns --workflow and --item values into push targets. +func buildPushItems(workflows, items []string) ([]api.SourceControlItem, error) { + var out []api.SourceControlItem + for _, id := range workflows { + if id == "" { + return nil, fmt.Errorf("--workflow needs a non-empty ID") + } + out = append(out, api.SourceControlItem{ID: id, Type: "workflow"}) + } + for _, item := range items { + typ, id, ok := strings.Cut(item, ":") + if !ok || typ == "" || id == "" { + return nil, fmt.Errorf("invalid --item %q (expected :)", item) + } + if !slices.Contains(api.SourceControlTypes, typ) { + return nil, fmt.Errorf("invalid --item type %q (must be one of: %s)", typ, strings.Join(api.SourceControlTypes, ", ")) + } + out = append(out, api.SourceControlItem{ID: id, Type: typ}) + } + if len(out) == 0 { + return nil, fmt.Errorf("select at least one item with --workflow or --item") + } + return out, nil +} + +func newPullCmd() *cobra.Command { + var ( + force bool + autoPublish string + ) + + cmd := &cobra.Command{ + Use: "pull", + Short: "Import the connected Git branch into the instance", + Long: `Import the Git branch connected to the instance. + +A pull overwrites the drafts of every changed item with the version from Git. +Run 'n8nctl source-control status --direction pull' first to preview it.`, + Example: ` n8nctl source-control pull + n8nctl sc pull --auto-publish published`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if autoPublish != "" && autoPublish != "none" && autoPublish != "all" && autoPublish != "published" { + return fmt.Errorf("invalid --auto-publish %q (must be none, all, or published)", autoPublish) + } + + client, err := getClient() + if err != nil { + return err + } + + pulled, err := client.SourceControlPull(api.SourceControlPullOptions{ + Force: force, + AutoPublish: autoPublish, + }) + if err != nil { + return conflictError("pull", err) + } + + jsonFlag, _ := cmd.Flags().GetBool("json") + if jsonFlag { + return printJSON(pulled) + } + + fmt.Printf("Pulled %d items.\n", len(pulled)) + if len(pulled) > 0 { + printFiles(os.Stdout, pulled) + } + return nil + }, + } + + cmd.Flags().BoolVar(&force, "force", false, "Discard local changes that conflict with the pull") + cmd.Flags().StringVar(&autoPublish, "auto-publish", "", "Publish imported workflows: none (server default), all, or published (only those published before the pull)") + + return cmd +} + +// conflictError lists the conflicting files of a rejected push or pull and +// points at --force. +func conflictError(action string, err error) error { + var conflict *api.SourceControlConflictError + if !errors.As(err, &conflict) { + return fmt.Errorf("failed to %s: %w", action, err) + } + if len(conflict.Conflicts) > 0 { + fmt.Fprintln(os.Stderr, "Conflicting items:") + printFiles(os.Stderr, conflict.Conflicts) + } + return fmt.Errorf("%s rejected: %w; rerun with --force to override", action, err) +} + +func printFiles(w io.Writer, files []api.SourceControlFile) { + fmt.Fprintf(w, "%-10s %-10s %-8s %-18s %s\n", "TYPE", "STATUS", "LOCATION", "ID", "NAME") + fmt.Fprintf(w, "%-10s %-10s %-8s %-18s %s\n", strings.Repeat("-", 10), strings.Repeat("-", 10), strings.Repeat("-", 8), strings.Repeat("-", 18), strings.Repeat("-", 30)) + for _, f := range files { + name := f.Name + if f.Conflict { + name += " [conflict]" + } + fmt.Fprintf(w, "%-10s %-10s %-8s %-18s %s\n", f.Type, f.Status, f.Location, f.ID, name) + if f.PublishingError != "" || f.PublishingErrorDetails != nil { + reason := "" + if f.PublishingErrorDetails != nil { + reason = " (" + f.PublishingErrorDetails.Reason + ")" + } + fmt.Fprintf(w, " not published: %s%s\n", f.PublishingError, reason) + } + } +} + +func printJSON(v interface{}) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} diff --git a/internal/cmd/sourcecontrol/sourcecontrol_test.go b/internal/cmd/sourcecontrol/sourcecontrol_test.go new file mode 100644 index 0000000..aeba4ee --- /dev/null +++ b/internal/cmd/sourcecontrol/sourcecontrol_test.go @@ -0,0 +1,47 @@ +package sourcecontrol + +import ( + "reflect" + "strings" + "testing" + + "github.com/enthus-appdev/n8n-cli/internal/api" +) + +func TestBuildPushItems(t *testing.T) { + got, err := buildPushItems([]string{"wf1"}, []string{"credential:c1", "datatable:dt1"}) + if err != nil { + t.Fatalf("buildPushItems: %v", err) + } + want := []api.SourceControlItem{ + {ID: "wf1", Type: "workflow"}, + {ID: "c1", Type: "credential"}, + {ID: "dt1", Type: "datatable"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("got %+v, want %+v", got, want) + } +} + +func TestBuildPushItemsErrors(t *testing.T) { + tests := []struct { + name string + workflows []string + items []string + wantErr string + }{ + {"nothing selected", nil, nil, "select at least one item"}, + {"empty workflow", []string{""}, nil, "non-empty ID"}, + {"missing colon", nil, []string{"workflow"}, "expected :"}, + {"empty id", nil, []string{"workflow:"}, "expected :"}, + {"unknown type", nil, []string{"node:abc"}, `invalid --item type "node"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := buildPushItems(tt.workflows, tt.items) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("err = %v, want containing %q", err, tt.wantErr) + } + }) + } +} From 17beb2959a97feaae72048eb60e751711573f01e Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Tue, 13 Oct 2026 12:29:29 +0200 Subject: [PATCH 2/4] fix: check Fprintf results in source-control table output --- internal/cmd/sourcecontrol/sourcecontrol.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/cmd/sourcecontrol/sourcecontrol.go b/internal/cmd/sourcecontrol/sourcecontrol.go index 09f3c04..c2b2646 100644 --- a/internal/cmd/sourcecontrol/sourcecontrol.go +++ b/internal/cmd/sourcecontrol/sourcecontrol.go @@ -251,20 +251,20 @@ func conflictError(action string, err error) error { } func printFiles(w io.Writer, files []api.SourceControlFile) { - fmt.Fprintf(w, "%-10s %-10s %-8s %-18s %s\n", "TYPE", "STATUS", "LOCATION", "ID", "NAME") - fmt.Fprintf(w, "%-10s %-10s %-8s %-18s %s\n", strings.Repeat("-", 10), strings.Repeat("-", 10), strings.Repeat("-", 8), strings.Repeat("-", 18), strings.Repeat("-", 30)) + _, _ = fmt.Fprintf(w, "%-10s %-10s %-8s %-18s %s\n", "TYPE", "STATUS", "LOCATION", "ID", "NAME") + _, _ = fmt.Fprintf(w, "%-10s %-10s %-8s %-18s %s\n", strings.Repeat("-", 10), strings.Repeat("-", 10), strings.Repeat("-", 8), strings.Repeat("-", 18), strings.Repeat("-", 30)) for _, f := range files { name := f.Name if f.Conflict { name += " [conflict]" } - fmt.Fprintf(w, "%-10s %-10s %-8s %-18s %s\n", f.Type, f.Status, f.Location, f.ID, name) + _, _ = fmt.Fprintf(w, "%-10s %-10s %-8s %-18s %s\n", f.Type, f.Status, f.Location, f.ID, name) if f.PublishingError != "" || f.PublishingErrorDetails != nil { reason := "" if f.PublishingErrorDetails != nil { reason = " (" + f.PublishingErrorDetails.Reason + ")" } - fmt.Fprintf(w, " not published: %s%s\n", f.PublishingError, reason) + _, _ = fmt.Fprintf(w, " not published: %s%s\n", f.PublishingError, reason) } } } From 3b68d6be4e33260b20db5e307f91a58af5c32140 Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Tue, 13 Oct 2026 12:48:18 +0200 Subject: [PATCH 3/4] chore: drop comments that restate the code --- internal/api/sourcecontrol.go | 4 ---- internal/cmd/sourcecontrol/sourcecontrol.go | 3 --- 2 files changed, 7 deletions(-) diff --git a/internal/api/sourcecontrol.go b/internal/api/sourcecontrol.go index 34118c2..8b7912e 100644 --- a/internal/api/sourcecontrol.go +++ b/internal/api/sourcecontrol.go @@ -67,7 +67,6 @@ func (e *SourceControlConflictError) Error() string { return fmt.Sprintf("source control conflict (%d files): %s", len(e.Conflicts), msg) } -// withScopeHint adds the required API key scope to a 403 error. func withScopeHint(err error, scope string) error { var apiErr *APIError if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden { @@ -136,13 +135,10 @@ type SourceControlPullOptions struct { // SourceControlPull imports the connected branch into the instance. // Unlike status and push, the pull endpoint returns a bare array. func (c *Client) SourceControlPull(opts SourceControlPullOptions) ([]SourceControlFile, error) { - // The request body is required, so an empty options struct is sent as {}. respBody, err := c.request(http.MethodPost, "/source-control/pull", opts) if err != nil { var apiErr *APIError if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict { - // The 409 body lists every file in the pull diff; the conflicting - // ones are flagged or still modified locally. var files []SourceControlFile if json.Unmarshal(apiErr.Body, &files) == nil { var conflicts []SourceControlFile diff --git a/internal/cmd/sourcecontrol/sourcecontrol.go b/internal/cmd/sourcecontrol/sourcecontrol.go index c2b2646..cfd1c83 100644 --- a/internal/cmd/sourcecontrol/sourcecontrol.go +++ b/internal/cmd/sourcecontrol/sourcecontrol.go @@ -158,7 +158,6 @@ Run 'n8nctl source-control status' to list pending items and their IDs.`, return cmd } -// buildPushItems turns --workflow and --item values into push targets. func buildPushItems(workflows, items []string) ([]api.SourceControlItem, error) { var out []api.SourceControlItem for _, id := range workflows { @@ -236,8 +235,6 @@ Run 'n8nctl source-control status --direction pull' first to preview it.`, return cmd } -// conflictError lists the conflicting files of a rejected push or pull and -// points at --force. func conflictError(action string, err error) error { var conflict *api.SourceControlConflictError if !errors.As(err, &conflict) { From d48e6e08809c3c970b8c44d9b219e8270c07195e Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Tue, 13 Oct 2026 13:14:41 +0200 Subject: [PATCH 4/4] feat: print source-control conflicts as JSON under --json --- README.md | 2 +- internal/cmd/sourcecontrol/sourcecontrol.go | 19 ++++++++-- .../cmd/sourcecontrol/sourcecontrol_test.go | 37 +++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d994724..45572f3 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Things to know before using it: state, `all` publishes everything imported, `published` republishes only workflows that were published before the pull. - On a 409 the CLI lists the conflicting items and exits non-zero; rerun with `--force` only - after checking them. + after checking them. With `--json`, stdout carries `{"error": ..., "conflicts": [...]}`. - The API key needs the `sourceControl:read` (status), `sourceControl:push`, or `sourceControl:pull` scope, and the instance needs source control connected. A missing scope returns 403. diff --git a/internal/cmd/sourcecontrol/sourcecontrol.go b/internal/cmd/sourcecontrol/sourcecontrol.go index cfd1c83..1244abb 100644 --- a/internal/cmd/sourcecontrol/sourcecontrol.go +++ b/internal/cmd/sourcecontrol/sourcecontrol.go @@ -134,7 +134,7 @@ Run 'n8nctl source-control status' to list pending items and their IDs.`, Force: force, }) if err != nil { - return conflictError("push", err) + return conflictError(cmd, "push", err) } jsonFlag, _ := cmd.Flags().GetBool("json") @@ -213,7 +213,7 @@ Run 'n8nctl source-control status --direction pull' first to preview it.`, AutoPublish: autoPublish, }) if err != nil { - return conflictError("pull", err) + return conflictError(cmd, "pull", err) } jsonFlag, _ := cmd.Flags().GetBool("json") @@ -235,16 +235,27 @@ Run 'n8nctl source-control status --direction pull' first to preview it.`, return cmd } -func conflictError(action string, err error) error { +func conflictError(cmd *cobra.Command, action string, err error) error { var conflict *api.SourceControlConflictError if !errors.As(err, &conflict) { return fmt.Errorf("failed to %s: %w", action, err) } + wrapped := fmt.Errorf("%s rejected: %w; rerun with --force to override", action, err) + if jsonFlag, _ := cmd.Flags().GetBool("json"); jsonFlag { + conflicts := conflict.Conflicts + if conflicts == nil { + conflicts = []api.SourceControlFile{} + } + if perr := printJSON(map[string]interface{}{"error": wrapped.Error(), "conflicts": conflicts}); perr != nil { + return perr + } + return wrapped + } if len(conflict.Conflicts) > 0 { fmt.Fprintln(os.Stderr, "Conflicting items:") printFiles(os.Stderr, conflict.Conflicts) } - return fmt.Errorf("%s rejected: %w; rerun with --force to override", action, err) + return wrapped } func printFiles(w io.Writer, files []api.SourceControlFile) { diff --git a/internal/cmd/sourcecontrol/sourcecontrol_test.go b/internal/cmd/sourcecontrol/sourcecontrol_test.go index aeba4ee..cc23f27 100644 --- a/internal/cmd/sourcecontrol/sourcecontrol_test.go +++ b/internal/cmd/sourcecontrol/sourcecontrol_test.go @@ -1,10 +1,16 @@ package sourcecontrol import ( + "encoding/json" + "errors" + "io" + "os" "reflect" "strings" "testing" + "github.com/spf13/cobra" + "github.com/enthus-appdev/n8n-cli/internal/api" ) @@ -45,3 +51,34 @@ func TestBuildPushItemsErrors(t *testing.T) { }) } } + +func TestConflictErrorJSON(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().Bool("json", true, "") + + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + stdout := os.Stdout + os.Stdout = w + conflictErr := &api.SourceControlConflictError{Message: "Conflicts found", Conflicts: []api.SourceControlFile{{ID: "wf1", Type: "workflow", Conflict: true}}} + got := conflictError(cmd, "push", conflictErr) + os.Stdout = stdout + _ = w.Close() + out, _ := io.ReadAll(r) + + if got == nil || !errors.Is(got, conflictErr) { + t.Fatalf("err = %v, want wrapped conflict error", got) + } + var body struct { + Error string `json:"error"` + Conflicts []api.SourceControlFile `json:"conflicts"` + } + if err := json.Unmarshal(out, &body); err != nil { + t.Fatalf("stdout is not one JSON object: %v\n%s", err, out) + } + if !strings.Contains(body.Error, "push rejected") || len(body.Conflicts) != 1 || body.Conflicts[0].ID != "wf1" { + t.Errorf("body = %+v", body) + } +}