From bb8c7d1d13c2b43d549c07780e4739b586186318 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:33:42 -0700 Subject: [PATCH 1/2] add workflows command --- cmd/workflows.go | 324 ++++++++++++++++++++++++++++++++ cmd/workflows_get_test.go | 94 +++++++++ cmd/workflows_list_test.go | 65 +++++++ cmd/workflows_nodes_get_test.go | 103 ++++++++++ 4 files changed, 586 insertions(+) create mode 100644 cmd/workflows.go create mode 100644 cmd/workflows_get_test.go create mode 100644 cmd/workflows_list_test.go create mode 100644 cmd/workflows_nodes_get_test.go diff --git a/cmd/workflows.go b/cmd/workflows.go new file mode 100644 index 0000000..a219b14 --- /dev/null +++ b/cmd/workflows.go @@ -0,0 +1,324 @@ +package cmd + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/loops-so/cli/internal/config" + "github.com/loops-so/loops-go" + "github.com/spf13/cobra" +) + +func runWorkflowsList(cfg *config.Config, params loops.PaginationParams) ([]loops.WorkflowSummary, error) { + client := newAPIClient(cfg) + if params.Cursor != "" { + workflows, _, err := client.ListWorkflows(params) + return workflows, err + } + return loops.Paginate(func(cursor string) ([]loops.WorkflowSummary, *loops.Pagination, error) { + return client.ListWorkflows(loops.PaginationParams{ + PerPage: params.PerPage, + Cursor: cursor, + }) + }) +} + +func runWorkflowsGet(cfg *config.Config, id string) (*loops.SimplifiedWorkflow, error) { + return newAPIClient(cfg).GetWorkflow(id) +} + +func runWorkflowsNodeGet(cfg *config.Config, workflowID, nodeID string) (*loops.WorkflowNode, error) { + return newAPIClient(cfg).GetWorkflowNode(workflowID, nodeID) +} + +var workflowsCmd = &cobra.Command{ + Use: "workflows", + Short: "Manage workflows", +} + +var workflowsListCmd = &cobra.Command{ + Use: "list", + Short: "List workflows", + RunE: func(cmd *cobra.Command, args []string) error { + if err := validatePickFlags(cmd); err != nil { + return err + } + + cfg, err := loadConfig() + if err != nil { + return err + } + + workflows, err := runWorkflowsList(cfg, paginationParams(cmd)) + if err != nil { + return err + } + + if isJSONOutput() { + if workflows == nil { + workflows = []loops.WorkflowSummary{} + } + return printJSON(cmd.OutOrStdout(), workflows) + } + + if len(workflows) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No workflows found.") + return nil + } + + headers := []string{"ID", "NAME", "CREATED", "UPDATED"} + rows := make([][]string, 0, len(workflows)) + for _, w := range workflows { + rows = append(rows, []string{w.ID, w.Name, w.CreatedAt, w.UpdatedAt}) + } + + if isPicking(cmd) { + return runPicker(headers, rows, []pickBinding{ + copyColumnBinding("enter", "copy id", "workflow ID", rows, 0, cmd.OutOrStdout()), + }) + } + + t := newStyledTable(cmd.OutOrStdout(), headers...) + for _, r := range rows { + t.Row(r...) + } + return t.Render() + }, +} + +var workflowsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a workflow", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + w, err := runWorkflowsGet(cfg, args[0]) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), w) + } + + t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") + t.Row("workflowId", w.ID) + t.Row("name", w.Name) + t.Row("description", w.Description) + t.Row("emoji", w.Emoji) + t.Row("mailingListId", deref(w.MailingListID)) + t.Row("rootNodeId", deref(w.RootNodeID)) + if err := t.Render(); err != nil { + return err + } + + if len(w.Nodes) == 0 { + return nil + } + + fmt.Fprintln(cmd.OutOrStdout()) + nt := newStyledTable(cmd.OutOrStdout(), "NODE ID", "TYPE", "NEXT IDS") + ids := make([]string, 0, len(w.Nodes)) + for id := range w.Nodes { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + n := w.Nodes[id] + nt.Row(id, n.TypeName, strings.Join(simplifiedNodeNextIDs(n), ", ")) + } + return nt.Render() + }, +} + +var workflowsNodesCmd = &cobra.Command{ + Use: "nodes", + Short: "Manage workflow nodes", +} + +var workflowsNodesGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a workflow node", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + n, err := runWorkflowsNodeGet(cfg, args[0], args[1]) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), n) + } + + return printWorkflowNode(cmd, n) + }, +} + +func simplifiedNodeNextIDs(n loops.SimplifiedWorkflowNode) []string { + switch n.TypeName { + case loops.WorkflowNodeTypeSignupTrigger: + if n.SignupTrigger != nil { + return n.SignupTrigger.NextNodeIDs + } + case loops.WorkflowNodeTypeEventTrigger: + if n.EventTrigger != nil { + return n.EventTrigger.NextNodeIDs + } + case loops.WorkflowNodeTypeContactPropertyTrigger: + if n.ContactPropertyTrigger != nil { + return n.ContactPropertyTrigger.NextNodeIDs + } + case loops.WorkflowNodeTypeAddToListTrigger: + if n.AddToListTrigger != nil { + return n.AddToListTrigger.NextNodeIDs + } + case loops.WorkflowNodeTypeBlankTrigger: + if n.BlankTrigger != nil { + return n.BlankTrigger.NextNodeIDs + } + case loops.WorkflowNodeTypeAudienceFilter: + if n.AudienceFilter != nil { + return n.AudienceFilter.NextNodeIDs + } + case loops.WorkflowNodeTypeTimerAction: + if n.TimerAction != nil { + return n.TimerAction.NextNodeIDs + } + case loops.WorkflowNodeTypeSendEmailAction: + if n.SendEmailAction != nil { + return n.SendEmailAction.NextNodeIDs + } + case loops.WorkflowNodeTypeExitAction: + if n.ExitAction != nil { + return n.ExitAction.NextNodeIDs + } + case loops.WorkflowNodeTypeBranchNode: + if n.BranchNode != nil { + return n.BranchNode.NextNodeIDs + } + case loops.WorkflowNodeTypeExperimentBranchNode: + if n.ExperimentBranchNode != nil { + return n.ExperimentBranchNode.NextNodeIDs + } + case loops.WorkflowNodeTypeVariantNode: + if n.VariantNode != nil { + return n.VariantNode.NextNodeIDs + } + } + return nil +} + +func printWorkflowNode(cmd *cobra.Command, n *loops.WorkflowNode) error { + rows := workflowNodeRows(n) + t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") + for _, r := range rows { + t.Row(r[0], r[1]) + } + return t.Render() +} + +func workflowNodeRows(n *loops.WorkflowNode) [][2]string { + rows := [][2]string{{"typeName", n.TypeName}} + add := func(field, value string) { rows = append(rows, [2]string{field, value}) } + addCommon := func(id, workflowID string, nextIDs []string) { + add("nodeId", id) + add("workflowId", workflowID) + add("nextNodeIds", strings.Join(nextIDs, ", ")) + } + switch n.TypeName { + case loops.WorkflowNodeTypeSignupTrigger: + if v := n.SignupTrigger; v != nil { + addCommon(v.ID, v.WorkflowID, v.NextNodeIDs) + } + case loops.WorkflowNodeTypeEventTrigger: + if v := n.EventTrigger; v != nil { + addCommon(v.ID, v.WorkflowID, v.NextNodeIDs) + add("eventName", deref(v.EventName)) + add("reEligible", strconv.FormatBool(v.ReEligible)) + add("eventProperties", fmt.Sprintf("%d properties", len(v.EventProperties))) + } + case loops.WorkflowNodeTypeContactPropertyTrigger: + if v := n.ContactPropertyTrigger; v != nil { + addCommon(v.ID, v.WorkflowID, v.NextNodeIDs) + add("reEligible", strconv.FormatBool(v.ReEligible)) + if v.ContactPropertyQuery != nil { + add("contactPropertyQuery", fmt.Sprintf("key=%s (see -o json)", v.ContactPropertyQuery.Key)) + } else { + add("contactPropertyQuery", "") + } + } + case loops.WorkflowNodeTypeAddToListTrigger: + if v := n.AddToListTrigger; v != nil { + addCommon(v.ID, v.WorkflowID, v.NextNodeIDs) + add("reEligible", strconv.FormatBool(v.ReEligible)) + } + case loops.WorkflowNodeTypeBlankTrigger: + if v := n.BlankTrigger; v != nil { + addCommon(v.ID, v.WorkflowID, v.NextNodeIDs) + } + case loops.WorkflowNodeTypeAudienceFilter: + if v := n.AudienceFilter; v != nil { + addCommon(v.ID, v.WorkflowID, v.NextNodeIDs) + add("audienceSegmentId", v.AudienceSegmentID) + add("audienceFilter", formatAudienceFilter(v.AudienceFilter)) + } + case loops.WorkflowNodeTypeTimerAction: + if v := n.TimerAction; v != nil { + addCommon(v.ID, v.WorkflowID, v.NextNodeIDs) + add("amount", formatFloat(v.Amount)) + add("unit", string(v.Unit)) + } + case loops.WorkflowNodeTypeSendEmailAction: + if v := n.SendEmailAction; v != nil { + addCommon(v.ID, v.WorkflowID, v.NextNodeIDs) + add("subject", v.Subject) + } + case loops.WorkflowNodeTypeExitAction: + if v := n.ExitAction; v != nil { + addCommon(v.ID, v.WorkflowID, v.NextNodeIDs) + } + case loops.WorkflowNodeTypeBranchNode: + if v := n.BranchNode; v != nil { + addCommon(v.ID, v.WorkflowID, v.NextNodeIDs) + add("evalStrategy", v.EvalStrategy) + } + case loops.WorkflowNodeTypeExperimentBranchNode: + if v := n.ExperimentBranchNode; v != nil { + addCommon(v.ID, v.WorkflowID, v.NextNodeIDs) + add("samplingRate", formatFloat(v.SamplingRate)) + add("url", v.URL) + add("experimentId", v.ExperimentID) + add("experimentType", string(v.ExperimentType)) + } + case loops.WorkflowNodeTypeVariantNode: + if v := n.VariantNode; v != nil { + addCommon(v.ID, v.WorkflowID, v.NextNodeIDs) + add("variantId", v.VariantID) + if v.IsControl != nil { + add("isControl", strconv.FormatBool(*v.IsControl)) + } + } + } + return rows +} + +func init() { + addPaginationFlags(workflowsListCmd) + addPickFlag(workflowsListCmd) + workflowsCmd.AddCommand(workflowsListCmd) + workflowsCmd.AddCommand(workflowsGetCmd) + workflowsNodesCmd.AddCommand(workflowsNodesGetCmd) + workflowsCmd.AddCommand(workflowsNodesCmd) + rootCmd.AddCommand(workflowsCmd) +} diff --git a/cmd/workflows_get_test.go b/cmd/workflows_get_test.go new file mode 100644 index 0000000..516a430 --- /dev/null +++ b/cmd/workflows_get_test.go @@ -0,0 +1,94 @@ +package cmd + +import ( + "net/http" + "testing" + + "github.com/loops-so/loops-go" +) + +func TestRunWorkflowsGet(t *testing.T) { + body := `{ + "id": "wf_123", + "name": "Welcome", + "description": "New user welcome series", + "emoji": "👋", + "mailingListId": null, + "rootNodeId": "node_root", + "nodes": { + "node_root": { + "typeName": "SignupTrigger", + "nextNodeIds": ["node_email"] + }, + "node_email": { + "typeName": "SendEmailAction", + "nextNodeIds": [], + "emailMessageId": "em_1", + "subject": "Hello" + } + } + }` + + t.Run("returns the workflow", func(t *testing.T) { + cap := serveJSONCapture(t, http.StatusOK, body) + w, err := runWorkflowsGet(cfg(t), "wf_123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if w.ID != "wf_123" { + t.Errorf("ID = %q, want wf_123", w.ID) + } + if w.Name != "Welcome" { + t.Errorf("Name = %q, want Welcome", w.Name) + } + if w.MailingListID != nil { + t.Errorf("MailingListID = %v, want nil", w.MailingListID) + } + if deref(w.RootNodeID) != "node_root" { + t.Errorf("RootNodeID = %q, want node_root", deref(w.RootNodeID)) + } + if len(w.Nodes) != 2 { + t.Fatalf("expected 2 nodes, got %d", len(w.Nodes)) + } + root := w.Nodes["node_root"] + if root.TypeName != loops.WorkflowNodeTypeSignupTrigger { + t.Errorf("root.TypeName = %q, want SignupTrigger", root.TypeName) + } + if root.SignupTrigger == nil { + t.Fatal("root.SignupTrigger = nil, want populated") + } + email := w.Nodes["node_email"] + if email.SendEmailAction == nil { + t.Fatal("email.SendEmailAction = nil, want populated") + } + if email.SendEmailAction.EmailMessageID != "em_1" { + t.Errorf("emailMessageId = %q, want em_1", email.SendEmailAction.EmailMessageID) + } + if cap.Method != http.MethodGet { + t.Errorf("Method = %q, want GET", cap.Method) + } + if cap.Path != "/workflows/wf_123" { + t.Errorf("Path = %q, want /workflows/wf_123", cap.Path) + } + }) + + t.Run("simplifiedNodeNextIDs extracts next IDs", func(t *testing.T) { + serveJSON(t, http.StatusOK, body) + w, err := runWorkflowsGet(cfg(t), "wf_123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := simplifiedNodeNextIDs(w.Nodes["node_root"]) + if len(got) != 1 || got[0] != "node_email" { + t.Errorf("simplifiedNodeNextIDs = %v, want [node_email]", got) + } + }) + + t.Run("returns error on non-200 response", func(t *testing.T) { + serveJSON(t, http.StatusNotFound, `{"success":false,"message":"Workflow not found"}`) + _, err := runWorkflowsGet(cfg(t), "wf_missing") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/cmd/workflows_list_test.go b/cmd/workflows_list_test.go new file mode 100644 index 0000000..bf01ad9 --- /dev/null +++ b/cmd/workflows_list_test.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "net/http" + "testing" + + "github.com/loops-so/loops-go" +) + +func TestRunWorkflowsList(t *testing.T) { + t.Run("returns workflows", func(t *testing.T) { + body := `{ + "pagination":{"nextCursor":""}, + "data":[ + {"id":"wf_1","name":"Welcome","createdAt":"2026-04-01","updatedAt":"2026-04-02"}, + {"id":"wf_2","name":"Onboarding","createdAt":"2026-05-01","updatedAt":"2026-05-02"} + ] + }` + cap := serveJSONCapture(t, http.StatusOK, body) + + workflows, err := runWorkflowsList(cfg(t), loops.PaginationParams{PerPage: "10"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(workflows) != 2 { + t.Fatalf("expected 2 workflows, got %d", len(workflows)) + } + if workflows[0].ID != "wf_1" { + t.Errorf("ID = %q, want wf_1", workflows[0].ID) + } + if workflows[1].Name != "Onboarding" { + t.Errorf("Name = %q, want Onboarding", workflows[1].Name) + } + if cap.Method != http.MethodGet { + t.Errorf("Method = %q, want GET", cap.Method) + } + if cap.Path != "/workflows?perPage=10" { + t.Errorf("Path = %q, want /workflows?perPage=10", cap.Path) + } + }) + + t.Run("single page when cursor set", func(t *testing.T) { + body := `{"pagination":{"nextCursor":"next"},"data":[{"id":"wf_1","name":"Welcome","createdAt":"","updatedAt":""}]}` + cap := serveJSONCapture(t, http.StatusOK, body) + + workflows, err := runWorkflowsList(cfg(t), loops.PaginationParams{Cursor: "abc"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(workflows) != 1 { + t.Fatalf("expected 1 workflow, got %d", len(workflows)) + } + if cap.Path != "/workflows?cursor=abc" { + t.Errorf("Path = %q, want /workflows?cursor=abc", cap.Path) + } + }) + + t.Run("returns error on api failure", func(t *testing.T) { + serveJSON(t, http.StatusUnauthorized, `{"error":"unauthorized"}`) + _, err := runWorkflowsList(cfg(t), loops.PaginationParams{}) + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/cmd/workflows_nodes_get_test.go b/cmd/workflows_nodes_get_test.go new file mode 100644 index 0000000..ce10613 --- /dev/null +++ b/cmd/workflows_nodes_get_test.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "net/http" + "testing" + + "github.com/loops-so/loops-go" +) + +func TestRunWorkflowsNodeGet(t *testing.T) { + t.Run("returns event trigger node", func(t *testing.T) { + body := `{ + "typeName": "EventTrigger", + "id": "node_abc", + "workflowId": "wf_123", + "nextNodeIds": ["node_next"], + "eventName": "signup", + "reEligible": true, + "eventProperties": [ + {"name":"plan","type":"string"}, + {"name":"seats","type":"number"} + ] + }` + cap := serveJSONCapture(t, http.StatusOK, body) + + n, err := runWorkflowsNodeGet(cfg(t), "wf_123", "node_abc") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if n.TypeName != loops.WorkflowNodeTypeEventTrigger { + t.Errorf("TypeName = %q, want EventTrigger", n.TypeName) + } + if n.EventTrigger == nil { + t.Fatal("EventTrigger = nil, want populated") + } + if deref(n.EventTrigger.EventName) != "signup" { + t.Errorf("EventName = %q, want signup", deref(n.EventTrigger.EventName)) + } + if !n.EventTrigger.ReEligible { + t.Error("ReEligible = false, want true") + } + if len(n.EventTrigger.EventProperties) != 2 { + t.Errorf("EventProperties = %d, want 2", len(n.EventTrigger.EventProperties)) + } + if cap.Method != http.MethodGet { + t.Errorf("Method = %q, want GET", cap.Method) + } + if cap.Path != "/workflows/wf_123/nodes/node_abc" { + t.Errorf("Path = %q, want /workflows/wf_123/nodes/node_abc", cap.Path) + } + }) + + t.Run("workflowNodeRows renders event trigger fields", func(t *testing.T) { + eventName := "signup" + n := &loops.WorkflowNode{ + TypeName: loops.WorkflowNodeTypeEventTrigger, + EventTrigger: &loops.EventTriggerWorkflowNode{ + ID: "node_abc", + WorkflowID: "wf_123", + NextNodeIDs: []string{"a", "b"}, + EventName: &eventName, + ReEligible: true, + EventProperties: []loops.WorkflowEventProperty{ + {Name: "plan", Type: "string"}, + }, + }, + } + rows := workflowNodeRows(n) + got := map[string]string{} + for _, r := range rows { + got[r[0]] = r[1] + } + if got["typeName"] != "EventTrigger" { + t.Errorf("typeName = %q, want EventTrigger", got["typeName"]) + } + if got["nodeId"] != "node_abc" { + t.Errorf("nodeId = %q, want node_abc", got["nodeId"]) + } + if got["workflowId"] != "wf_123" { + t.Errorf("workflowId = %q, want wf_123", got["workflowId"]) + } + if got["nextNodeIds"] != "a, b" { + t.Errorf("nextNodeIds = %q, want \"a, b\"", got["nextNodeIds"]) + } + if got["eventName"] != "signup" { + t.Errorf("eventName = %q, want signup", got["eventName"]) + } + if got["reEligible"] != "true" { + t.Errorf("reEligible = %q, want true", got["reEligible"]) + } + if got["eventProperties"] != "1 properties" { + t.Errorf("eventProperties = %q, want \"1 properties\"", got["eventProperties"]) + } + }) + + t.Run("returns error on non-200 response", func(t *testing.T) { + serveJSON(t, http.StatusNotFound, `{"success":false,"message":"Node not found"}`) + _, err := runWorkflowsNodeGet(cfg(t), "wf_123", "node_missing") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} From 41b37f7276d67a48a18d74ea933ff0c952affc91 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:11:36 -0700 Subject: [PATCH 2/2] loops-go v0.3.1 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 24e6e47..b2572fc 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/colorprofile v0.4.2 github.com/charmbracelet/x/term v0.2.2 - github.com/loops-so/loops-go v0.3.0 + github.com/loops-so/loops-go v0.3.1 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/zalando/go-keyring v0.2.6 diff --git a/go.sum b/go.sum index 8e307d9..ea1ace2 100644 --- a/go.sum +++ b/go.sum @@ -299,8 +299,8 @@ github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/loops-so/loops-go v0.3.0 h1:E4hMTtr1BH0JF0/B3QKUMfMes+Czjh90toS5XpPiMZg= -github.com/loops-so/loops-go v0.3.0/go.mod h1:BDzBhAn/4e2QSKXrpXufIpSuH8xUPv9oa+hazH01ejE= +github.com/loops-so/loops-go v0.3.1 h1:fpkNQWRVj3oAOu+w2duUIvRtLrfiuMk4RBHqqCLa5M0= +github.com/loops-so/loops-go v0.3.1/go.mod h1:BDzBhAn/4e2QSKXrpXufIpSuH8xUPv9oa+hazH01ejE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=