Skip to content
20 changes: 20 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,31 @@ tasks:
- git tag {{.tag}}
- git push origin {{.tag}}
preconditions:
- sh: 'echo "{{.tag}}" | grep -Eq "^v[0-9]+\.[0-9]+\.[0-9]+$"'
msg: "tag {{.tag}} must be a release formatted: v0.0.0 (use release:pre for prereleases)"
- sh: '[ -z "$(git ls-remote --tags origin refs/tags/{{.tag}})" ]'
msg: "tag {{.tag}} already exists on remote"
- sh: '[ "$(git branch --show-current)" = "main" ]'
msg: make sure you're on main

release:pre:
desc: release a new cli prerelease version
aliases: [prerelease]
deps:
- release:check
requires:
vars:
- tag
cmds:
- git pull --rebase
- git tag {{.tag}}
- git push origin {{.tag}}
preconditions:
- sh: 'echo "{{.tag}}" | grep -Eq "^v[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z.-]+$"'
msg: "tag {{.tag}} must be prerelease formatted: v0.0.0-some-tag"
- sh: '[ -z "$(git ls-remote --tags origin refs/tags/{{.tag}})" ]'
msg: "tag {{.tag}} already exists on remote"

release:local:
desc: build with goreleaser
dotenv: [.env.macos-signing]
Expand Down
184 changes: 184 additions & 0 deletions cmd/campaigns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package cmd

import (
"fmt"
"strings"

"github.com/loops-so/cli/internal/api"
"github.com/loops-so/cli/internal/config"
"github.com/spf13/cobra"
)

func fromEmailUsername(s string) string {
before, _, _ := strings.Cut(s, "@")
return before
}

func runCampaignsGet(cfg *config.Config, id string) (*api.Campaign, error) {
return newAPIClient(cfg).GetCampaign(id)
}

func runCampaignsList(cfg *config.Config, params api.PaginationParams) ([]api.Campaign, error) {
client := newAPIClient(cfg)
if params.Cursor != "" {
campaigns, _, err := client.ListCampaigns(params)
return campaigns, err
}
return api.Paginate(func(cursor string) ([]api.Campaign, *api.Pagination, error) {
return client.ListCampaigns(api.PaginationParams{
PerPage: params.PerPage,
Cursor: cursor,
})
})
}

var campaignsCmd = &cobra.Command{
Use: "campaigns",
Short: "Manage campaigns",
}

var campaignsListCmd = &cobra.Command{
Use: "list",
Short: "List campaigns",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}

campaigns, err := runCampaignsList(cfg, paginationParams(cmd))
if err != nil {
return err
}

if isJSONOutput() {
if campaigns == nil {
campaigns = []api.Campaign{}
}
return printJSON(cmd.OutOrStdout(), campaigns)
}

if len(campaigns) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No campaigns found.")
return nil
}

t := newStyledTable(cmd.OutOrStdout(), "ID", "MESSAGE ID", "NAME", "STATUS", "SUBJECT", "UPDATED")
for _, c := range campaigns {
t.Row(
c.CampaignID,
deref(c.EmailMessageID),
c.Name,
c.Status,
c.Subject,
c.UpdatedAt,
)
}
return t.Render()
},
}

func runCampaignsCreate(cfg *config.Config, req api.CreateCampaignRequest) (*api.CampaignCreateResponse, error) {
return newAPIClient(cfg).CreateCampaign(req)
}

var campaignsCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a draft campaign",
RunE: func(cmd *cobra.Command, args []string) error {
name, _ := cmd.Flags().GetString("name")
params, err := emailMessageFieldParamsFromCmd(cmd)
if err != nil {
return err
}

req := api.CreateCampaignRequest{Name: name}
if len(params.Set) > 0 {
req.EmailMessage = &api.EmailMessageFields{
Subject: params.Subject,
PreviewText: params.PreviewText,
FromName: params.FromName,
FromEmail: params.FromEmail,
ReplyToEmail: params.ReplyToEmail,
LMX: params.LMX,
}
}

cfg, err := loadConfig()
if err != nil {
return err
}

resp, err := runCampaignsCreate(cfg, req)
if err != nil {
return err
}

if isJSONOutput() {
return printJSON(cmd.OutOrStdout(), resp)
}

emailMessageID := ""
if resp.EmailMessage != nil {
emailMessageID = resp.EmailMessage.EmailMessageID
}
fmt.Fprintf(cmd.OutOrStdout(), "Created. (id: %s, emailMessageId: %s)\n", resp.CampaignID, emailMessageID)

if len(resp.Warnings) > 0 {
fmt.Fprintln(cmd.OutOrStdout())
fmt.Fprintln(cmd.OutOrStdout(), "Warnings:")
for _, warn := range resp.Warnings {
if warn.Path != "" {
fmt.Fprintf(cmd.OutOrStdout(), " [%s] %s (%s)\n", warn.Rule, warn.Message, warn.Path)
} else {
fmt.Fprintf(cmd.OutOrStdout(), " [%s] %s\n", warn.Rule, warn.Message)
}
}
}

return nil
},
}

var campaignsGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get a campaign",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}

c, err := runCampaignsGet(cfg, args[0])
if err != nil {
return err
}

if isJSONOutput() {
return printJSON(cmd.OutOrStdout(), c)
}

t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE")
t.Row("campaignId", c.CampaignID)
t.Row("emailMessageId", deref(c.EmailMessageID))
t.Row("name", c.Name)
t.Row("status", c.Status)
t.Row("createdAt", c.CreatedAt)
t.Row("updatedAt", c.UpdatedAt)
return t.Render()
},
}

