detached fda3c2b

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, including mr:review for verdicts, 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			"Scopes are repo:read, repo:write, repo:admin, mr:write, mr:merge, issue:write and more; give\n" +
94			"issue:write (a server with the issues feature) to let a token write issues.\n" +
95			"A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +
96			"repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +
97			"--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" +
98			"(for example agent/claude/). The token can push, force-push and delete only branches under its\n" +
99			"prefixes, and merge only into them; it cannot push tags, create repositories or delete one,\n" +
100			"and it never has the rights of the owner. It can only create tokens within its own prefixes.\n" +
101			"To give an AI agent its own space:\n" +
102			"  ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/<name>/\n\n" +
103			"The label is free text of up to 100 bytes, except \"owner\" and \"root\" (in any case): they are\n" +
104			"what the audit trail shows for the admin token and the web owner, so the server refuses them." + jsonTokenHelp,
105		Args: cobra.ExactArgs(1),
106	}
107	cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", "))
108	cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)")
109	cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)")
110	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)")
111	cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never")
112	_ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
113		return client.PresetNames, cobra.ShellCompDirectiveNoFileComp
114	})
115	_ = cmd.RegisterFlagCompletionFunc("scope", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
116		return client.ScopeNames(client.AllScopes()), cobra.ShellCompDirectiveNoFileComp
117	})
118	_ = cmd.RegisterFlagCompletionFunc("repo", func(c *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
119		return repoNames(env, c, toComplete), cobra.ShellCompDirectiveNoFileComp
120	})
121	asJSON := addJSONFlag(cmd)
122	cmd.RunE = func(cmd *cobra.Command, args []string) error {
123		req, err := buildCreateToken(args[0], preset, scopes, repos, prefixes, expires, env.now())
124		if err != nil {
125			return err
126		}
127		c, err := env.client(cmd)
128		if err != nil {
129			return err
130		}
131		if len(req.GetBranchPrefixes()) > 0 {
132			if err := requireBranchPrefixes(env.ctx(cmd), c); err != nil {
133				return err
134			}
135		}
136		// A preset such as cli grows with the contract and must keep working on an
137		// older server: issue:write from a preset alone is dropped there, while an
138		// explicit --scope issue:write is refused.
139		if slices.Contains(req.Scopes, ermite.Scope_SCOPE_ISSUE_WRITE) {
140			if err := requireIssues(env.ctx(cmd), c); err != nil {
141				if !errors.Is(err, errNoIssues) || explicitScope(scopes, ermite.Scope_SCOPE_ISSUE_WRITE) {
142					return fmt.Errorf("%w (needed for the issue:write scope; nothing was created)", err)
143				}
144				req.Scopes = slices.DeleteFunc(req.Scopes, func(s ermite.Scope) bool { return s == ermite.Scope_SCOPE_ISSUE_WRITE })
145				_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: the server has no issues feature, so the token has no issue:write scope")
146			}
147		}
148		if slices.Contains(req.Scopes, ermite.Scope_SCOPE_RELEASE_WRITE) {
149			if err := requireReleases(env.ctx(cmd), c); err != nil {
150				if !errors.Is(err, errNoReleases) || explicitScope(scopes, ermite.Scope_SCOPE_RELEASE_WRITE) {
151					return fmt.Errorf("%w (needed for the release:write scope; nothing was created)", err)
152				}
153				req.Scopes = slices.DeleteFunc(req.Scopes, func(s ermite.Scope) bool { return s == ermite.Scope_SCOPE_RELEASE_WRITE })
154				_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: the server has no releases feature, so the token has no release:write scope")
155			}
156		}
157		res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
158		if err != nil {
159			return c.Describe(err)
160		}
161		if err := checkBranchPrefixes(req, res.Msg.GetToken()); err != nil {
162			return err
163		}
164		return printCreatedToken(cmd, res.Msg, *asJSON)
165	}
166	return cmd
167}
168
169// explicitScope reports whether the --scope flags name want.
170func explicitScope(flags []string, want ermite.Scope) bool {
171	return slices.ContainsFunc(flags, func(f string) bool {
172		s, err := client.ParseScope(f)
173		return err == nil && s == want
174	})
175}
176
177// requireBranchPrefixes refuses, before anything is created, to ask a server
178// for a branch-limited token when it does not list token.branch_prefixes: such
179// a server ignores the field. A server whose features cannot be read is refused
180// too (fail closed).
181func requireBranchPrefixes(ctx context.Context, c *client.Client) error {
182	res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
183	if err != nil {
184		return c.Describe(err)
185	}
186	if !slices.Contains(res.Msg.GetFeatures(), client.FeatureTokenBranchPrefixes) {
187		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)
188	}
189	return nil
190}
191
192// checkBranchPrefixes is the backstop of requireBranchPrefixes: it refuses to hand out a token that is less limited than
193// asked for. A server that predates branch prefixes ignores the field and
194// creates an unlimited token: the secret is then not printed.
195func checkBranchPrefixes(req *ermite.CreateTokenRequest, got *ermite.Token) error {
196	if slices.Equal(req.GetBranchPrefixes(), got.GetBranchPrefixes()) {
197		return nil
198	}
199	return fmt.Errorf("the server created token %s without the requested branch prefixes (it may be too old: "+
200		"see `ermite version`); its secret is not shown, revoke it with `ermite token revoke %s`", got.GetId(), got.GetId())
201}
202
203// buildCreateToken maps the flags of `token create` to the request.
204func buildCreateToken(label, preset string, scopeFlags, repoFlags, prefixFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) {
205	label = strings.TrimSpace(label)
206	if label == "" {
207		return nil, errors.New("the label must not be empty")
208	}
209	scopes, err := client.MergeScopes(preset, scopeFlags)
210	if err != nil {
211		return nil, err
212	}
213	req := &ermite.CreateTokenRequest{Label: label, Scopes: scopes}
214	for _, r := range repoFlags {
215		if r = strings.TrimSpace(r); r != "" && !slices.Contains(req.Repos, r) {
216			req.Repos = append(req.Repos, r)
217		}
218	}
219	for _, bp := range prefixFlags {
220		if bp = strings.TrimSpace(bp); bp != "" && !slices.Contains(req.BranchPrefixes, bp) {
221			req.BranchPrefixes = append(req.BranchPrefixes, bp)
222		}
223	}
224	if expires != "" {
225		at, err := client.ParseExpiry(expires, now)
226		if err != nil {
227			return nil, err
228		}
229		req.ExpiresAt = timestamppb.New(at)
230	}
231	return req, nil
232}
233
234func printCreatedToken(cmd *cobra.Command, res *ermite.CreateTokenResponse, asJSON bool) error {
235	if asJSON {
236		return writeJSON(cmd.OutOrStdout(), jsonCreatedToken{jsonToken: toJSONToken(res.GetToken()), Secret: res.GetSecret()})
237	}
238	t := res.GetToken()
239	repos := "all"
240	if len(t.GetRepos()) > 0 {
241		repos = strings.Join(t.GetRepos(), ", ")
242	}
243	branches := "all"
244	if len(t.GetBranchPrefixes()) > 0 {
245		branches = strings.Join(t.GetBranchPrefixes(), ", ")
246	}
247	noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel()))
248	_ = fields(cmd.ErrOrStderr(), [][2]string{
249		{"  scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")},
250		{"  repos", repos},
251		{"  branches", branches},
252		{"  expires", shortTime(t.GetExpiresAt(), "never")},
253	})
254	noticef(cmd, "the secret below is shown once and cannot be retrieved later")
255	_, err := fmt.Fprintln(cmd.OutOrStdout(), res.GetSecret())
256	return err
257}
258
259func listTokens(ctx context.Context, c *client.Client) ([]*ermite.Token, error) {
260	return collect(ctx, func(page string) ([]*ermite.Token, string, error) {
261		res, err := c.Tokens().ListTokens(ctx, connect.NewRequest(&ermite.ListTokensRequest{PageToken: page}))
262		if err != nil {
263			return nil, "", c.Describe(err)
264		}
265		return res.Msg.GetTokens(), res.Msg.GetNextPageToken(), nil
266	})
267}
268
269func newTokenListCmd(env *cliEnv) *cobra.Command {
270	cmd := &cobra.Command{
271		Use:   "list",
272		Short: "List stored tokens",
273		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.",
274		Args:  cobra.NoArgs,
275	}
276	asJSON := addJSONFlag(cmd)
277	cmd.RunE = func(cmd *cobra.Command, _ []string) error {
278		c, err := env.client(cmd)
279		if err != nil {
280			return err
281		}
282		tokens, err := listTokens(env.ctx(cmd), c)
283		if err != nil {
284			return err
285		}
286		if *asJSON {
287			out := make([]jsonToken, 0, len(tokens))
288			for _, t := range tokens {
289				out = append(out, toJSONToken(t))
290			}
291			return writeJSON(cmd.OutOrStdout(), out)
292		}
293		if len(tokens) == 0 {
294			noticef(cmd, "no tokens")
295			return nil
296		}
297		rows := make([][]string, 0, len(tokens))
298		for _, t := range tokens {
299			repos := "all"
300			if len(t.GetRepos()) > 0 {
301				repos = strings.Join(t.GetRepos(), ",")
302			}
303			branches := "all"
304			if len(t.GetBranchPrefixes()) > 0 {
305				branches = strings.Join(t.GetBranchPrefixes(), ",")
306			}
307			rows = append(rows, []string{
308				t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, branches,
309				shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"),
310			})
311		}
312		return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "BRANCHES", "EXPIRES", "LAST USED"}, rows)
313	}
314	return cmd
315}
316
317func newTokenRevokeCmd(env *cliEnv) *cobra.Command {
318	return &cobra.Command{
319		Use:   "revoke <id>",
320		Short: "Revoke a token",
321		Long: "Revoke a token by id (see `ermite token list`). It stops working immediately.\n\n" +
322			"A token with every scope, no --repo list and no --branch-prefix has the rights of the owner (it can open web\n" +
323			"sessions). Revoking one also signs every browser out, like `ermite web logout --all`:\n" +
324			"web sessions are not tied to the token that opened them. Sign in again with `ermite web login`.",
325		Args: cobra.ExactArgs(1),
326		ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
327			if len(args) > 0 {
328				return nil, cobra.ShellCompDirectiveNoFileComp
329			}
330			ids := complete(env, cmd, toComplete, listTokens, func(t *ermite.Token) (string, string) {
331				return t.GetId(), clean(t.GetLabel())
332			})
333			return ids, cobra.ShellCompDirectiveNoFileComp
334		},
335		RunE: func(cmd *cobra.Command, args []string) error {
336			c, err := env.client(cmd)
337			if err != nil {
338				return err
339			}
340			if _, err := c.Tokens().RevokeToken(env.ctx(cmd), connect.NewRequest(&ermite.RevokeTokenRequest{Id: args[0]})); err != nil {
341				return c.Describe(err)
342			}
343			_, err = fmt.Fprintf(cmd.OutOrStdout(), "revoked %s\n", args[0])
344			return err
345		},
346	}
347}

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 45

↑↓ moveenter openesc close