detached 26943f5

feat(cli): add ermite web passkey list and remove

commit
26943f5f8868608bcc594a695c03a0b79df4f916
author
brice · · 2026-10-12 15:16 UTC
parent
0ab5a9d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +263 -3
M internal/app/cli_fake_test.go +12 -0
@@ -29,6 +29,7 @@ type fakeAPI struct {
2929 ermiteconnect.UnimplementedServerServiceHandler
3030 ermiteconnect.UnimplementedEventServiceHandler
3131 ermiteconnect.UnimplementedMergeRequestServiceHandler
32+ ermiteconnect.UnimplementedSessionServiceHandler
3233
3334 mu sync.Mutex
3435 calls []string // procedure names, in order
@@ -55,6 +56,9 @@ type fakeAPI struct {
5556 mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error)
5657 markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error)
5758 getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error)
59+
60+ listPasskeys func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error)
61+ removePasskey func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error)
5862 }
5963
6064 func (f *fakeAPI) record(name string, h http.Header, req any) {
@@ -137,6 +141,12 @@ func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkRead
137141 func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) {
138142 return respond(f, "GetSigningKey", r, f.getSigningKey)
139143 }
144+func (f *fakeAPI) ListPasskeys(_ context.Context, r *connect.Request[ermite.ListPasskeysRequest]) (*connect.Response[ermite.ListPasskeysResponse], error) {
145+ return respond(f, "ListPasskeys", r, f.listPasskeys)
146+}
147+func (f *fakeAPI) RemovePasskey(_ context.Context, r *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error) {
148+ return respond(f, "RemovePasskey", r, f.removePasskey)
149+}
140150 func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error {
141151 f.record("Watch", r.Header(), r.Msg)
142152 if f.watch == nil {
@@ -160,6 +170,8 @@ func (f *fakeAPI) serve(t *testing.T) string {
160170 mount(p, h)
161171 p, h = ermiteconnect.NewMergeRequestServiceHandler(f)
162172 mount(p, h)
173+ p, h = ermiteconnect.NewSessionServiceHandler(f)
174+ mount(p, h)
163175 srv := httptest.NewServer(mux)
164176 t.Cleanup(srv.Close)
165177 return srv.URL
@@ -29,6 +29,7 @@ type fakeAPI struct {
29 ermiteconnect.UnimplementedServerServiceHandler29 ermiteconnect.UnimplementedServerServiceHandler
30 ermiteconnect.UnimplementedEventServiceHandler30 ermiteconnect.UnimplementedEventServiceHandler
31 ermiteconnect.UnimplementedMergeRequestServiceHandler31 ermiteconnect.UnimplementedMergeRequestServiceHandler
32+ ermiteconnect.UnimplementedSessionServiceHandler
32 33
33 mu sync.Mutex34 mu sync.Mutex
34 calls []string // procedure names, in order35 calls []string // procedure names, in order
@@ -55,6 +56,9 @@ type fakeAPI struct {
55 mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error)56 mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error)
56 markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error)57 markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error)
57 getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error)58 getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error)
59+
60+ listPasskeys func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error)
61+ removePasskey func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error)
58 }62 }
59 63
60 func (f *fakeAPI) record(name string, h http.Header, req any) {64 func (f *fakeAPI) record(name string, h http.Header, req any) {
@@ -137,6 +141,12 @@ func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkRead
137 func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) {141 func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) {
138 return respond(f, "GetSigningKey", r, f.getSigningKey)142 return respond(f, "GetSigningKey", r, f.getSigningKey)
139 }143 }
144+func (f *fakeAPI) ListPasskeys(_ context.Context, r *connect.Request[ermite.ListPasskeysRequest]) (*connect.Response[ermite.ListPasskeysResponse], error) {
145+ return respond(f, "ListPasskeys", r, f.listPasskeys)
146+}
147+func (f *fakeAPI) RemovePasskey(_ context.Context, r *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error) {
148+ return respond(f, "RemovePasskey", r, f.removePasskey)
149+}
140 func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error {150 func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error {
141 f.record("Watch", r.Header(), r.Msg)151 f.record("Watch", r.Header(), r.Msg)
142 if f.watch == nil {152 if f.watch == nil {
@@ -160,6 +170,8 @@ func (f *fakeAPI) serve(t *testing.T) string {
160 mount(p, h)170 mount(p, h)
161 p, h = ermiteconnect.NewMergeRequestServiceHandler(f)171 p, h = ermiteconnect.NewMergeRequestServiceHandler(f)
162 mount(p, h)172 mount(p, h)
173+ p, h = ermiteconnect.NewSessionServiceHandler(f)
174+ mount(p, h)
163 srv := httptest.NewServer(mux)175 srv := httptest.NewServer(mux)
164 t.Cleanup(srv.Close)176 t.Cleanup(srv.Close)
165 return srv.URL177 return srv.URL
M internal/app/cli_web.go +117 -3
@@ -3,6 +3,7 @@
33 package app
44
55 import (
6+ "context"
67 "errors"
78 "fmt"
89
@@ -10,6 +11,7 @@ import (
1011 "github.com/spf13/cobra"
1112
1213 "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/adapters/client"
1315 )
1416
1517 // jsonLoginLink is the stable JSON shape of `ermite web login --json`.
@@ -22,10 +24,10 @@ func newWebCmd(env *cliEnv) *cobra.Command {
2224 web := &cobra.Command{
2325 Use: "web",
2426 Short: "Sign the owner in to the web interface of a remote server",
25- Long: "Manage the owner's browser sessions on a remote server. They need the server's admin token, or a\n" +
26- "token that holds every scope and is not limited to repositories.",
27+ Long: "Manage the owner's browser sessions and passkeys on a remote server. They need the server's admin\n" +
28+ "token, or a token that holds every scope and is not limited to repositories.",
2729 }
28- web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env))
30+ web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env), newWebPasskeyCmd(env))
2931 return web
3032 }
3133
@@ -87,3 +89,115 @@ func newWebLogoutCmd(env *cliEnv) *cobra.Command {
8789 }
8890 return cmd
8991 }
92+
93+// jsonPasskey is the stable JSON shape of a passkey. It never holds key
94+// material.
95+type jsonPasskey struct {
96+ ID string `json:"id"`
97+ Name string `json:"name"`
98+ CreatedAt *string `json:"created_at"`
99+ LastUsedAt *string `json:"last_used_at"`
100+ Synced bool `json:"synced"`
101+}
102+
103+const jsonPasskeyHelp = "\n\nJSON fields (--json): id (credential id, base64url), name, created_at (RFC 3339 UTC), last_used_at\n" +
104+ "(null if never used), synced (the authenticator reports the passkey as backed up)."
105+
106+func newWebPasskeyCmd(env *cliEnv) *cobra.Command {
107+ cmd := &cobra.Command{
108+ Use: "passkey",
109+ Short: "List or remove the passkeys that sign the owner in",
110+ Long: "List or remove the passkeys that sign the owner in to the web interface. Adding one needs a browser:\n" +
111+ "sign in (`ermite web login`), then open /-/passkeys. Passkeys are only available when the server's\n" +
112+ "public URL is https (or localhost) with a host name, not an IP address.",
113+ }
114+ cmd.AddCommand(newWebPasskeyListCmd(env), newWebPasskeyRemoveCmd(env))
115+ return cmd
116+}
117+
118+func listPasskeys(ctx context.Context, c *client.Client) ([]*ermite.Passkey, error) {
119+ res, err := c.Sessions().ListPasskeys(ctx, connect.NewRequest(&ermite.ListPasskeysRequest{}))
120+ if err != nil {
121+ return nil, c.Describe(err)
122+ }
123+ return res.Msg.GetPasskeys(), nil
124+}
125+
126+func newWebPasskeyListCmd(env *cliEnv) *cobra.Command {
127+ cmd := &cobra.Command{
128+ Use: "list",
129+ Short: "List the registered passkeys",
130+ Long: "List the passkeys, oldest first, with the id to give to `ermite web passkey remove`." + jsonPasskeyHelp +
131+ "\n--json prints an array of passkeys.",
132+ Args: cobra.NoArgs,
133+ }
134+ asJSON := addJSONFlag(cmd)
135+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
136+ c, err := env.client(cmd, true)
137+ if err != nil {
138+ return err
139+ }
140+ passkeys, err := listPasskeys(env.ctx(cmd), c)
141+ if err != nil {
142+ return err
143+ }
144+ if *asJSON {
145+ out := make([]jsonPasskey, 0, len(passkeys))
146+ for _, p := range passkeys {
147+ out = append(out, jsonPasskey{
148+ ID: p.GetId(), Name: p.GetName(), CreatedAt: jsonTime(p.GetCreatedAt()),
149+ LastUsedAt: jsonTime(p.GetLastUsedAt()), Synced: p.GetSynced(),
150+ })
151+ }
152+ return writeJSON(cmd.OutOrStdout(), out)
153+ }
154+ if len(passkeys) == 0 {
155+ noticef(cmd, "no passkeys")
156+ return nil
157+ }
158+ rows := make([][]string, 0, len(passkeys))
159+ for _, p := range passkeys {
160+ synced := "no"
161+ if p.GetSynced() {
162+ synced = "yes"
163+ }
164+ rows = append(rows, []string{
165+ p.GetId(), p.GetName(), shortTime(p.GetCreatedAt(), "-"), shortTime(p.GetLastUsedAt(), "never"), synced,
166+ })
167+ }
168+ return table(cmd.OutOrStdout(), []string{"ID", "NAME", "CREATED", "LAST USED", "SYNCED"}, rows)
169+ }
170+ return cmd
171+}
172+
173+func newWebPasskeyRemoveCmd(env *cliEnv) *cobra.Command {
174+ return &cobra.Command{
175+ Use: "remove <id>",
176+ Short: "Remove a passkey and sign every browser out",
177+ Long: "Remove a passkey by id (see `ermite web passkey list`). It can no longer sign in. Removing one also\n" +
178+ "signs every browser out, like `ermite web logout --all`: web sessions are not tied to the passkey that\n" +
179+ "opened them. Sign in again with another passkey or `ermite web login`.",
180+ Args: cobra.ExactArgs(1),
181+ ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
182+ if len(args) > 0 {
183+ return nil, cobra.ShellCompDirectiveNoFileComp
184+ }
185+ ids := complete(env, cmd, toComplete, listPasskeys, func(p *ermite.Passkey) (string, string) {
186+ return p.GetId(), clean(p.GetName())
187+ })
188+ return ids, cobra.ShellCompDirectiveNoFileComp
189+ },
190+ RunE: func(cmd *cobra.Command, args []string) error {
191+ c, err := env.client(cmd, true)
192+ if err != nil {
193+ return err
194+ }
195+ if _, err := c.Sessions().RemovePasskey(env.ctx(cmd), connect.NewRequest(&ermite.RemovePasskeyRequest{Id: args[0]})); err != nil {
196+ return c.Describe(err)
197+ }
198+ noticef(cmd, "passkey removed; every browser is signed out")
199+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "removed %s\n", args[0])
200+ return err
201+ },
202+ }
203+}
@@ -3,6 +3,7 @@
3 package app3 package app
4 4
5 import (5 import (
6+ "context"
6 "errors"7 "errors"
7 "fmt"8 "fmt"
8 9
@@ -10,6 +11,7 @@ import (
10 "github.com/spf13/cobra"11 "github.com/spf13/cobra"
11 12
12 "bnema.dev/ermite-api/gen/ermite"13 "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/adapters/client"
13 )15 )
14 16
15 // jsonLoginLink is the stable JSON shape of `ermite web login --json`.17 // jsonLoginLink is the stable JSON shape of `ermite web login --json`.
@@ -22,10 +24,10 @@ func newWebCmd(env *cliEnv) *cobra.Command {
22 web := &cobra.Command{24 web := &cobra.Command{
23 Use: "web",25 Use: "web",
24 Short: "Sign the owner in to the web interface of a remote server",26 Short: "Sign the owner in to the web interface of a remote server",
25- Long: "Manage the owner's browser sessions on a remote server. They need the server's admin token, or a\n" +27+ Long: "Manage the owner's browser sessions and passkeys on a remote server. They need the server's admin\n" +
26- "token that holds every scope and is not limited to repositories.",28+ "token, or a token that holds every scope and is not limited to repositories.",
27 }29 }
28- web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env))30+ web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env), newWebPasskeyCmd(env))
29 return web31 return web
30 }32 }
31 33
@@ -87,3 +89,115 @@ func newWebLogoutCmd(env *cliEnv) *cobra.Command {
87 }89 }
88 return cmd90 return cmd
89 }91 }
92+
93+// jsonPasskey is the stable JSON shape of a passkey. It never holds key
94+// material.
95+type jsonPasskey struct {
96+ ID string `json:"id"`
97+ Name string `json:"name"`
98+ CreatedAt *string `json:"created_at"`
99+ LastUsedAt *string `json:"last_used_at"`
100+ Synced bool `json:"synced"`
101+}
102+
103+const jsonPasskeyHelp = "\n\nJSON fields (--json): id (credential id, base64url), name, created_at (RFC 3339 UTC), last_used_at\n" +
104+ "(null if never used), synced (the authenticator reports the passkey as backed up)."
105+
106+func newWebPasskeyCmd(env *cliEnv) *cobra.Command {
107+ cmd := &cobra.Command{
108+ Use: "passkey",
109+ Short: "List or remove the passkeys that sign the owner in",
110+ Long: "List or remove the passkeys that sign the owner in to the web interface. Adding one needs a browser:\n" +
111+ "sign in (`ermite web login`), then open /-/passkeys. Passkeys are only available when the server's\n" +
112+ "public URL is https (or localhost) with a host name, not an IP address.",
113+ }
114+ cmd.AddCommand(newWebPasskeyListCmd(env), newWebPasskeyRemoveCmd(env))
115+ return cmd
116+}
117+
118+func listPasskeys(ctx context.Context, c *client.Client) ([]*ermite.Passkey, error) {
119+ res, err := c.Sessions().ListPasskeys(ctx, connect.NewRequest(&ermite.ListPasskeysRequest{}))
120+ if err != nil {
121+ return nil, c.Describe(err)
122+ }
123+ return res.Msg.GetPasskeys(), nil
124+}
125+
126+func newWebPasskeyListCmd(env *cliEnv) *cobra.Command {
127+ cmd := &cobra.Command{
128+ Use: "list",
129+ Short: "List the registered passkeys",
130+ Long: "List the passkeys, oldest first, with the id to give to `ermite web passkey remove`." + jsonPasskeyHelp +
131+ "\n--json prints an array of passkeys.",
132+ Args: cobra.NoArgs,
133+ }
134+ asJSON := addJSONFlag(cmd)
135+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
136+ c, err := env.client(cmd, true)
137+ if err != nil {
138+ return err
139+ }
140+ passkeys, err := listPasskeys(env.ctx(cmd), c)
141+ if err != nil {
142+ return err
143+ }
144+ if *asJSON {
145+ out := make([]jsonPasskey, 0, len(passkeys))
146+ for _, p := range passkeys {
147+ out = append(out, jsonPasskey{
148+ ID: p.GetId(), Name: p.GetName(), CreatedAt: jsonTime(p.GetCreatedAt()),
149+ LastUsedAt: jsonTime(p.GetLastUsedAt()), Synced: p.GetSynced(),
150+ })
151+ }
152+ return writeJSON(cmd.OutOrStdout(), out)
153+ }
154+ if len(passkeys) == 0 {
155+ noticef(cmd, "no passkeys")
156+ return nil
157+ }
158+ rows := make([][]string, 0, len(passkeys))
159+ for _, p := range passkeys {
160+ synced := "no"
161+ if p.GetSynced() {
162+ synced = "yes"
163+ }
164+ rows = append(rows, []string{
165+ p.GetId(), p.GetName(), shortTime(p.GetCreatedAt(), "-"), shortTime(p.GetLastUsedAt(), "never"), synced,
166+ })
167+ }
168+ return table(cmd.OutOrStdout(), []string{"ID", "NAME", "CREATED", "LAST USED", "SYNCED"}, rows)
169+ }
170+ return cmd
171+}
172+
173+func newWebPasskeyRemoveCmd(env *cliEnv) *cobra.Command {
174+ return &cobra.Command{
175+ Use: "remove <id>",
176+ Short: "Remove a passkey and sign every browser out",
177+ Long: "Remove a passkey by id (see `ermite web passkey list`). It can no longer sign in. Removing one also\n" +
178+ "signs every browser out, like `ermite web logout --all`: web sessions are not tied to the passkey that\n" +
179+ "opened them. Sign in again with another passkey or `ermite web login`.",
180+ Args: cobra.ExactArgs(1),
181+ ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
182+ if len(args) > 0 {
183+ return nil, cobra.ShellCompDirectiveNoFileComp
184+ }
185+ ids := complete(env, cmd, toComplete, listPasskeys, func(p *ermite.Passkey) (string, string) {
186+ return p.GetId(), clean(p.GetName())
187+ })
188+ return ids, cobra.ShellCompDirectiveNoFileComp
189+ },
190+ RunE: func(cmd *cobra.Command, args []string) error {
191+ c, err := env.client(cmd, true)
192+ if err != nil {
193+ return err
194+ }
195+ if _, err := c.Sessions().RemovePasskey(env.ctx(cmd), connect.NewRequest(&ermite.RemovePasskeyRequest{Id: args[0]})); err != nil {
196+ return c.Describe(err)
197+ }
198+ noticef(cmd, "passkey removed; every browser is signed out")
199+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "removed %s\n", args[0])
200+ return err
201+ },
202+ }
203+}
A internal/app/cli_web_test.go +134 -0
@@ -0,0 +1,134 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "errors"
7+ "testing"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite-api/gen/ermite"
14+)
15+
16+func twoPasskeys() *ermite.ListPasskeysResponse {
17+ return &ermite.ListPasskeysResponse{Passkeys: []*ermite.Passkey{
18+ {Id: "AQID", Name: "Pixel 9", CreatedAt: ts("2026-10-01T08:00:00Z"), LastUsedAt: ts("2026-10-09T18:30:00Z"), Synced: true},
19+ {Id: "BAUG", Name: "yubikey", CreatedAt: ts("2026-10-02T09:15:00Z")},
20+ }}
21+}
22+
23+func TestWebPasskeyList(t *testing.T) {
24+ f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }}
25+ res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list").ok(t)
26+ assert.Empty(t, res.stderr)
27+ assert.Regexp(t, `(?m)^ID\s+NAME\s+CREATED\s+LAST USED\s+SYNCED$`, res.stdout)
28+ assert.Regexp(t, `(?m)^AQID\s+Pixel 9\s+2026-10-01 08:00\s+2026-10-09 18:30\s+yes$`, res.stdout)
29+ assert.Regexp(t, `(?m)^BAUG\s+yubikey\s+2026-10-02 09:15\s+never\s+no$`, res.stdout)
30+}
31+
32+func TestWebPasskeyListEmpty(t *testing.T) {
33+ f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) {
34+ return &ermite.ListPasskeysResponse{}, nil
35+ }}
36+ url := f.serve(t)
37+ res := runCLI(t, envFor(url), "", "web", "passkey", "list").ok(t)
38+ assert.Empty(t, res.stdout)
39+ assert.Contains(t, res.stderr, "no passkeys")
40+
41+ res = runCLI(t, envFor(url), "", "web", "passkey", "list", "--json").ok(t)
42+ assert.JSONEq(t, `[]`, res.stdout)
43+}
44+
45+func TestWebPasskeyListJSON(t *testing.T) {
46+ f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }}
47+ res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list", "--json").ok(t)
48+ assert.Empty(t, res.stderr)
49+ assert.JSONEq(t, `[
50+ {"id":"AQID","name":"Pixel 9","created_at":"2026-10-01T08:00:00Z","last_used_at":"2026-10-09T18:30:00Z","synced":true},
51+ {"id":"BAUG","name":"yubikey","created_at":"2026-10-02T09:15:00Z","last_used_at":null,"synced":false}
52+ ]`, res.stdout)
53+}
54+
55+func TestWebPasskeyListCleansServerText(t *testing.T) {
56+ f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) {
57+ return &ermite.ListPasskeysResponse{Passkeys: []*ermite.Passkey{{Id: "AQ", Name: "evil\x1b[2J name", CreatedAt: ts("2026-10-01T08:00:00Z")}}}, nil
58+ }}
59+ res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list").ok(t)
60+ assert.NotContains(t, res.stdout, "\x1b")
61+}
62+
63+func TestWebPasskeyRemove(t *testing.T) {
64+ f := &fakeAPI{removePasskey: func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) {
65+ return &ermite.RemovePasskeyResponse{}, nil
66+ }}
67+ res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "remove", "AQID").ok(t)
68+ assert.Equal(t, "removed AQID\n", res.stdout)
69+ assert.Contains(t, res.stderr, "every browser is signed out")
70+
71+ calls, reqs := f.snapshot()
72+ assert.Equal(t, []string{"RemovePasskey"}, calls)
73+ assert.Equal(t, "AQID", reqs[0].(*ermite.RemovePasskeyRequest).GetId())
74+}
75+
76+func TestWebPasskeyRemoveErrors(t *testing.T) {
77+ for name, tc := range map[string]struct {
78+ code connect.Code
79+ msg string
80+ want string
81+ }{
82+ "unknown id": {connect.CodeNotFound, "not found", "not found"},
83+ "not the owner": {connect.CodePermissionDenied, "", "permission denied"},
84+ "unavailable": {connect.CodeFailedPrecondition, "passkeys are not available on this server", "passkeys are not available on this server"},
85+ "bad id": {connect.CodeInvalidArgument, "id is not a base64url credential id", "invalid request"},
86+ } {
87+ t.Run(name, func(t *testing.T) {
88+ f := &fakeAPI{removePasskey: func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) {
89+ return nil, connect.NewError(tc.code, errors.New(tc.msg))
90+ }}
91+ res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "remove", "AQID")
92+ require.Error(t, res.err)
93+ assert.Contains(t, res.err.Error(), tc.want)
94+ assert.Empty(t, res.stdout, "nothing is reported as removed")
95+ assert.NotContains(t, res.stderr, "signed out")
96+ })
97+ }
98+}
99+
100+func TestWebPasskeyListUnavailable(t *testing.T) {
101+ f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) {
102+ return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("passkeys are not available on this server"))
103+ }}
104+ res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list")
105+ require.Error(t, res.err)
106+ assert.Contains(t, res.err.Error(), "passkeys are not available on this server")
107+}
108+
109+func TestWebPasskeyArgs(t *testing.T) {
110+ f := &fakeAPI{}
111+ url := f.serve(t)
112+ for _, args := range [][]string{
113+ {"web", "passkey", "remove"},
114+ {"web", "passkey", "remove", "a", "b"},
115+ {"web", "passkey", "list", "extra"},
116+ } {
117+ res := runCLI(t, envFor(url), "", args...)
118+ require.Error(t, res.err, args)
119+ }
120+ calls, _ := f.snapshot()
121+ assert.Empty(t, calls, "nothing reached the server")
122+}
123+
124+// The ids complete from the server's list.
125+func TestWebPasskeyRemoveCompletesIDs(t *testing.T) {
126+ f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }}
127+ res := runCLI(t, envFor(f.serve(t)), "", "__complete", "web", "passkey", "remove", "").ok(t)
128+ assert.Contains(t, res.stdout, "AQID\tPixel 9")
129+ assert.Contains(t, res.stdout, "BAUG\tyubikey")
130+
131+ res = runCLI(t, envFor(f.serve(t)), "", "__complete", "web", "passkey", "remove", "B").ok(t)
132+ assert.NotContains(t, res.stdout, "AQID")
133+ assert.Contains(t, res.stdout, "BAUG\tyubikey")
134+}
@@ -0,0 +1,134 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "errors"
7+ "testing"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite-api/gen/ermite"
14+)
15+
16+func twoPasskeys() *ermite.ListPasskeysResponse {
17+ return &ermite.ListPasskeysResponse{Passkeys: []*ermite.Passkey{
18+ {Id: "AQID", Name: "Pixel 9", CreatedAt: ts("2026-10-01T08:00:00Z"), LastUsedAt: ts("2026-10-09T18:30:00Z"), Synced: true},
19+ {Id: "BAUG", Name: "yubikey", CreatedAt: ts("2026-10-02T09:15:00Z")},
20+ }}
21+}
22+
23+func TestWebPasskeyList(t *testing.T) {
24+ f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }}
25+ res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list").ok(t)
26+ assert.Empty(t, res.stderr)
27+ assert.Regexp(t, `(?m)^ID\s+NAME\s+CREATED\s+LAST USED\s+SYNCED$`, res.stdout)
28+ assert.Regexp(t, `(?m)^AQID\s+Pixel 9\s+2026-10-01 08:00\s+2026-10-09 18:30\s+yes$`, res.stdout)
29+ assert.Regexp(t, `(?m)^BAUG\s+yubikey\s+2026-10-02 09:15\s+never\s+no$`, res.stdout)
30+}
31+
32+func TestWebPasskeyListEmpty(t *testing.T) {
33+ f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) {
34+ return &ermite.ListPasskeysResponse{}, nil
35+ }}
36+ url := f.serve(t)
37+ res := runCLI(t, envFor(url), "", "web", "passkey", "list").ok(t)
38+ assert.Empty(t, res.stdout)
39+ assert.Contains(t, res.stderr, "no passkeys")
40+
41+ res = runCLI(t, envFor(url), "", "web", "passkey", "list", "--json").ok(t)
42+ assert.JSONEq(t, `[]`, res.stdout)
43+}
44+
45+func TestWebPasskeyListJSON(t *testing.T) {
46+ f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }}
47+ res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list", "--json").ok(t)
48+ assert.Empty(t, res.stderr)
49+ assert.JSONEq(t, `[
50+ {"id":"AQID","name":"Pixel 9","created_at":"2026-10-01T08:00:00Z","last_used_at":"2026-10-09T18:30:00Z","synced":true},
51+ {"id":"BAUG","name":"yubikey","created_at":"2026-10-02T09:15:00Z","last_used_at":null,"synced":false}
52+ ]`, res.stdout)
53+}
54+
55+func TestWebPasskeyListCleansServerText(t *testing.T) {
56+ f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) {
57+ return &ermite.ListPasskeysResponse{Passkeys: []*ermite.Passkey{{Id: "AQ", Name: "evil\x1b[2J name", CreatedAt: ts("2026-10-01T08:00:00Z")}}}, nil
58+ }}
59+ res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list").ok(t)
60+ assert.NotContains(t, res.stdout, "\x1b")
61+}
62+
63+func TestWebPasskeyRemove(t *testing.T) {
64+ f := &fakeAPI{removePasskey: func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) {
65+ return &ermite.RemovePasskeyResponse{}, nil
66+ }}
67+ res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "remove", "AQID").ok(t)
68+ assert.Equal(t, "removed AQID\n", res.stdout)
69+ assert.Contains(t, res.stderr, "every browser is signed out")
70+
71+ calls, reqs := f.snapshot()
72+ assert.Equal(t, []string{"RemovePasskey"}, calls)
73+ assert.Equal(t, "AQID", reqs[0].(*ermite.RemovePasskeyRequest).GetId())
74+}
75+
76+func TestWebPasskeyRemoveErrors(t *testing.T) {
77+ for name, tc := range map[string]struct {
78+ code connect.Code
79+ msg string
80+ want string
81+ }{
82+ "unknown id": {connect.CodeNotFound, "not found", "not found"},
83+ "not the owner": {connect.CodePermissionDenied, "", "permission denied"},
84+ "unavailable": {connect.CodeFailedPrecondition, "passkeys are not available on this server", "passkeys are not available on this server"},
85+ "bad id": {connect.CodeInvalidArgument, "id is not a base64url credential id", "invalid request"},
86+ } {
87+ t.Run(name, func(t *testing.T) {
88+ f := &fakeAPI{removePasskey: func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) {
89+ return nil, connect.NewError(tc.code, errors.New(tc.msg))
90+ }}
91+ res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "remove", "AQID")
92+ require.Error(t, res.err)
93+ assert.Contains(t, res.err.Error(), tc.want)
94+ assert.Empty(t, res.stdout, "nothing is reported as removed")
95+ assert.NotContains(t, res.stderr, "signed out")
96+ })
97+ }
98+}
99+
100+func TestWebPasskeyListUnavailable(t *testing.T) {
101+ f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) {
102+ return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("passkeys are not available on this server"))
103+ }}
104+ res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list")
105+ require.Error(t, res.err)
106+ assert.Contains(t, res.err.Error(), "passkeys are not available on this server")
107+}
108+
109+func TestWebPasskeyArgs(t *testing.T) {
110+ f := &fakeAPI{}
111+ url := f.serve(t)
112+ for _, args := range [][]string{
113+ {"web", "passkey", "remove"},
114+ {"web", "passkey", "remove", "a", "b"},
115+ {"web", "passkey", "list", "extra"},
116+ } {
117+ res := runCLI(t, envFor(url), "", args...)
118+ require.Error(t, res.err, args)
119+ }
120+ calls, _ := f.snapshot()
121+ assert.Empty(t, calls, "nothing reached the server")
122+}
123+
124+// The ids complete from the server's list.
125+func TestWebPasskeyRemoveCompletesIDs(t *testing.T) {
126+ f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }}
127+ res := runCLI(t, envFor(f.serve(t)), "", "__complete", "web", "passkey", "remove", "").ok(t)
128+ assert.Contains(t, res.stdout, "AQID\tPixel 9")
129+ assert.Contains(t, res.stdout, "BAUG\tyubikey")
130+
131+ res = runCLI(t, envFor(f.serve(t)), "", "__complete", "web", "passkey", "remove", "B").ok(t)
132+ assert.NotContains(t, res.stdout, "AQID")
133+ assert.Contains(t, res.stdout, "BAUG\tyubikey")
134+}

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 3

Tags 44

↑↓ moveenter openesc close