func init() {
addPaginationFlags(campaignsListCmd)
campaignsCmd.AddCommand(campaignsListCmd)
campaignsCmd.AddCommand(campaignsGetCmd)

campaignsCreateCmd.Flags().StringP("name", "n", "", "Campaign name (required)")
addEmailMessageFieldFlags(campaignsCreateCmd)
campaignsCreateCmd.MarkFlagRequired("name")
campaignsCmd.AddCommand(campaignsCreateCmd)

rootCmd.AddCommand(campaignsCmd)
}
73 changes: 73 additions & 0 deletions cmd/campaigns_create_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package cmd

import (
"net/http"
"testing"

"github.com/loops-so/cli/internal/api"
)

func TestRunCampaignsCreate(t *testing.T) {
body := `{
"success": true,
"campaignId": "cmp_new",
"name": "Spring",
"status": "Draft",
"createdAt": "2026-04-20T10:00:00Z",
"updatedAt": "2026-04-20T10:00:00Z",
"emailMessage": {
"emailMessageId": "em_new",
"campaignId": "cmp_new",
"subject": "Hello",
"previewText": "",
"fromName": "",
"fromEmail": "",
"replyToEmail": "",
"lmx": "",
"contentRevisionId": "rev_1",
"updatedAt": "2026-04-20T10:00:00Z"
}
}`

t.Run("returns response on success", func(t *testing.T) {
serveJSON(t, http.StatusCreated, body)
resp, err := runCampaignsCreate(cfg(t), api.CreateCampaignRequest{Name: "Spring"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.CampaignID != "cmp_new" {
t.Errorf("CampaignID = %q, want cmp_new", resp.CampaignID)
}
if resp.EmailMessage == nil || resp.EmailMessage.EmailMessageID != "em_new" {
t.Errorf("EmailMessage = %v, want em_new", resp.EmailMessage)
}
})

t.Run("returns error on non-201 response", func(t *testing.T) {
serveJSON(t, http.StatusBadRequest, `{"success":false,"message":"name is required"}`)
_, err := runCampaignsCreate(cfg(t), api.CreateCampaignRequest{})
if err == nil {
t.Fatal("expected error, got nil")
}
})
}

func TestFromEmailUsername(t *testing.T) {
tests := []struct {
in string
want string
}{
{"hello", "hello"},
{"hello@acme.com", "hello"},
{"hello@", "hello"},
{"", ""},
{"@acme.com", ""},
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
if got := fromEmailUsername(tt.in); got != tt.want {
t.Errorf("fromEmailUsername(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
46 changes: 46 additions & 0 deletions cmd/campaigns_get_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package cmd

import (
"net/http"
"testing"
)

func TestRunCampaignsGet(t *testing.T) {
body := `{
"success": true,
"campaignId": "cmp_abc123",
"emailMessageId": "em_abc123",
"name": "Spring Launch",
"status": "Draft",
"createdAt": "2026-04-01T10:00:00Z",
"updatedAt": "2026-04-02T10:00:00Z"
}`

t.Run("returns the campaign", func(t *testing.T) {
serveJSON(t, http.StatusOK, body)
c, err := runCampaignsGet(cfg(t), "cmp_abc123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c.CampaignID != "cmp_abc123" {
t.Errorf("CampaignID = %q, want cmp_abc123", c.CampaignID)
}
if deref(c.EmailMessageID) != "em_abc123" {
t.Errorf("EmailMessageID = %q, want em_abc123", deref(c.EmailMessageID))
}
if c.Name != "Spring Launch" {
t.Errorf("Name = %q, want Spring Launch", c.Name)
}
if c.Status != "Draft" {
t.Errorf("Status = %q, want Draft", c.Status)
}
})

t.Run("returns error on non-200 response", func(t *testing.T) {
serveJSON(t, http.StatusNotFound, `{"success":false,"message":"Campaign not found"}`)
_, err := runCampaignsGet(cfg(t), "cmp_missing")
if err == nil {
t.Fatal("expected error, got nil")
}
})
}
35 changes: 35 additions & 0 deletions cmd/campaigns_list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package cmd

import (
"net/http"
"testing"

"github.com/loops-so/cli/internal/api"
)

func TestRunCampaignsList(t *testing.T) {
t.Run("returns campaigns", func(t *testing.T) {
serveJSON(t, http.StatusOK, `{"pagination":{"nextCursor":""},"data":[{"campaignId":"cmp_1","emailMessageId":"em_1","name":"Spring","subject":"Hi","status":"Draft","createdAt":"2026-04-01","updatedAt":"2026-04-02"}]}`)
campaigns, err := runCampaignsList(cfg(t), api.PaginationParams{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(campaigns) != 1 {
t.Fatalf("expected 1 campaign, got %d", len(campaigns))
}
if campaigns[0].CampaignID != "cmp_1" {
t.Errorf("CampaignID = %q, want cmp_1", campaigns[0].CampaignID)
}
if deref(campaigns[0].EmailMessageID) != "em_1" {
t.Errorf("EmailMessageID = %q, want em_1", deref(campaigns[0].EmailMessageID))
}
})

t.Run("returns error on api failure", func(t *testing.T) {
serveJSON(t, http.StatusUnauthorized, `{"error":"unauthorized"}`)
_, err := runCampaignsList(cfg(t), api.PaginationParams{})
if err == nil {
t.Fatal("expected error, got nil")
}
})
}
Loading