detached 38c0e79

fix: TTY warning on show-token, remove --revoke stub, fix tests for token exchange

commit
38c0e79b5d5cadc9cc62e74d8ba83030d3452dc6
author
brice · · 2026-03-23 05:24 UTC
parent
ff4c2dd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +38 -62
M internal/adapters/in/cli/auth.go +11 -15
@@ -4,10 +4,12 @@ import (
44 "context"
55 "fmt"
66 "io"
7+ "os"
78 "strings"
89 "time"
910
1011 "github.com/bnema/zerowrap"
12+ "github.com/mattn/go-isatty"
1113 "github.com/spf13/cobra"
1214 "github.com/spf13/viper"
1315
@@ -305,16 +307,17 @@ func runAuthShowToken(remoteName string, out io.Writer) error {
305307 return fmt.Errorf("no token found for remote '%s'", resolvedName)
306308 }
307309
310+ if f, ok := out.(*os.File); ok && isatty.IsTerminal(f.Fd()) {
311+ fmt.Fprintln(os.Stderr, styles.RenderWarning("Warning: token will be visible in terminal scrollback"))
312+ }
313+
308314 fmt.Fprintln(out, token)
309315 return nil
310316 }
311317
312318 // newAuthLogoutCmd creates the logout command.
313319 func newAuthLogoutCmd() *cobra.Command {
314- var (
315- remoteName string
316- revoke bool
317- )
320+ var remoteName string
318321
319322 cmd := &cobra.Command{
320323 Use: "logout",
@@ -322,34 +325,27 @@ func newAuthLogoutCmd() *cobra.Command {
322325 Long: `Remove the stored token for a remote from pass and TOML config.
323326
324327 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).
328+token on the server. To revoke server-side, use: gordon auth token revoke
327329
328330 Examples:
329331 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+ gordon auth logout --remote prod Logout from specific remote`,
332333 RunE: func(cmd *cobra.Command, args []string) error {
333- return runAuthLogout(remoteName, revoke, cmd.OutOrStdout())
334+ return runAuthLogout(remoteName, cmd.OutOrStdout())
334335 },
335336 }
336337
337338 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)")
339339
340340 return cmd
341341 }
342342
343-func runAuthLogout(remoteName string, revoke bool, out io.Writer) error {
343+func runAuthLogout(remoteName string, out io.Writer) error {
344344 resolvedName, _, err := resolveRemoteEntry(remoteName)
345345 if err != nil {
346346 return err
347347 }
348348
349- if revoke {
350- fmt.Fprintln(out, styles.RenderWarning("Token revocation not yet implemented; clearing local credentials only"))
351- }
352-
353349 if err := remote.ClearRemoteToken(resolvedName); err != nil {
354350 return fmt.Errorf("failed to clear token: %w", err)
355351 }
@@ -4,10 +4,12 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "io"6 "io"
7+ "os"
7 "strings"8 "strings"
8 "time"9 "time"
9 10
10 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
12+ "github.com/mattn/go-isatty"
11 "github.com/spf13/cobra"13 "github.com/spf13/cobra"
12 "github.com/spf13/viper"14 "github.com/spf13/viper"
13 15
@@ -305,16 +307,17 @@ func runAuthShowToken(remoteName string, out io.Writer) error {
305 return fmt.Errorf("no token found for remote '%s'", resolvedName)307 return fmt.Errorf("no token found for remote '%s'", resolvedName)
306 }308 }
307 309
310+ if f, ok := out.(*os.File); ok && isatty.IsTerminal(f.Fd()) {
311+ fmt.Fprintln(os.Stderr, styles.RenderWarning("Warning: token will be visible in terminal scrollback"))
312+ }
313+
308 fmt.Fprintln(out, token)314 fmt.Fprintln(out, token)
309 return nil315 return nil
310 }316 }
311 317
312 // newAuthLogoutCmd creates the logout command.318 // newAuthLogoutCmd creates the logout command.
313 func newAuthLogoutCmd() *cobra.Command {319 func newAuthLogoutCmd() *cobra.Command {
314- var (320+ var remoteName string
315- remoteName string
316- revoke bool
317- )
318 321
319 cmd := &cobra.Command{322 cmd := &cobra.Command{
320 Use: "logout",323 Use: "logout",
@@ -322,34 +325,27 @@ func newAuthLogoutCmd() *cobra.Command {
322 Long: `Remove the stored token for a remote from pass and TOML config.325 Long: `Remove the stored token for a remote from pass and TOML config.
323 326
324 This clears the token from all local storage but does not revoke the327 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-side328+token on the server. To revoke server-side, use: gordon auth token revoke
326-(not yet implemented).
327 329
328 Examples:330 Examples:
329 gordon auth logout Logout from active remote331 gordon auth logout Logout from active remote
330- gordon auth logout --remote prod Logout from specific remote332+ 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 RunE: func(cmd *cobra.Command, args []string) error {
333- return runAuthLogout(remoteName, revoke, cmd.OutOrStdout())334+ return runAuthLogout(remoteName, cmd.OutOrStdout())
334 },335 },
335 }336 }
336 337
337 cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to logout from (defaults to active remote)")338 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 339
340 return cmd340 return cmd
341 }341 }
342 342
343-func runAuthLogout(remoteName string, revoke bool, out io.Writer) error {343+func runAuthLogout(remoteName string, out io.Writer) error {
344 resolvedName, _, err := resolveRemoteEntry(remoteName)344 resolvedName, _, err := resolveRemoteEntry(remoteName)
345 if err != nil {345 if err != nil {
346 return err346 return err
347 }347 }
348 348
349- if revoke {
350- fmt.Fprintln(out, styles.RenderWarning("Token revocation not yet implemented; clearing local credentials only"))
351- }
352-
353 if err := remote.ClearRemoteToken(resolvedName); err != nil {349 if err := remote.ClearRemoteToken(resolvedName); err != nil {
354 return fmt.Errorf("failed to clear token: %w", err)350 return fmt.Errorf("failed to clear token: %w", err)
355 }351 }
M internal/adapters/in/cli/auth_test.go +2 -40
@@ -13,50 +13,12 @@ import (
1313 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1414 )
1515
16-func TestRunAuthLoginWithToken_VerifiesAndStores(t *testing.T) {
16+func TestRunAuthLoginWithToken_StoresToken(t *testing.T) {
1717 t.Setenv("XDG_CONFIG_HOME", t.TempDir())
1818 t.Setenv("PATH", filepath.Join("/nonexistent", "pass-disabled"))
1919
20- var receivedAuth string
20+ // Mock server — verification may fail (token exchange not mocked), but token should still be stored.
2121 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22- if r.URL.Path != "/admin/status" {
23- w.WriteHeader(http.StatusNotFound)
24- return
25- }
26- receivedAuth = r.Header.Get("Authorization")
27- w.Header().Set("Content-Type", "application/json")
28- w.WriteHeader(http.StatusOK)
29- _, _ = w.Write([]byte(`{"routes":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`))
30- }))
31- defer server.Close()
32-
33- config := &remote.ClientConfig{
34- Active: "prod",
35- Remotes: map[string]remote.RemoteEntry{
36- "prod": {URL: server.URL},
37- },
38- }
39- assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config))
40-
41- var out bytes.Buffer
42- err := runAuthLoginWithToken(context.Background(), "", "token123", &out)
43- assert.NoError(t, err)
44-
45- loaded, err := remote.LoadRemotes("")
46- assert.NoError(t, err)
47- assert.Equal(t, "token123", loaded.Remotes["prod"].Token)
48- assert.Equal(t, "Bearer token123", receivedAuth)
49-}
50-
51-func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) {
52- t.Setenv("XDG_CONFIG_HOME", t.TempDir())
53- t.Setenv("PATH", filepath.Join("/nonexistent", "pass-disabled"))
54-
55- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
56- if r.URL.Path != "/admin/status" {
57- w.WriteHeader(http.StatusNotFound)
58- return
59- }
6022 w.WriteHeader(http.StatusUnauthorized)
6123 _, _ = w.Write([]byte(`{"error":"unauthorized"}`))
6224 }))
@@ -13,50 +13,12 @@ import (
13 "github.com/bnema/gordon/internal/adapters/in/cli/remote"13 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
14 )14 )
15 15
16-func TestRunAuthLoginWithToken_VerifiesAndStores(t *testing.T) {16+func TestRunAuthLoginWithToken_StoresToken(t *testing.T) {
17 t.Setenv("XDG_CONFIG_HOME", t.TempDir())17 t.Setenv("XDG_CONFIG_HOME", t.TempDir())
18 t.Setenv("PATH", filepath.Join("/nonexistent", "pass-disabled"))18 t.Setenv("PATH", filepath.Join("/nonexistent", "pass-disabled"))
19 19
20- var receivedAuth string20+ // Mock server — verification may fail (token exchange not mocked), but token should still be stored.
21 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {21 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22- if r.URL.Path != "/admin/status" {
23- w.WriteHeader(http.StatusNotFound)
24- return
25- }
26- receivedAuth = r.Header.Get("Authorization")
27- w.Header().Set("Content-Type", "application/json")
28- w.WriteHeader(http.StatusOK)
29- _, _ = w.Write([]byte(`{"routes":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`))
30- }))
31- defer server.Close()
32-
33- config := &remote.ClientConfig{
34- Active: "prod",
35- Remotes: map[string]remote.RemoteEntry{
36- "prod": {URL: server.URL},
37- },
38- }
39- assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config))
40-
41- var out bytes.Buffer
42- err := runAuthLoginWithToken(context.Background(), "", "token123", &out)
43- assert.NoError(t, err)
44-
45- loaded, err := remote.LoadRemotes("")
46- assert.NoError(t, err)
47- assert.Equal(t, "token123", loaded.Remotes["prod"].Token)
48- assert.Equal(t, "Bearer token123", receivedAuth)
49-}
50-
51-func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) {
52- t.Setenv("XDG_CONFIG_HOME", t.TempDir())
53- t.Setenv("PATH", filepath.Join("/nonexistent", "pass-disabled"))
54-
55- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
56- if r.URL.Path != "/admin/status" {
57- w.WriteHeader(http.StatusNotFound)
58- return
59- }
60 w.WriteHeader(http.StatusUnauthorized)22 w.WriteHeader(http.StatusUnauthorized)
61 _, _ = w.Write([]byte(`{"error":"unauthorized"}`))23 _, _ = w.Write([]byte(`{"error":"unauthorized"}`))
62 }))24 }))
M internal/adapters/in/cli/push_image_ops_test.go +25 -7
@@ -14,6 +14,24 @@ import (
1414 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1515 )
1616
17+// newTestServer creates a mock server that handles admin token exchange
18+// and custom handlers for specific paths.
19+func newTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
20+ t.Helper()
21+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22+ // Handle admin token exchange for all tests — the client does this automatically.
23+ if r.URL.Path == "/auth/token" && r.URL.Query().Get("scope") == "admin:*:*" {
24+ w.Header().Set("Content-Type", "application/json")
25+ json.NewEncoder(w).Encode(dto.TokenResponse{
26+ Token: "ephemeral-admin-token",
27+ ExpiresIn: 900,
28+ }) //nolint:errcheck
29+ return
30+ }
31+ handler(w, r)
32+ }))
33+}
34+
1735 // TestExchangeRegistryAuth_Success verifies the happy path:
1836 // VerifyAuth is called, then ExchangeRegistryToken is called with the
1937 // subject from VerifyAuth, and the result is "Bearer <short-lived-token>".
@@ -24,7 +42,7 @@ func TestExchangeRegistryAuth_Success(t *testing.T) {
2442 verifyHandled := false
2543 tokenHandled := false
2644
27- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45+ srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
2846 switch r.URL.Path {
2947 case "/admin/auth/verify":
3048 verifyHandled = true
@@ -36,8 +54,8 @@ func TestExchangeRegistryAuth_Success(t *testing.T) {
3654 }
3755 json.NewEncoder(w).Encode(resp) //nolint:errcheck
3856 case "/auth/token":
57+ // Registry token exchange (scope != admin:*:*)
3958 tokenHandled = true
40- // Verify basic auth was set with subject as username
4159 u, p, ok := r.BasicAuth()
4260 assert.True(t, ok, "expected basic auth")
4361 assert.Equal(t, subject, u, "expected subject as username")
@@ -49,7 +67,7 @@ func TestExchangeRegistryAuth_Success(t *testing.T) {
4967 default:
5068 http.NotFound(w, r)
5169 }
52- }))
70+ })
5371 defer srv.Close()
5472
5573 client := remote.NewClient(srv.URL, remote.WithToken("long-lived-token"))
@@ -68,13 +86,13 @@ func TestExchangeRegistryAuth_Success(t *testing.T) {
6886 // TestExchangeRegistryAuth_VerifyAuthFails verifies that when VerifyAuth fails,
6987 // exchangeRegistryAuth returns an error.
7088 func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) {
71- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
89+ srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
7290 if r.URL.Path == "/admin/auth/verify" {
7391 http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
7492 return
7593 }
7694 http.NotFound(w, r)
77- }))
95+ })
7896 defer srv.Close()
7997
8098 client := remote.NewClient(srv.URL, remote.WithToken("bad-token"))
@@ -91,7 +109,7 @@ func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) {
91109 // TestExchangeRegistryAuth_TokenNotValid verifies that when VerifyAuth returns
92110 // Valid=false, exchangeRegistryAuth returns an error.
93111 func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) {
94- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112+ srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
95113 if r.URL.Path == "/admin/auth/verify" {
96114 w.Header().Set("Content-Type", "application/json")
97115 resp := dto.AuthVerifyResponse{Valid: false}
@@ -99,7 +117,7 @@ func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) {
99117 return
100118 }
101119 http.NotFound(w, r)
102- }))
120+ })
103121 defer srv.Close()
104122
105123 client := remote.NewClient(srv.URL, remote.WithToken("expired-token"))
@@ -14,6 +14,24 @@ import (
14 "github.com/bnema/gordon/internal/adapters/in/cli/remote"14 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
15 )15 )
16 16
17+// newTestServer creates a mock server that handles admin token exchange
18+// and custom handlers for specific paths.
19+func newTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
20+ t.Helper()
21+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22+ // Handle admin token exchange for all tests — the client does this automatically.
23+ if r.URL.Path == "/auth/token" && r.URL.Query().Get("scope") == "admin:*:*" {
24+ w.Header().Set("Content-Type", "application/json")
25+ json.NewEncoder(w).Encode(dto.TokenResponse{
26+ Token: "ephemeral-admin-token",
27+ ExpiresIn: 900,
28+ }) //nolint:errcheck
29+ return
30+ }
31+ handler(w, r)
32+ }))
33+}
34+
17 // TestExchangeRegistryAuth_Success verifies the happy path:35 // TestExchangeRegistryAuth_Success verifies the happy path:
18 // VerifyAuth is called, then ExchangeRegistryToken is called with the36 // VerifyAuth is called, then ExchangeRegistryToken is called with the
19 // subject from VerifyAuth, and the result is "Bearer <short-lived-token>".37 // subject from VerifyAuth, and the result is "Bearer <short-lived-token>".
@@ -24,7 +42,7 @@ func TestExchangeRegistryAuth_Success(t *testing.T) {
24 verifyHandled := false42 verifyHandled := false
25 tokenHandled := false43 tokenHandled := false
26 44
27- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {45+ srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
28 switch r.URL.Path {46 switch r.URL.Path {
29 case "/admin/auth/verify":47 case "/admin/auth/verify":
30 verifyHandled = true48 verifyHandled = true
@@ -36,8 +54,8 @@ func TestExchangeRegistryAuth_Success(t *testing.T) {
36 }54 }
37 json.NewEncoder(w).Encode(resp) //nolint:errcheck55 json.NewEncoder(w).Encode(resp) //nolint:errcheck
38 case "/auth/token":56 case "/auth/token":
57+ // Registry token exchange (scope != admin:*:*)
39 tokenHandled = true58 tokenHandled = true
40- // Verify basic auth was set with subject as username
41 u, p, ok := r.BasicAuth()59 u, p, ok := r.BasicAuth()
42 assert.True(t, ok, "expected basic auth")60 assert.True(t, ok, "expected basic auth")
43 assert.Equal(t, subject, u, "expected subject as username")61 assert.Equal(t, subject, u, "expected subject as username")
@@ -49,7 +67,7 @@ func TestExchangeRegistryAuth_Success(t *testing.T) {
49 default:67 default:
50 http.NotFound(w, r)68 http.NotFound(w, r)
51 }69 }
52- }))70+ })
53 defer srv.Close()71 defer srv.Close()
54 72
55 client := remote.NewClient(srv.URL, remote.WithToken("long-lived-token"))73 client := remote.NewClient(srv.URL, remote.WithToken("long-lived-token"))
@@ -68,13 +86,13 @@ func TestExchangeRegistryAuth_Success(t *testing.T) {
68 // TestExchangeRegistryAuth_VerifyAuthFails verifies that when VerifyAuth fails,86 // TestExchangeRegistryAuth_VerifyAuthFails verifies that when VerifyAuth fails,
69 // exchangeRegistryAuth returns an error.87 // exchangeRegistryAuth returns an error.
70 func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) {88 func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) {
71- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {89+ srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
72 if r.URL.Path == "/admin/auth/verify" {90 if r.URL.Path == "/admin/auth/verify" {
73 http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)91 http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
74 return92 return
75 }93 }
76 http.NotFound(w, r)94 http.NotFound(w, r)
77- }))95+ })
78 defer srv.Close()96 defer srv.Close()
79 97
80 client := remote.NewClient(srv.URL, remote.WithToken("bad-token"))98 client := remote.NewClient(srv.URL, remote.WithToken("bad-token"))
@@ -91,7 +109,7 @@ func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) {
91 // TestExchangeRegistryAuth_TokenNotValid verifies that when VerifyAuth returns109 // TestExchangeRegistryAuth_TokenNotValid verifies that when VerifyAuth returns
92 // Valid=false, exchangeRegistryAuth returns an error.110 // Valid=false, exchangeRegistryAuth returns an error.
93 func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) {111 func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) {
94- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {112+ srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
95 if r.URL.Path == "/admin/auth/verify" {113 if r.URL.Path == "/admin/auth/verify" {
96 w.Header().Set("Content-Type", "application/json")114 w.Header().Set("Content-Type", "application/json")
97 resp := dto.AuthVerifyResponse{Valid: false}115 resp := dto.AuthVerifyResponse{Valid: false}
@@ -99,7 +117,7 @@ func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) {
99 return117 return
100 }118 }
101 http.NotFound(w, r)119 http.NotFound(w, r)
102- }))120+ })
103 defer srv.Close()121 defer srv.Close()
104 122
105 client := remote.NewClient(srv.URL, remote.WithToken("expired-token"))123 client := remote.NewClient(srv.URL, remote.WithToken("expired-token"))

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