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}