1package cli
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 "gordon/internal/adapters/in/cli/ui/components"
9 "gordon/internal/adapters/in/cli/ui/styles"
10
11 "github.com/spf13/cobra"
12)
13
14// newSecretsCmd creates the secrets command group.
15func newSecretsCmd() *cobra.Command {
16 cmd := &cobra.Command{
17 Use: "secrets",
18 Short: "Manage secrets",
19 Long: `Manage secrets (environment variables) for routes.
20
21Secrets are stored per-domain and injected into containers as environment variables.
22
23When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
24these commands operate on the remote server.`,
25 }
26
27 cmd.AddCommand(newSecretsListCmd())
28 cmd.AddCommand(newSecretsSetCmd())
29 cmd.AddCommand(newSecretsRemoveCmd())
30
31 return cmd
32}
33
34// newSecretsListCmd creates the secrets list command.
35func newSecretsListCmd() *cobra.Command {
36 return &cobra.Command{
37 Use: "list <domain>",
38 Short: "List secrets for a domain",
39 Long: `List all secret keys configured for a domain.
40
41Note: Only secret keys are shown, not values (for security).
42
43Examples:
44 gordon secrets list app.mydomain.com
45 gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`,
46 Args: cobra.ExactArgs(1),
47 RunE: func(cmd *cobra.Command, args []string) error {
48 ctx := context.Background()
49 secretDomain := args[0]
50
51 var keys []string
52 var err error
53
54 client, isRemote := GetRemoteClient()
55 if isRemote {
56 keys, err = client.ListSecrets(ctx, secretDomain)
57 } else {
58 local, localErr := GetLocalServices(configPath)
59 if localErr != nil {
60 return fmt.Errorf("failed to initialize local services: %w", localErr)
61 }
62 keys, err = local.GetSecretService().ListKeys(ctx, secretDomain)
63 }
64
65 if err != nil {
66 return fmt.Errorf("failed to list secrets: %w", err)
67 }
68
69 if len(keys) == 0 {
70 fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain)))
71 return nil
72 }
73
74 title := fmt.Sprintf("Secrets for %s", secretDomain)
75 if !isRemote {
76 title = fmt.Sprintf("Secrets for %s (local)", secretDomain)
77 }
78 fmt.Println(styles.Theme.Title.Render(title))
79 fmt.Println()
80
81 // Build table rows
82 rows := make([][]string, len(keys))
83 for i, key := range keys {
84 rows[i] = []string{key, styles.Theme.Muted.Render("(hidden)")}
85 }
86
87 // Render table
88 table := components.NewTable(
89 components.WithColumns([]components.TableColumn{
90 {Title: "Key", Width: 30},
91 {Title: "Value", Width: 20},
92 }),
93 components.WithRows(rows),
94 )
95
96 fmt.Println(table.View())
97
98 return nil
99 },
100 }
101}
102
103// newSecretsSetCmd creates the secrets set command.
104func newSecretsSetCmd() *cobra.Command {
105 return &cobra.Command{
106 Use: "set <domain> <KEY=value>...",
107 Short: "Set secrets for a domain",
108 Long: `Set one or more secrets for a domain.
109
110Secrets are specified as KEY=value pairs. Multiple secrets can be set at once.
111
112Examples:
113 gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db
114 gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false
115 gordon --remote https://gordon.mydomain.com secrets set api.mydomain.com TOKEN=abc`,
116 Args: cobra.MinimumNArgs(2),
117 RunE: func(cmd *cobra.Command, args []string) error {
118 ctx := context.Background()
119 secretDomain := args[0]
120 pairs := args[1:]
121
122 // Parse KEY=value pairs
123 secrets := make(map[string]string)
124 for _, pair := range pairs {
125 parts := strings.SplitN(pair, "=", 2)
126 if len(parts) != 2 {
127 fmt.Println(styles.RenderError(fmt.Sprintf("Invalid format: %s (expected KEY=value)", pair)))
128 return nil
129 }
130 secrets[parts[0]] = parts[1]
131 }
132
133 client, isRemote := GetRemoteClient()
134 if isRemote {
135 if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil {
136 return fmt.Errorf("failed to set secrets: %w", err)
137 }
138 } else {
139 local, err := GetLocalServices(configPath)
140 if err != nil {
141 return fmt.Errorf("failed to initialize local services: %w", err)
142 }
143 if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil {
144 return fmt.Errorf("failed to set secrets: %w", err)
145 }
146 }
147
148 if len(secrets) == 1 {
149 for key := range secrets {
150 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s", key)))
151 }
152 } else {
153 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), secretDomain)))
154 }
155
156 return nil
157 },
158 }
159}
160
161// newSecretsRemoveCmd creates the secrets remove command.
162func newSecretsRemoveCmd() *cobra.Command {
163 var force bool
164
165 cmd := &cobra.Command{
166 Use: "remove <domain> <key>",
167 Short: "Remove a secret",
168 Long: `Remove a secret from a domain.
169
170Examples:
171 gordon secrets remove app.mydomain.com OLD_API_KEY
172 gordon secrets remove app.mydomain.com OLD_API_KEY --force`,
173 Args: cobra.ExactArgs(2),
174 RunE: func(cmd *cobra.Command, args []string) error {
175 ctx := context.Background()
176 secretDomain := args[0]
177 key := args[1]
178
179 // Confirm unless --force
180 if !force {
181 confirmed, err := components.RunConfirm(
182 fmt.Sprintf("Remove secret '%s' from %s?", key, secretDomain),
183 )
184 if err != nil {
185 return err
186 }
187 if !confirmed {
188 fmt.Println(styles.Theme.Muted.Render("Cancelled"))
189 return nil
190 }
191 }
192
193 client, isRemote := GetRemoteClient()
194 if isRemote {
195 if err := client.DeleteSecret(ctx, secretDomain, key); err != nil {
196 return fmt.Errorf("failed to remove secret: %w", err)
197 }
198 } else {
199 local, err := GetLocalServices(configPath)
200 if err != nil {
201 return fmt.Errorf("failed to initialize local services: %w", err)
202 }
203 if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil {
204 return fmt.Errorf("failed to remove secret: %w", err)
205 }
206 }
207
208 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed: %s", key)))
209 return nil
210 },
211 }
212
213 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
214
215 return cmd
216}