From 82372a776ee459d82a5111094fa167837433d188 Mon Sep 17 00:00:00 2001 From: Lucas Polesello Date: Thu, 19 Feb 2026 17:17:39 -0300 Subject: [PATCH] feat(Formatters + ListDetails): add `instance list --details` and `-o json` for better automations --- Makefile | 4 ++ cmd/instance_config.go | 17 +++--- cmd/instance_get.go | 36 +++++++---- cmd/instance_list.go | 83 +++++++++++++++++++++++--- cmd/instance_nodes.go | 19 +++--- cmd/instance_plugins.go | 17 +++--- cmd/plans.go | 23 ++++--- cmd/regions.go | 16 +++-- cmd/root.go | 11 ++++ cmd/team_list.go | 17 +++--- cmd/vpc_get.go | 25 ++++++-- cmd/vpc_list.go | 19 +++--- internal/output/output.go | 122 ++++++++++++++++++++++++++++++++++++++ 13 files changed, 327 insertions(+), 82 deletions(-) create mode 100644 internal/output/output.go diff --git a/Makefile b/Makefile index 5503b0c..24c0a7c 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,10 @@ GO_LDFLAGS=-X cloudamqp-cli/cmd.Version=$(VERSION) \ -X cloudamqp-cli/cmd.BuildDate=$(BUILD_DATE) \ -X cloudamqp-cli/cmd.GitCommit=$(GIT_COMMIT) + +bin/cloudamqp: + $(MAKE) build BINARY_NAME="bin/cloudamqp" + # Default target .PHONY: all all: build diff --git a/cmd/instance_config.go b/cmd/instance_config.go index 6cb4836..9070c1a 100644 --- a/cmd/instance_config.go +++ b/cmd/instance_config.go @@ -50,18 +50,17 @@ var instanceConfigListCmd = &cobra.Command{ return nil } - // Print table header - fmt.Printf("%-40s %-30s\n", "KEY", "VALUE") - fmt.Printf("%-40s %-30s\n", "---", "-----") + p, err := getPrinter(cmd) + if err != nil { + return err + } - // Print configuration data + headers := []string{"KEY", "VALUE"} + rows := make([][]string, 0, len(config)) for key, value := range config { - valueStr := fmt.Sprintf("%v", value) - if len(valueStr) > 30 { - valueStr = valueStr[:27] + "..." - } - fmt.Printf("%-40s %-30s\n", key, valueStr) + rows = append(rows, []string{key, fmt.Sprintf("%v", value)}) } + p.PrintRecords(headers, rows) return nil }, diff --git a/cmd/instance_get.go b/cmd/instance_get.go index faf160f..86f141c 100644 --- a/cmd/instance_get.go +++ b/cmd/instance_get.go @@ -50,25 +50,35 @@ var instanceGetCmd = &cobra.Command{ return err } - // Format output as "Name = Value" - fmt.Printf("Name = %s\n", instance.Name) - fmt.Printf("Plan = %s\n", instance.Plan) - fmt.Printf("Region = %s\n", instance.Region) - fmt.Printf("Tags = %s\n", strings.Join(instance.Tags, ",")) - - showURL, _ := cmd.Flags().GetBool("show-url") - if showURL { - fmt.Printf("URL = %s\n", instance.URL) - } else { - fmt.Printf("URL = %s\n", maskPassword(instance.URL)) + p, err := getPrinter(cmd) + if err != nil { + return err } - fmt.Printf("Hostname = %s\n", instance.HostnameExternal) + showURL, _ := cmd.Flags().GetBool("show-url") ready := "No" if instance.Ready { ready = "Yes" } - fmt.Printf("Ready = %s\n", ready) + + urlVal := maskPassword(instance.URL) + if showURL { + urlVal = instance.URL + } + + p.PrintRecord( + []string{"ID", "NAME", "PLAN", "REGION", "TAGS", "URL", "HOSTNAME", "READY"}, + []string{ + strconv.Itoa(instance.ID), + instance.Name, + instance.Plan, + instance.Region, + strings.Join(instance.Tags, ","), + urlVal, + instance.HostnameExternal, + ready, + }, + ) return nil }, diff --git a/cmd/instance_list.go b/cmd/instance_list.go index 998ad96..2dba881 100644 --- a/cmd/instance_list.go +++ b/cmd/instance_list.go @@ -2,11 +2,11 @@ package cmd import ( "fmt" - "os" "strconv" + "strings" + "sync" "cloudamqp-cli/client" - "cloudamqp-cli/internal/table" "github.com/spf13/cobra" ) @@ -35,18 +35,85 @@ var instanceListCmd = &cobra.Command{ return nil } - // Create table and populate data - t := table.New(os.Stdout, "ID", "NAME", "PLAN", "REGION") - for _, instance := range instances { - t.AddRow( + p, err := getPrinter(cmd) + if err != nil { + return err + } + + details, _ := cmd.Flags().GetBool("details") + + if details { + showURL, _ := cmd.Flags().GetBool("show-url") + detailed := make([]*client.Instance, len(instances)) + headers := []string{"ID", "NAME", "PLAN", "REGION", "TAGS", "URL", "HOSTNAME", "READY"} + rows := make([][]string, len(instances)) + var ( + mu sync.Mutex + firstErr error + wg sync.WaitGroup + ) + for i, instance := range instances { + wg.Add(1) + go func(idx, id int) { + defer wg.Done() + det, err := c.GetInstance(id) + mu.Lock() + defer mu.Unlock() + if err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("error fetching instance %d: %w", id, err) + } + return + } + detailed[idx] = det + }(i, instance.ID) + } + wg.Wait() + if firstErr != nil { + return firstErr + } + + for i, inst := range detailed { + ready := "No" + if inst.Ready { + ready = "Yes" + } + urlVal := maskPassword(inst.URL) + if showURL { + urlVal = inst.URL + } + rows[i] = []string{ + strconv.Itoa(inst.ID), + inst.Name, + inst.Plan, + inst.Region, + strings.Join(inst.Tags, ","), + urlVal, + inst.HostnameExternal, + ready, + } + } + p.PrintRecords(headers, rows) + return nil + } + + headers := []string{"ID", "NAME", "PLAN", "REGION"} + rows := make([][]string, len(instances)) + for i, instance := range instances { + rows[i] = []string{ strconv.Itoa(instance.ID), instance.Name, instance.Plan, instance.Region, - ) + } } - t.Print() + p.PrintRecords(headers, rows) return nil }, } + +func init() { + instanceListCmd.Flags().BoolP("details", "", false, "Fetch full details for each instance (one GET request per instance)") + instanceListCmd.Flags().BoolP("show-url", "", false, "Show full connection URL with credentials (requires --details)") +} diff --git a/cmd/instance_nodes.go b/cmd/instance_nodes.go index 6d90878..5e7086c 100644 --- a/cmd/instance_nodes.go +++ b/cmd/instance_nodes.go @@ -2,10 +2,8 @@ package cmd import ( "fmt" - "os" "cloudamqp-cli/client" - "cloudamqp-cli/internal/table" "github.com/spf13/cobra" ) @@ -50,9 +48,14 @@ var instanceNodesListCmd = &cobra.Command{ return nil } - // Create table and populate data - t := table.New(os.Stdout, "NAME", "CONFIGURED", "RUNNING", "DISK_SIZE", "RABBITMQ_VERSION") - for _, node := range nodes { + p, err := getPrinter(cmd) + if err != nil { + return err + } + + headers := []string{"NAME", "CONFIGURED", "RUNNING", "DISK_SIZE", "RABBITMQ_VERSION"} + rows := make([][]string, len(nodes)) + for i, node := range nodes { configured := "No" if node.Configured { configured = "Yes" @@ -62,15 +65,15 @@ var instanceNodesListCmd = &cobra.Command{ running = "Yes" } totalDisk := node.DiskSize + node.AdditionalDiskSize - t.AddRow( + rows[i] = []string{ node.Name, configured, running, fmt.Sprintf("%d GB", totalDisk), node.RabbitMQVersion, - ) + } } - t.Print() + p.PrintRecords(headers, rows) return nil }, diff --git a/cmd/instance_plugins.go b/cmd/instance_plugins.go index b0360f4..6ee91cd 100644 --- a/cmd/instance_plugins.go +++ b/cmd/instance_plugins.go @@ -2,10 +2,8 @@ package cmd import ( "fmt" - "os" "cloudamqp-cli/client" - "cloudamqp-cli/internal/table" "github.com/spf13/cobra" ) @@ -50,16 +48,21 @@ var instancePluginsListCmd = &cobra.Command{ return nil } - // Create table and populate data - t := table.New(os.Stdout, "NAME", "ENABLED") - for _, plugin := range plugins { + p, err := getPrinter(cmd) + if err != nil { + return err + } + + headers := []string{"NAME", "ENABLED"} + rows := make([][]string, len(plugins)) + for i, plugin := range plugins { enabled := "No" if plugin.Enabled { enabled = "Yes" } - t.AddRow(plugin.Name, enabled) + rows[i] = []string{plugin.Name, enabled} } - t.Print() + p.PrintRecords(headers, rows) return nil }, diff --git a/cmd/plans.go b/cmd/plans.go index 4d760b2..e80b806 100644 --- a/cmd/plans.go +++ b/cmd/plans.go @@ -2,10 +2,8 @@ package cmd import ( "fmt" - "os" "cloudamqp-cli/client" - "cloudamqp-cli/internal/table" "github.com/spf13/cobra" ) @@ -37,9 +35,14 @@ var plansCmd = &cobra.Command{ return nil } - // Create table and populate data - t := table.New(os.Stdout, "NAME", "PRICE", "BACKEND", "SHARED") - for _, plan := range plans { + p, err := getPrinter(cmd) + if err != nil { + return err + } + + headers := []string{"NAME", "PRICE", "BACKEND", "SHARED"} + rows := make([][]string, len(plans)) + for i, plan := range plans { shared := "No" if plan.Shared { shared = "Yes" @@ -48,14 +51,10 @@ var plansCmd = &cobra.Command{ if plan.Price == 0 { price = "Free" } - t.AddRow( - plan.Name, - price, - plan.Backend, - shared, - ) + rows[i] = []string{plan.Name, price, plan.Backend, shared} } - t.Print() + p.PrintRecords(headers, rows) + return nil }, } diff --git a/cmd/regions.go b/cmd/regions.go index 4d25438..87f0d18 100644 --- a/cmd/regions.go +++ b/cmd/regions.go @@ -2,10 +2,8 @@ package cmd import ( "fmt" - "os" "cloudamqp-cli/client" - "cloudamqp-cli/internal/table" "github.com/spf13/cobra" ) @@ -37,11 +35,17 @@ var regionsCmd = &cobra.Command{ return nil } - t := table.New(os.Stdout, "PROVIDER", "REGION", "NAME") - for _, region := range regions { - t.AddRow(region.Provider, region.Region, region.Name) + p, err := getPrinter(cmd) + if err != nil { + return err + } + + headers := []string{"PROVIDER", "REGION", "NAME"} + rows := make([][]string, len(regions)) + for i, region := range regions { + rows[i] = []string{region.Provider, region.Region, region.Name} } - t.Print() + p.PrintRecords(headers, rows) return nil }, diff --git a/cmd/root.go b/cmd/root.go index e4fe996..a3bc6b3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,11 +2,19 @@ package cmd import ( "fmt" + "os" "strings" + "cloudamqp-cli/internal/output" "github.com/spf13/cobra" ) +func getPrinter(cmd *cobra.Command) (*output.Printer, error) { + format, _ := cmd.Flags().GetString("output") + fields, _ := cmd.Flags().GetStringSlice("fields") + return output.New(os.Stdout, output.Format(format), fields) +} + var apiKey string func getVersionString() string { @@ -42,6 +50,9 @@ func init() { // Set custom version template to match gh style rootCmd.SetVersionTemplate("cloudamqp version {{.Version}}\n") + rootCmd.PersistentFlags().StringP("output", "o", "table", "Output format: table or json") + rootCmd.PersistentFlags().StringSlice("fields", nil, "Fields to include in output (comma-separated)") + rootCmd.AddCommand(instanceCmd) rootCmd.AddCommand(vpcCmd) rootCmd.AddCommand(regionsCmd) diff --git a/cmd/team_list.go b/cmd/team_list.go index 56a112e..565fcf8 100644 --- a/cmd/team_list.go +++ b/cmd/team_list.go @@ -2,11 +2,9 @@ package cmd import ( "fmt" - "os" "strings" "cloudamqp-cli/client" - "cloudamqp-cli/internal/table" "github.com/spf13/cobra" ) @@ -35,9 +33,14 @@ var teamListCmd = &cobra.Command{ return nil } - // Create table and populate data - t := table.New(os.Stdout, "EMAIL", "ROLES", "2FA") - for _, member := range members { + p, err := getPrinter(cmd) + if err != nil { + return err + } + + headers := []string{"EMAIL", "ROLES", "2FA"} + rows := make([][]string, len(members)) + for i, member := range members { roles := strings.Join(member.Roles, ", ") if roles == "" { roles = "-" @@ -46,9 +49,9 @@ var teamListCmd = &cobra.Command{ if member.TFAAuthEnabled { tfa = "Yes" } - t.AddRow(member.Email, roles, tfa) + rows[i] = []string{member.Email, roles, tfa} } - t.Print() + p.PrintRecords(headers, rows) return nil }, diff --git a/cmd/vpc_get.go b/cmd/vpc_get.go index e0f8f29..b4ec0f2 100644 --- a/cmd/vpc_get.go +++ b/cmd/vpc_get.go @@ -1,9 +1,9 @@ package cmd import ( - "encoding/json" "fmt" "strconv" + "strings" "cloudamqp-cli/client" "github.com/spf13/cobra" @@ -39,12 +39,29 @@ var vpcGetCmd = &cobra.Command{ return err } - output, err := json.MarshalIndent(vpc, "", " ") + p, err := getPrinter(cmd) if err != nil { - return fmt.Errorf("failed to format response: %v", err) + return err + } + + instanceIDs := make([]string, len(vpc.Instances)) + for i, id := range vpc.Instances { + instanceIDs[i] = strconv.Itoa(id) } - fmt.Printf("VPC details:\n%s\n", string(output)) + p.PrintRecord( + []string{"ID", "NAME", "REGION", "SUBNET", "PLAN", "TAGS", "INSTANCES"}, + []string{ + strconv.Itoa(vpc.ID), + vpc.Name, + vpc.Region, + vpc.Subnet, + vpc.Plan, + strings.Join(vpc.Tags, ","), + strings.Join(instanceIDs, ","), + }, + ) + return nil }, } diff --git a/cmd/vpc_list.go b/cmd/vpc_list.go index fa5b2c9..1c2a312 100644 --- a/cmd/vpc_list.go +++ b/cmd/vpc_list.go @@ -2,11 +2,9 @@ package cmd import ( "fmt" - "os" "strconv" "cloudamqp-cli/client" - "cloudamqp-cli/internal/table" "github.com/spf13/cobra" ) @@ -35,17 +33,22 @@ var vpcListCmd = &cobra.Command{ return nil } - // Create table and populate data - t := table.New(os.Stdout, "ID", "NAME", "SUBNET", "REGION") - for _, vpc := range vpcs { - t.AddRow( + p, err := getPrinter(cmd) + if err != nil { + return err + } + + headers := []string{"ID", "NAME", "SUBNET", "REGION"} + rows := make([][]string, len(vpcs)) + for i, vpc := range vpcs { + rows[i] = []string{ strconv.Itoa(vpc.ID), vpc.Name, vpc.Subnet, vpc.Region, - ) + } } - t.Print() + p.PrintRecords(headers, rows) return nil }, diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 0000000..cac2878 --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,122 @@ +package output + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "cloudamqp-cli/internal/table" +) + +type Format string + +const ( + FormatTable Format = "table" + FormatJSON Format = "json" +) + +type Printer struct { + format Format + fields []string + writer io.Writer +} + +func New(writer io.Writer, format Format, fields []string) (*Printer, error) { + switch format { + case FormatTable, FormatJSON, "": + if format == "" { + format = FormatTable + } + default: + return nil, fmt.Errorf("unknown output format %q: use \"table\" or \"json\"", format) + } + return &Printer{format: format, fields: fields, writer: writer}, nil +} + +func (p *Printer) filterColumns(headers []string, rows [][]string) ([]string, [][]string) { + if len(p.fields) == 0 { + return headers, rows + } + + var filteredHeaders []string + var indices []int + filteredRows := make([][]string, len(rows)) + fieldSet := make(map[string]bool, len(p.fields)) + for _, f := range p.fields { + fieldSet[strings.ToUpper(strings.TrimSpace(f))] = true + } + + for i, h := range headers { + if fieldSet[strings.ToUpper(h)] { + filteredHeaders = append(filteredHeaders, h) + indices = append(indices, i) + } + } + + for i, row := range rows { + filteredRow := make([]string, len(indices)) + for j, idx := range indices { + if idx < len(row) { + filteredRow[j] = row[idx] + } + } + filteredRows[i] = filteredRow + } + + return filteredHeaders, filteredRows +} + +func (p *Printer) PrintRecords(headers []string, rows [][]string) { + headers, rows = p.filterColumns(headers, rows) + + switch p.format { + case FormatJSON: + records := make([]map[string]string, len(rows)) + for i, row := range rows { + record := make(map[string]string, len(headers)) + for j, h := range headers { + if j < len(row) { + record[strings.ToLower(h)] = row[j] + } + } + records[i] = record + } + data, _ := json.MarshalIndent(records, "", " ") + fmt.Fprintln(p.writer, string(data)) + default: + t := table.New(p.writer, headers...) + for _, row := range rows { + t.AddRow(row...) + } + t.Print() + } +} + +func (p *Printer) PrintRecord(headers []string, values []string) { + headers, rows := p.filterColumns(headers, [][]string{values}) + var row []string + if len(rows) > 0 { + row = rows[0] + } + + switch p.format { + case FormatJSON: + record := make(map[string]string, len(headers)) + for i, h := range headers { + if i < len(row) { + record[strings.ToLower(h)] = row[i] + } + } + data, _ := json.MarshalIndent(record, "", " ") + fmt.Fprintln(p.writer, string(data)) + default: + for i, h := range headers { + val := "" + if i < len(row) { + val = row[i] + } + fmt.Fprintf(p.writer, "%s = %s\n", h, val) + } + } +}