v3.4.0 593c1c3

remotes.go RawHistoryFolder

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 daemon status  # 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}

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