From c3d80e0af1d1c8c40e8aca1f89e96645afbaf312 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:00:26 -0700 Subject: [PATCH] feat: components create and update subcommands --- cmd/components.go | 132 +++++++++++++++++++++++++++++-- cmd/components_create_test.go | 145 ++++++++++++++++++++++++++++++++++ cmd/components_update_test.go | 101 +++++++++++++++++++++++ 3 files changed, 372 insertions(+), 6 deletions(-) create mode 100644 cmd/components_create_test.go create mode 100644 cmd/components_update_test.go diff --git a/cmd/components.go b/cmd/components.go index a537142..29aec85 100644 --- a/cmd/components.go +++ b/cmd/components.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "os" "github.com/loops-so/cli/internal/config" "github.com/loops-so/loops-go" @@ -12,6 +13,47 @@ func runComponentsGet(cfg *config.Config, id string) (*loops.Component, error) { return newAPIClient(cfg).GetComponent(id) } +func runComponentsCreate(cfg *config.Config, req loops.CreateComponentRequest) (*loops.Component, error) { + return newAPIClient(cfg).CreateComponent(req) +} + +func runComponentsUpdate(cfg *config.Config, id string, req loops.UpdateComponentRequest) (*loops.UpdateComponentResult, error) { + return newAPIClient(cfg).UpdateComponent(id, req) +} + +// lmxFromCmd resolves the LMX body from either --lmx (inline) or --lmx-file +// (raw file contents). The two flags are mutually exclusive. The second return +// value reports whether either flag was set. +func lmxFromCmd(cmd *cobra.Command) (string, bool, error) { + if cmd.Flags().Changed("lmx") { + lmx, _ := cmd.Flags().GetString("lmx") + return lmx, true, nil + } + if cmd.Flags().Changed("lmx-file") { + path, _ := cmd.Flags().GetString("lmx-file") + data, err := os.ReadFile(path) + if err != nil { + return "", false, fmt.Errorf("read --lmx-file: %w", err) + } + return string(data), true, nil + } + return "", false, nil +} + +// printComponent renders a component as a field table followed by its +// highlighted LMX body, matching the `components get` output. +func printComponent(cmd *cobra.Command, c *loops.Component) error { + t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") + t.Row("componentId", c.ID) + t.Row("name", c.Name) + if err := t.Render(); err != nil { + return err + } + + fmt.Fprintln(cmd.OutOrStdout()) + return renderLMX(cmd.OutOrStdout(), c.LMX) +} + func runComponentsList(cfg *config.Config, params loops.PaginationParams) ([]loops.Component, error) { client := newAPIClient(cfg) if params.Cursor != "" { @@ -100,15 +142,77 @@ var componentsGetCmd = &cobra.Command{ return printJSON(cmd.OutOrStdout(), c) } - t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") - t.Row("componentId", c.ID) - t.Row("name", c.Name) - if err := t.Render(); err != nil { + return printComponent(cmd, c) + }, +} + +var componentsCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a component", + RunE: func(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + + lmx, _, err := lmxFromCmd(cmd) + if err != nil { + return err + } + + cfg, err := loadConfig() + if err != nil { + return err + } + + c, err := runComponentsCreate(cfg, loops.CreateComponentRequest{ + Name: name, + LMX: lmx, + }) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), c) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Created. (componentId: %s)\n\n", c.ID) + return printComponent(cmd, c) + }, +} + +var componentsUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a component", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + lmx, lmxSet, err := lmxFromCmd(cmd) + if err != nil { + return err + } + + req := loops.UpdateComponentRequest{} + if cmd.Flags().Changed("name") { + req.Name, _ = cmd.Flags().GetString("name") + } + if lmxSet { + req.LMX = lmx + } + + cfg, err := loadConfig() + if err != nil { return err } - fmt.Fprintln(cmd.OutOrStdout()) - return renderLMX(cmd.OutOrStdout(), c.LMX) + result, err := runComponentsUpdate(cfg, args[0], req) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), result) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Updated. (componentId: %s, affectedEmailCount: %d)\n\n", result.ID, result.AffectedEmailCount) + return printComponent(cmd, &result.Component) }, } @@ -117,5 +221,21 @@ func init() { addPickFlag(componentsListCmd) componentsCmd.AddCommand(componentsListCmd) componentsCmd.AddCommand(componentsGetCmd) + + componentsCreateCmd.Flags().String("name", "", "Component name") + componentsCreateCmd.Flags().String("lmx", "", "LMX markup (inline)") + componentsCreateCmd.Flags().String("lmx-file", "", "Path to a file containing LMX markup") + componentsCreateCmd.MarkFlagRequired("name") + componentsCreateCmd.MarkFlagsMutuallyExclusive("lmx", "lmx-file") + componentsCreateCmd.MarkFlagsOneRequired("lmx", "lmx-file") + componentsCmd.AddCommand(componentsCreateCmd) + + componentsUpdateCmd.Flags().String("name", "", "Component name") + componentsUpdateCmd.Flags().String("lmx", "", "LMX markup (inline)") + componentsUpdateCmd.Flags().String("lmx-file", "", "Path to a file containing LMX markup") + componentsUpdateCmd.MarkFlagsMutuallyExclusive("lmx", "lmx-file") + componentsUpdateCmd.MarkFlagsOneRequired("name", "lmx", "lmx-file") + componentsCmd.AddCommand(componentsUpdateCmd) + rootCmd.AddCommand(componentsCmd) } diff --git a/cmd/components_create_test.go b/cmd/components_create_test.go new file mode 100644 index 0000000..a3ee0e8 --- /dev/null +++ b/cmd/components_create_test.go @@ -0,0 +1,145 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/loops-so/loops-go" + "github.com/spf13/cobra" +) + +func TestRunComponentsCreate(t *testing.T) { + body := `{ + "id": "cmp_new", + "name": "Footer", + "lmx": "Hi" + }` + + t.Run("returns component on success", func(t *testing.T) { + serveJSON(t, http.StatusCreated, body) + c, err := runComponentsCreate(cfg(t), loops.CreateComponentRequest{Name: "Footer", LMX: "Hi"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.ID != "cmp_new" { + t.Errorf("ID = %q, want cmp_new", c.ID) + } + if c.Name != "Footer" { + t.Errorf("Name = %q, want Footer", c.Name) + } + }) + + t.Run("returns error on non-2xx response", func(t *testing.T) { + serveJSON(t, http.StatusBadRequest, `{"success":false,"message":"name is required"}`) + _, err := runComponentsCreate(cfg(t), loops.CreateComponentRequest{}) + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("sends name and lmx in request body", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusCreated, body) + _, err := runComponentsCreate(cfg(t), loops.CreateComponentRequest{ + Name: "Footer", + LMX: "Hi", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var sent map[string]any + if err := json.Unmarshal(got.Body, &sent); err != nil { + t.Fatalf("decode request body: %v\nraw: %s", err, got.Body) + } + if sent["name"] != "Footer" { + t.Errorf("name = %v, want Footer", sent["name"]) + } + if sent["lmx"] != "Hi" { + t.Errorf("lmx = %v", sent["lmx"]) + } + }) +} + +func TestComponentsCreateCmdLmxFile(t *testing.T) { + body := `{"id":"cmp_new","name":"Footer","lmx":"From file"}` + got := serveJSONCapture(t, http.StatusCreated, body) + + dir := t.TempDir() + path := filepath.Join(dir, "footer.lmx") + if err := os.WriteFile(path, []byte("From file"), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + saved := outputFormat + outputFormat = "text" + t.Cleanup(func() { outputFormat = saved }) + + cmd := *componentsCreateCmd + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + if err := cmd.ParseFlags([]string{"--name", "Footer", "--lmx-file", path}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + if err := cmd.RunE(&cmd, []string{}); err != nil { + t.Fatalf("RunE: %v", err) + } + + var sent map[string]any + if err := json.Unmarshal(got.Body, &sent); err != nil { + t.Fatalf("decode request body: %v\nraw: %s", err, got.Body) + } + if sent["lmx"] != "From file" { + t.Errorf("lmx = %v, want file contents", sent["lmx"]) + } + if sent["name"] != "Footer" { + t.Errorf("name = %v, want Footer", sent["name"]) + } +} + +// newLmxTestCmd returns a fresh command carrying its own flag set so subtests +// do not alias the shared package-level command's flags. +func newLmxTestCmd() *cobra.Command { + cmd := &cobra.Command{} + cmd.Flags().String("name", "", "") + cmd.Flags().String("lmx", "", "") + cmd.Flags().String("lmx-file", "", "") + return cmd +} + +func TestLmxFromCmd(t *testing.T) { + t.Run("inline lmx", func(t *testing.T) { + cmd := newLmxTestCmd() + cmd.ParseFlags([]string{"--lmx", "Inline"}) + lmx, set, err := lmxFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !set || lmx != "Inline" { + t.Errorf("lmx=%q set=%v", lmx, set) + } + }) + + t.Run("missing lmx-file returns error", func(t *testing.T) { + cmd := newLmxTestCmd() + cmd.ParseFlags([]string{"--lmx-file", "/does/not/exist.lmx"}) + if _, _, err := lmxFromCmd(cmd); err == nil { + t.Fatal("expected error for missing file, got nil") + } + }) + + t.Run("neither flag set", func(t *testing.T) { + cmd := newLmxTestCmd() + cmd.ParseFlags([]string{"--name", "x"}) + lmx, set, err := lmxFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if set || lmx != "" { + t.Errorf("expected unset, got lmx=%q set=%v", lmx, set) + } + }) +} diff --git a/cmd/components_update_test.go b/cmd/components_update_test.go new file mode 100644 index 0000000..d620795 --- /dev/null +++ b/cmd/components_update_test.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/loops-so/loops-go" +) + +func TestRunComponentsUpdate(t *testing.T) { + body := `{ + "id": "cmp_1", + "name": "Footer", + "lmx": "Updated", + "affectedEmailCount": 3 + }` + + t.Run("returns result with affectedEmailCount on success", func(t *testing.T) { + serveJSON(t, http.StatusOK, body) + result, err := runComponentsUpdate(cfg(t), "cmp_1", loops.UpdateComponentRequest{LMX: "Updated"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.ID != "cmp_1" { + t.Errorf("ID = %q, want cmp_1", result.ID) + } + if result.AffectedEmailCount != 3 { + t.Errorf("AffectedEmailCount = %d, want 3", result.AffectedEmailCount) + } + }) + + t.Run("returns error on non-2xx response", func(t *testing.T) { + serveJSON(t, http.StatusNotFound, `{"success":false,"message":"not found"}`) + _, err := runComponentsUpdate(cfg(t), "cmp_missing", loops.UpdateComponentRequest{Name: "x"}) + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("sends only name when only name set", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusOK, body) + _, err := runComponentsUpdate(cfg(t), "cmp_1", loops.UpdateComponentRequest{Name: "Renamed"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var sent map[string]any + if err := json.Unmarshal(got.Body, &sent); err != nil { + t.Fatalf("decode request body: %v\nraw: %s", err, got.Body) + } + if sent["name"] != "Renamed" { + t.Errorf("name = %v, want Renamed", sent["name"]) + } + if _, ok := sent["lmx"]; ok { + t.Errorf("lmx should be omitted when unset, got %v", sent["lmx"]) + } + }) +} + +func TestComponentsUpdateCmdLmxFileAndAffectedCount(t *testing.T) { + body := `{"id":"cmp_1","name":"Footer","lmx":"From file","affectedEmailCount":7}` + got := serveJSONCapture(t, http.StatusOK, body) + + dir := t.TempDir() + path := filepath.Join(dir, "footer.lmx") + if err := os.WriteFile(path, []byte("From file"), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + saved := outputFormat + outputFormat = "text" + t.Cleanup(func() { outputFormat = saved }) + + var out bytes.Buffer + cmd := *componentsUpdateCmd + cmd.SetOut(&out) + cmd.SetErr(&out) + if err := cmd.ParseFlags([]string{"--lmx-file", path}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + if err := cmd.RunE(&cmd, []string{"cmp_1"}); err != nil { + t.Fatalf("RunE: %v", err) + } + + var sent map[string]any + if err := json.Unmarshal(got.Body, &sent); err != nil { + t.Fatalf("decode request body: %v\nraw: %s", err, got.Body) + } + if sent["lmx"] != "From file" { + t.Errorf("lmx = %v, want file contents", sent["lmx"]) + } + + if !strings.Contains(out.String(), "affectedEmailCount: 7") { + t.Errorf("output missing affectedEmailCount: 7\ngot:\n%s", out.String()) + } +}