diff --git a/cmd/build_info.go b/cmd/build_info.go index f94e1db..94ba8fd 100644 --- a/cmd/build_info.go +++ b/cmd/build_info.go @@ -3,6 +3,8 @@ package cmd import ( "runtime/debug" "strings" + + "github.com/loops-so/loops-go" ) var ( @@ -48,6 +50,7 @@ func init() { if sdkVersion != "" { parts = append(parts, "sdk "+sdkVersion) } + parts = append(parts, "spec "+loops.SpecVersion) suffix := "" if len(parts) > 0 { suffix = " (" + strings.Join(parts, ", ") + ")" diff --git a/cmd/campaigns.go b/cmd/campaigns.go index b2af86f..f443559 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -359,6 +359,7 @@ var campaignsGetCmd = &cobra.Command{ func printCampaign(cmd *cobra.Command, c *loops.Campaign) error { t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") t.Row("campaignId", c.ID) + t.Row("url", c.URL) t.Row("emailMessageId", deref(c.EmailMessageID)) t.Row("name", c.Name) t.Row("status", c.Status) diff --git a/cmd/transactional.go b/cmd/transactional.go index 859c5ad..5238add 100644 --- a/cmd/transactional.go +++ b/cmd/transactional.go @@ -293,6 +293,7 @@ var transactionalPublishCmd = &cobra.Command{ func printTransactional(cmd *cobra.Command, tx *loops.Transactional) error { t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") t.Row("transactionalId", tx.ID) + t.Row("url", tx.URL) t.Row("name", tx.Name) t.Row("transactionalGroupId", deref(tx.TransactionalGroupID)) t.Row("draftEmailMessageId", deref(tx.DraftEmailMessageID)) diff --git a/cmd/workflows.go b/cmd/workflows.go index 378cf3b..d8bfe3a 100644 --- a/cmd/workflows.go +++ b/cmd/workflows.go @@ -2,6 +2,7 @@ package cmd import ( "encoding/json" + "errors" "fmt" "os" "slices" @@ -117,6 +118,7 @@ var workflowsGetCmd = &cobra.Command{ func printSimplifiedWorkflow(cmd *cobra.Command, w *loops.SimplifiedWorkflow) error { t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") t.Row("workflowId", w.ID) + t.Row("url", w.URL) t.Row("name", w.Name) t.Row("description", w.Description) t.Row("emoji", w.Emoji) @@ -458,6 +460,10 @@ func runWorkflowsChangeMailingList(cfg *config.Config, id string, req loops.Chan return newAPIClient(cfg).ChangeWorkflowMailingList(id, req) } +func runWorkflowsDelete(cfg *config.Config, id string, req loops.DeleteWorkflowRequest) error { + return newAPIClient(cfg).DeleteWorkflow(id, req) +} + func runWorkflowsNodeCreate(cfg *config.Config, id string, req loops.CreateWorkflowNodeRequest) (*loops.CreateWorkflowNodeResponse, error) { return newAPIClient(cfg).CreateWorkflowNode(id, req) } @@ -828,6 +834,48 @@ var workflowsNodesDeleteCmd = &cobra.Command{ }, } +// apiConfirmSentence is the trailing sentence the API adds to a +// confirmation-required delete error. It is replaced with CLI guidance. +const apiConfirmSentence = "Confirm deletion by sending a second request with confirmDelete: true." + +var workflowsDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a workflow", + Long: "Deletes a workflow. A workflow that is sending or has queued contacts is not deleted on the first\n" + + "attempt; re-run with --confirm to delete it, stop sending, and cancel its queued contacts.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + confirm, _ := cmd.Flags().GetBool("confirm") + err = runWorkflowsDelete(cfg, args[0], loops.DeleteWorkflowRequest{ + ExpectedRevisionID: readExpectedRevisionID(cmd), + ConfirmDelete: confirm, + }) + if errors.Is(err, loops.ErrWorkflowDeleteConfirmationRequired) { + var apiErr *loops.APIError + msg := err.Error() + if errors.As(err, &apiErr) { + msg = apiErr.Message + } + msg = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(msg), apiConfirmSentence)) + return fmt.Errorf("%s Re-run with --confirm to delete it anyway", msg) + } + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), Result{Success: true}) + } + fmt.Fprintln(cmd.OutOrStdout(), "Deleted.") + return nil + }, +} + // mutationNodeID returns the ID of the active variant of a WorkflowMutationNode. func mutationNodeID(n *loops.WorkflowMutationNode) string { switch n.TypeName { @@ -895,6 +943,10 @@ func init() { workflowsCreateCmd.MarkFlagRequired("name") workflowsCmd.AddCommand(workflowsCreateCmd) + workflowsDeleteCmd.Flags().String("expected-revision-id", "", "Expected workflow revision ID (optimistic concurrency)") + workflowsDeleteCmd.Flags().Bool("confirm", false, "Confirm deletion of a workflow that is sending or has queued contacts") + workflowsCmd.AddCommand(workflowsDeleteCmd) + workflowsUpdateCmd.Flags().StringP("name", "n", "", "Workflow name") workflowsUpdateCmd.Flags().StringP("description", "d", "", "Workflow description") workflowsUpdateCmd.Flags().String("expected-revision-id", "", "Expected workflow revision ID (optimistic concurrency)") diff --git a/cmd/workflows_delete_test.go b/cmd/workflows_delete_test.go new file mode 100644 index 0000000..25959d9 --- /dev/null +++ b/cmd/workflows_delete_test.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "encoding/json" + "errors" + "net/http" + "testing" + + "github.com/loops-so/loops-go" +) + +func TestRunWorkflowsDelete(t *testing.T) { + t.Run("sends expectedRevisionId and omits confirmDelete when false", func(t *testing.T) { + cap := serveJSONCapture(t, http.StatusNoContent, "") + rev := "rev_1" + if err := runWorkflowsDelete(cfg(t), "wf_abc", loops.DeleteWorkflowRequest{ExpectedRevisionID: &rev}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cap.Method != http.MethodDelete { + t.Errorf("Method = %q, want DELETE", cap.Method) + } + if cap.Path != "/workflows/wf_abc" { + t.Errorf("Path = %q, want /workflows/wf_abc", cap.Path) + } + + var body map[string]any + if err := json.Unmarshal(cap.Body, &body); err != nil { + t.Fatalf("unmarshal body: %v", err) + } + if body["expectedRevisionId"] != "rev_1" { + t.Errorf("expectedRevisionId = %v, want rev_1", body["expectedRevisionId"]) + } + if _, ok := body["confirmDelete"]; ok { + t.Errorf("confirmDelete present, want omitted") + } + }) + + t.Run("sends null revision and confirmDelete when confirmed", func(t *testing.T) { + cap := serveJSONCapture(t, http.StatusNoContent, "") + if err := runWorkflowsDelete(cfg(t), "wf_abc", loops.DeleteWorkflowRequest{ConfirmDelete: true}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body map[string]any + if err := json.Unmarshal(cap.Body, &body); err != nil { + t.Fatalf("unmarshal body: %v", err) + } + v, ok := body["expectedRevisionId"] + if !ok || v != nil { + t.Errorf("expectedRevisionId = %v (present %v), want JSON null", v, ok) + } + if body["confirmDelete"] != true { + t.Errorf("confirmDelete = %v, want true", body["confirmDelete"]) + } + }) + + t.Run("reports confirmation required on 409 with the confirm sentence", func(t *testing.T) { + serveJSON(t, http.StatusConflict, `{"success":false,"message":"This workflow is currently sending to 12 contacts. `+apiConfirmSentence+`"}`) + err := runWorkflowsDelete(cfg(t), "wf_abc", loops.DeleteWorkflowRequest{}) + if !errors.Is(err, loops.ErrWorkflowDeleteConfirmationRequired) { + t.Fatalf("error = %v, want ErrWorkflowDeleteConfirmationRequired", err) + } + var apiErr *loops.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error does not unwrap to *loops.APIError: %v", err) + } + if apiErr.StatusCode != http.StatusConflict { + t.Errorf("StatusCode = %d, want 409", apiErr.StatusCode) + } + }) + + t.Run("a stale revision 409 is a plain API error", func(t *testing.T) { + serveJSON(t, http.StatusConflict, `{"success":false,"message":"Workflow revision is out of date."}`) + err := runWorkflowsDelete(cfg(t), "wf_abc", loops.DeleteWorkflowRequest{}) + if err == nil { + t.Fatal("expected error, got nil") + } + if errors.Is(err, loops.ErrWorkflowDeleteConfirmationRequired) { + t.Errorf("stale revision reported as confirmation required: %v", err) + } + }) +} diff --git a/go.mod b/go.mod index 7e8399e..a8dc0e0 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/loops-so/cli -go 1.26.5 +go 1.26.7 require ( charm.land/fang/v2 v2.0.1 @@ -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.5.0 + github.com/loops-so/loops-go v0.6.0 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 705a743..0cdf174 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.5.0 h1:Oh77cLVmD+UYA86LzvNqwrFyh8uHoPA20IcSdqZJ2ZM= -github.com/loops-so/loops-go v0.5.0/go.mod h1:BDzBhAn/4e2QSKXrpXufIpSuH8xUPv9oa+hazH01ejE= +github.com/loops-so/loops-go v0.6.0 h1:j0D5W3iRsZwuQyI0BZRBloyIDjTnYisWPajrgqV/ku4= +github.com/loops-so/loops-go v0.6.0/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= diff --git a/mise.toml b/mise.toml index cd0d999..c0c04bf 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,5 @@ [tools] -go = "1.26.4" +go = "1.26.7" # goreleaser-pro is required, but not available via mise. try, # brew install --cask goreleaser/tap/goreleaser-pro