feat: add --json output to all list commands
Every list command now accepts --json for structured output: routes list, attachments list, secrets list, images list, backups list, auth token list, remotes list, rollback list. Default styled table output unchanged. Part of #98.
- commit
- 2b8d4f7d117b098ca535d79efa9bda5203b57aec
- author
- brice · · 2026-03-13 20:43 UTC
- parent
- 4aeb35b
- signature
- unsigned
13 files · +559 -107
M internal/adapters/in/cli/attachments.go +28 -5
| @@ -3,6 +3,7 @@ package cli | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "io" | |
| 6 | 7 | "sort" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| @@ -37,7 +38,9 @@ the local Gordon configuration.`, | |||
| 37 | 38 | ||
| 38 | 39 | // newAttachmentsListCmd creates the attachments list command. | |
| 39 | 40 | func newAttachmentsListCmd() *cobra.Command { | |
| 40 | - | return &cobra.Command{ | |
| 41 | + | var jsonOut bool | |
| 42 | + | ||
| 43 | + | cmd := &cobra.Command{ | |
| 41 | 44 | Use: "list [domain-or-group]", | |
| 42 | 45 | Short: "List configured attachments", | |
| 43 | 46 | Long: `List all configured attachments, or attachments for a specific domain/group. | |
| @@ -56,15 +59,19 @@ Examples: | |||
| 56 | 59 | ||
| 57 | 60 | client, isRemote := GetRemoteClient() | |
| 58 | 61 | if isRemote { | |
| 59 | - | return runAttachmentsListRemote(ctx, client, target) | |
| 62 | + | return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout()) | |
| 60 | 63 | } | |
| 61 | - | return runAttachmentsListLocal(ctx, configPath, target) | |
| 64 | + | return runAttachmentsListLocal(ctx, configPath, target, jsonOut, cmd.OutOrStdout()) | |
| 62 | 65 | }, | |
| 63 | 66 | } | |
| 67 | + | ||
| 68 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 69 | + | ||
| 70 | + | return cmd | |
| 64 | 71 | } | |
| 65 | 72 | ||
| 66 | 73 | // runAttachmentsListRemote lists attachments from a remote Gordon instance. | |
| 67 | - | func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string) error { | |
| 74 | + | func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error { | |
| 68 | 75 | if target != "" { | |
| 69 | 76 | // List for specific target | |
| 70 | 77 | images, err := client.GetAttachmentsConfig(ctx, target) | |
| @@ -77,6 +84,10 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target | |||
| 77 | 84 | return nil | |
| 78 | 85 | } | |
| 79 | 86 | ||
| 87 | + | if jsonOut { | |
| 88 | + | return writeJSON(out, map[string]any{"target": target, "images": images}) | |
| 89 | + | } | |
| 90 | + | ||
| 80 | 91 | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target))) | |
| 81 | 92 | fmt.Println() | |
| 82 | 93 | for _, img := range images { | |
| @@ -96,6 +107,10 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target | |||
| 96 | 107 | return nil | |
| 97 | 108 | } | |
| 98 | 109 | ||
| 110 | + | if jsonOut { | |
| 111 | + | return writeJSON(out, map[string]any{"attachments": attachments}) | |
| 112 | + | } | |
| 113 | + | ||
| 99 | 114 | // Sort targets for consistent output | |
| 100 | 115 | targets := make([]string, 0, len(attachments)) | |
| 101 | 116 | for t := range attachments { | |
| @@ -118,7 +133,7 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target | |||
| 118 | 133 | } | |
| 119 | 134 | ||
| 120 | 135 | // runAttachmentsListLocal lists attachments from local configuration. | |
| 121 | - | func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) error { | |
| 136 | + | func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string, jsonOut bool, out io.Writer) error { | |
| 122 | 137 | local, err := GetLocalServices(cfgPath) | |
| 123 | 138 | if err != nil { | |
| 124 | 139 | return fmt.Errorf("failed to initialize local services: %w", err) | |
| @@ -136,6 +151,10 @@ func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) | |||
| 136 | 151 | return nil | |
| 137 | 152 | } | |
| 138 | 153 | ||
| 154 | + | if jsonOut { | |
| 155 | + | return writeJSON(out, map[string]any{"target": target, "images": images}) | |
| 156 | + | } | |
| 157 | + | ||
| 139 | 158 | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target))) | |
| 140 | 159 | fmt.Println() | |
| 141 | 160 | for _, img := range images { | |
| @@ -152,6 +171,10 @@ func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) | |||
| 152 | 171 | return nil | |
| 153 | 172 | } | |
| 154 | 173 | ||
| 174 | + | if jsonOut { | |
| 175 | + | return writeJSON(out, map[string]any{"attachments": attachments}) | |
| 176 | + | } | |
| 177 | + | ||
| 155 | 178 | // Sort targets for consistent output | |
| 156 | 179 | targets := make([]string, 0, len(attachments)) | |
| 157 | 180 | for t := range attachments { | |
| @@ -3,6 +3,7 @@ package cli | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "io" | |||
| 6 | "sort" | 7 | "sort" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | 9 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | ||
| @@ -37,7 +38,9 @@ the local Gordon configuration.`, | |||||
| 37 | 38 | ||||
| 38 | // newAttachmentsListCmd creates the attachments list command. | 39 | // newAttachmentsListCmd creates the attachments list command. | ||
| 39 | func newAttachmentsListCmd() *cobra.Command { | 40 | func newAttachmentsListCmd() *cobra.Command { | ||
| 40 | - | return &cobra.Command{ | 41 | + | var jsonOut bool |
| 42 | + | ||||
| 43 | + | cmd := &cobra.Command{ | |||
| 41 | Use: "list [domain-or-group]", | 44 | Use: "list [domain-or-group]", | ||
| 42 | Short: "List configured attachments", | 45 | Short: "List configured attachments", | ||
| 43 | Long: `List all configured attachments, or attachments for a specific domain/group. | 46 | Long: `List all configured attachments, or attachments for a specific domain/group. | ||
| @@ -56,15 +59,19 @@ Examples: | |||||
| 56 | 59 | ||||
| 57 | client, isRemote := GetRemoteClient() | 60 | client, isRemote := GetRemoteClient() | ||
| 58 | if isRemote { | 61 | if isRemote { | ||
| 59 | - | return runAttachmentsListRemote(ctx, client, target) | 62 | + | return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout()) |
| 60 | } | 63 | } | ||
| 61 | - | return runAttachmentsListLocal(ctx, configPath, target) | 64 | + | return runAttachmentsListLocal(ctx, configPath, target, jsonOut, cmd.OutOrStdout()) |
| 62 | }, | 65 | }, | ||
| 63 | } | 66 | } | ||
| 67 | + | ||||
| 68 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 69 | + | ||||
| 70 | + | return cmd | |||
| 64 | } | 71 | } | ||
| 65 | 72 | ||||
| 66 | // runAttachmentsListRemote lists attachments from a remote Gordon instance. | 73 | // runAttachmentsListRemote lists attachments from a remote Gordon instance. | ||
| 67 | - | func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string) error { | 74 | + | func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error { |
| 68 | if target != "" { | 75 | if target != "" { | ||
| 69 | // List for specific target | 76 | // List for specific target | ||
| 70 | images, err := client.GetAttachmentsConfig(ctx, target) | 77 | images, err := client.GetAttachmentsConfig(ctx, target) | ||
| @@ -77,6 +84,10 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target | |||||
| 77 | return nil | 84 | return nil | ||
| 78 | } | 85 | } | ||
| 79 | 86 | ||||
| 87 | + | if jsonOut { | |||
| 88 | + | return writeJSON(out, map[string]any{"target": target, "images": images}) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 80 | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target))) | 91 | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target))) | ||
| 81 | fmt.Println() | 92 | fmt.Println() | ||
| 82 | for _, img := range images { | 93 | for _, img := range images { | ||
| @@ -96,6 +107,10 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target | |||||
| 96 | return nil | 107 | return nil | ||
| 97 | } | 108 | } | ||
| 98 | 109 | ||||
| 110 | + | if jsonOut { | |||
| 111 | + | return writeJSON(out, map[string]any{"attachments": attachments}) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 99 | // Sort targets for consistent output | 114 | // Sort targets for consistent output | ||
| 100 | targets := make([]string, 0, len(attachments)) | 115 | targets := make([]string, 0, len(attachments)) | ||
| 101 | for t := range attachments { | 116 | for t := range attachments { | ||
| @@ -118,7 +133,7 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target | |||||
| 118 | } | 133 | } | ||
| 119 | 134 | ||||
| 120 | // runAttachmentsListLocal lists attachments from local configuration. | 135 | // runAttachmentsListLocal lists attachments from local configuration. | ||
| 121 | - | func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) error { | 136 | + | func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string, jsonOut bool, out io.Writer) error { |
| 122 | local, err := GetLocalServices(cfgPath) | 137 | local, err := GetLocalServices(cfgPath) | ||
| 123 | if err != nil { | 138 | if err != nil { | ||
| 124 | return fmt.Errorf("failed to initialize local services: %w", err) | 139 | return fmt.Errorf("failed to initialize local services: %w", err) | ||
| @@ -136,6 +151,10 @@ func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) | |||||
| 136 | return nil | 151 | return nil | ||
| 137 | } | 152 | } | ||
| 138 | 153 | ||||
| 154 | + | if jsonOut { | |||
| 155 | + | return writeJSON(out, map[string]any{"target": target, "images": images}) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 139 | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target))) | 158 | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target))) | ||
| 140 | fmt.Println() | 159 | fmt.Println() | ||
| 141 | for _, img := range images { | 160 | for _, img := range images { | ||
| @@ -152,6 +171,10 @@ func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) | |||||
| 152 | return nil | 171 | return nil | ||
| 153 | } | 172 | } | ||
| 154 | 173 | ||||
| 174 | + | if jsonOut { | |||
| 175 | + | return writeJSON(out, map[string]any{"attachments": attachments}) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 155 | // Sort targets for consistent output | 178 | // Sort targets for consistent output | ||
| 156 | targets := make([]string, 0, len(attachments)) | 179 | targets := make([]string, 0, len(attachments)) | ||
| 157 | for t := range attachments { | 180 | for t := range attachments { | ||
M internal/adapters/in/cli/auth.go +29 -3
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bufio" | |
| 5 | 5 | "context" | |
| 6 | 6 | "fmt" | |
| 7 | + | "io" | |
| 7 | 8 | "os" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "time" | |
| @@ -130,18 +131,22 @@ Use --expiry=0 for a token that never expires (useful for CI).`, | |||
| 130 | 131 | ||
| 131 | 132 | // newTokenListCmd creates the token list command. | |
| 132 | 133 | func newTokenListCmd() *cobra.Command { | |
| 133 | - | var configPath string | |
| 134 | + | var ( | |
| 135 | + | configPath string | |
| 136 | + | jsonOut bool | |
| 137 | + | ) | |
| 134 | 138 | ||
| 135 | 139 | cmd := &cobra.Command{ | |
| 136 | 140 | Use: "list", | |
| 137 | 141 | Short: "List all authentication tokens", | |
| 138 | 142 | Long: `List all stored authentication tokens with their subjects and expiry information.`, | |
| 139 | 143 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 140 | - | return runTokenList(configPath) | |
| 144 | + | return runTokenList(configPath, cmd.OutOrStdout(), jsonOut) | |
| 141 | 145 | }, | |
| 142 | 146 | } | |
| 143 | 147 | ||
| 144 | 148 | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | |
| 149 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 145 | 150 | ||
| 146 | 151 | return cmd | |
| 147 | 152 | } | |
| @@ -514,7 +519,7 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error { | |||
| 514 | 519 | } | |
| 515 | 520 | ||
| 516 | 521 | // runTokenList lists all stored tokens. | |
| 517 | - | func runTokenList(configPath string) error { | |
| 522 | + | func runTokenList(configPath string, out io.Writer, jsonOut bool) error { | |
| 518 | 523 | cfg, err := loadAuthConfig(configPath) | |
| 519 | 524 | if err != nil { | |
| 520 | 525 | return err | |
| @@ -533,10 +538,31 @@ func runTokenList(configPath string) error { | |||
| 533 | 538 | } | |
| 534 | 539 | ||
| 535 | 540 | if len(tokens) == 0 { | |
| 541 | + | if jsonOut { | |
| 542 | + | return writeJSON(out, []map[string]any{}) | |
| 543 | + | } | |
| 536 | 544 | fmt.Println("No tokens found.") | |
| 537 | 545 | return nil | |
| 538 | 546 | } | |
| 539 | 547 | ||
| 548 | + | if jsonOut { | |
| 549 | + | payload := make([]map[string]any, 0, len(tokens)) | |
| 550 | + | for _, t := range tokens { | |
| 551 | + | var expiresAt *time.Time | |
| 552 | + | if !t.ExpiresAt.IsZero() { | |
| 553 | + | expiresAt = &t.ExpiresAt | |
| 554 | + | } | |
| 555 | + | payload = append(payload, map[string]any{ | |
| 556 | + | "id": t.ID, | |
| 557 | + | "description": t.Subject, | |
| 558 | + | "scopes": t.Scopes, | |
| 559 | + | "created_at": t.IssuedAt, | |
| 560 | + | "expires_at": expiresAt, | |
| 561 | + | }) | |
| 562 | + | } | |
| 563 | + | return writeJSON(out, payload) | |
| 564 | + | } | |
| 565 | + | ||
| 540 | 566 | fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked") | |
| 541 | 567 | fmt.Println(strings.Repeat("-", 90)) | |
| 542 | 568 | ||
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "io" | |||
| 7 | "os" | 8 | "os" | ||
| 8 | "strings" | 9 | "strings" | ||
| 9 | "time" | 10 | "time" | ||
| @@ -130,18 +131,22 @@ Use --expiry=0 for a token that never expires (useful for CI).`, | |||||
| 130 | 131 | ||||
| 131 | // newTokenListCmd creates the token list command. | 132 | // newTokenListCmd creates the token list command. | ||
| 132 | func newTokenListCmd() *cobra.Command { | 133 | func newTokenListCmd() *cobra.Command { | ||
| 133 | - | var configPath string | 134 | + | var ( |
| 135 | + | configPath string | |||
| 136 | + | jsonOut bool | |||
| 137 | + | ) | |||
| 134 | 138 | ||||
| 135 | cmd := &cobra.Command{ | 139 | cmd := &cobra.Command{ | ||
| 136 | Use: "list", | 140 | Use: "list", | ||
| 137 | Short: "List all authentication tokens", | 141 | Short: "List all authentication tokens", | ||
| 138 | Long: `List all stored authentication tokens with their subjects and expiry information.`, | 142 | Long: `List all stored authentication tokens with their subjects and expiry information.`, | ||
| 139 | RunE: func(cmd *cobra.Command, args []string) error { | 143 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 140 | - | return runTokenList(configPath) | 144 | + | return runTokenList(configPath, cmd.OutOrStdout(), jsonOut) |
| 141 | }, | 145 | }, | ||
| 142 | } | 146 | } | ||
| 143 | 147 | ||||
| 144 | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | 148 | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | ||
| 149 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 145 | 150 | ||||
| 146 | return cmd | 151 | return cmd | ||
| 147 | } | 152 | } | ||
| @@ -514,7 +519,7 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error { | |||||
| 514 | } | 519 | } | ||
| 515 | 520 | ||||
| 516 | // runTokenList lists all stored tokens. | 521 | // runTokenList lists all stored tokens. | ||
| 517 | - | func runTokenList(configPath string) error { | 522 | + | func runTokenList(configPath string, out io.Writer, jsonOut bool) error { |
| 518 | cfg, err := loadAuthConfig(configPath) | 523 | cfg, err := loadAuthConfig(configPath) | ||
| 519 | if err != nil { | 524 | if err != nil { | ||
| 520 | return err | 525 | return err | ||
| @@ -533,10 +538,31 @@ func runTokenList(configPath string) error { | |||||
| 533 | } | 538 | } | ||
| 534 | 539 | ||||
| 535 | if len(tokens) == 0 { | 540 | if len(tokens) == 0 { | ||
| 541 | + | if jsonOut { | |||
| 542 | + | return writeJSON(out, []map[string]any{}) | |||
| 543 | + | } | |||
| 536 | fmt.Println("No tokens found.") | 544 | fmt.Println("No tokens found.") | ||
| 537 | return nil | 545 | return nil | ||
| 538 | } | 546 | } | ||
| 539 | 547 | ||||
| 548 | + | if jsonOut { | |||
| 549 | + | payload := make([]map[string]any, 0, len(tokens)) | |||
| 550 | + | for _, t := range tokens { | |||
| 551 | + | var expiresAt *time.Time | |||
| 552 | + | if !t.ExpiresAt.IsZero() { | |||
| 553 | + | expiresAt = &t.ExpiresAt | |||
| 554 | + | } | |||
| 555 | + | payload = append(payload, map[string]any{ | |||
| 556 | + | "id": t.ID, | |||
| 557 | + | "description": t.Subject, | |||
| 558 | + | "scopes": t.Scopes, | |||
| 559 | + | "created_at": t.IssuedAt, | |||
| 560 | + | "expires_at": expiresAt, | |||
| 561 | + | }) | |||
| 562 | + | } | |||
| 563 | + | return writeJSON(out, payload) | |||
| 564 | + | } | |||
| 565 | + | ||||
| 540 | fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked") | 566 | fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked") | ||
| 541 | fmt.Println(strings.Repeat("-", 90)) | 567 | fmt.Println(strings.Repeat("-", 90)) | ||
| 542 | 568 | ||||
M internal/adapters/in/cli/backup.go +40 -24
| @@ -2,10 +2,12 @@ package cli | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "io" | |
| 5 | 6 | "os" | |
| 6 | 7 | "text/tabwriter" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| 10 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 9 | 11 | "github.com/spf13/cobra" | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| @@ -30,7 +32,10 @@ instance when --remote targeting is configured.`, | |||
| 30 | 32 | } | |
| 31 | 33 | ||
| 32 | 34 | func newBackupListCmd() *cobra.Command { | |
| 33 | - | return &cobra.Command{ | |
| 35 | + | var jsonOut bool | |
| 36 | + | _ = cliRenderTitle("Backups") | |
| 37 | + | ||
| 38 | + | cmd := &cobra.Command{ | |
| 34 | 39 | Use: "list [domain]", | |
| 35 | 40 | Short: "List backups", | |
| 36 | 41 | Args: cobra.MaximumNArgs(1), | |
| @@ -51,32 +56,43 @@ func newBackupListCmd() *cobra.Command { | |||
| 51 | 56 | return fmt.Errorf("failed to list backups: %w", err) | |
| 52 | 57 | } | |
| 53 | 58 | ||
| 54 | - | if len(jobs) == 0 { | |
| 55 | - | if err := cliWriteLine(os.Stdout, cliRenderMuted("No backups found")); err != nil { | |
| 56 | - | return err | |
| 57 | - | } | |
| 58 | - | return nil | |
| 59 | - | } | |
| 60 | - | if err := cliWriteLine(os.Stdout, cliRenderTitle("Backups")); err != nil { | |
| 61 | - | return err | |
| 62 | - | } | |
| 63 | - | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 64 | - | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil { | |
| 65 | - | return err | |
| 66 | - | } | |
| 59 | + | return printBackupJobs(cmd.OutOrStdout(), jobs, jsonOut) | |
| 60 | + | }, | |
| 61 | + | } | |
| 67 | 62 | ||
| 68 | - | for _, job := range jobs { | |
| 69 | - | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil { | |
| 70 | - | return err | |
| 71 | - | } | |
| 72 | - | } | |
| 73 | - | if err := w.Flush(); err != nil { | |
| 74 | - | return err | |
| 75 | - | } | |
| 63 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 76 | 64 | ||
| 77 | - | return nil | |
| 78 | - | }, | |
| 65 | + | return cmd | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error { | |
| 69 | + | if len(jobs) == 0 { | |
| 70 | + | if jsonOut { | |
| 71 | + | return writeJSON(out, []dto.BackupJob{}) | |
| 72 | + | } | |
| 73 | + | return cliWriteLine(out, cliRenderMuted("No backups found")) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | if jsonOut { | |
| 77 | + | return writeJSON(out, jobs) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | _ = cliRenderTitle("Backups") | |
| 81 | + | ||
| 82 | + | if err := cliWriteLine(out, cliRenderTitle("Backups")); err != nil { | |
| 83 | + | return err | |
| 84 | + | } | |
| 85 | + | w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) | |
| 86 | + | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil { | |
| 87 | + | return err | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | for _, job := range jobs { | |
| 91 | + | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil { | |
| 92 | + | return err | |
| 93 | + | } | |
| 79 | 94 | } | |
| 95 | + | return w.Flush() | |
| 80 | 96 | } | |
| 81 | 97 | ||
| 82 | 98 | func newBackupRunCmd() *cobra.Command { | |
| @@ -2,10 +2,12 @@ package cli | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "io" | |||
| 5 | "os" | 6 | "os" | ||
| 6 | "text/tabwriter" | 7 | "text/tabwriter" | ||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| 10 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 9 | "github.com/spf13/cobra" | 11 | "github.com/spf13/cobra" | ||
| 10 | ) | 12 | ) | ||
| 11 | 13 | ||||
| @@ -30,7 +32,10 @@ instance when --remote targeting is configured.`, | |||||
| 30 | } | 32 | } | ||
| 31 | 33 | ||||
| 32 | func newBackupListCmd() *cobra.Command { | 34 | func newBackupListCmd() *cobra.Command { | ||
| 33 | - | return &cobra.Command{ | 35 | + | var jsonOut bool |
| 36 | + | _ = cliRenderTitle("Backups") | |||
| 37 | + | ||||
| 38 | + | cmd := &cobra.Command{ | |||
| 34 | Use: "list [domain]", | 39 | Use: "list [domain]", | ||
| 35 | Short: "List backups", | 40 | Short: "List backups", | ||
| 36 | Args: cobra.MaximumNArgs(1), | 41 | Args: cobra.MaximumNArgs(1), | ||
| @@ -51,32 +56,43 @@ func newBackupListCmd() *cobra.Command { | |||||
| 51 | return fmt.Errorf("failed to list backups: %w", err) | 56 | return fmt.Errorf("failed to list backups: %w", err) | ||
| 52 | } | 57 | } | ||
| 53 | 58 | ||||
| 54 | - | if len(jobs) == 0 { | 59 | + | return printBackupJobs(cmd.OutOrStdout(), jobs, jsonOut) |
| 55 | - | if err := cliWriteLine(os.Stdout, cliRenderMuted("No backups found")); err != nil { | 60 | + | }, |
| 56 | - | return err | 61 | + | } |
| 57 | - | } | |||
| 58 | - | return nil | |||
| 59 | - | } | |||
| 60 | - | if err := cliWriteLine(os.Stdout, cliRenderTitle("Backups")); err != nil { | |||
| 61 | - | return err | |||
| 62 | - | } | |||
| 63 | - | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |||
| 64 | - | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil { | |||
| 65 | - | return err | |||
| 66 | - | } | |||
| 67 | 62 | ||||
| 68 | - | for _, job := range jobs { | 63 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") |
| 69 | - | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil { | |||
| 70 | - | return err | |||
| 71 | - | } | |||
| 72 | - | } | |||
| 73 | - | if err := w.Flush(); err != nil { | |||
| 74 | - | return err | |||
| 75 | - | } | |||
| 76 | 64 | ||||
| 77 | - | return nil | 65 | + | return cmd |
| 78 | - | }, | 66 | + | } |
| 67 | + | ||||
| 68 | + | func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error { | |||
| 69 | + | if len(jobs) == 0 { | |||
| 70 | + | if jsonOut { | |||
| 71 | + | return writeJSON(out, []dto.BackupJob{}) | |||
| 72 | + | } | |||
| 73 | + | return cliWriteLine(out, cliRenderMuted("No backups found")) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | if jsonOut { | |||
| 77 | + | return writeJSON(out, jobs) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | _ = cliRenderTitle("Backups") | |||
| 81 | + | ||||
| 82 | + | if err := cliWriteLine(out, cliRenderTitle("Backups")); err != nil { | |||
| 83 | + | return err | |||
| 84 | + | } | |||
| 85 | + | w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) | |||
| 86 | + | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil { | |||
| 87 | + | return err | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | for _, job := range jobs { | |||
| 91 | + | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil { | |||
| 92 | + | return err | |||
| 93 | + | } | |||
| 79 | } | 94 | } | ||
| 95 | + | return w.Flush() | |||
| 80 | } | 96 | } | ||
| 81 | 97 | ||||
| 82 | func newBackupRunCmd() *cobra.Command { | 98 | func newBackupRunCmd() *cobra.Command { | ||
M internal/adapters/in/cli/images.go +13 -3
| @@ -81,7 +81,9 @@ These commands currently require remote mode with a configured target.`, | |||
| 81 | 81 | } | |
| 82 | 82 | ||
| 83 | 83 | func newImagesListCmd() *cobra.Command { | |
| 84 | - | return &cobra.Command{ | |
| 84 | + | var jsonOut bool | |
| 85 | + | ||
| 86 | + | cmd := &cobra.Command{ | |
| 85 | 87 | Use: "list", | |
| 86 | 88 | Short: "List runtime images and registry tags", | |
| 87 | 89 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| @@ -90,9 +92,13 @@ func newImagesListCmd() *cobra.Command { | |||
| 90 | 92 | return fmt.Errorf("images commands require a configured remote target") | |
| 91 | 93 | } | |
| 92 | 94 | ||
| 93 | - | return runImagesList(cmd.Context(), client, cmd.OutOrStdout()) | |
| 95 | + | return runImagesList(cmd.Context(), client, cmd.OutOrStdout(), jsonOut) | |
| 94 | 96 | }, | |
| 95 | 97 | } | |
| 98 | + | ||
| 99 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 100 | + | ||
| 101 | + | return cmd | |
| 96 | 102 | } | |
| 97 | 103 | ||
| 98 | 104 | func newImagesPruneCmd() *cobra.Command { | |
| @@ -191,12 +197,16 @@ func pruneWithSpinner(ctx context.Context, client imagesClient, req dto.ImagePru | |||
| 191 | 197 | } | |
| 192 | 198 | } | |
| 193 | 199 | ||
| 194 | - | func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error { | |
| 200 | + | func runImagesList(ctx context.Context, client imagesClient, out io.Writer, jsonOut bool) error { | |
| 195 | 201 | images, err := client.ListImages(ctx) | |
| 196 | 202 | if err != nil { | |
| 197 | 203 | return fmt.Errorf("failed to list images: %w", err) | |
| 198 | 204 | } | |
| 199 | 205 | ||
| 206 | + | if jsonOut { | |
| 207 | + | return writeJSON(out, images) | |
| 208 | + | } | |
| 209 | + | ||
| 200 | 210 | if len(images) == 0 { | |
| 201 | 211 | if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil { | |
| 202 | 212 | return err | |
| @@ -81,7 +81,9 @@ These commands currently require remote mode with a configured target.`, | |||||
| 81 | } | 81 | } | ||
| 82 | 82 | ||||
| 83 | func newImagesListCmd() *cobra.Command { | 83 | func newImagesListCmd() *cobra.Command { | ||
| 84 | - | return &cobra.Command{ | 84 | + | var jsonOut bool |
| 85 | + | ||||
| 86 | + | cmd := &cobra.Command{ | |||
| 85 | Use: "list", | 87 | Use: "list", | ||
| 86 | Short: "List runtime images and registry tags", | 88 | Short: "List runtime images and registry tags", | ||
| 87 | RunE: func(cmd *cobra.Command, _ []string) error { | 89 | RunE: func(cmd *cobra.Command, _ []string) error { | ||
| @@ -90,9 +92,13 @@ func newImagesListCmd() *cobra.Command { | |||||
| 90 | return fmt.Errorf("images commands require a configured remote target") | 92 | return fmt.Errorf("images commands require a configured remote target") | ||
| 91 | } | 93 | } | ||
| 92 | 94 | ||||
| 93 | - | return runImagesList(cmd.Context(), client, cmd.OutOrStdout()) | 95 | + | return runImagesList(cmd.Context(), client, cmd.OutOrStdout(), jsonOut) |
| 94 | }, | 96 | }, | ||
| 95 | } | 97 | } | ||
| 98 | + | ||||
| 99 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 100 | + | ||||
| 101 | + | return cmd | |||
| 96 | } | 102 | } | ||
| 97 | 103 | ||||
| 98 | func newImagesPruneCmd() *cobra.Command { | 104 | func newImagesPruneCmd() *cobra.Command { | ||
| @@ -191,12 +197,16 @@ func pruneWithSpinner(ctx context.Context, client imagesClient, req dto.ImagePru | |||||
| 191 | } | 197 | } | ||
| 192 | } | 198 | } | ||
| 193 | 199 | ||||
| 194 | - | func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error { | 200 | + | func runImagesList(ctx context.Context, client imagesClient, out io.Writer, jsonOut bool) error { |
| 195 | images, err := client.ListImages(ctx) | 201 | images, err := client.ListImages(ctx) | ||
| 196 | if err != nil { | 202 | if err != nil { | ||
| 197 | return fmt.Errorf("failed to list images: %w", err) | 203 | return fmt.Errorf("failed to list images: %w", err) | ||
| 198 | } | 204 | } | ||
| 199 | 205 | ||||
| 206 | + | if jsonOut { | |||
| 207 | + | return writeJSON(out, images) | |||
| 208 | + | } | |||
| 209 | + | ||||
| 200 | if len(images) == 0 { | 210 | if len(images) == 0 { | ||
| 201 | if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil { | 211 | if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil { | ||
| 202 | return err | 212 | return err | ||
M internal/adapters/in/cli/images_test.go +5 -5
| @@ -58,7 +58,7 @@ func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) { | |||
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | 60 | var out bytes.Buffer | |
| 61 | - | err := runImagesList(context.Background(), client, &out) | |
| 61 | + | err := runImagesList(context.Background(), client, &out, false) | |
| 62 | 62 | require.NoError(t, err) | |
| 63 | 63 | ||
| 64 | 64 | text := out.String() | |
| @@ -78,7 +78,7 @@ func TestRunImagesList_HeaderOrderAndSummaryLine(t *testing.T) { | |||
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | 80 | var out bytes.Buffer | |
| 81 | - | err := runImagesList(context.Background(), client, &out) | |
| 81 | + | err := runImagesList(context.Background(), client, &out, false) | |
| 82 | 82 | require.NoError(t, err) | |
| 83 | 83 | ||
| 84 | 84 | text := stripANSI(out.String()) | |
| @@ -103,7 +103,7 @@ func TestRunImagesList_TruncatesLongValuesAndKeepsBoundedRowShape(t *testing.T) | |||
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | 105 | var out bytes.Buffer | |
| 106 | - | err := runImagesList(context.Background(), client, &out) | |
| 106 | + | err := runImagesList(context.Background(), client, &out, false) | |
| 107 | 107 | require.NoError(t, err) | |
| 108 | 108 | ||
| 109 | 109 | text := stripANSI(out.String()) | |
| @@ -145,7 +145,7 @@ func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T) | |||
| 145 | 145 | } | |
| 146 | 146 | ||
| 147 | 147 | var out bytes.Buffer | |
| 148 | - | err := runImagesList(context.Background(), client, &out) | |
| 148 | + | err := runImagesList(context.Background(), client, &out, false) | |
| 149 | 149 | require.NoError(t, err) | |
| 150 | 150 | ||
| 151 | 151 | text := stripANSI(out.String()) | |
| @@ -177,7 +177,7 @@ func TestRunImagesList_EmptyOutput(t *testing.T) { | |||
| 177 | 177 | client := &fakeImagesClient{listImagesResp: []dto.Image{}} | |
| 178 | 178 | ||
| 179 | 179 | var out bytes.Buffer | |
| 180 | - | err := runImagesList(context.Background(), client, &out) | |
| 180 | + | err := runImagesList(context.Background(), client, &out, false) | |
| 181 | 181 | require.NoError(t, err) | |
| 182 | 182 | ||
| 183 | 183 | text := out.String() | |
| @@ -58,7 +58,7 @@ func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) { | |||||
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | var out bytes.Buffer | 60 | var out bytes.Buffer | ||
| 61 | - | err := runImagesList(context.Background(), client, &out) | 61 | + | err := runImagesList(context.Background(), client, &out, false) |
| 62 | require.NoError(t, err) | 62 | require.NoError(t, err) | ||
| 63 | 63 | ||||
| 64 | text := out.String() | 64 | text := out.String() | ||
| @@ -78,7 +78,7 @@ func TestRunImagesList_HeaderOrderAndSummaryLine(t *testing.T) { | |||||
| 78 | } | 78 | } | ||
| 79 | 79 | ||||
| 80 | var out bytes.Buffer | 80 | var out bytes.Buffer | ||
| 81 | - | err := runImagesList(context.Background(), client, &out) | 81 | + | err := runImagesList(context.Background(), client, &out, false) |
| 82 | require.NoError(t, err) | 82 | require.NoError(t, err) | ||
| 83 | 83 | ||||
| 84 | text := stripANSI(out.String()) | 84 | text := stripANSI(out.String()) | ||
| @@ -103,7 +103,7 @@ func TestRunImagesList_TruncatesLongValuesAndKeepsBoundedRowShape(t *testing.T) | |||||
| 103 | } | 103 | } | ||
| 104 | 104 | ||||
| 105 | var out bytes.Buffer | 105 | var out bytes.Buffer | ||
| 106 | - | err := runImagesList(context.Background(), client, &out) | 106 | + | err := runImagesList(context.Background(), client, &out, false) |
| 107 | require.NoError(t, err) | 107 | require.NoError(t, err) | ||
| 108 | 108 | ||||
| 109 | text := stripANSI(out.String()) | 109 | text := stripANSI(out.String()) | ||
| @@ -145,7 +145,7 @@ func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T) | |||||
| 145 | } | 145 | } | ||
| 146 | 146 | ||||
| 147 | var out bytes.Buffer | 147 | var out bytes.Buffer | ||
| 148 | - | err := runImagesList(context.Background(), client, &out) | 148 | + | err := runImagesList(context.Background(), client, &out, false) |
| 149 | require.NoError(t, err) | 149 | require.NoError(t, err) | ||
| 150 | 150 | ||||
| 151 | text := stripANSI(out.String()) | 151 | text := stripANSI(out.String()) | ||
| @@ -177,7 +177,7 @@ func TestRunImagesList_EmptyOutput(t *testing.T) { | |||||
| 177 | client := &fakeImagesClient{listImagesResp: []dto.Image{}} | 177 | client := &fakeImagesClient{listImagesResp: []dto.Image{}} | ||
| 178 | 178 | ||||
| 179 | var out bytes.Buffer | 179 | var out bytes.Buffer | ||
| 180 | - | err := runImagesList(context.Background(), client, &out) | 180 | + | err := runImagesList(context.Background(), client, &out, false) |
| 181 | require.NoError(t, err) | 181 | require.NoError(t, err) | ||
| 182 | 182 | ||||
| 183 | text := out.String() | 183 | text := out.String() | ||
A internal/adapters/in/cli/json.go +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "io" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // writeJSON encodes v as indented JSON to the given writer. | |
| 9 | + | func writeJSON(out io.Writer, v any) error { | |
| 10 | + | enc := json.NewEncoder(out) | |
| 11 | + | enc.SetIndent("", " ") | |
| 12 | + | return enc.Encode(v) | |
| 13 | + | } | |
| @@ -0,0 +1,13 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "io" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // writeJSON encodes v as indented JSON to the given writer. | |||
| 9 | + | func writeJSON(out io.Writer, v any) error { | |||
| 10 | + | enc := json.NewEncoder(out) | |||
| 11 | + | enc.SetIndent("", " ") | |||
| 12 | + | return enc.Encode(v) | |||
| 13 | + | } | |||
A internal/adapters/in/cli/json_test.go +250 -0
| @@ -0,0 +1,250 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 13 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 14 | + | "github.com/bnema/gordon/internal/domain" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestRoutesList_JSONFlag_Accepted(t *testing.T) { | |
| 18 | + | cmd := newRoutesListCmd() | |
| 19 | + | f := cmd.Flags().Lookup("json") | |
| 20 | + | assert.NotNil(t, f) | |
| 21 | + | if f != nil { | |
| 22 | + | assert.Equal(t, "false", f.DefValue) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestAttachmentsList_JSONFlag_Accepted(t *testing.T) { | |
| 27 | + | cmd := newAttachmentsListCmd() | |
| 28 | + | f := cmd.Flags().Lookup("json") | |
| 29 | + | assert.NotNil(t, f) | |
| 30 | + | if f != nil { | |
| 31 | + | assert.Equal(t, "false", f.DefValue) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestSecretsList_JSONFlag_Accepted(t *testing.T) { | |
| 36 | + | cmd := newSecretsListCmd() | |
| 37 | + | f := cmd.Flags().Lookup("json") | |
| 38 | + | assert.NotNil(t, f) | |
| 39 | + | if f != nil { | |
| 40 | + | assert.Equal(t, "false", f.DefValue) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestImagesList_JSONFlag_Accepted(t *testing.T) { | |
| 45 | + | cmd := newImagesListCmd() | |
| 46 | + | f := cmd.Flags().Lookup("json") | |
| 47 | + | assert.NotNil(t, f) | |
| 48 | + | if f != nil { | |
| 49 | + | assert.Equal(t, "false", f.DefValue) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestBackupList_JSONFlag_Accepted(t *testing.T) { | |
| 54 | + | cmd := newBackupListCmd() | |
| 55 | + | f := cmd.Flags().Lookup("json") | |
| 56 | + | assert.NotNil(t, f) | |
| 57 | + | if f != nil { | |
| 58 | + | assert.Equal(t, "false", f.DefValue) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestTokenList_JSONFlag_Accepted(t *testing.T) { | |
| 63 | + | cmd := newTokenListCmd() | |
| 64 | + | f := cmd.Flags().Lookup("json") | |
| 65 | + | assert.NotNil(t, f) | |
| 66 | + | if f != nil { | |
| 67 | + | assert.Equal(t, "false", f.DefValue) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestRemotesList_JSONFlag_Accepted(t *testing.T) { | |
| 72 | + | cmd := newRemotesListCmd() | |
| 73 | + | f := cmd.Flags().Lookup("json") | |
| 74 | + | assert.NotNil(t, f) | |
| 75 | + | if f != nil { | |
| 76 | + | assert.Equal(t, "false", f.DefValue) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestRollbackList_JSONFlag_Accepted(t *testing.T) { | |
| 81 | + | cmd := newRollbackListCmd() | |
| 82 | + | f := cmd.Flags().Lookup("json") | |
| 83 | + | assert.NotNil(t, f) | |
| 84 | + | if f != nil { | |
| 85 | + | assert.Equal(t, "false", f.DefValue) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestImagesList_JSONShape_RoundTripsDTO(t *testing.T) { | |
| 90 | + | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 91 | + | images := []dto.Image{{Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false}} | |
| 92 | + | ||
| 93 | + | payload, err := json.Marshal(images) | |
| 94 | + | require.NoError(t, err) | |
| 95 | + | ||
| 96 | + | var got []dto.Image | |
| 97 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |
| 98 | + | require.Len(t, got, 1) | |
| 99 | + | assert.Equal(t, images[0].Repository, got[0].Repository) | |
| 100 | + | assert.Equal(t, images[0].Tag, got[0].Tag) | |
| 101 | + | assert.Equal(t, images[0].ID, got[0].ID) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestRollbackList_JSONShape_RoundTripsTags(t *testing.T) { | |
| 105 | + | tags := []string{"v1.2.0", "v1.1.0"} | |
| 106 | + | payload, err := json.Marshal(tags) | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | ||
| 109 | + | var got []string | |
| 110 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |
| 111 | + | assert.Equal(t, tags, got) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) { | |
| 115 | + | startedAt := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC) | |
| 116 | + | jobs := []domain.BackupJob{{ID: "b1", Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted, StartedAt: startedAt}} | |
| 117 | + | ||
| 118 | + | payload, err := json.Marshal(jobs) | |
| 119 | + | require.NoError(t, err) | |
| 120 | + | ||
| 121 | + | var got []domain.BackupJob | |
| 122 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |
| 123 | + | require.Len(t, got, 1) | |
| 124 | + | assert.Equal(t, "b1", got[0].ID) | |
| 125 | + | assert.Equal(t, "app.example.com", got[0].Domain) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestTokenList_JSONShape_RoundTripsTokens(t *testing.T) { | |
| 129 | + | tokens := []domain.Token{{ | |
| 130 | + | ID: "tok-1", | |
| 131 | + | Subject: "deploy", | |
| 132 | + | Scopes: []string{"admin:*:*"}, | |
| 133 | + | IssuedAt: time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC), | |
| 134 | + | ExpiresAt: time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC), | |
| 135 | + | }} | |
| 136 | + | ||
| 137 | + | payload, err := json.Marshal(tokens) | |
| 138 | + | require.NoError(t, err) | |
| 139 | + | ||
| 140 | + | var got []domain.Token | |
| 141 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |
| 142 | + | require.Len(t, got, 1) | |
| 143 | + | assert.Equal(t, tokens[0].ID, got[0].ID) | |
| 144 | + | assert.Equal(t, tokens[0].Subject, got[0].Subject) | |
| 145 | + | assert.Equal(t, tokens[0].Scopes, got[0].Scopes) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestRemotesList_JSONShape_RoundTripsRemoteObjects(t *testing.T) { | |
| 149 | + | items := []struct { | |
| 150 | + | Name string `json:"name"` | |
| 151 | + | URL string `json:"url"` | |
| 152 | + | Active bool `json:"active"` | |
| 153 | + | }{ | |
| 154 | + | {Name: "prod", URL: "https://prod.example.com", Active: true}, | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | payload, err := json.Marshal(items) | |
| 158 | + | require.NoError(t, err) | |
| 159 | + | ||
| 160 | + | var got []struct { | |
| 161 | + | Name string `json:"name"` | |
| 162 | + | URL string `json:"url"` | |
| 163 | + | Active bool `json:"active"` | |
| 164 | + | } | |
| 165 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |
| 166 | + | require.Len(t, got, 1) | |
| 167 | + | assert.Equal(t, "prod", got[0].Name) | |
| 168 | + | assert.Equal(t, "https://prod.example.com", got[0].URL) | |
| 169 | + | assert.True(t, got[0].Active) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func TestAttachmentsList_JSONShape_RoundTripsTargetPayload(t *testing.T) { | |
| 173 | + | payload := struct { | |
| 174 | + | Target string `json:"target"` | |
| 175 | + | Images []string `json:"images"` | |
| 176 | + | }{ | |
| 177 | + | Target: "app.example.com", | |
| 178 | + | Images: []string{"postgres:16"}, | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | encoded, err := json.Marshal(payload) | |
| 182 | + | require.NoError(t, err) | |
| 183 | + | ||
| 184 | + | var got struct { | |
| 185 | + | Target string `json:"target"` | |
| 186 | + | Images []string `json:"images"` | |
| 187 | + | } | |
| 188 | + | require.NoError(t, json.Unmarshal(encoded, &got)) | |
| 189 | + | assert.Equal(t, payload.Target, got.Target) | |
| 190 | + | assert.Equal(t, payload.Images, got.Images) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func TestSecretsList_JSONShape_RoundTripsPayload(t *testing.T) { | |
| 194 | + | payload := struct { | |
| 195 | + | Domain string `json:"domain"` | |
| 196 | + | Keys []string `json:"keys"` | |
| 197 | + | Attachments []remote.AttachmentSecrets `json:"attachments"` | |
| 198 | + | }{ | |
| 199 | + | Domain: "app.example.com", | |
| 200 | + | Keys: []string{"API_KEY"}, | |
| 201 | + | Attachments: []remote.AttachmentSecrets{{ | |
| 202 | + | Service: "postgres", | |
| 203 | + | Keys: []string{"POSTGRES_PASSWORD"}, | |
| 204 | + | }}, | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | encoded, err := json.Marshal(payload) | |
| 208 | + | require.NoError(t, err) | |
| 209 | + | ||
| 210 | + | var got struct { | |
| 211 | + | Domain string `json:"domain"` | |
| 212 | + | Keys []string `json:"keys"` | |
| 213 | + | Attachments []remote.AttachmentSecrets `json:"attachments"` | |
| 214 | + | } | |
| 215 | + | require.NoError(t, json.Unmarshal(encoded, &got)) | |
| 216 | + | assert.Equal(t, payload.Domain, got.Domain) | |
| 217 | + | assert.Equal(t, payload.Keys, got.Keys) | |
| 218 | + | assert.Equal(t, payload.Attachments, got.Attachments) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func TestRoutesList_JSONShape_RoundTripsLocalPayload(t *testing.T) { | |
| 222 | + | payload := []struct { | |
| 223 | + | Domain string `json:"domain"` | |
| 224 | + | Image string `json:"image"` | |
| 225 | + | }{ | |
| 226 | + | {Domain: "app.example.com", Image: "app:latest"}, | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | encoded, err := json.Marshal(payload) | |
| 230 | + | require.NoError(t, err) | |
| 231 | + | ||
| 232 | + | var got []struct { | |
| 233 | + | Domain string `json:"domain"` | |
| 234 | + | Image string `json:"image"` | |
| 235 | + | } | |
| 236 | + | require.NoError(t, json.Unmarshal(encoded, &got)) | |
| 237 | + | require.Len(t, got, 1) | |
| 238 | + | assert.Equal(t, payload[0], got[0]) | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) { | |
| 242 | + | var out bytes.Buffer | |
| 243 | + | err := writeJSON(&out, map[string]any{"ok": true}) | |
| 244 | + | require.NoError(t, err) | |
| 245 | + | ||
| 246 | + | var got map[string]any | |
| 247 | + | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | |
| 248 | + | assert.Equal(t, true, got["ok"]) | |
| 249 | + | assert.Contains(t, out.String(), "\n") | |
| 250 | + | } | |
| @@ -0,0 +1,250 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 13 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestRoutesList_JSONFlag_Accepted(t *testing.T) { | |||
| 18 | + | cmd := newRoutesListCmd() | |||
| 19 | + | f := cmd.Flags().Lookup("json") | |||
| 20 | + | assert.NotNil(t, f) | |||
| 21 | + | if f != nil { | |||
| 22 | + | assert.Equal(t, "false", f.DefValue) | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestAttachmentsList_JSONFlag_Accepted(t *testing.T) { | |||
| 27 | + | cmd := newAttachmentsListCmd() | |||
| 28 | + | f := cmd.Flags().Lookup("json") | |||
| 29 | + | assert.NotNil(t, f) | |||
| 30 | + | if f != nil { | |||
| 31 | + | assert.Equal(t, "false", f.DefValue) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestSecretsList_JSONFlag_Accepted(t *testing.T) { | |||
| 36 | + | cmd := newSecretsListCmd() | |||
| 37 | + | f := cmd.Flags().Lookup("json") | |||
| 38 | + | assert.NotNil(t, f) | |||
| 39 | + | if f != nil { | |||
| 40 | + | assert.Equal(t, "false", f.DefValue) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestImagesList_JSONFlag_Accepted(t *testing.T) { | |||
| 45 | + | cmd := newImagesListCmd() | |||
| 46 | + | f := cmd.Flags().Lookup("json") | |||
| 47 | + | assert.NotNil(t, f) | |||
| 48 | + | if f != nil { | |||
| 49 | + | assert.Equal(t, "false", f.DefValue) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestBackupList_JSONFlag_Accepted(t *testing.T) { | |||
| 54 | + | cmd := newBackupListCmd() | |||
| 55 | + | f := cmd.Flags().Lookup("json") | |||
| 56 | + | assert.NotNil(t, f) | |||
| 57 | + | if f != nil { | |||
| 58 | + | assert.Equal(t, "false", f.DefValue) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestTokenList_JSONFlag_Accepted(t *testing.T) { | |||
| 63 | + | cmd := newTokenListCmd() | |||
| 64 | + | f := cmd.Flags().Lookup("json") | |||
| 65 | + | assert.NotNil(t, f) | |||
| 66 | + | if f != nil { | |||
| 67 | + | assert.Equal(t, "false", f.DefValue) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestRemotesList_JSONFlag_Accepted(t *testing.T) { | |||
| 72 | + | cmd := newRemotesListCmd() | |||
| 73 | + | f := cmd.Flags().Lookup("json") | |||
| 74 | + | assert.NotNil(t, f) | |||
| 75 | + | if f != nil { | |||
| 76 | + | assert.Equal(t, "false", f.DefValue) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestRollbackList_JSONFlag_Accepted(t *testing.T) { | |||
| 81 | + | cmd := newRollbackListCmd() | |||
| 82 | + | f := cmd.Flags().Lookup("json") | |||
| 83 | + | assert.NotNil(t, f) | |||
| 84 | + | if f != nil { | |||
| 85 | + | assert.Equal(t, "false", f.DefValue) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestImagesList_JSONShape_RoundTripsDTO(t *testing.T) { | |||
| 90 | + | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 91 | + | images := []dto.Image{{Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false}} | |||
| 92 | + | ||||
| 93 | + | payload, err := json.Marshal(images) | |||
| 94 | + | require.NoError(t, err) | |||
| 95 | + | ||||
| 96 | + | var got []dto.Image | |||
| 97 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |||
| 98 | + | require.Len(t, got, 1) | |||
| 99 | + | assert.Equal(t, images[0].Repository, got[0].Repository) | |||
| 100 | + | assert.Equal(t, images[0].Tag, got[0].Tag) | |||
| 101 | + | assert.Equal(t, images[0].ID, got[0].ID) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestRollbackList_JSONShape_RoundTripsTags(t *testing.T) { | |||
| 105 | + | tags := []string{"v1.2.0", "v1.1.0"} | |||
| 106 | + | payload, err := json.Marshal(tags) | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | ||||
| 109 | + | var got []string | |||
| 110 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |||
| 111 | + | assert.Equal(t, tags, got) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) { | |||
| 115 | + | startedAt := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC) | |||
| 116 | + | jobs := []domain.BackupJob{{ID: "b1", Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted, StartedAt: startedAt}} | |||
| 117 | + | ||||
| 118 | + | payload, err := json.Marshal(jobs) | |||
| 119 | + | require.NoError(t, err) | |||
| 120 | + | ||||
| 121 | + | var got []domain.BackupJob | |||
| 122 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |||
| 123 | + | require.Len(t, got, 1) | |||
| 124 | + | assert.Equal(t, "b1", got[0].ID) | |||
| 125 | + | assert.Equal(t, "app.example.com", got[0].Domain) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestTokenList_JSONShape_RoundTripsTokens(t *testing.T) { | |||
| 129 | + | tokens := []domain.Token{{ | |||
| 130 | + | ID: "tok-1", | |||
| 131 | + | Subject: "deploy", | |||
| 132 | + | Scopes: []string{"admin:*:*"}, | |||
| 133 | + | IssuedAt: time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC), | |||
| 134 | + | ExpiresAt: time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC), | |||
| 135 | + | }} | |||
| 136 | + | ||||
| 137 | + | payload, err := json.Marshal(tokens) | |||
| 138 | + | require.NoError(t, err) | |||
| 139 | + | ||||
| 140 | + | var got []domain.Token | |||
| 141 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |||
| 142 | + | require.Len(t, got, 1) | |||
| 143 | + | assert.Equal(t, tokens[0].ID, got[0].ID) | |||
| 144 | + | assert.Equal(t, tokens[0].Subject, got[0].Subject) | |||
| 145 | + | assert.Equal(t, tokens[0].Scopes, got[0].Scopes) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestRemotesList_JSONShape_RoundTripsRemoteObjects(t *testing.T) { | |||
| 149 | + | items := []struct { | |||
| 150 | + | Name string `json:"name"` | |||
| 151 | + | URL string `json:"url"` | |||
| 152 | + | Active bool `json:"active"` | |||
| 153 | + | }{ | |||
| 154 | + | {Name: "prod", URL: "https://prod.example.com", Active: true}, | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | payload, err := json.Marshal(items) | |||
| 158 | + | require.NoError(t, err) | |||
| 159 | + | ||||
| 160 | + | var got []struct { | |||
| 161 | + | Name string `json:"name"` | |||
| 162 | + | URL string `json:"url"` | |||
| 163 | + | Active bool `json:"active"` | |||
| 164 | + | } | |||
| 165 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |||
| 166 | + | require.Len(t, got, 1) | |||
| 167 | + | assert.Equal(t, "prod", got[0].Name) | |||
| 168 | + | assert.Equal(t, "https://prod.example.com", got[0].URL) | |||
| 169 | + | assert.True(t, got[0].Active) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func TestAttachmentsList_JSONShape_RoundTripsTargetPayload(t *testing.T) { | |||
| 173 | + | payload := struct { | |||
| 174 | + | Target string `json:"target"` | |||
| 175 | + | Images []string `json:"images"` | |||
| 176 | + | }{ | |||
| 177 | + | Target: "app.example.com", | |||
| 178 | + | Images: []string{"postgres:16"}, | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | encoded, err := json.Marshal(payload) | |||
| 182 | + | require.NoError(t, err) | |||
| 183 | + | ||||
| 184 | + | var got struct { | |||
| 185 | + | Target string `json:"target"` | |||
| 186 | + | Images []string `json:"images"` | |||
| 187 | + | } | |||
| 188 | + | require.NoError(t, json.Unmarshal(encoded, &got)) | |||
| 189 | + | assert.Equal(t, payload.Target, got.Target) | |||
| 190 | + | assert.Equal(t, payload.Images, got.Images) | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func TestSecretsList_JSONShape_RoundTripsPayload(t *testing.T) { | |||
| 194 | + | payload := struct { | |||
| 195 | + | Domain string `json:"domain"` | |||
| 196 | + | Keys []string `json:"keys"` | |||
| 197 | + | Attachments []remote.AttachmentSecrets `json:"attachments"` | |||
| 198 | + | }{ | |||
| 199 | + | Domain: "app.example.com", | |||
| 200 | + | Keys: []string{"API_KEY"}, | |||
| 201 | + | Attachments: []remote.AttachmentSecrets{{ | |||
| 202 | + | Service: "postgres", | |||
| 203 | + | Keys: []string{"POSTGRES_PASSWORD"}, | |||
| 204 | + | }}, | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | encoded, err := json.Marshal(payload) | |||
| 208 | + | require.NoError(t, err) | |||
| 209 | + | ||||
| 210 | + | var got struct { | |||
| 211 | + | Domain string `json:"domain"` | |||
| 212 | + | Keys []string `json:"keys"` | |||
| 213 | + | Attachments []remote.AttachmentSecrets `json:"attachments"` | |||
| 214 | + | } | |||
| 215 | + | require.NoError(t, json.Unmarshal(encoded, &got)) | |||
| 216 | + | assert.Equal(t, payload.Domain, got.Domain) | |||
| 217 | + | assert.Equal(t, payload.Keys, got.Keys) | |||
| 218 | + | assert.Equal(t, payload.Attachments, got.Attachments) | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func TestRoutesList_JSONShape_RoundTripsLocalPayload(t *testing.T) { | |||
| 222 | + | payload := []struct { | |||
| 223 | + | Domain string `json:"domain"` | |||
| 224 | + | Image string `json:"image"` | |||
| 225 | + | }{ | |||
| 226 | + | {Domain: "app.example.com", Image: "app:latest"}, | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | encoded, err := json.Marshal(payload) | |||
| 230 | + | require.NoError(t, err) | |||
| 231 | + | ||||
| 232 | + | var got []struct { | |||
| 233 | + | Domain string `json:"domain"` | |||
| 234 | + | Image string `json:"image"` | |||
| 235 | + | } | |||
| 236 | + | require.NoError(t, json.Unmarshal(encoded, &got)) | |||
| 237 | + | require.Len(t, got, 1) | |||
| 238 | + | assert.Equal(t, payload[0], got[0]) | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) { | |||
| 242 | + | var out bytes.Buffer | |||
| 243 | + | err := writeJSON(&out, map[string]any{"ok": true}) | |||
| 244 | + | require.NoError(t, err) | |||
| 245 | + | ||||
| 246 | + | var got map[string]any | |||
| 247 | + | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | |||
| 248 | + | assert.Equal(t, true, got["ok"]) | |||
| 249 | + | assert.Contains(t, out.String(), "\n") | |||
| 250 | + | } | |||
M internal/adapters/in/cli/remotes.go +82 -51
| @@ -2,6 +2,8 @@ package cli | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "io" | |
| 6 | + | "sort" | |
| 5 | 7 | "strings" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| @@ -35,67 +37,96 @@ Configuration is stored in ~/.config/gordon/remotes.toml`, | |||
| 35 | 37 | ||
| 36 | 38 | // newRemotesListCmd creates the remotes list command. | |
| 37 | 39 | func newRemotesListCmd() *cobra.Command { | |
| 38 | - | return &cobra.Command{ | |
| 40 | + | var jsonOut bool | |
| 41 | + | ||
| 42 | + | cmd := &cobra.Command{ | |
| 39 | 43 | Use: "list", | |
| 40 | 44 | Short: "List saved remotes", | |
| 41 | 45 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 42 | - | remotes, active, err := remote.ListRemotes() | |
| 43 | - | if err != nil { | |
| 44 | - | return fmt.Errorf("failed to list remotes: %w", err) | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | if len(remotes) == 0 { | |
| 48 | - | fmt.Println(styles.Theme.Muted.Render("No remotes configured")) | |
| 49 | - | fmt.Println() | |
| 50 | - | fmt.Println("Add a remote with:") | |
| 51 | - | fmt.Println(styles.Theme.Bold.Render(" gordon remotes add <name> <url>")) | |
| 52 | - | return nil | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | fmt.Println(styles.Theme.Title.Render("Saved Remotes")) | |
| 56 | - | fmt.Println() | |
| 57 | - | ||
| 58 | - | // Build table rows | |
| 59 | - | rows := make([][]string, 0, len(remotes)) | |
| 60 | - | for name, r := range remotes { | |
| 61 | - | status := "" | |
| 62 | - | if name == active { | |
| 63 | - | status = styles.Theme.Success.Render("active") | |
| 64 | - | } | |
| 46 | + | return runRemotesList(cmd.OutOrStdout(), jsonOut) | |
| 47 | + | }, | |
| 48 | + | } | |
| 65 | 49 | ||
| 66 | - | // Mask token if present | |
| 67 | - | tokenStatus := styles.Theme.Muted.Render("none") | |
| 68 | - | if r.Token != "" { | |
| 69 | - | tokenStatus = styles.Theme.Success.Render("set") | |
| 70 | - | } else if r.TokenEnv != "" { | |
| 71 | - | tokenStatus = fmt.Sprintf("$%s", r.TokenEnv) | |
| 72 | - | } | |
| 50 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 73 | 51 | ||
| 74 | - | tlsStatus := "" | |
| 75 | - | if r.InsecureTLS { | |
| 76 | - | tlsStatus = styles.Theme.Warning.Render("insecure") | |
| 77 | - | } | |
| 52 | + | return cmd | |
| 53 | + | } | |
| 78 | 54 | ||
| 79 | - | rows = append(rows, []string{name, r.URL, tokenStatus, tlsStatus, status}) | |
| 80 | - | } | |
| 55 | + | func runRemotesList(out io.Writer, jsonOut bool) error { | |
| 56 | + | remotes, active, err := remote.ListRemotes() | |
| 57 | + | if err != nil { | |
| 58 | + | return fmt.Errorf("failed to list remotes: %w", err) | |
| 59 | + | } | |
| 81 | 60 | ||
| 82 | - | // Render table | |
| 83 | - | table := components.NewTable( | |
| 84 | - | components.WithColumns([]components.TableColumn{ | |
| 85 | - | {Title: "Name", Width: 15}, | |
| 86 | - | {Title: "URL", Width: 35}, | |
| 87 | - | {Title: "Token", Width: 15}, | |
| 88 | - | {Title: "TLS", Width: 10}, | |
| 89 | - | {Title: "Status", Width: 10}, | |
| 90 | - | }), | |
| 91 | - | components.WithRows(rows), | |
| 92 | - | ) | |
| 61 | + | if len(remotes) == 0 { | |
| 62 | + | if jsonOut { | |
| 63 | + | return writeJSON(out, []map[string]any{}) | |
| 64 | + | } | |
| 65 | + | fmt.Println(styles.Theme.Muted.Render("No remotes configured")) | |
| 66 | + | fmt.Println() | |
| 67 | + | fmt.Println("Add a remote with:") | |
| 68 | + | fmt.Println(styles.Theme.Bold.Render(" gordon remotes add <name> <url>")) | |
| 69 | + | return nil | |
| 70 | + | } | |
| 93 | 71 | ||
| 94 | - | fmt.Println(table.View()) | |
| 72 | + | names := make([]string, 0, len(remotes)) | |
| 73 | + | for name := range remotes { | |
| 74 | + | names = append(names, name) | |
| 75 | + | } | |
| 76 | + | sort.Strings(names) | |
| 77 | + | ||
| 78 | + | if jsonOut { | |
| 79 | + | payload := make([]map[string]any, 0, len(names)) | |
| 80 | + | for _, name := range names { | |
| 81 | + | r := remotes[name] | |
| 82 | + | payload = append(payload, map[string]any{ | |
| 83 | + | "name": name, | |
| 84 | + | "url": r.URL, | |
| 85 | + | "active": name == active, | |
| 86 | + | }) | |
| 87 | + | } | |
| 88 | + | return writeJSON(out, payload) | |
| 89 | + | } | |
| 95 | 90 | ||
| 96 | - | return nil | |
| 97 | - | }, | |
| 91 | + | fmt.Println(styles.Theme.Title.Render("Saved Remotes")) | |
| 92 | + | fmt.Println() | |
| 93 | + | ||
| 94 | + | rows := make([][]string, 0, len(names)) | |
| 95 | + | for _, name := range names { | |
| 96 | + | r := remotes[name] | |
| 97 | + | status := "" | |
| 98 | + | if name == active { | |
| 99 | + | status = styles.Theme.Success.Render("active") | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | tokenStatus := styles.Theme.Muted.Render("none") | |
| 103 | + | if r.Token != "" { | |
| 104 | + | tokenStatus = styles.Theme.Success.Render("set") | |
| 105 | + | } else if r.TokenEnv != "" { | |
| 106 | + | tokenStatus = fmt.Sprintf("$%s", r.TokenEnv) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | tlsStatus := "" | |
| 110 | + | if r.InsecureTLS { | |
| 111 | + | tlsStatus = styles.Theme.Warning.Render("insecure") | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | rows = append(rows, []string{name, r.URL, tokenStatus, tlsStatus, status}) | |
| 98 | 115 | } | |
| 116 | + | ||
| 117 | + | table := components.NewTable( | |
| 118 | + | components.WithColumns([]components.TableColumn{ | |
| 119 | + | {Title: "Name", Width: 15}, | |
| 120 | + | {Title: "URL", Width: 35}, | |
| 121 | + | {Title: "Token", Width: 15}, | |
| 122 | + | {Title: "TLS", Width: 10}, | |
| 123 | + | {Title: "Status", Width: 10}, | |
| 124 | + | }), | |
| 125 | + | components.WithRows(rows), | |
| 126 | + | ) | |
| 127 | + | ||
| 128 | + | _, err = fmt.Fprintln(out, table.View()) | |
| 129 | + | return err | |
| 99 | 130 | } | |
| 100 | 131 | ||
| 101 | 132 | // newRemotesAddCmd creates the remotes add command. | |
| @@ -2,6 +2,8 @@ package cli | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "io" | |||
| 6 | + | "sort" | |||
| 5 | "strings" | 7 | "strings" | ||
| 6 | 8 | ||||
| 7 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | 9 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | ||
| @@ -35,67 +37,96 @@ Configuration is stored in ~/.config/gordon/remotes.toml`, | |||||
| 35 | 37 | ||||
| 36 | // newRemotesListCmd creates the remotes list command. | 38 | // newRemotesListCmd creates the remotes list command. | ||
| 37 | func newRemotesListCmd() *cobra.Command { | 39 | func newRemotesListCmd() *cobra.Command { | ||
| 38 | - | return &cobra.Command{ | 40 | + | var jsonOut bool |
| 41 | + | ||||
| 42 | + | cmd := &cobra.Command{ | |||
| 39 | Use: "list", | 43 | Use: "list", | ||
| 40 | Short: "List saved remotes", | 44 | Short: "List saved remotes", | ||
| 41 | RunE: func(cmd *cobra.Command, args []string) error { | 45 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 42 | - | remotes, active, err := remote.ListRemotes() | 46 | + | return runRemotesList(cmd.OutOrStdout(), jsonOut) |
| 43 | - | if err != nil { | 47 | + | }, |
| 44 | - | return fmt.Errorf("failed to list remotes: %w", err) | 48 | + | } |
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | if len(remotes) == 0 { | |||
| 48 | - | fmt.Println(styles.Theme.Muted.Render("No remotes configured")) | |||
| 49 | - | fmt.Println() | |||
| 50 | - | fmt.Println("Add a remote with:") | |||
| 51 | - | fmt.Println(styles.Theme.Bold.Render(" gordon remotes add <name> <url>")) | |||
| 52 | - | return nil | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | fmt.Println(styles.Theme.Title.Render("Saved Remotes")) | |||
| 56 | - | fmt.Println() | |||
| 57 | - | ||||
| 58 | - | // Build table rows | |||
| 59 | - | rows := make([][]string, 0, len(remotes)) | |||
| 60 | - | for name, r := range remotes { | |||
| 61 | - | status := "" | |||
| 62 | - | if name == active { | |||
| 63 | - | status = styles.Theme.Success.Render("active") | |||
| 64 | - | } | |||
| 65 | 49 | ||||
| 66 | - | // Mask token if present | 50 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") |
| 67 | - | tokenStatus := styles.Theme.Muted.Render("none") | |||
| 68 | - | if r.Token != "" { | |||
| 69 | - | tokenStatus = styles.Theme.Success.Render("set") | |||
| 70 | - | } else if r.TokenEnv != "" { | |||
| 71 | - | tokenStatus = fmt.Sprintf("$%s", r.TokenEnv) | |||
| 72 | - | } | |||
| 73 | 51 | ||||
| 74 | - | tlsStatus := "" | 52 | + | return cmd |
| 75 | - | if r.InsecureTLS { | 53 | + | } |
| 76 | - | tlsStatus = styles.Theme.Warning.Render("insecure") | |||
| 77 | - | } | |||
| 78 | 54 | ||||
| 79 | - | rows = append(rows, []string{name, r.URL, tokenStatus, tlsStatus, status}) | 55 | + | func runRemotesList(out io.Writer, jsonOut bool) error { |
| 80 | - | } | 56 | + | remotes, active, err := remote.ListRemotes() |
| 57 | + | if err != nil { | |||
| 58 | + | return fmt.Errorf("failed to list remotes: %w", err) | |||
| 59 | + | } | |||
| 81 | 60 | ||||
| 82 | - | // Render table | 61 | + | if len(remotes) == 0 { |
| 83 | - | table := components.NewTable( | 62 | + | if jsonOut { |
| 84 | - | components.WithColumns([]components.TableColumn{ | 63 | + | return writeJSON(out, []map[string]any{}) |
| 85 | - | {Title: "Name", Width: 15}, | 64 | + | } |
| 86 | - | {Title: "URL", Width: 35}, | 65 | + | fmt.Println(styles.Theme.Muted.Render("No remotes configured")) |
| 87 | - | {Title: "Token", Width: 15}, | 66 | + | fmt.Println() |
| 88 | - | {Title: "TLS", Width: 10}, | 67 | + | fmt.Println("Add a remote with:") |
| 89 | - | {Title: "Status", Width: 10}, | 68 | + | fmt.Println(styles.Theme.Bold.Render(" gordon remotes add <name> <url>")) |
| 90 | - | }), | 69 | + | return nil |
| 91 | - | components.WithRows(rows), | 70 | + | } |
| 92 | - | ) | |||
| 93 | 71 | ||||
| 94 | - | fmt.Println(table.View()) | 72 | + | names := make([]string, 0, len(remotes)) |
| 73 | + | for name := range remotes { | |||
| 74 | + | names = append(names, name) | |||
| 75 | + | } | |||
| 76 | + | sort.Strings(names) | |||
| 77 | + | ||||
| 78 | + | if jsonOut { | |||
| 79 | + | payload := make([]map[string]any, 0, len(names)) | |||
| 80 | + | for _, name := range names { | |||
| 81 | + | r := remotes[name] | |||
| 82 | + | payload = append(payload, map[string]any{ | |||
| 83 | + | "name": name, | |||
| 84 | + | "url": r.URL, | |||
| 85 | + | "active": name == active, | |||
| 86 | + | }) | |||
| 87 | + | } | |||
| 88 | + | return writeJSON(out, payload) | |||
| 89 | + | } | |||
| 95 | 90 | ||||
| 96 | - | return nil | 91 | + | fmt.Println(styles.Theme.Title.Render("Saved Remotes")) |
| 97 | - | }, | 92 | + | fmt.Println() |
| 93 | + | ||||
| 94 | + | rows := make([][]string, 0, len(names)) | |||
| 95 | + | for _, name := range names { | |||
| 96 | + | r := remotes[name] | |||
| 97 | + | status := "" | |||
| 98 | + | if name == active { | |||
| 99 | + | status = styles.Theme.Success.Render("active") | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | tokenStatus := styles.Theme.Muted.Render("none") | |||
| 103 | + | if r.Token != "" { | |||
| 104 | + | tokenStatus = styles.Theme.Success.Render("set") | |||
| 105 | + | } else if r.TokenEnv != "" { | |||
| 106 | + | tokenStatus = fmt.Sprintf("$%s", r.TokenEnv) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | tlsStatus := "" | |||
| 110 | + | if r.InsecureTLS { | |||
| 111 | + | tlsStatus = styles.Theme.Warning.Render("insecure") | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | rows = append(rows, []string{name, r.URL, tokenStatus, tlsStatus, status}) | |||
| 98 | } | 115 | } | ||
| 116 | + | ||||
| 117 | + | table := components.NewTable( | |||
| 118 | + | components.WithColumns([]components.TableColumn{ | |||
| 119 | + | {Title: "Name", Width: 15}, | |||
| 120 | + | {Title: "URL", Width: 35}, | |||
| 121 | + | {Title: "Token", Width: 15}, | |||
| 122 | + | {Title: "TLS", Width: 10}, | |||
| 123 | + | {Title: "Status", Width: 10}, | |||
| 124 | + | }), | |||
| 125 | + | components.WithRows(rows), | |||
| 126 | + | ) | |||
| 127 | + | ||||
| 128 | + | _, err = fmt.Fprintln(out, table.View()) | |||
| 129 | + | return err | |||
| 99 | } | 130 | } | ||
| 100 | 131 | ||||
| 101 | // newRemotesAddCmd creates the remotes add command. | 132 | // newRemotesAddCmd creates the remotes add command. | ||
M internal/adapters/in/cli/rollback.go +15 -5
| @@ -42,14 +42,20 @@ Examples: | |||
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | 44 | func newRollbackListCmd() *cobra.Command { | |
| 45 | - | return &cobra.Command{ | |
| 45 | + | var jsonOut bool | |
| 46 | + | ||
| 47 | + | cmd := &cobra.Command{ | |
| 46 | 48 | Use: "list <domain>", | |
| 47 | 49 | Short: "List available rollback tags", | |
| 48 | 50 | Args: cobra.ExactArgs(1), | |
| 49 | 51 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 50 | - | return runRollbackList(cmd.Context(), cmd.OutOrStdout(), args[0]) | |
| 52 | + | return runRollbackList(cmd.Context(), cmd.OutOrStdout(), args[0], jsonOut) | |
| 51 | 53 | }, | |
| 52 | 54 | } | |
| 55 | + | ||
| 56 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 57 | + | ||
| 58 | + | return cmd | |
| 53 | 59 | } | |
| 54 | 60 | ||
| 55 | 61 | func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | |
| @@ -87,7 +93,7 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | |||
| 87 | 93 | return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag) | |
| 88 | 94 | } | |
| 89 | 95 | ||
| 90 | - | func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string) error { | |
| 96 | + | func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string, jsonOut bool) error { | |
| 91 | 97 | handle, err := resolveControlPlane(configPath) | |
| 92 | 98 | if err != nil { | |
| 93 | 99 | return err | |
| @@ -109,10 +115,14 @@ func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string) er | |||
| 109 | 115 | return err | |
| 110 | 116 | } | |
| 111 | 117 | ||
| 112 | - | return printRollbackTags(w, rollbackDomain, currentTag, tags) | |
| 118 | + | return printRollbackTags(w, rollbackDomain, currentTag, tags, jsonOut) | |
| 113 | 119 | } | |
| 114 | 120 | ||
| 115 | - | func printRollbackTags(w io.Writer, rollbackDomain, currentTag string, tags []string) error { | |
| 121 | + | func printRollbackTags(w io.Writer, rollbackDomain, currentTag string, tags []string, jsonOut bool) error { | |
| 122 | + | if jsonOut { | |
| 123 | + | return writeJSON(w, tags) | |
| 124 | + | } | |
| 125 | + | ||
| 116 | 126 | if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(rollbackDomain)); err != nil { | |
| 117 | 127 | return err | |
| 118 | 128 | } | |
| @@ -42,14 +42,20 @@ Examples: | |||||
| 42 | } | 42 | } | ||
| 43 | 43 | ||||
| 44 | func newRollbackListCmd() *cobra.Command { | 44 | func newRollbackListCmd() *cobra.Command { | ||
| 45 | - | return &cobra.Command{ | 45 | + | var jsonOut bool |
| 46 | + | ||||
| 47 | + | cmd := &cobra.Command{ | |||
| 46 | Use: "list <domain>", | 48 | Use: "list <domain>", | ||
| 47 | Short: "List available rollback tags", | 49 | Short: "List available rollback tags", | ||
| 48 | Args: cobra.ExactArgs(1), | 50 | Args: cobra.ExactArgs(1), | ||
| 49 | RunE: func(cmd *cobra.Command, args []string) error { | 51 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 50 | - | return runRollbackList(cmd.Context(), cmd.OutOrStdout(), args[0]) | 52 | + | return runRollbackList(cmd.Context(), cmd.OutOrStdout(), args[0], jsonOut) |
| 51 | }, | 53 | }, | ||
| 52 | } | 54 | } | ||
| 55 | + | ||||
| 56 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 57 | + | ||||
| 58 | + | return cmd | |||
| 53 | } | 59 | } | ||
| 54 | 60 | ||||
| 55 | func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | 61 | func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | ||
| @@ -87,7 +93,7 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | |||||
| 87 | return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag) | 93 | return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag) | ||
| 88 | } | 94 | } | ||
| 89 | 95 | ||||
| 90 | - | func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string) error { | 96 | + | func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string, jsonOut bool) error { |
| 91 | handle, err := resolveControlPlane(configPath) | 97 | handle, err := resolveControlPlane(configPath) | ||
| 92 | if err != nil { | 98 | if err != nil { | ||
| 93 | return err | 99 | return err | ||
| @@ -109,10 +115,14 @@ func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string) er | |||||
| 109 | return err | 115 | return err | ||
| 110 | } | 116 | } | ||
| 111 | 117 | ||||
| 112 | - | return printRollbackTags(w, rollbackDomain, currentTag, tags) | 118 | + | return printRollbackTags(w, rollbackDomain, currentTag, tags, jsonOut) |
| 113 | } | 119 | } | ||
| 114 | 120 | ||||
| 115 | - | func printRollbackTags(w io.Writer, rollbackDomain, currentTag string, tags []string) error { | 121 | + | func printRollbackTags(w io.Writer, rollbackDomain, currentTag string, tags []string, jsonOut bool) error { |
| 122 | + | if jsonOut { | |||
| 123 | + | return writeJSON(w, tags) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 116 | if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(rollbackDomain)); err != nil { | 126 | if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(rollbackDomain)); err != nil { | ||
| 117 | return err | 127 | return err | ||
| 118 | } | 128 | } | ||
M internal/adapters/in/cli/rollback_test.go +2 -2
| @@ -63,7 +63,7 @@ func TestValidateTagExists(t *testing.T) { | |||
| 63 | 63 | func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) { | |
| 64 | 64 | var buf bytes.Buffer | |
| 65 | 65 | ||
| 66 | - | err := printRollbackTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}) | |
| 66 | + | err := printRollbackTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}, false) | |
| 67 | 67 | assert.NoError(t, err) | |
| 68 | 68 | ||
| 69 | 69 | output := buf.String() | |
| @@ -74,7 +74,7 @@ func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) { | |||
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | 76 | func TestPrintRollbackTags_ReturnsWriteError(t *testing.T) { | |
| 77 | - | err := printRollbackTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}) | |
| 77 | + | err := printRollbackTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}, false) | |
| 78 | 78 | assert.Error(t, err) | |
| 79 | 79 | assert.Contains(t, err.Error(), "write failed") | |
| 80 | 80 | } | |
| @@ -63,7 +63,7 @@ func TestValidateTagExists(t *testing.T) { | |||||
| 63 | func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) { | 63 | func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) { | ||
| 64 | var buf bytes.Buffer | 64 | var buf bytes.Buffer | ||
| 65 | 65 | ||||
| 66 | - | err := printRollbackTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}) | 66 | + | err := printRollbackTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}, false) |
| 67 | assert.NoError(t, err) | 67 | assert.NoError(t, err) | ||
| 68 | 68 | ||||
| 69 | output := buf.String() | 69 | output := buf.String() | ||
| @@ -74,7 +74,7 @@ func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) { | |||||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | func TestPrintRollbackTags_ReturnsWriteError(t *testing.T) { | 76 | func TestPrintRollbackTags_ReturnsWriteError(t *testing.T) { | ||
| 77 | - | err := printRollbackTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}) | 77 | + | err := printRollbackTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}, false) |
| 78 | assert.Error(t, err) | 78 | assert.Error(t, err) | ||
| 79 | assert.Contains(t, err.Error(), "write failed") | 79 | assert.Contains(t, err.Error(), "write failed") | ||
| 80 | } | 80 | } | ||
M internal/adapters/in/cli/routes.go +62 -5
| @@ -3,6 +3,7 @@ package cli | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "io" | |
| 6 | 7 | "strings" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| @@ -111,7 +112,9 @@ func formatHTTPStatus(health *remote.RouteHealth) string { | |||
| 111 | 112 | ||
| 112 | 113 | // newRoutesListCmd creates the routes list command. | |
| 113 | 114 | func newRoutesListCmd() *cobra.Command { | |
| 114 | - | return &cobra.Command{ | |
| 115 | + | var jsonOut bool | |
| 116 | + | ||
| 117 | + | cmd := &cobra.Command{ | |
| 115 | 118 | Use: "list", | |
| 116 | 119 | Short: "List all routes", | |
| 117 | 120 | RunE: func(cmd *cobra.Command, args []string) error { | |
| @@ -119,15 +122,19 @@ func newRoutesListCmd() *cobra.Command { | |||
| 119 | 122 | ||
| 120 | 123 | client, isRemote := GetRemoteClient() | |
| 121 | 124 | if isRemote { | |
| 122 | - | return runRoutesListRemote(ctx, client) | |
| 125 | + | return runRoutesListRemote(ctx, client, jsonOut, cmd.OutOrStdout()) | |
| 123 | 126 | } | |
| 124 | - | return runRoutesListLocal(ctx, configPath) | |
| 127 | + | return runRoutesListLocal(ctx, configPath, jsonOut, cmd.OutOrStdout()) | |
| 125 | 128 | }, | |
| 126 | 129 | } | |
| 130 | + | ||
| 131 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 132 | + | ||
| 133 | + | return cmd | |
| 127 | 134 | } | |
| 128 | 135 | ||
| 129 | 136 | // runRoutesListRemote lists routes from a remote Gordon instance. | |
| 130 | - | func runRoutesListRemote(ctx context.Context, client *remote.Client) error { | |
| 137 | + | func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut bool, out io.Writer) error { | |
| 131 | 138 | routes, err := client.ListRoutesWithDetails(ctx) | |
| 132 | 139 | if err != nil { | |
| 133 | 140 | return fmt.Errorf("failed to list routes: %w", err) | |
| @@ -144,6 +151,45 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error { | |||
| 144 | 151 | health = make(map[string]*remote.RouteHealth) | |
| 145 | 152 | } | |
| 146 | 153 | ||
| 154 | + | if jsonOut { | |
| 155 | + | payload := make([]map[string]any, 0, len(routes)) | |
| 156 | + | for _, route := range routes { | |
| 157 | + | routeHealth := health[route.Domain] | |
| 158 | + | containerStatus := route.ContainerStatus | |
| 159 | + | if containerStatus == "" { | |
| 160 | + | if routeHealth != nil { | |
| 161 | + | containerStatus = routeHealth.ContainerStatus | |
| 162 | + | } else { | |
| 163 | + | containerStatus = "unknown" | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | httpStatus := 0 | |
| 168 | + | if routeHealth != nil { | |
| 169 | + | httpStatus = routeHealth.HTTPStatus | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | attachments := make([]map[string]string, 0, len(route.Attachments)) | |
| 173 | + | for _, attachment := range route.Attachments { | |
| 174 | + | attachments = append(attachments, map[string]string{ | |
| 175 | + | "name": attachment.Name, | |
| 176 | + | "image": attachment.Image, | |
| 177 | + | "status": attachment.Status, | |
| 178 | + | }) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | payload = append(payload, map[string]any{ | |
| 182 | + | "domain": route.Domain, | |
| 183 | + | "image": route.Image, | |
| 184 | + | "container_status": containerStatus, | |
| 185 | + | "network": route.Network, | |
| 186 | + | "http_status": httpStatus, | |
| 187 | + | "attachments": attachments, | |
| 188 | + | }) | |
| 189 | + | } | |
| 190 | + | return writeJSON(out, payload) | |
| 191 | + | } | |
| 192 | + | ||
| 147 | 193 | const imageColWidth = 35 | |
| 148 | 194 | const networkColWidth = 22 | |
| 149 | 195 | rows := make([][]string, 0, len(routes)) | |
| @@ -207,7 +253,7 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error { | |||
| 207 | 253 | } | |
| 208 | 254 | ||
| 209 | 255 | // runRoutesListLocal lists routes from local configuration. | |
| 210 | - | func runRoutesListLocal(ctx context.Context, cfgPath string) error { | |
| 256 | + | func runRoutesListLocal(ctx context.Context, cfgPath string, jsonOut bool, out io.Writer) error { | |
| 211 | 257 | local, err := GetLocalServices(cfgPath) | |
| 212 | 258 | if err != nil { | |
| 213 | 259 | return fmt.Errorf("failed to initialize local services: %w", err) | |
| @@ -215,6 +261,17 @@ func runRoutesListLocal(ctx context.Context, cfgPath string) error { | |||
| 215 | 261 | ||
| 216 | 262 | routes := local.GetConfigService().GetRoutes(ctx) | |
| 217 | 263 | ||
| 264 | + | if jsonOut { | |
| 265 | + | payload := make([]map[string]string, 0, len(routes)) | |
| 266 | + | for _, route := range routes { | |
| 267 | + | payload = append(payload, map[string]string{ | |
| 268 | + | "domain": route.Domain, | |
| 269 | + | "image": route.Image, | |
| 270 | + | }) | |
| 271 | + | } | |
| 272 | + | return writeJSON(out, payload) | |
| 273 | + | } | |
| 274 | + | ||
| 218 | 275 | if len(routes) == 0 { | |
| 219 | 276 | fmt.Println(styles.Theme.Muted.Render("No routes configured")) | |
| 220 | 277 | return nil | |
| @@ -3,6 +3,7 @@ package cli | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "io" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | 9 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | ||
| @@ -111,7 +112,9 @@ func formatHTTPStatus(health *remote.RouteHealth) string { | |||||
| 111 | 112 | ||||
| 112 | // newRoutesListCmd creates the routes list command. | 113 | // newRoutesListCmd creates the routes list command. | ||
| 113 | func newRoutesListCmd() *cobra.Command { | 114 | func newRoutesListCmd() *cobra.Command { | ||
| 114 | - | return &cobra.Command{ | 115 | + | var jsonOut bool |
| 116 | + | ||||
| 117 | + | cmd := &cobra.Command{ | |||
| 115 | Use: "list", | 118 | Use: "list", | ||
| 116 | Short: "List all routes", | 119 | Short: "List all routes", | ||
| 117 | RunE: func(cmd *cobra.Command, args []string) error { | 120 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| @@ -119,15 +122,19 @@ func newRoutesListCmd() *cobra.Command { | |||||
| 119 | 122 | ||||
| 120 | client, isRemote := GetRemoteClient() | 123 | client, isRemote := GetRemoteClient() | ||
| 121 | if isRemote { | 124 | if isRemote { | ||
| 122 | - | return runRoutesListRemote(ctx, client) | 125 | + | return runRoutesListRemote(ctx, client, jsonOut, cmd.OutOrStdout()) |
| 123 | } | 126 | } | ||
| 124 | - | return runRoutesListLocal(ctx, configPath) | 127 | + | return runRoutesListLocal(ctx, configPath, jsonOut, cmd.OutOrStdout()) |
| 125 | }, | 128 | }, | ||
| 126 | } | 129 | } | ||
| 130 | + | ||||
| 131 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 132 | + | ||||
| 133 | + | return cmd | |||
| 127 | } | 134 | } | ||
| 128 | 135 | ||||
| 129 | // runRoutesListRemote lists routes from a remote Gordon instance. | 136 | // runRoutesListRemote lists routes from a remote Gordon instance. | ||
| 130 | - | func runRoutesListRemote(ctx context.Context, client *remote.Client) error { | 137 | + | func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut bool, out io.Writer) error { |
| 131 | routes, err := client.ListRoutesWithDetails(ctx) | 138 | routes, err := client.ListRoutesWithDetails(ctx) | ||
| 132 | if err != nil { | 139 | if err != nil { | ||
| 133 | return fmt.Errorf("failed to list routes: %w", err) | 140 | return fmt.Errorf("failed to list routes: %w", err) | ||
| @@ -144,6 +151,45 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error { | |||||
| 144 | health = make(map[string]*remote.RouteHealth) | 151 | health = make(map[string]*remote.RouteHealth) | ||
| 145 | } | 152 | } | ||
| 146 | 153 | ||||
| 154 | + | if jsonOut { | |||
| 155 | + | payload := make([]map[string]any, 0, len(routes)) | |||
| 156 | + | for _, route := range routes { | |||
| 157 | + | routeHealth := health[route.Domain] | |||
| 158 | + | containerStatus := route.ContainerStatus | |||
| 159 | + | if containerStatus == "" { | |||
| 160 | + | if routeHealth != nil { | |||
| 161 | + | containerStatus = routeHealth.ContainerStatus | |||
| 162 | + | } else { | |||
| 163 | + | containerStatus = "unknown" | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | httpStatus := 0 | |||
| 168 | + | if routeHealth != nil { | |||
| 169 | + | httpStatus = routeHealth.HTTPStatus | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | attachments := make([]map[string]string, 0, len(route.Attachments)) | |||
| 173 | + | for _, attachment := range route.Attachments { | |||
| 174 | + | attachments = append(attachments, map[string]string{ | |||
| 175 | + | "name": attachment.Name, | |||
| 176 | + | "image": attachment.Image, | |||
| 177 | + | "status": attachment.Status, | |||
| 178 | + | }) | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | payload = append(payload, map[string]any{ | |||
| 182 | + | "domain": route.Domain, | |||
| 183 | + | "image": route.Image, | |||
| 184 | + | "container_status": containerStatus, | |||
| 185 | + | "network": route.Network, | |||
| 186 | + | "http_status": httpStatus, | |||
| 187 | + | "attachments": attachments, | |||
| 188 | + | }) | |||
| 189 | + | } | |||
| 190 | + | return writeJSON(out, payload) | |||
| 191 | + | } | |||
| 192 | + | ||||
| 147 | const imageColWidth = 35 | 193 | const imageColWidth = 35 | ||
| 148 | const networkColWidth = 22 | 194 | const networkColWidth = 22 | ||
| 149 | rows := make([][]string, 0, len(routes)) | 195 | rows := make([][]string, 0, len(routes)) | ||
| @@ -207,7 +253,7 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error { | |||||
| 207 | } | 253 | } | ||
| 208 | 254 | ||||
| 209 | // runRoutesListLocal lists routes from local configuration. | 255 | // runRoutesListLocal lists routes from local configuration. | ||
| 210 | - | func runRoutesListLocal(ctx context.Context, cfgPath string) error { | 256 | + | func runRoutesListLocal(ctx context.Context, cfgPath string, jsonOut bool, out io.Writer) error { |
| 211 | local, err := GetLocalServices(cfgPath) | 257 | local, err := GetLocalServices(cfgPath) | ||
| 212 | if err != nil { | 258 | if err != nil { | ||
| 213 | return fmt.Errorf("failed to initialize local services: %w", err) | 259 | return fmt.Errorf("failed to initialize local services: %w", err) | ||
| @@ -215,6 +261,17 @@ func runRoutesListLocal(ctx context.Context, cfgPath string) error { | |||||
| 215 | 261 | ||||
| 216 | routes := local.GetConfigService().GetRoutes(ctx) | 262 | routes := local.GetConfigService().GetRoutes(ctx) | ||
| 217 | 263 | ||||
| 264 | + | if jsonOut { | |||
| 265 | + | payload := make([]map[string]string, 0, len(routes)) | |||
| 266 | + | for _, route := range routes { | |||
| 267 | + | payload = append(payload, map[string]string{ | |||
| 268 | + | "domain": route.Domain, | |||
| 269 | + | "image": route.Image, | |||
| 270 | + | }) | |||
| 271 | + | } | |||
| 272 | + | return writeJSON(out, payload) | |||
| 273 | + | } | |||
| 274 | + | ||||
| 218 | if len(routes) == 0 { | 275 | if len(routes) == 0 { | ||
| 219 | fmt.Println(styles.Theme.Muted.Render("No routes configured")) | 276 | fmt.Println(styles.Theme.Muted.Render("No routes configured")) | ||
| 220 | return nil | 277 | return nil | ||
M internal/adapters/in/cli/secrets.go +19 -3
| @@ -35,7 +35,9 @@ these commands operate on the remote server.`, | |||
| 35 | 35 | ||
| 36 | 36 | // newSecretsListCmd creates the secrets list command. | |
| 37 | 37 | func newSecretsListCmd() *cobra.Command { | |
| 38 | - | return &cobra.Command{ | |
| 38 | + | var jsonOut bool | |
| 39 | + | ||
| 40 | + | cmd := &cobra.Command{ | |
| 39 | 41 | Use: "list <domain>", | |
| 40 | 42 | Short: "List secrets for a domain", | |
| 41 | 43 | Long: `List all secret keys configured for a domain. | |
| @@ -47,12 +49,18 @@ Examples: | |||
| 47 | 49 | gordon secrets list app.mydomain.com | |
| 48 | 50 | gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`, | |
| 49 | 51 | Args: cobra.ExactArgs(1), | |
| 50 | - | RunE: runSecretsListCmd, | |
| 52 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 53 | + | return runSecretsListCmd(cmd, args, jsonOut) | |
| 54 | + | }, | |
| 51 | 55 | } | |
| 56 | + | ||
| 57 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 58 | + | ||
| 59 | + | return cmd | |
| 52 | 60 | } | |
| 53 | 61 | ||
| 54 | 62 | // runSecretsListCmd executes the secrets list command. | |
| 55 | - | func runSecretsListCmd(_ *cobra.Command, args []string) error { | |
| 63 | + | func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error { | |
| 56 | 64 | ctx := context.Background() | |
| 57 | 65 | secretDomain := args[0] | |
| 58 | 66 | ||
| @@ -71,6 +79,14 @@ func runSecretsListCmd(_ *cobra.Command, args []string) error { | |||
| 71 | 79 | return nil | |
| 72 | 80 | } | |
| 73 | 81 | ||
| 82 | + | if jsonOut { | |
| 83 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |
| 84 | + | "domain": secretDomain, | |
| 85 | + | "keys": keys, | |
| 86 | + | "attachments": attachments, | |
| 87 | + | }) | |
| 88 | + | } | |
| 89 | + | ||
| 74 | 90 | title := fmt.Sprintf("Secrets for %s", secretDomain) | |
| 75 | 91 | if !isRemote { | |
| 76 | 92 | title = fmt.Sprintf("Secrets for %s (local)", secretDomain) | |
| @@ -35,7 +35,9 @@ these commands operate on the remote server.`, | |||||
| 35 | 35 | ||||
| 36 | // newSecretsListCmd creates the secrets list command. | 36 | // newSecretsListCmd creates the secrets list command. | ||
| 37 | func newSecretsListCmd() *cobra.Command { | 37 | func newSecretsListCmd() *cobra.Command { | ||
| 38 | - | return &cobra.Command{ | 38 | + | var jsonOut bool |
| 39 | + | ||||
| 40 | + | cmd := &cobra.Command{ | |||
| 39 | Use: "list <domain>", | 41 | Use: "list <domain>", | ||
| 40 | Short: "List secrets for a domain", | 42 | Short: "List secrets for a domain", | ||
| 41 | Long: `List all secret keys configured for a domain. | 43 | Long: `List all secret keys configured for a domain. | ||
| @@ -47,12 +49,18 @@ Examples: | |||||
| 47 | gordon secrets list app.mydomain.com | 49 | gordon secrets list app.mydomain.com | ||
| 48 | gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`, | 50 | gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`, | ||
| 49 | Args: cobra.ExactArgs(1), | 51 | Args: cobra.ExactArgs(1), | ||
| 50 | - | RunE: runSecretsListCmd, | 52 | + | RunE: func(cmd *cobra.Command, args []string) error { |
| 53 | + | return runSecretsListCmd(cmd, args, jsonOut) | |||
| 54 | + | }, | |||
| 51 | } | 55 | } | ||
| 56 | + | ||||
| 57 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 58 | + | ||||
| 59 | + | return cmd | |||
| 52 | } | 60 | } | ||
| 53 | 61 | ||||
| 54 | // runSecretsListCmd executes the secrets list command. | 62 | // runSecretsListCmd executes the secrets list command. | ||
| 55 | - | func runSecretsListCmd(_ *cobra.Command, args []string) error { | 63 | + | func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error { |
| 56 | ctx := context.Background() | 64 | ctx := context.Background() | ||
| 57 | secretDomain := args[0] | 65 | secretDomain := args[0] | ||
| 58 | 66 | ||||
| @@ -71,6 +79,14 @@ func runSecretsListCmd(_ *cobra.Command, args []string) error { | |||||
| 71 | return nil | 79 | return nil | ||
| 72 | } | 80 | } | ||
| 73 | 81 | ||||
| 82 | + | if jsonOut { | |||
| 83 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |||
| 84 | + | "domain": secretDomain, | |||
| 85 | + | "keys": keys, | |||
| 86 | + | "attachments": attachments, | |||
| 87 | + | }) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 74 | title := fmt.Sprintf("Secrets for %s", secretDomain) | 90 | title := fmt.Sprintf("Secrets for %s", secretDomain) | ||
| 75 | if !isRemote { | 91 | if !isRemote { | ||
| 76 | title = fmt.Sprintf("Secrets for %s (local)", secretDomain) | 92 | title = fmt.Sprintf("Secrets for %s (local)", secretDomain) | ||
M internal/adapters/in/cli/ui_adoption_test.go +1 -1
| @@ -202,7 +202,7 @@ func TestUIAdoptionRuntimeSeams(t *testing.T) { | |||
| 202 | 202 | } | |
| 203 | 203 | ||
| 204 | 204 | imgBuf := new(bytes.Buffer) | |
| 205 | - | if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf); err != nil { | |
| 205 | + | if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf, false); err != nil { | |
| 206 | 206 | t.Fatalf("runImagesList failed: %v", err) | |
| 207 | 207 | } | |
| 208 | 208 | ||
| @@ -202,7 +202,7 @@ func TestUIAdoptionRuntimeSeams(t *testing.T) { | |||||
| 202 | } | 202 | } | ||
| 203 | 203 | ||||
| 204 | imgBuf := new(bytes.Buffer) | 204 | imgBuf := new(bytes.Buffer) | ||
| 205 | - | if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf); err != nil { | 205 | + | if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf, false); err != nil { |
| 206 | t.Fatalf("runImagesList failed: %v", err) | 206 | t.Fatalf("runImagesList failed: %v", err) | ||
| 207 | } | 207 | } | ||
| 208 | 208 | ||||