detached 2b8d4f7

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
33 import (
44 "context"
55 "fmt"
6+ "io"
67 "sort"
78
89 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -37,7 +38,9 @@ the local Gordon configuration.`,
3738
3839 // newAttachmentsListCmd creates the attachments list command.
3940 func newAttachmentsListCmd() *cobra.Command {
40- return &cobra.Command{
41+ var jsonOut bool
42+
43+ cmd := &cobra.Command{
4144 Use: "list [domain-or-group]",
4245 Short: "List configured attachments",
4346 Long: `List all configured attachments, or attachments for a specific domain/group.
@@ -56,15 +59,19 @@ Examples:
5659
5760 client, isRemote := GetRemoteClient()
5861 if isRemote {
59- return runAttachmentsListRemote(ctx, client, target)
62+ return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout())
6063 }
61- return runAttachmentsListLocal(ctx, configPath, target)
64+ return runAttachmentsListLocal(ctx, configPath, target, jsonOut, cmd.OutOrStdout())
6265 },
6366 }
67+
68+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
69+
70+ return cmd
6471 }
6572
6673 // 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 {
6875 if target != "" {
6976 // List for specific target
7077 images, err := client.GetAttachmentsConfig(ctx, target)
@@ -77,6 +84,10 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target
7784 return nil
7885 }
7986
87+ if jsonOut {
88+ return writeJSON(out, map[string]any{"target": target, "images": images})
89+ }
90+
8091 fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target)))
8192 fmt.Println()
8293 for _, img := range images {
@@ -96,6 +107,10 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target
96107 return nil
97108 }
98109
110+ if jsonOut {
111+ return writeJSON(out, map[string]any{"attachments": attachments})
112+ }
113+
99114 // Sort targets for consistent output
100115 targets := make([]string, 0, len(attachments))
101116 for t := range attachments {
@@ -118,7 +133,7 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target
118133 }
119134
120135 // 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 {
122137 local, err := GetLocalServices(cfgPath)
123138 if err != nil {
124139 return fmt.Errorf("failed to initialize local services: %w", err)
@@ -136,6 +151,10 @@ func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string)
136151 return nil
137152 }
138153
154+ if jsonOut {
155+ return writeJSON(out, map[string]any{"target": target, "images": images})
156+ }
157+
139158 fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target)))
140159 fmt.Println()
141160 for _, img := range images {
@@ -152,6 +171,10 @@ func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string)
152171 return nil
153172 }
154173
174+ if jsonOut {
175+ return writeJSON(out, map[string]any{"attachments": attachments})
176+ }
177+
155178 // Sort targets for consistent output
156179 targets := make([]string, 0, len(attachments))
157180 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 target76 // 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 nil84 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 nil107 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 output114 // 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 nil151 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 nil171 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 output178 // 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 (
44 "bufio"
55 "context"
66 "fmt"
7+ "io"
78 "os"
89 "strings"
910 "time"
@@ -130,18 +131,22 @@ Use --expiry=0 for a token that never expires (useful for CI).`,
130131
131132 // newTokenListCmd creates the token list command.
132133 func newTokenListCmd() *cobra.Command {
133- var configPath string
134+ var (
135+ configPath string
136+ jsonOut bool
137+ )
134138
135139 cmd := &cobra.Command{
136140 Use: "list",
137141 Short: "List all authentication tokens",
138142 Long: `List all stored authentication tokens with their subjects and expiry information.`,
139143 RunE: func(cmd *cobra.Command, args []string) error {
140- return runTokenList(configPath)
144+ return runTokenList(configPath, cmd.OutOrStdout(), jsonOut)
141145 },
142146 }
143147
144148 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
149+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
145150
146151 return cmd
147152 }
@@ -514,7 +519,7 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
514519 }
515520
516521 // runTokenList lists all stored tokens.
517-func runTokenList(configPath string) error {
522+func runTokenList(configPath string, out io.Writer, jsonOut bool) error {
518523 cfg, err := loadAuthConfig(configPath)
519524 if err != nil {
520525 return err
@@ -533,10 +538,31 @@ func runTokenList(configPath string) error {
533538 }
534539
535540 if len(tokens) == 0 {
541+ if jsonOut {
542+ return writeJSON(out, []map[string]any{})
543+ }
536544 fmt.Println("No tokens found.")
537545 return nil
538546 }
539547
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+
540566 fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked")
541567 fmt.Println(strings.Repeat("-", 90))
542568
@@ -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 string134+ 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 cmd151 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 err525 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 nil545 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
22
33 import (
44 "fmt"
5+ "io"
56 "os"
67 "text/tabwriter"
78 "time"
89
10+ "github.com/bnema/gordon/internal/adapters/dto"
911 "github.com/spf13/cobra"
1012 )
1113
@@ -30,7 +32,10 @@ instance when --remote targeting is configured.`,
3032 }
3133
3234 func newBackupListCmd() *cobra.Command {
33- return &cobra.Command{
35+ var jsonOut bool
36+ _ = cliRenderTitle("Backups")
37+
38+ cmd := &cobra.Command{
3439 Use: "list [domain]",
3540 Short: "List backups",
3641 Args: cobra.MaximumNArgs(1),
@@ -51,32 +56,43 @@ func newBackupListCmd() *cobra.Command {
5156 return fmt.Errorf("failed to list backups: %w", err)
5257 }
5358
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+ }
6762
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")
7664
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+ }
7994 }
95+ return w.Flush()
8096 }
8197
8298 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 err61+ }
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 nil65+ 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.`,
8181 }
8282
8383 func newImagesListCmd() *cobra.Command {
84- return &cobra.Command{
84+ var jsonOut bool
85+
86+ cmd := &cobra.Command{
8587 Use: "list",
8688 Short: "List runtime images and registry tags",
8789 RunE: func(cmd *cobra.Command, _ []string) error {
@@ -90,9 +92,13 @@ func newImagesListCmd() *cobra.Command {
9092 return fmt.Errorf("images commands require a configured remote target")
9193 }
9294
93- return runImagesList(cmd.Context(), client, cmd.OutOrStdout())
95+ return runImagesList(cmd.Context(), client, cmd.OutOrStdout(), jsonOut)
9496 },
9597 }
98+
99+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
100+
101+ return cmd
96102 }
97103
98104 func newImagesPruneCmd() *cobra.Command {
@@ -191,12 +197,16 @@ func pruneWithSpinner(ctx context.Context, client imagesClient, req dto.ImagePru
191197 }
192198 }
193199
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 {
195201 images, err := client.ListImages(ctx)
196202 if err != nil {
197203 return fmt.Errorf("failed to list images: %w", err)
198204 }
199205
206+ if jsonOut {
207+ return writeJSON(out, images)
208+ }
209+
200210 if len(images) == 0 {
201211 if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil {
202212 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 err212 return err
M internal/adapters/in/cli/images_test.go +5 -5
@@ -58,7 +58,7 @@ func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) {
5858 }
5959
6060 var out bytes.Buffer
61- err := runImagesList(context.Background(), client, &out)
61+ err := runImagesList(context.Background(), client, &out, false)
6262 require.NoError(t, err)
6363
6464 text := out.String()
@@ -78,7 +78,7 @@ func TestRunImagesList_HeaderOrderAndSummaryLine(t *testing.T) {
7878 }
7979
8080 var out bytes.Buffer
81- err := runImagesList(context.Background(), client, &out)
81+ err := runImagesList(context.Background(), client, &out, false)
8282 require.NoError(t, err)
8383
8484 text := stripANSI(out.String())
@@ -103,7 +103,7 @@ func TestRunImagesList_TruncatesLongValuesAndKeepsBoundedRowShape(t *testing.T)
103103 }
104104
105105 var out bytes.Buffer
106- err := runImagesList(context.Background(), client, &out)
106+ err := runImagesList(context.Background(), client, &out, false)
107107 require.NoError(t, err)
108108
109109 text := stripANSI(out.String())
@@ -145,7 +145,7 @@ func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T)
145145 }
146146
147147 var out bytes.Buffer
148- err := runImagesList(context.Background(), client, &out)
148+ err := runImagesList(context.Background(), client, &out, false)
149149 require.NoError(t, err)
150150
151151 text := stripANSI(out.String())
@@ -177,7 +177,7 @@ func TestRunImagesList_EmptyOutput(t *testing.T) {
177177 client := &fakeImagesClient{listImagesResp: []dto.Image{}}
178178
179179 var out bytes.Buffer
180- err := runImagesList(context.Background(), client, &out)
180+ err := runImagesList(context.Background(), client, &out, false)
181181 require.NoError(t, err)
182182
183183 text := out.String()
@@ -58,7 +58,7 @@ func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) {
58 }58 }
59 59
60 var out bytes.Buffer60 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.Buffer80 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.Buffer105 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.Buffer147 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.Buffer179 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
22
33 import (
44 "fmt"
5+ "io"
6+ "sort"
57 "strings"
68
79 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -35,67 +37,96 @@ Configuration is stored in ~/.config/gordon/remotes.toml`,
3537
3638 // newRemotesListCmd creates the remotes list command.
3739 func newRemotesListCmd() *cobra.Command {
38- return &cobra.Command{
40+ var jsonOut bool
41+
42+ cmd := &cobra.Command{
3943 Use: "list",
4044 Short: "List saved remotes",
4145 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+ }
6549
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")
7351
74- tlsStatus := ""
75- if r.InsecureTLS {
76- tlsStatus = styles.Theme.Warning.Render("insecure")
77- }
52+ return cmd
53+}
7854
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+ }
8160
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+ }
9371
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+ }
9590
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})
98115 }
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
99130 }
100131
101132 // 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 present50+ 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 table61+ 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 nil91+ 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:
4242 }
4343
4444 func newRollbackListCmd() *cobra.Command {
45- return &cobra.Command{
45+ var jsonOut bool
46+
47+ cmd := &cobra.Command{
4648 Use: "list <domain>",
4749 Short: "List available rollback tags",
4850 Args: cobra.ExactArgs(1),
4951 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)
5153 },
5254 }
55+
56+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
57+
58+ return cmd
5359 }
5460
5561 func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
@@ -87,7 +93,7 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
8793 return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag)
8894 }
8995
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 {
9197 handle, err := resolveControlPlane(configPath)
9298 if err != nil {
9399 return err
@@ -109,10 +115,14 @@ func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string) er
109115 return err
110116 }
111117
112- return printRollbackTags(w, rollbackDomain, currentTag, tags)
118+ return printRollbackTags(w, rollbackDomain, currentTag, tags, jsonOut)
113119 }
114120
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+
116126 if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(rollbackDomain)); err != nil {
117127 return err
118128 }
@@ -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 err99 return err
@@ -109,10 +115,14 @@ func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string) er
109 return err115 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 err127 return err
118 }128 }
M internal/adapters/in/cli/rollback_test.go +2 -2
@@ -63,7 +63,7 @@ func TestValidateTagExists(t *testing.T) {
6363 func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) {
6464 var buf bytes.Buffer
6565
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)
6767 assert.NoError(t, err)
6868
6969 output := buf.String()
@@ -74,7 +74,7 @@ func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) {
7474 }
7575
7676 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)
7878 assert.Error(t, err)
7979 assert.Contains(t, err.Error(), "write failed")
8080 }
@@ -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.Buffer64 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
33 import (
44 "context"
55 "fmt"
6+ "io"
67 "strings"
78
89 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -111,7 +112,9 @@ func formatHTTPStatus(health *remote.RouteHealth) string {
111112
112113 // newRoutesListCmd creates the routes list command.
113114 func newRoutesListCmd() *cobra.Command {
114- return &cobra.Command{
115+ var jsonOut bool
116+
117+ cmd := &cobra.Command{
115118 Use: "list",
116119 Short: "List all routes",
117120 RunE: func(cmd *cobra.Command, args []string) error {
@@ -119,15 +122,19 @@ func newRoutesListCmd() *cobra.Command {
119122
120123 client, isRemote := GetRemoteClient()
121124 if isRemote {
122- return runRoutesListRemote(ctx, client)
125+ return runRoutesListRemote(ctx, client, jsonOut, cmd.OutOrStdout())
123126 }
124- return runRoutesListLocal(ctx, configPath)
127+ return runRoutesListLocal(ctx, configPath, jsonOut, cmd.OutOrStdout())
125128 },
126129 }
130+
131+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
132+
133+ return cmd
127134 }
128135
129136 // 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 {
131138 routes, err := client.ListRoutesWithDetails(ctx)
132139 if err != nil {
133140 return fmt.Errorf("failed to list routes: %w", err)
@@ -144,6 +151,45 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
144151 health = make(map[string]*remote.RouteHealth)
145152 }
146153
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+
147193 const imageColWidth = 35
148194 const networkColWidth = 22
149195 rows := make([][]string, 0, len(routes))
@@ -207,7 +253,7 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
207253 }
208254
209255 // 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 {
211257 local, err := GetLocalServices(cfgPath)
212258 if err != nil {
213259 return fmt.Errorf("failed to initialize local services: %w", err)
@@ -215,6 +261,17 @@ func runRoutesListLocal(ctx context.Context, cfgPath string) error {
215261
216262 routes := local.GetConfigService().GetRoutes(ctx)
217263
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+
218275 if len(routes) == 0 {
219276 fmt.Println(styles.Theme.Muted.Render("No routes configured"))
220277 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 = 35193 const imageColWidth = 35
148 const networkColWidth = 22194 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 nil277 return nil
M internal/adapters/in/cli/secrets.go +19 -3
@@ -35,7 +35,9 @@ these commands operate on the remote server.`,
3535
3636 // newSecretsListCmd creates the secrets list command.
3737 func newSecretsListCmd() *cobra.Command {
38- return &cobra.Command{
38+ var jsonOut bool
39+
40+ cmd := &cobra.Command{
3941 Use: "list <domain>",
4042 Short: "List secrets for a domain",
4143 Long: `List all secret keys configured for a domain.
@@ -47,12 +49,18 @@ Examples:
4749 gordon secrets list app.mydomain.com
4850 gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`,
4951 Args: cobra.ExactArgs(1),
50- RunE: runSecretsListCmd,
52+ RunE: func(cmd *cobra.Command, args []string) error {
53+ return runSecretsListCmd(cmd, args, jsonOut)
54+ },
5155 }
56+
57+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
58+
59+ return cmd
5260 }
5361
5462 // 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 {
5664 ctx := context.Background()
5765 secretDomain := args[0]
5866
@@ -71,6 +79,14 @@ func runSecretsListCmd(_ *cobra.Command, args []string) error {
7179 return nil
7280 }
7381
82+ if jsonOut {
83+ return writeJSON(cmd.OutOrStdout(), map[string]any{
84+ "domain": secretDomain,
85+ "keys": keys,
86+ "attachments": attachments,
87+ })
88+ }
89+
7490 title := fmt.Sprintf("Secrets for %s", secretDomain)
7591 if !isRemote {
7692 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.com49 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 nil79 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) {
202202 }
203203
204204 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 {
206206 t.Fatalf("runImagesList failed: %v", err)
207207 }
208208
@@ -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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close