v0.23.0 333c710

cli_token.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"slices"
10	"strings"
11	"time"
12
13	"connectrpc.com/connect"
14	"github.com/spf13/cobra"
15	"google.golang.org/protobuf/types/known/timestamppb"
16
17	"bnema.dev/ermite-api/gen/ermite"
18	"bnema.dev/ermite/internal/adapters/client"
19)
20
21// jsonToken is the stable JSON shape of a stored token. The secret is only
22// present in the output of `token create`.
23type jsonToken struct {
24	ID     string   `json:"id"`
25	Label  string   `json:"label"`
26	Scopes []string `json:"scopes"`
27	Repos  []string `json:"repos"`
28	// BranchPrefixes limits the branches the token may write; empty means all.
29	BranchPrefixes []string `json:"branch_prefixes"`
30	ExpiresAt      *string  `json:"expires_at"`
31	CreatedAt      *string  `json:"created_at"`
32	LastUsedAt     *string  `json:"last_used_at"`
33}
34
35type jsonCreatedToken struct {
36	jsonToken
37	Secret string `json:"secret"`
38}
39
40func toJSONToken(t *ermite.Token) jsonToken {
41	scopes := make([]string, 0, len(t.GetScopes()))
42	for _, s := range t.GetScopes() {
43		scopes = append(scopes, client.ScopeName(s))
44	}
45	repos := t.GetRepos()
46	if repos == nil {
47		repos = []string{}
48	}
49	prefixes := t.GetBranchPrefixes()
50	if prefixes == nil {
51		prefixes = []string{}
52	}
53	return jsonToken{
54		ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos, BranchPrefixes: prefixes,
55		ExpiresAt: jsonTime(t.GetExpiresAt()), CreatedAt: jsonTime(t.GetCreatedAt()), LastUsedAt: jsonTime(t.GetLastUsedAt()),
56	}
57}
58
59const jsonTokenHelp = `
60JSON fields of a token (--json): id, label, scopes (array of names like "repo:read"),
61repos (array; empty means every repository), branch_prefixes (array; empty means every branch),
62expires_at, created_at and last_used_at (RFC 3339 UTC, null when unset or never used).`
63
64func newTokenCmd(env *cliEnv) *cobra.Command {
65	token := &cobra.Command{
66		Use:   "token",
67		Short: "Manage API tokens on a remote server",
68		Long:  "Create, list and revoke API tokens. Needs the token:admin scope (or the server's admin token).",
69	}
70	token.AddCommand(newTokenCreateCmd(env), newTokenListCmd(env), newTokenRevokeCmd(env))
71	return token
72}
73
74func newTokenCreateCmd(env *cliEnv) *cobra.Command {
75	var (
76		preset   string
77		scopes   []string
78		repos    []string
79		prefixes []string
80		expires  string
81	)
82	cmd := &cobra.Command{
83		Use:   "create <label>",
84		Short: "Create a token and print its secret once",
85		Long: "Create a token. The secret is printed once on standard output (and nowhere else: pipe it\n" +
86			"or store it right away); details go to standard error. With --json, standard output is one\n" +
87			"object holding the token fields and the secret.\n\n" +
88			"What the token may do is chosen with --preset and/or --scope (they add up); there is no default:\n" +
89			"  cli    every scope, for your own daily use\n" +
90			"  agent  repo:write, mr:write\n" +
91			"  runner runner:register (a CI runner host)\n" +
92			"  ci     repo:read\n" +
93			"A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +
94			"repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +
95			"--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" +
96			"(for example agent/claude/). The token can push, force-push and delete only branches under its\n" +
97			"prefixes, and merge only into them; it cannot push tags, create repositories or delete one,\n" +
98			"and it never has the rights of the owner. It can only create tokens within its own prefixes.\n" +
99			"To give an AI agent its own space:\n" +
100			"  ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/<name>/\n\n" +
101			"The label is free text of up to 100 bytes, except \"owner\" and \"root\" (in any case): they are\n" +
102			"what the audit trail shows for the admin token and the web owner, so the server refuses them." + jsonTokenHelp,
103		Args: cobra.ExactArgs(1),
104	}
105	cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", "))
106	cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)")
107	cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)")
108	cmd.Flags().StringSliceVar(&prefixes, "branch-prefix", nil, "limit the branches the token may write to this prefix, ending with / (for example agent/claude/); repeatable (default: all)")
109	cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never")
110	_ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
111		return client.PresetNames, cobra.ShellCompDirectiveNoFileComp
112	})
113	_ = cmd.RegisterFlagCompletionFunc("scope", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
114		return client.ScopeNames(client.AllScopes()), cobra.ShellCompDirectiveNoFileComp
115	})
116	_ = cmd.RegisterFlagCompletionFunc("repo", func(c *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
117		return repoNames(env, c, toComplete), cobra.ShellCompDirectiveNoFileComp
118	})
119	asJSON := addJSONFlag(cmd)
120	cmd.RunE = func(cmd *cobra.Command, args []string) error {
121		req, err := buildCreateToken(args[0], preset, scopes, repos, prefixes, expires, env.now())
122		if err != nil {
123			return err
124		}
125		c, err := env.client(cmd)
126		if err != nil {
127			return err
128		}
129		if len(req.GetBranchPrefixes()) > 0 {
130			if err := requireBranchPrefixes(env.ctx(cmd), c); err != nil {
131				return err
132			}
133		}
134		res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
135		if err != nil {
136			return c.Describe(err)
137		}
138		if err := checkBranchPrefixes(req, res.Msg.GetToken()); err != nil {
139			return err
140		}
141		return printCreatedToken(cmd, res.Msg, *asJSON)
142	}
143	return cmd
144}
145
146// requireBranchPrefixes refuses, before anything is created, to ask a server
147// for a branch-limited token when it does not list token.branch_prefixes: such
148// a server ignores the field. A server whose features cannot be read is refused
149// too (fail closed).
150func requireBranchPrefixes(ctx context.Context, c *client.Client) error {
151	res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
152	if err != nil {
153		return c.Describe(err)
154	}
155	if !slices.Contains(res.Msg.GetFeatures(), client.FeatureTokenBranchPrefixes) {
156		return fmt.Errorf("the server does not support %s, so it would ignore --branch-prefix and create an unlimited token: nothing was created (upgrade the server, see `ermite version`)", client.FeatureTokenBranchPrefixes)
157	}
158	return nil
159}
160
161// checkBranchPrefixes is the backstop of requireBranchPrefixes: it refuses to hand out a token that is less limited than
162// asked for. A server that predates branch prefixes ignores the field and
163// creates an unlimited token: the secret is then not printed.
164func checkBranchPrefixes(req *ermite.CreateTokenRequest, got *ermite.Token) error {
165	if slices.Equal(req.GetBranchPrefixes(), got.GetBranchPrefixes()) {
166		return nil
167	}
168	return fmt.Errorf("the server created token %s without the requested branch prefixes (it may be too old: "+
169		"see `ermite version`); its secret is not shown, revoke it with `ermite token revoke %s`", got.GetId(), got.GetId())
170}
171
172// buildCreateToken maps the flags of `token create` to the request.
173func buildCreateToken(label, preset string, scopeFlags, repoFlags, prefixFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) {
174	label = strings.TrimSpace(label)
175	if label == "" {
176		return nil, errors.New("the label must not be empty")
177	}
178	scopes, err := client.MergeScopes(preset, scopeFlags)
179	if err != nil {
180		return nil, err
181	}
182	req := &ermite.CreateTokenRequest{Label: label, Scopes: scopes}
183	for _, r := range repoFlags {
184		if r = strings.TrimSpace(r); r != "" && !slices.Contains(req.Repos, r) {
185			req.Repos = append(req.Repos, r)
186		}
187	}
188	for _, bp := range prefixFlags {
189		if bp = strings.TrimSpace(bp); bp != "" && !slices.Contains(req.BranchPrefixes, bp) {
190			req.BranchPrefixes = append(req.BranchPrefixes, bp)
191		}
192	}
193	if expires != "" {
194		at, err := client.ParseExpiry(expires, now)
195		if err != nil {
196			return nil, err
197		}
198		req.ExpiresAt = timestamppb.New(at)
199	}
200	return req, nil
201}
202
203func printCreatedToken(cmd *cobra.Command, res *ermite.CreateTokenResponse, asJSON bool) error {
204	if asJSON {
205		return writeJSON(cmd.OutOrStdout(), jsonCreatedToken{jsonToken: toJSONToken(res.GetToken()), Secret: res.GetSecret()})
206	}
207	t := res.GetToken()
208	repos := "all"
209	if len(t.GetRepos()) > 0 {
210		repos = strings.Join(t.GetRepos(), ", ")
211	}
212	branches := "all"
213	if len(t.GetBranchPrefixes()) > 0 {
214		branches = strings.Join(t.GetBranchPrefixes(), ", ")
215	}
216	noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel()))
217	_ = fields(cmd.ErrOrStderr(), [][2]string{
218		{"  scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")},
219		{"  repos", repos},
220		{"  branches", branches},
221		{"  expires", shortTime(t.GetExpiresAt(), "never")},
222	})
223	noticef(cmd, "the secret below is shown once and cannot be retrieved later")
224	_, err := fmt.Fprintln(cmd.OutOrStdout(), res.GetSecret())
225	return err
226}
227
228func listTokens(ctx context.Context, c *client.Client) ([]*ermite.Token, error) {
229	return collect(ctx, func(page string) ([]*ermite.Token, string, error) {
230		res, err := c.Tokens().ListTokens(ctx, connect.NewRequest(&ermite.ListTokensRequest{PageToken: page}))
231		if err != nil {
232			return nil, "", c.Describe(err)
233		}
234		return res.Msg.GetTokens(), res.Msg.GetNextPageToken(), nil
235	})
236}
237
238func newTokenListCmd(env *cliEnv) *cobra.Command {
239	cmd := &cobra.Command{
240		Use:   "list",
241		Short: "List stored tokens",
242		Long:  "List stored tokens, newest first. Secrets are never shown. The server's admin token is not listed." + jsonTokenHelp + "\n--json prints an array of tokens.",
243		Args:  cobra.NoArgs,
244	}
245	asJSON := addJSONFlag(cmd)
246	cmd.RunE = func(cmd *cobra.Command, _ []string) error {
247		c, err := env.client(cmd)
248		if err != nil {
249			return err
250		}
251		tokens, err := listTokens(env.ctx(cmd), c)
252		if err != nil {
253			return err
254		}
255		if *asJSON {
256			out := make([]jsonToken, 0, len(tokens))
257			for _, t := range tokens {
258				out = append(out, toJSONToken(t))
259			}
260			return writeJSON(cmd.OutOrStdout(), out)
261		}
262		if len(tokens) == 0 {
263			noticef(cmd, "no tokens")
264			return nil
265		}
266		rows := make([][]string, 0, len(tokens))
267		for _, t := range tokens {
268			repos := "all"
269			if len(t.GetRepos()) > 0 {
270				repos = strings.Join(t.GetRepos(), ",")
271			}
272			branches := "all"
273			if len(t.GetBranchPrefixes()) > 0 {
274				branches = strings.Join(t.GetBranchPrefixes(), ",")
275			}
276			rows = append(rows, []string{
277				t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, branches,
278				shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"),
279			})
280		}
281		return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "BRANCHES", "EXPIRES", "LAST USED"}, rows)
282	}
283	return cmd
284}
285
286func newTokenRevokeCmd(env *cliEnv) *cobra.Command {
287	return &cobra.Command{
288		Use:   "revoke <id>",
289		Short: "Revoke a token",
290		Long: "Revoke a token by id (see `ermite token list`). It stops working immediately.\n\n" +
291			"A token with every scope, no --repo list and no --branch-prefix has the rights of the owner (it can open web\n" +
292			"sessions). Revoking one also signs every browser out, like `ermite web logout --all`:\n" +
293			"web sessions are not tied to the token that opened them. Sign in again with `ermite web login`.",
294		Args: cobra.ExactArgs(1),
295		ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
296			if len(args) > 0 {
297				return nil, cobra.ShellCompDirectiveNoFileComp
298			}
299			ids := complete(env, cmd, toComplete, listTokens, func(t *ermite.Token) (string, string) {
300				return t.GetId(), clean(t.GetLabel())
301			})
302			return ids, cobra.ShellCompDirectiveNoFileComp
303		},
304		RunE: func(cmd *cobra.Command, args []string) error {
305			c, err := env.client(cmd)
306			if err != nil {
307				return err
308			}
309			if _, err := c.Tokens().RevokeToken(env.ctx(cmd), connect.NewRequest(&ermite.RevokeTokenRequest{Id: args[0]})); err != nil {
310				return c.Describe(err)
311			}
312			_, err = fmt.Fprintf(cmd.OutOrStdout(), "revoked %s\n", args[0])
313			return err
314		},
315	}
316}

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 2

Tags 44

↑↓ moveenter openesc close