1package cli
2
3import (
4 "fmt"
5 "io"
6 "sort"
7 "strings"
8
9 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
10 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
11 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
12
13 "github.com/spf13/cobra"
14)
15
16// newRemotesCmd creates the remotes command group.
17func newRemotesCmd() *cobra.Command {
18 cmd := &cobra.Command{
19 Use: "remotes",
20 Short: "Manage saved remote Gordon instances",
21 Long: `Manage saved remote Gordon instances.
22
23Remotes allow you to save frequently used remote Gordon instances
24and quickly switch between them.
25
26Configuration is stored in ~/.config/gordon/remotes.toml`,
27 }
28
29 cmd.AddCommand(newRemotesListCmd())
30 cmd.AddCommand(newRemotesAddCmd())
31 cmd.AddCommand(newRemotesRemoveCmd())
32 cmd.AddCommand(newRemotesUseCmd())
33 cmd.AddCommand(newRemotesSetTokenCmd())
34
35 return cmd
36}
37
38// newRemotesListCmd creates the remotes list command.
39func newRemotesListCmd() *cobra.Command {
40 var jsonOut bool
41
42 cmd := &cobra.Command{
43 Use: "list",
44 Short: "List saved remotes",
45 RunE: func(cmd *cobra.Command, args []string) error {
46 return runRemotesList(cmd.OutOrStdout(), jsonOut)
47 },
48 }
49
50 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
51
52 return cmd
53}
54
55func 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 }
60
61 if len(remotes) == 0 {
62 return remotesListEmpty(out, jsonOut)
63 }
64
65 names := sortedRemoteNames(remotes)
66
67 if jsonOut {
68 return remotesListJSON(out, remotes, names, active)
69 }
70
71 if err := cliWriteLine(out, cliRenderTitle("Saved Remotes")); err != nil {
72 return err
73 }
74 if err := cliWriteLine(out, ""); err != nil {
75 return err
76 }
77
78 rows := make([][]string, 0, len(names))
79 for _, name := range names {
80 r := remotes[name]
81 status := ""
82 if name == active {
83 status = styles.Theme.Success.Render("active")
84 }
85
86 tokenStatus := styles.Theme.Muted.Render("none")
87 if token := remote.ResolveTokenForRemote(name, r); token != "" {
88 tokenStatus = styles.Theme.Success.Render("set")
89 } else if r.TokenEnv != "" {
90 tokenStatus = fmt.Sprintf("$%s", r.TokenEnv)
91 }
92
93 tlsStatus := ""
94 if r.InsecureTLS {
95 tlsStatus = styles.Theme.Warning.Render("insecure")
96 }
97
98 rows = append(rows, []string{name, r.URL, tokenStatus, tlsStatus, status})
99 }
100
101 table := components.NewTable(
102 components.WithColumns([]components.TableColumn{
103 {Title: "Name", Width: 15},
104 {Title: "URL", Width: 35},
105 {Title: "Token", Width: 15},
106 {Title: "TLS", Width: 10},
107 {Title: "Status", Width: 10},
108 }),
109 components.WithRows(rows),
110 )
111
112 _, err = fmt.Fprintln(out, table.View())
113 return err
114}
115
116func remotesListEmpty(out io.Writer, jsonOut bool) error {
117 if jsonOut {
118 return writeJSON(out, []map[string]any{})
119 }
120 if err := cliWriteLine(out, cliRenderMuted("No remotes configured")); err != nil {
121 return err
122 }
123 if err := cliWriteLine(out, ""); err != nil {
124 return err
125 }
126 if err := cliWriteLine(out, "Add a remote with:"); err != nil {
127 return err
128 }
129 return cliWriteLine(out, styles.Theme.Bold.Render(" gordon remotes add <name> <url>"))
130}
131
132func sortedRemoteNames(remotes map[string]remote.RemoteEntry) []string {
133 names := make([]string, 0, len(remotes))
134 for name := range remotes {
135 names = append(names, name)
136 }
137 sort.Strings(names)
138 return names
139}
140
141func remotesListJSON(out io.Writer, remotes map[string]remote.RemoteEntry, names []string, active string) error {
142 payload := make([]map[string]any, 0, len(names))
143 for _, name := range names {
144 r := remotes[name]
145 payload = append(payload, map[string]any{
146 "name": name,
147 "url": r.URL,
148 "active": name == active,
149 "insecure_tls": r.InsecureTLS,
150 })
151 }
152 return writeJSON(out, payload)
153}
154
155// newRemotesAddCmd creates the remotes add command.
156func newRemotesAddCmd() *cobra.Command {
157 var tokenFile string
158 var tokenEnv string
159 var insecureTLS bool
160 var jsonOut bool
161
162 cmd := &cobra.Command{
163 Use: "add <name> <url>",
164 Short: "Add a new remote",
165 Long: `Add a new saved remote.
166
167Examples:
168 gordon remotes add prod https://gordon.mydomain.com
169 gordon remotes add prod https://gordon.mydomain.com --token-file ./token
170 gordon remotes add staging https://staging.mydomain.com --token-env STAGING_TOKEN
171 gordon remotes add dev https://dev.internal --insecure`,
172 Args: cobra.ExactArgs(2),
173 RunE: func(cmd *cobra.Command, args []string) error {
174 name := args[0]
175 url := normalizeURL(args[1])
176
177 var finalToken string
178 if tokenFile != "" {
179 var err error
180 finalToken, err = readProtectedSecretFile(tokenFile)
181 if err != nil {
182 return fmt.Errorf("read token: %w", err)
183 }
184 }
185 if tokenEnv != "" {
186 // Store token_env reference instead of actual token
187 if err := addRemoteWithEnv(name, url, tokenEnv, insecureTLS); err != nil {
188 return fmt.Errorf("failed to add remote: %w", err)
189 }
190 } else {
191 if err := remote.AddRemote(name, url, finalToken, insecureTLS); err != nil {
192 return fmt.Errorf("failed to add remote: %w", err)
193 }
194 }
195
196 if jsonOut {
197 return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "url": url, "added": true})
198 }
199 if err := cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url))); err != nil {
200 return err
201 }
202 if tokenFile == "" && tokenEnv == "" {
203 return cliWriteLine(cmd.OutOrStdout(), styles.Theme.Muted.Render("Tip: Add a token with --token-file or --token-env for authenticated access"))
204 }
205 return nil
206 },
207 }
208
209 cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
210 cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token")
211 cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification")
212 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
213
214 return cmd
215}
216
217// addRemoteWithEnv adds a remote with a token environment variable reference.
218func addRemoteWithEnv(name, url, tokenEnv string, insecureTLS bool) error {
219 config, err := remote.LoadRemotes("")
220 if err != nil {
221 return err
222 }
223
224 config.Remotes[name] = remote.RemoteEntry{
225 URL: url,
226 TokenEnv: tokenEnv,
227 InsecureTLS: insecureTLS,
228 }
229
230 return remote.SaveRemotes("", config)
231}
232
233// newRemotesRemoveCmd creates the remotes remove command.
234func newRemotesRemoveCmd() *cobra.Command {
235 var force bool
236
237 cmd := &cobra.Command{
238 Use: "remove <name>",
239 Aliases: []string{"rm"},
240 Short: "Remove a remote",
241 Args: cobra.ExactArgs(1),
242 RunE: func(cmd *cobra.Command, args []string) error {
243 name := args[0]
244
245 // Check if remote exists
246 remotes, active, err := remote.ListRemotes()
247 if err != nil {
248 return fmt.Errorf("failed to list remotes: %w", err)
249 }
250
251 if _, exists := remotes[name]; !exists {
252 fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name)))
253 return nil
254 }
255
256 // Confirm unless --force
257 if !force {
258 message := fmt.Sprintf("Remove remote '%s'?", name)
259 if name == active {
260 message = fmt.Sprintf("Remove remote '%s'? (currently active)", name)
261 }
262
263 confirmed, err := components.RunConfirm(message)
264 if err != nil {
265 return err
266 }
267 if !confirmed {
268 fmt.Println(styles.Theme.Muted.Render("Cancelled"))
269 return nil
270 }
271 }
272
273 if err := remote.RemoveRemote(name); err != nil {
274 return fmt.Errorf("failed to remove remote: %w", err)
275 }
276
277 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote removed: %s", name)))
278 return nil
279 },
280 }
281
282 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
283
284 return cmd
285}
286
287// newRemotesUseCmd creates the remotes use command.
288func newRemotesUseCmd() *cobra.Command {
289 return &cobra.Command{
290 Use: "use <name>",
291 Short: "Set the active remote",
292 Long: `Set a remote as the active default.
293
294When a remote is active, it will be used automatically for remote commands
295without needing to specify --remote.
296
297Examples:
298 gordon remotes use prod
299 gordon routes list # Uses prod remote automatically`,
300 Args: cobra.ExactArgs(1),
301 RunE: func(cmd *cobra.Command, args []string) error {
302 name := args[0]
303
304 if err := remote.SetActiveRemote(name); err != nil {
305 if err.Error() == fmt.Sprintf("remote '%s' not found", name) {
306 fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name)))
307 fmt.Println()
308 fmt.Println("Available remotes:")
309
310 remotes, _, _ := remote.ListRemotes()
311 for remoteName := range remotes {
312 fmt.Printf(" %s\n", remoteName)
313 }
314 return nil
315 }
316 return fmt.Errorf("failed to set active remote: %w", err)
317 }
318
319 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Active remote set to '%s'", name)))
320 return nil
321 },
322 }
323}
324
325// newRemotesSetTokenCmd creates the remotes set-token command.
326func newRemotesSetTokenCmd() *cobra.Command {
327 var tokenFile string
328 var jsonOut bool
329 cmd := &cobra.Command{
330 Use: "set-token <name> --token-file <path>",
331 Short: "Set the token for a remote",
332 Long: `Set or update the authentication token for a saved remote.
333
334This is useful when:
335- The server uses token-based authentication (not password auth)
336- You have a pre-generated token from 'gordon auth token generate'
337- You want to update an expired token
338
339For servers with password authentication, use 'gordon auth login' instead.
340
341Examples:
342 gordon remotes set-token prod --token-file ./token`,
343 Args: cobra.ExactArgs(1),
344 RunE: func(cmd *cobra.Command, args []string) error {
345 name := args[0]
346 token, err := readProtectedSecretFile(tokenFile)
347 if err != nil {
348 return fmt.Errorf("read token: %w", err)
349 }
350
351 // Verify remote exists
352 remotes, _, err := remote.ListRemotes()
353 if err != nil {
354 return fmt.Errorf("failed to list remotes: %w", err)
355 }
356
357 if _, exists := remotes[name]; !exists {
358 if jsonOut {
359 return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": false, "error": "remote not found"})
360 }
361 return cliWriteLine(cmd.OutOrStdout(), styles.RenderError(fmt.Sprintf("Remote '%s' not found", name)))
362 }
363
364 if err := remote.UpdateRemoteToken(name, token); err != nil {
365 return fmt.Errorf("failed to update token: %w", err)
366 }
367
368 if jsonOut {
369 return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": true})
370 }
371 return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name)))
372 },
373 }
374 cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
375 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
376 _ = cmd.MarkFlagRequired("token-file")
377 return cmd
378}
379
380// normalizeURL ensures the URL has a protocol scheme, defaulting to https.
381func normalizeURL(url string) string {
382 if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
383 return url
384 }
385 return "https://" + url
386}