v0.35.2 80d5067

cli_settings.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"bufio"
7	"bytes"
8	"errors"
9	"fmt"
10	"io"
11	"os"
12	"strings"
13
14	"connectrpc.com/connect"
15	"github.com/spf13/cobra"
16	"golang.org/x/term"
17
18	"bnema.dev/ermite-api/gen/ermite"
19	"bnema.dev/ermite/internal/domain"
20)
21
22// `ermite secret`, `ermite variable` and `ermite environment`: the secrets,
23// variables and environments CI jobs receive (ADR 0019), with the flags of
24// `gh secret` and `gh variable`.
25
26// settingsFlags are the scope flags of secret and variable commands.
27type settingsFlags struct {
28	repo     string
29	env      string
30	instance bool
31}
32
33func (f *settingsFlags) add(cmd *cobra.Command, env *cliEnv, what string) {
34	cmd.Flags().StringVarP(&f.repo, "repo", "R", "", "the repository whose "+what+" to use")
35	cmd.Flags().StringVarP(&f.env, "env", "e", "", "the environment of --repo (default: the repository's own)")
36	cmd.Flags().BoolVar(&f.instance, "instance", false, "the "+what+" of the whole server (every repository)")
37	cmd.MarkFlagsMutuallyExclusive("instance", "repo")
38	cmd.MarkFlagsMutuallyExclusive("instance", "env")
39	_ = cmd.RegisterFlagCompletionFunc("repo", func(c *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
40		return repoNames(env, c, toComplete), cobra.ShellCompDirectiveNoFileComp
41	})
42}
43
44// scope builds the scope of the request and the words that name it in output.
45func (f *settingsFlags) scope() (*ermite.SettingsScope, string, error) {
46	switch {
47	case f.instance:
48		return &ermite.SettingsScope{Where: &ermite.SettingsScope_Instance{Instance: true}}, "the instance", nil
49	case f.repo == "":
50		return nil, "", errors.New("choose where it lives: --repo <repo> (with --env <environment> for an environment), or --instance")
51	case f.env != "":
52		return &ermite.SettingsScope{Where: &ermite.SettingsScope_Environment{Environment: &ermite.EnvironmentRef{Repo: f.repo, Name: f.env}}},
53			"environment " + f.env + " of " + f.repo, nil
54	}
55	return &ermite.SettingsScope{Where: &ermite.SettingsScope_Repo{Repo: f.repo}}, f.repo, nil
56}
57
58func (f *settingsFlags) check() error {
59	if f.env != "" && f.repo == "" {
60		return errors.New("--env needs --repo: an environment belongs to a repository")
61	}
62	return nil
63}
64
65// --- values ---
66
67// readValue returns the value of a secret or variable: --body, else standard
68// input. A terminal is asked for it, without echo for a secret.
69func readValue(cmd *cobra.Command, body string, bodySet, secret bool, name string) (string, error) {
70	if bodySet {
71		return body, nil
72	}
73	in := cmd.InOrStdin()
74	if f, ok := in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
75		_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Paste the value of %s: ", name)
76		var raw []byte
77		var err error
78		if secret {
79			raw, err = term.ReadPassword(int(f.Fd()))
80			_, _ = fmt.Fprintln(cmd.ErrOrStderr())
81		} else {
82			var line string
83			line, err = bufio.NewReader(f).ReadString('\n')
84			raw = []byte(strings.TrimRight(line, "\r\n"))
85			if errors.Is(err, io.EOF) && line != "" {
86				err = nil
87			}
88		}
89		if err != nil {
90			return "", fmt.Errorf("read the value: %w", err)
91		}
92		return string(raw), nil
93	}
94	// A pipe: everything, minus the line break that ends it (like gh). The read
95	// stops one byte past the cap, so input that exhausts it is refused and
96	// never silently cut.
97	const slack = 1024 // room for line breaks around a value of the largest size
98	raw, err := io.ReadAll(io.LimitReader(in, domain.MaxSettingValueBytes+slack+1))
99	if err != nil {
100		return "", fmt.Errorf("read the value from standard input: %w", err)
101	}
102	if len(raw) > domain.MaxSettingValueBytes+slack {
103		return "", fmt.Errorf("the value on standard input is larger than %d bytes (48 KiB)", domain.MaxSettingValueBytes)
104	}
105	return strings.TrimRight(string(raw), "\r\n"), nil
106}
107
108// parseEnvFile reads KEY=VALUE lines: blank lines and # comments are skipped,
109// "export " is allowed, and a value may be in matching single quotes (literal)
110// or double quotes (with \n, \r, \t, \" and \\ escapes). A value is one line.
111func parseEnvFile(r io.Reader) ([][2]string, error) {
112	var out [][2]string
113	sc := bufio.NewScanner(r)
114	sc.Buffer(make([]byte, 0, 64<<10), domain.MaxSettingValueBytes*2)
115	for n := 1; sc.Scan(); n++ {
116		line := strings.TrimSpace(strings.TrimSuffix(sc.Text(), "\r"))
117		if line == "" || strings.HasPrefix(line, "#") {
118			continue
119		}
120		line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
121		k, v, ok := strings.Cut(line, "=")
122		k = strings.TrimSpace(k)
123		if !ok || k == "" {
124			return nil, fmt.Errorf("line %d: expected NAME=value", n)
125		}
126		v = strings.TrimSpace(v)
127		switch {
128		case len(v) >= 2 && v[0] == '\'' && v[len(v)-1] == '\'':
129			v = v[1 : len(v)-1]
130		case len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"':
131			v = strings.NewReplacer(`\n`, "\n", `\r`, "\r", `\t`, "\t", `\"`, `"`, `\\`, `\`).Replace(v[1 : len(v)-1])
132		}
133		out = append(out, [2]string{k, v})
134	}
135	if err := sc.Err(); err != nil {
136		return nil, fmt.Errorf("read the file: %w", err)
137	}
138	return out, nil
139}
140
141// readPairs reads what `set` was asked to set: the NAME argument with its value
142// (--body, standard input or a prompt), or the NAME=value lines of --env-file.
143// noun is "secret" or "variable"; secret hides the prompt's echo.
144func readPairs(cmd *cobra.Command, args []string, body, envFile, noun string, secret bool) ([][2]string, error) {
145	switch {
146	case envFile != "" && len(args) > 0:
147		return nil, errors.New("give either a NAME or --env-file, not both")
148	case envFile != "":
149		pairs, err := openEnvFile(cmd, envFile)
150		if err != nil {
151			return nil, err
152		}
153		if len(pairs) == 0 {
154			return nil, fmt.Errorf("%s holds no NAME=value line", envFile)
155		}
156		return pairs, nil
157	case len(args) == 0:
158		return nil, fmt.Errorf("give the NAME of the %s, or --env-file", noun)
159	}
160	value, err := readValue(cmd, body, cmd.Flags().Changed("body"), secret, args[0])
161	if err != nil {
162		return nil, err
163	}
164	return [][2]string{{args[0], value}}, nil
165}
166
167// openEnvFile reads the --env-file; "-" is standard input.
168func openEnvFile(cmd *cobra.Command, path string) ([][2]string, error) {
169	if path == "-" {
170		return parseEnvFile(cmd.InOrStdin())
171	}
172	raw, err := os.ReadFile(path) //nolint:gosec // the user names the file
173	if err != nil {
174		return nil, fmt.Errorf("read %s: %w", path, err)
175	}
176	return parseEnvFile(bytes.NewReader(raw))
177}
178
179// --- secret ---
180
181func newSecretCmd(env *cliEnv) *cobra.Command {
182	secret := &cobra.Command{
183		Use:   "secret",
184		Short: "Manage the secrets CI jobs receive",
185		Long: "Set, list and delete the secrets that workflows read as ${{ secrets.NAME }}, like `gh secret`.\n" +
186			"A secret lives in a repository, in one of its environments (--env) or on the whole instance\n" +
187			"(--instance); the environment wins over the repository, and the repository over the instance.\n" +
188			"Values are write-only: no command prints one. Names are letters, digits and underscores, do not\n" +
189			"start with a digit or GITHUB_, and are stored in upper case; a value is up to 48 KiB.\n\n" +
190			"Needs the secrets:write scope (on a token limited to some repositories, only for them; --instance\n" +
191			"needs a token that is not). A token limited to branch prefixes never manages secrets. Runs started\n" +
192			"by such a token do not receive secrets either. The server needs ERMITE_SECRETS_KEY to store them.",
193	}
194	secret.AddCommand(newSecretSetCmd(env), newSecretListCmd(env), newSecretDeleteCmd(env))
195	return secret
196}
197
198func newSecretSetCmd(env *cliEnv) *cobra.Command {
199	var (
200		f       settingsFlags
201		body    string
202		envFile string
203	)
204	cmd := &cobra.Command{
205		Use:   "set [NAME]",
206		Short: "Create or update a secret",
207		Long: "Create or update a secret. The value comes from --body, from standard input (a pipe or a\n" +
208			"redirection) or, on a terminal, from a prompt that does not echo it. A trailing line break of\n" +
209			"standard input is dropped. --env-file sets several secrets at once from a file of NAME=value lines\n" +
210			"(\"-\" reads standard input); NAME is then not given.\n\n" +
211			"  ermite secret set AUR_KEY --repo dumber < key.pem\n" +
212			"  ermite secret set CODECOV_TOKEN --repo gordon --body \"$TOKEN\"\n" +
213			"  ermite secret set REGISTRY_PASSWORD --repo app --env production\n" +
214			"  ermite secret set --env-file .env --instance",
215		Args: cobra.MaximumNArgs(1),
216	}
217	f.add(cmd, env, "secret")
218	cmd.Flags().StringVarP(&body, "body", "b", "", "the value (visible in the process list: prefer standard input)")
219	cmd.Flags().StringVarP(&envFile, "env-file", "f", "", "set the secrets of a file of NAME=value lines (\"-\" is standard input)")
220	cmd.MarkFlagsMutuallyExclusive("body", "env-file")
221	cmd.RunE = func(cmd *cobra.Command, args []string) error {
222		if err := f.check(); err != nil {
223			return err
224		}
225		scope, where, err := f.scope()
226		if err != nil {
227			return err
228		}
229		pairs, err := readPairs(cmd, args, body, envFile, "secret", true)
230		if err != nil {
231			return err
232		}
233		c, err := env.client(cmd)
234		if err != nil {
235			return err
236		}
237		var errs []error
238		for _, p := range pairs {
239			res, err := c.Secrets().SetSecret(env.ctx(cmd), connect.NewRequest(&ermite.SetSecretRequest{Scope: scope, Name: p[0], Value: p[1]}))
240			if err != nil {
241				errs = append(errs, fmt.Errorf("%s: %w", clean(p[0]), c.Describe(err)))
242				continue
243			}
244			if _, err := fmt.Fprintf(cmd.OutOrStdout(), "set secret %s for %s\n", clean(res.Msg.GetSecret().GetName()), clean(where)); err != nil {
245				return err
246			}
247		}
248		return errors.Join(errs...)
249	}
250	return cmd
251}
252
253type jsonSecret struct {
254	Name      string  `json:"name"`
255	UpdatedAt *string `json:"updated_at"`
256}
257
258func newSecretListCmd(env *cliEnv) *cobra.Command {
259	var f settingsFlags
260	cmd := &cobra.Command{
261		Use:   "list",
262		Short: "List the names of the secrets",
263		Long: "List the secrets of one scope with their last update. Only the secrets set in that scope are\n" +
264			"listed, not the ones it inherits. Values are never shown.\n\n" +
265			"JSON fields of a secret (--json): name, updated_at (RFC 3339 UTC, null when unset).",
266		Args: cobra.NoArgs,
267	}
268	f.add(cmd, env, "secrets")
269	asJSON := addJSONFlag(cmd)
270	cmd.RunE = func(cmd *cobra.Command, _ []string) error {
271		if err := f.check(); err != nil {
272			return err
273		}
274		scope, _, err := f.scope()
275		if err != nil {
276			return err
277		}
278		c, err := env.client(cmd)
279		if err != nil {
280			return err
281		}
282		ctx := env.ctx(cmd)
283		items, err := collect(ctx, func(token string) ([]*ermite.Secret, string, error) {
284			res, err := c.Secrets().ListSecrets(ctx, connect.NewRequest(&ermite.ListSecretsRequest{Scope: scope, PageToken: token}))
285			if err != nil {
286				return nil, "", c.Describe(err)
287			}
288			return res.Msg.GetSecrets(), res.Msg.GetNextPageToken(), nil
289		})
290		if err != nil {
291			return err
292		}
293		if *asJSON {
294			out := make([]jsonSecret, 0, len(items))
295			for _, s := range items {
296				out = append(out, jsonSecret{Name: s.GetName(), UpdatedAt: jsonTime(s.GetUpdatedAt())})
297			}
298			return writeJSON(cmd.OutOrStdout(), out)
299		}
300		if len(items) == 0 {
301			noticef(cmd, "no secrets")
302			return nil
303		}
304		rows := make([][]string, 0, len(items))
305		for _, s := range items {
306			rows = append(rows, []string{s.GetName(), shortTime(s.GetUpdatedAt(), "")})
307		}
308		return table(cmd.OutOrStdout(), []string{"NAME", "UPDATED"}, rows)
309	}
310	return cmd
311}
312
313func newSecretDeleteCmd(env *cliEnv) *cobra.Command {
314	var f settingsFlags
315	cmd := &cobra.Command{
316		Use:   "delete NAME",
317		Short: "Delete a secret",
318		Args:  cobra.ExactArgs(1),
319	}
320	f.add(cmd, env, "secret")
321	cmd.RunE = func(cmd *cobra.Command, args []string) error {
322		if err := f.check(); err != nil {
323			return err
324		}
325		scope, where, err := f.scope()
326		if err != nil {
327			return err
328		}
329		c, err := env.client(cmd)
330		if err != nil {
331			return err
332		}
333		if _, err := c.Secrets().DeleteSecret(env.ctx(cmd), connect.NewRequest(&ermite.DeleteSecretRequest{Scope: scope, Name: args[0]})); err != nil {
334			return c.Describe(err)
335		}
336		_, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted secret %s from %s\n", clean(strings.ToUpper(args[0])), clean(where))
337		return err
338	}
339	return cmd
340}
341
342// --- variable ---
343
344func newVariableCmd(env *cliEnv) *cobra.Command {
345	v := &cobra.Command{
346		Use:   "variable",
347		Short: "Manage the variables CI jobs receive",
348		Long: "Set, get, list and delete the configuration variables that workflows read as ${{ vars.NAME }},\n" +
349			"like `gh variable`. Scopes, names, precedence and access are those of `ermite secret`; unlike a\n" +
350			"secret, a variable can be read back and may appear in logs.",
351	}
352	v.AddCommand(newVariableSetCmd(env), newVariableGetCmd(env), newVariableListCmd(env), newVariableDeleteCmd(env))
353	return v
354}
355
356func newVariableSetCmd(env *cliEnv) *cobra.Command {
357	var (
358		f       settingsFlags
359		body    string
360		envFile string
361	)
362	cmd := &cobra.Command{
363		Use:   "set [NAME]",
364		Short: "Create or update a variable",
365		Long: "Create or update a variable. The value comes from --body, from standard input or, on a terminal,\n" +
366			"from a prompt. --env-file sets several from a file of NAME=value lines, as `ermite secret set`.",
367		Args: cobra.MaximumNArgs(1),
368	}
369	f.add(cmd, env, "variable")
370	cmd.Flags().StringVarP(&body, "body", "b", "", "the value")
371	cmd.Flags().StringVarP(&envFile, "env-file", "f", "", "set the variables of a file of NAME=value lines (\"-\" is standard input)")
372	cmd.MarkFlagsMutuallyExclusive("body", "env-file")
373	cmd.RunE = func(cmd *cobra.Command, args []string) error {
374		if err := f.check(); err != nil {
375			return err
376		}
377		scope, where, err := f.scope()
378		if err != nil {
379			return err
380		}
381		pairs, err := readPairs(cmd, args, body, envFile, "variable", false)
382		if err != nil {
383			return err
384		}
385		c, err := env.client(cmd)
386		if err != nil {
387			return err
388		}
389		var errs []error
390		for _, p := range pairs {
391			res, err := c.Variables().SetVariable(env.ctx(cmd), connect.NewRequest(&ermite.SetVariableRequest{Scope: scope, Name: p[0], Value: p[1]}))
392			if err != nil {
393				errs = append(errs, fmt.Errorf("%s: %w", clean(p[0]), c.Describe(err)))
394				continue
395			}
396			if _, err := fmt.Fprintf(cmd.OutOrStdout(), "set variable %s for %s\n", clean(res.Msg.GetVariable().GetName()), clean(where)); err != nil {
397				return err
398			}
399		}
400		return errors.Join(errs...)
401	}
402	return cmd
403}
404
405type jsonVariable struct {
406	Name      string  `json:"name"`
407	Value     string  `json:"value"`
408	UpdatedAt *string `json:"updated_at"`
409}
410
411func variableJSON(v *ermite.Variable) jsonVariable {
412	return jsonVariable{Name: v.GetName(), Value: v.GetValue(), UpdatedAt: jsonTime(v.GetUpdatedAt())}
413}
414
415func newVariableGetCmd(env *cliEnv) *cobra.Command {
416	var f settingsFlags
417	cmd := &cobra.Command{
418		Use:   "get NAME",
419		Short: "Print the value of a variable",
420		Long: "Print the value of a variable of one scope, and nothing else, so that it can be used in a script.\n\n" +
421			"JSON fields (--json): name, value, updated_at (RFC 3339 UTC, null when unset).",
422		Args: cobra.ExactArgs(1),
423	}
424	f.add(cmd, env, "variable")
425	asJSON := addJSONFlag(cmd)
426	cmd.RunE = func(cmd *cobra.Command, args []string) error {
427		if err := f.check(); err != nil {
428			return err
429		}
430		scope, _, err := f.scope()
431		if err != nil {
432			return err
433		}
434		c, err := env.client(cmd)
435		if err != nil {
436			return err
437		}
438		res, err := c.Variables().GetVariable(env.ctx(cmd), connect.NewRequest(&ermite.GetVariableRequest{Scope: scope, Name: args[0]}))
439		if err != nil {
440			return c.Describe(err)
441		}
442		if *asJSON {
443			return writeJSON(cmd.OutOrStdout(), variableJSON(res.Msg.GetVariable()))
444		}
445		_, err = fmt.Fprintln(cmd.OutOrStdout(), res.Msg.GetVariable().GetValue())
446		return err
447	}
448	return cmd
449}
450
451func newVariableListCmd(env *cliEnv) *cobra.Command {
452	var f settingsFlags
453	cmd := &cobra.Command{
454		Use:   "list",
455		Short: "List the variables with their values",
456		Long: "List the variables of one scope with their values and last update; the ones the scope inherits are\n" +
457			"not listed.\n\nJSON fields of a variable (--json): name, value, updated_at (RFC 3339 UTC, null when unset).",
458		Args: cobra.NoArgs,
459	}
460	f.add(cmd, env, "variables")
461	asJSON := addJSONFlag(cmd)
462	cmd.RunE = func(cmd *cobra.Command, _ []string) error {
463		if err := f.check(); err != nil {
464			return err
465		}
466		scope, _, err := f.scope()
467		if err != nil {
468			return err
469		}
470		c, err := env.client(cmd)
471		if err != nil {
472			return err
473		}
474		ctx := env.ctx(cmd)
475		items, err := collect(ctx, func(token string) ([]*ermite.Variable, string, error) {
476			res, err := c.Variables().ListVariables(ctx, connect.NewRequest(&ermite.ListVariablesRequest{Scope: scope, PageToken: token}))
477			if err != nil {
478				return nil, "", c.Describe(err)
479			}
480			return res.Msg.GetVariables(), res.Msg.GetNextPageToken(), nil
481		})
482		if err != nil {
483			return err
484		}
485		if *asJSON {
486			out := make([]jsonVariable, 0, len(items))
487			for _, v := range items {
488				out = append(out, variableJSON(v))
489			}
490			return writeJSON(cmd.OutOrStdout(), out)
491		}
492		if len(items) == 0 {
493			noticef(cmd, "no variables")
494			return nil
495		}
496		rows := make([][]string, 0, len(items))
497		for _, v := range items {
498			rows = append(rows, []string{v.GetName(), v.GetValue(), shortTime(v.GetUpdatedAt(), "")})
499		}
500		return table(cmd.OutOrStdout(), []string{"NAME", "VALUE", "UPDATED"}, rows)
501	}
502	return cmd
503}
504
505func newVariableDeleteCmd(env *cliEnv) *cobra.Command {
506	var f settingsFlags
507	cmd := &cobra.Command{
508		Use:   "delete NAME",
509		Short: "Delete a variable",
510		Args:  cobra.ExactArgs(1),
511	}
512	f.add(cmd, env, "variable")
513	cmd.RunE = func(cmd *cobra.Command, args []string) error {
514		if err := f.check(); err != nil {
515			return err
516		}
517		scope, where, err := f.scope()
518		if err != nil {
519			return err
520		}
521		c, err := env.client(cmd)
522		if err != nil {
523			return err
524		}
525		if _, err := c.Variables().DeleteVariable(env.ctx(cmd), connect.NewRequest(&ermite.DeleteVariableRequest{Scope: scope, Name: args[0]})); err != nil {
526			return c.Describe(err)
527		}
528		_, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted variable %s from %s\n", clean(strings.ToUpper(args[0])), clean(where))
529		return err
530	}
531	return cmd
532}
533
534// --- environment ---
535
536type jsonEnvironment struct {
537	Repo      string  `json:"repo"`
538	Name      string  `json:"name"`
539	CreatedAt *string `json:"created_at"`
540}
541
542func newEnvironmentCmd(env *cliEnv) *cobra.Command {
543	e := &cobra.Command{
544		Use:   "environment",
545		Short: "Manage the environments of a repository",
546		Long: "Create, list and delete the environments of a repository, to which secrets and variables are attached\n" +
547			"(`ermite secret set --env`). A job that names one with `environment:` creates it on first use. An\n" +
548			"environment has no protection rules and no deployments. Access is that of `ermite secret`.",
549	}
550	e.AddCommand(newEnvironmentCreateCmd(env), newEnvironmentListCmd(env), newEnvironmentDeleteCmd(env))
551	return e
552}
553
554func addRepoFlag(cmd *cobra.Command, env *cliEnv, repo *string) {
555	cmd.Flags().StringVarP(repo, "repo", "R", "", "the repository")
556	_ = cmd.MarkFlagRequired("repo")
557	_ = cmd.RegisterFlagCompletionFunc("repo", func(c *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
558		return repoNames(env, c, toComplete), cobra.ShellCompDirectiveNoFileComp
559	})
560}
561
562func newEnvironmentCreateCmd(env *cliEnv) *cobra.Command {
563	var repo string
564	cmd := &cobra.Command{
565		Use:   "create NAME --repo REPO",
566		Short: "Create an environment",
567		Long:  "Create an environment. One that exists already (in any case) is left as it is.",
568		Args:  cobra.ExactArgs(1),
569	}
570	addRepoFlag(cmd, env, &repo)
571	cmd.RunE = func(cmd *cobra.Command, args []string) error {
572		c, err := env.client(cmd)
573		if err != nil {
574			return err
575		}
576		res, err := c.Environments().CreateEnvironment(env.ctx(cmd), connect.NewRequest(&ermite.CreateEnvironmentRequest{Repo: repo, Name: args[0]}))
577		if err != nil {
578			return c.Describe(err)
579		}
580		_, err = fmt.Fprintf(cmd.OutOrStdout(), "environment %s of %s\n", clean(res.Msg.GetEnvironment().GetName()), clean(repo))
581		return err
582	}
583	return cmd
584}
585
586func newEnvironmentListCmd(env *cliEnv) *cobra.Command {
587	var repo string
588	cmd := &cobra.Command{
589		Use:   "list --repo REPO",
590		Short: "List the environments of a repository",
591		Long:  "List the environments of a repository ordered by name.\n\nJSON fields of an environment (--json): repo, name, created_at (RFC 3339 UTC, null when unset).",
592		Args:  cobra.NoArgs,
593	}
594	addRepoFlag(cmd, env, &repo)
595	asJSON := addJSONFlag(cmd)
596	cmd.RunE = func(cmd *cobra.Command, _ []string) error {
597		c, err := env.client(cmd)
598		if err != nil {
599			return err
600		}
601		ctx := env.ctx(cmd)
602		items, err := collect(ctx, func(token string) ([]*ermite.Environment, string, error) {
603			res, err := c.Environments().ListEnvironments(ctx, connect.NewRequest(&ermite.ListEnvironmentsRequest{Repo: repo, PageToken: token}))
604			if err != nil {
605				return nil, "", c.Describe(err)
606			}
607			return res.Msg.GetEnvironments(), res.Msg.GetNextPageToken(), nil
608		})
609		if err != nil {
610			return err
611		}
612		if *asJSON {
613			out := make([]jsonEnvironment, 0, len(items))
614			for _, e := range items {
615				out = append(out, jsonEnvironment{Repo: e.GetRepo(), Name: e.GetName(), CreatedAt: jsonTime(e.GetCreatedAt())})
616			}
617			return writeJSON(cmd.OutOrStdout(), out)
618		}
619		if len(items) == 0 {
620			noticef(cmd, "no environments")
621			return nil
622		}
623		rows := make([][]string, 0, len(items))
624		for _, e := range items {
625			rows = append(rows, []string{e.GetName(), shortTime(e.GetCreatedAt(), "")})
626		}
627		return table(cmd.OutOrStdout(), []string{"NAME", "CREATED"}, rows)
628	}
629	return cmd
630}
631
632func newEnvironmentDeleteCmd(env *cliEnv) *cobra.Command {
633	var repo string
634	cmd := &cobra.Command{
635		Use:   "delete NAME --repo REPO",
636		Short: "Delete an environment with its secrets and variables",
637		Args:  cobra.ExactArgs(1),
638	}
639	addRepoFlag(cmd, env, &repo)
640	cmd.RunE = func(cmd *cobra.Command, args []string) error {
641		c, err := env.client(cmd)
642		if err != nil {
643			return err
644		}
645		if _, err := c.Environments().DeleteEnvironment(env.ctx(cmd), connect.NewRequest(&ermite.DeleteEnvironmentRequest{Repo: repo, Name: args[0]})); err != nil {
646			return c.Describe(err)
647		}
648		_, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted environment %s of %s\n", clean(args[0]), clean(repo))
649		return err
650	}
651	return cmd
652}

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 4

Tags 45

↑↓ moveenter openesc close