detached 16515bf

feat(auth): add show-token and logout CLI commands

commit
16515bfe0380e2b8a17075e39231aeffc5317c1c
author
brice · · 2026-03-22 19:59 UTC
parent
8f94295
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +130 -0
M internal/adapters/in/cli/auth.go +94 -0
@@ -34,6 +34,8 @@ func newAuthCmd() *cobra.Command {
3434 cmd.AddCommand(newAuthInternalCmd())
3535 cmd.AddCommand(newAuthLoginCmd())
3636 cmd.AddCommand(newAuthStatusCmd())
37+ cmd.AddCommand(newAuthShowTokenCmd())
38+ cmd.AddCommand(newAuthLogoutCmd())
3739
3840 return cmd
3941 }
@@ -264,6 +266,98 @@ func runAuthLoginWithToken(remoteName, token string) error {
264266 return nil
265267 }
266268
269+// newAuthShowTokenCmd creates the show-token command.
270+func newAuthShowTokenCmd() *cobra.Command {
271+ var remoteName string
272+
273+ cmd := &cobra.Command{
274+ Use: "show-token",
275+ Short: "Print the stored token for a remote",
276+ Long: `Print the raw authentication token for a remote to stdout.
277+
278+The token is resolved from pass, TOML config, or environment variable
279+using the same precedence as other commands.
280+
281+Output is suitable for piping (no decoration, single line).
282+
283+Examples:
284+ gordon auth show-token Show token for active remote
285+ gordon auth show-token --remote prod Show token for specific remote
286+ gordon auth show-token | pbcopy Copy token to clipboard`,
287+ RunE: func(cmd *cobra.Command, args []string) error {
288+ return runAuthShowToken(remoteName)
289+ },
290+ }
291+
292+ cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to show token for (defaults to active remote)")
293+
294+ return cmd
295+}
296+
297+func runAuthShowToken(remoteName string) error {
298+ resolvedName, entry, err := resolveRemoteEntry(remoteName)
299+ if err != nil {
300+ return err
301+ }
302+
303+ token := remote.ResolveTokenForRemote(resolvedName, entry)
304+ if token == "" {
305+ return fmt.Errorf("no token found for remote '%s'", resolvedName)
306+ }
307+
308+ fmt.Println(token)
309+ return nil
310+}
311+
312+// newAuthLogoutCmd creates the logout command.
313+func newAuthLogoutCmd() *cobra.Command {
314+ var (
315+ remoteName string
316+ revoke bool
317+ )
318+
319+ cmd := &cobra.Command{
320+ Use: "logout",
321+ Short: "Remove stored authentication for a remote",
322+ Long: `Remove the stored token for a remote from pass and TOML config.
323+
324+This clears the token from all local storage but does not revoke the
325+token on the server. Use --revoke to also invalidate the token server-side
326+(not yet implemented).
327+
328+Examples:
329+ gordon auth logout Logout from active remote
330+ gordon auth logout --remote prod Logout from specific remote
331+ gordon auth logout --revoke Logout and revoke token on server`,
332+ RunE: func(cmd *cobra.Command, args []string) error {
333+ return runAuthLogout(remoteName, revoke)
334+ },
335+ }
336+
337+ cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to logout from (defaults to active remote)")
338+ cmd.Flags().BoolVar(&revoke, "revoke", false, "Also revoke the token on the server (not yet implemented)")
339+
340+ return cmd
341+}
342+
343+func runAuthLogout(remoteName string, revoke bool) error {
344+ resolvedName, _, err := resolveRemoteEntry(remoteName)
345+ if err != nil {
346+ return err
347+ }
348+
349+ if revoke {
350+ fmt.Println(styles.RenderWarning("Token revocation not yet implemented; clearing local credentials only"))
351+ }
352+
353+ if err := remote.ClearRemoteToken(resolvedName); err != nil {
354+ return fmt.Errorf("failed to clear token: %w", err)
355+ }
356+
357+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolvedName)))
358+ return nil
359+}
360+
267361 func resolveRemoteEntry(remoteName string) (string, remote.RemoteEntry, error) {
268362 config, err := remote.LoadRemotes("")
269363 if err != nil {
@@ -34,6 +34,8 @@ func newAuthCmd() *cobra.Command {
34 cmd.AddCommand(newAuthInternalCmd())34 cmd.AddCommand(newAuthInternalCmd())
35 cmd.AddCommand(newAuthLoginCmd())35 cmd.AddCommand(newAuthLoginCmd())
36 cmd.AddCommand(newAuthStatusCmd())36 cmd.AddCommand(newAuthStatusCmd())
37+ cmd.AddCommand(newAuthShowTokenCmd())
38+ cmd.AddCommand(newAuthLogoutCmd())
37 39
38 return cmd40 return cmd
39 }41 }
@@ -264,6 +266,98 @@ func runAuthLoginWithToken(remoteName, token string) error {
264 return nil266 return nil
265 }267 }
266 268
269+// newAuthShowTokenCmd creates the show-token command.
270+func newAuthShowTokenCmd() *cobra.Command {
271+ var remoteName string
272+
273+ cmd := &cobra.Command{
274+ Use: "show-token",
275+ Short: "Print the stored token for a remote",
276+ Long: `Print the raw authentication token for a remote to stdout.
277+
278+The token is resolved from pass, TOML config, or environment variable
279+using the same precedence as other commands.
280+
281+Output is suitable for piping (no decoration, single line).
282+
283+Examples:
284+ gordon auth show-token Show token for active remote
285+ gordon auth show-token --remote prod Show token for specific remote
286+ gordon auth show-token | pbcopy Copy token to clipboard`,
287+ RunE: func(cmd *cobra.Command, args []string) error {
288+ return runAuthShowToken(remoteName)
289+ },
290+ }
291+
292+ cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to show token for (defaults to active remote)")
293+
294+ return cmd
295+}
296+
297+func runAuthShowToken(remoteName string) error {
298+ resolvedName, entry, err := resolveRemoteEntry(remoteName)
299+ if err != nil {
300+ return err
301+ }
302+
303+ token := remote.ResolveTokenForRemote(resolvedName, entry)
304+ if token == "" {
305+ return fmt.Errorf("no token found for remote '%s'", resolvedName)
306+ }
307+
308+ fmt.Println(token)
309+ return nil
310+}
311+
312+// newAuthLogoutCmd creates the logout command.
313+func newAuthLogoutCmd() *cobra.Command {
314+ var (
315+ remoteName string
316+ revoke bool
317+ )
318+
319+ cmd := &cobra.Command{
320+ Use: "logout",
321+ Short: "Remove stored authentication for a remote",
322+ Long: `Remove the stored token for a remote from pass and TOML config.
323+
324+This clears the token from all local storage but does not revoke the
325+token on the server. Use --revoke to also invalidate the token server-side
326+(not yet implemented).
327+
328+Examples:
329+ gordon auth logout Logout from active remote
330+ gordon auth logout --remote prod Logout from specific remote
331+ gordon auth logout --revoke Logout and revoke token on server`,
332+ RunE: func(cmd *cobra.Command, args []string) error {
333+ return runAuthLogout(remoteName, revoke)
334+ },
335+ }
336+
337+ cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to logout from (defaults to active remote)")
338+ cmd.Flags().BoolVar(&revoke, "revoke", false, "Also revoke the token on the server (not yet implemented)")
339+
340+ return cmd
341+}
342+
343+func runAuthLogout(remoteName string, revoke bool) error {
344+ resolvedName, _, err := resolveRemoteEntry(remoteName)
345+ if err != nil {
346+ return err
347+ }
348+
349+ if revoke {
350+ fmt.Println(styles.RenderWarning("Token revocation not yet implemented; clearing local credentials only"))
351+ }
352+
353+ if err := remote.ClearRemoteToken(resolvedName); err != nil {
354+ return fmt.Errorf("failed to clear token: %w", err)
355+ }
356+
357+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolvedName)))
358+ return nil
359+}
360+
267 func resolveRemoteEntry(remoteName string) (string, remote.RemoteEntry, error) {361 func resolveRemoteEntry(remoteName string) (string, remote.RemoteEntry, error) {
268 config, err := remote.LoadRemotes("")362 config, err := remote.LoadRemotes("")
269 if err != nil {363 if err != nil {
M internal/adapters/in/cli/remote/config.go +24 -0
@@ -353,6 +353,30 @@ func SetActiveRemote(name string) error {
353353 return SaveRemotes("", config)
354354 }
355355
356+// ClearRemoteToken removes the token and token_env fields for a named remote
357+// from the remotes TOML config, and deletes the pass store entry if present.
358+func ClearRemoteToken(name string) error {
359+ // Delete from pass (best-effort, no error if pass unavailable)
360+ _ = passDeleteToken(name)
361+
362+ // Clear TOML fields
363+ config, err := LoadRemotes("")
364+ if err != nil {
365+ return err
366+ }
367+
368+ entry, ok := config.Remotes[name]
369+ if !ok {
370+ return fmt.Errorf("remote '%s' not found", name)
371+ }
372+
373+ entry.Token = ""
374+ entry.TokenEnv = ""
375+ config.Remotes[name] = entry
376+
377+ return SaveRemotes("", config)
378+}
379+
356380 // ListRemotes returns all saved remotes.
357381 func ListRemotes() (map[string]RemoteEntry, string, error) {
358382 config, err := LoadRemotes("")
@@ -353,6 +353,30 @@ func SetActiveRemote(name string) error {
353 return SaveRemotes("", config)353 return SaveRemotes("", config)
354 }354 }
355 355
356+// ClearRemoteToken removes the token and token_env fields for a named remote
357+// from the remotes TOML config, and deletes the pass store entry if present.
358+func ClearRemoteToken(name string) error {
359+ // Delete from pass (best-effort, no error if pass unavailable)
360+ _ = passDeleteToken(name)
361+
362+ // Clear TOML fields
363+ config, err := LoadRemotes("")
364+ if err != nil {
365+ return err
366+ }
367+
368+ entry, ok := config.Remotes[name]
369+ if !ok {
370+ return fmt.Errorf("remote '%s' not found", name)
371+ }
372+
373+ entry.Token = ""
374+ entry.TokenEnv = ""
375+ config.Remotes[name] = entry
376+
377+ return SaveRemotes("", config)
378+}
379+
356 // ListRemotes returns all saved remotes.380 // ListRemotes returns all saved remotes.
357 func ListRemotes() (map[string]RemoteEntry, string, error) {381 func ListRemotes() (map[string]RemoteEntry, string, error) {
358 config, err := LoadRemotes("")382 config, err := LoadRemotes("")
M internal/adapters/in/cli/remote/pass.go +12 -0
@@ -95,6 +95,18 @@ func passWriteToken(name, token string) error {
9595 return nil
9696 }
9797
98+func passDeleteToken(name string) error {
99+ if err := validateRemoteName(name); err != nil {
100+ return err
101+ }
102+ if !passAvailable() {
103+ return nil // nothing to delete
104+ }
105+
106+ cmd := exec.Command("pass", "rm", "-f", passTokenPath(name)) //nolint:gosec // pass binary name is constant; remote names are validated
107+ return cmd.Run()
108+}
109+
98110 func warnPassUnavailable() {
99111 passWarnOnce.Do(func() {
100112 fmt.Fprintf(os.Stderr, "Warning: 'pass' not available. Storing token in plaintext config. Consider installing pass (https://www.passwordstore.org/) for secure token storage.\n")
@@ -95,6 +95,18 @@ func passWriteToken(name, token string) error {
95 return nil95 return nil
96 }96 }
97 97
98+func passDeleteToken(name string) error {
99+ if err := validateRemoteName(name); err != nil {
100+ return err
101+ }
102+ if !passAvailable() {
103+ return nil // nothing to delete
104+ }
105+
106+ cmd := exec.Command("pass", "rm", "-f", passTokenPath(name)) //nolint:gosec // pass binary name is constant; remote names are validated
107+ return cmd.Run()
108+}
109+
98 func warnPassUnavailable() {110 func warnPassUnavailable() {
99 passWarnOnce.Do(func() {111 passWarnOnce.Do(func() {
100 fmt.Fprintf(os.Stderr, "Warning: 'pass' not available. Storing token in plaintext config. Consider installing pass (https://www.passwordstore.org/) for secure token storage.\n")112 fmt.Fprintf(os.Stderr, "Warning: 'pass' not available. Storing token in plaintext config. Consider installing pass (https://www.passwordstore.org/) for secure token storage.\n")

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