Ctrl K
ComunidadGuías…

cli/secret.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package cli

import (
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"
	"unicode/utf8"

	"github.com/dustin/go-humanize"
	"github.com/dustin/go-humanize/english"
	"github.com/google/uuid"
	"golang.org/x/xerrors"

	"github.com/coder/coder/v2/cli/cliui"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/pretty"
	"github.com/coder/serpent"
)

func (r *RootCmd) secrets() *serpent.Command {
	cmd := &serpent.Command{
		Use:     "secret",
		Aliases: []string{"secrets"},
		Short:   "Manage secrets",
		Long: FormatExamples(
			Example{
				Description: "Create a secret",
				Command:     "printf %s \"$MYCLI_API_KEY\" | coder secret create api-key --description \"API key for workspace tools\" --env API_KEY --file \"~/.api-key\"",
			},
			Example{
				Description: "Update a secret",
				Command:     "echo -n \"$NEW_SECRET_VALUE\" | coder secret update api-key --description \"Rotated API key\" --env API_KEY --file \"~/.api-key\"",
			},
			Example{
				Description: "Import secrets from a file",
				Command:     "coder secret import ./secrets.env",
			},
			Example{
				Description: "List your secrets",
				Command:     "coder secret list",
			},
			Example{
				Description: "Show a specific secret",
				Command:     "coder secret list api-key",
			},
			Example{
				Description: "Delete a secret",
				Command:     "coder secret delete api-key",
			},
		),
		Handler: func(inv *serpent.Invocation) error {
			return inv.Command.HelpHandler(inv)
		},
		Children: []*serpent.Command{
			r.secretCreate(),
			r.secretUpdate(),
			r.secretImport(),
			r.secretEnable(),
			r.secretDisable(),
			r.secretList(),
			r.secretDelete(),
		},
	}

	return cmd
}

func (r *RootCmd) secretCreate() *serpent.Command {
	var (
		value       string
		description string
		env         string
		file        string
		enabled     bool
	)

	cmd := &serpent.Command{
		Use:   "create <name>",
		Short: "Create a secret",
		Long:  "Provide the secret value with --value or non-interactive stdin (pipe or redirect).",
		Middleware: serpent.Chain(
			serpent.RequireNArgs(1),
		),
		Options: serpent.OptionSet{
			{
				Name:        "value",
				Flag:        "value",
				Description: "Set the secret value. For security reasons, prefer non-interactive stdin (pipe or redirect).",
				Value:       serpent.StringOf(&value),
			},
			{
				Name:        "description",
				Flag:        "description",
				Description: "Set the secret description.",
				Value:       serpent.StringOf(&description),
			},
			{
				Name:        "env",
				Flag:        "env",
				Description: "Name of the workspace environment variable that this secret will set.",
				Value:       serpent.StringOf(&env),
			},
			{
				Name:        "file",
				Flag:        "file",
				Description: "Workspace file path where this secret will be written. Must start with ~/ or /.",
				Value:       serpent.StringOf(&file),
			},
			{
				Name:        "enabled",
				Flag:        "enabled",
				Description: "Whether the secret is injected into workspaces. An enabled secret must set --env or --file; pass --enabled=false to store a secret without injecting it.",
				Default:     "true",
				Value:       serpent.BoolOf(&enabled),
			},
		},
		Handler: func(inv *serpent.Invocation) error {
			client, err := r.InitClient(inv)
			if err != nil {
				return err
			}

			resolvedValue, ok, err := secretValue(inv, value)
			if err != nil {
				return err
			}
			if !ok {
				if isTTYIn(inv) {
					return xerrors.New("secret value must be provided with --value or stdin via pipe or redirect")
				}
				return xerrors.New("secret value must be provided by exactly one of --value or non-interactive stdin (pipe or redirect)")
			}

			req := codersdk.CreateUserSecretRequest{
				Name:        inv.Args[0],
				Value:       resolvedValue,
				Description: description,
				EnvName:     env,
				FilePath:    file,
			}
			if userSetOption(inv, "enabled") {
				req.Enabled = new(enabled)
			}

			secret, err := client.CreateUserSecret(inv.Context(), codersdk.Me, req)
			if err != nil {
				return xerrors.Errorf("create secret %q: %w", inv.Args[0], err)
			}

			_, _ = fmt.Fprintf(inv.Stdout, "Created secret %s.\n", cliui.Keyword(secret.Name))
			return nil
		},
	}

	return cmd
}

func (r *RootCmd) secretUpdate() *serpent.Command {
	var (
		value       string
		description string
		env         string
		file        string
		enabled     bool
	)

	cmd := &serpent.Command{
		Use:   "update <name>",
		Short: "Update a secret",
		Long: strings.Join([]string{
			"At least one of --value, --description, --env, --file, or --enabled must be specified.",
			"Provide the secret value by at most one of --value or non-interactive stdin (pipe or redirect).",
		}, " "),
		Middleware: serpent.Chain(
			serpent.RequireNArgs(1),
		),
		Options: serpent.OptionSet{
			{
				Name:        "value",
				Flag:        "value",
				Description: "Update the secret value. For security reasons, prefer non-interactive stdin (pipe or redirect).",
				Value:       serpent.StringOf(&value),
			},
			{
				Name:        "description",
				Flag:        "description",
				Description: "Update the secret description. Pass an empty string to clear it.",
				Value:       serpent.StringOf(&description),
			},
			{
				Name:        "env",
				Flag:        "env",
				Description: "Name of the workspace environment variable that this secret will set. Pass an empty string to clear it.",
				Value:       serpent.StringOf(&env),
			},
			{
				Name:        "file",
				Flag:        "file",
				Description: "Workspace file path where this secret will be written. Must start with ~/ or /. Pass an empty string to clear it.",
				Value:       serpent.StringOf(&file),
			},
			{
				Name:        "enabled",
				Flag:        "enabled",
				Description: "Whether the secret is injected into workspaces. An enabled secret must keep at least one of --env or --file; pass --enabled=false to stop injecting it without deleting it.",
				Value:       serpent.BoolOf(&enabled),
			},
		},
		Handler: func(inv *serpent.Invocation) error {
			client, err := r.InitClient(inv)
			if err != nil {
				return err
			}

			req := codersdk.UpdateUserSecretRequest{}
			resolvedValue, ok, err := secretValue(inv, value)
			if err != nil {
				return err
			}
			if ok {
				req.Value = &resolvedValue
			}
			if userSetOption(inv, "description") {
				req.Description = &description
			}
			if userSetOption(inv, "env") {
				req.EnvName = &env
			}
			if userSetOption(inv, "file") {
				req.FilePath = &file
			}
			if userSetOption(inv, "enabled") {
				req.Enabled = new(enabled)
			}

			secret, err := client.UpdateUserSecret(inv.Context(), codersdk.Me, inv.Args[0], req)
			if err != nil {
				return xerrors.Errorf("update secret %q: %w", inv.Args[0], err)
			}

			_, _ = fmt.Fprintf(inv.Stdout, "Updated secret %s.\n", cliui.Keyword(secret.Name))
			return nil
		},
	}

	return cmd
}

var secretsFileFormats = []string{
	string(codersdk.SecretsFileFormatEnv),
	string(codersdk.SecretsFileFormatJSON),
	string(codersdk.SecretsFileFormatYAML),
}

func (r *RootCmd) secretImport() *serpent.Command {
	var inputFormat string

	cmd := &serpent.Command{
		Use:   "import <file>",
		Short: "Import secrets from a file",
		Long: strings.Join([]string{
			"Every key in the file becomes a secret.",
			"Keys allowed as environment variable names are injected into workspaces under the same name.",
			"The import is all or nothing, and existing secrets are never overwritten.",
			"Pass - to read the file from non-interactive stdin (pipe or redirect).",
		}, " "),
		Middleware: serpent.Chain(
			serpent.RequireNArgs(1),
		),
		Options: serpent.OptionSet{
			{
				Name:        "input-format",
				Flag:        "input-format",
				Description: "Format of the secrets file. Inferred from the file extension when unset, and required when reading from stdin.",
				Value:       serpent.EnumOf(&inputFormat, secretsFileFormats...),
			},
		},
		Handler: func(inv *serpent.Invocation) error {
			client, err := r.InitClient(inv)
			if err != nil {
				return err
			}

			path := inv.Args[0]
			// serpent.EnumOf matches case-insensitively but keeps the input
			// verbatim, and the parser only accepts lowercase formats.
			format := codersdk.SecretsFileFormat(strings.ToLower(inputFormat))
			if format == "" {
				format, err = secretsFileFormatFromPath(path)
				if err != nil {
					return err
				}
			}

			content, err := readSecretsFile(inv, path)
			if err != nil {
				return err
			}
			// Parse and validate before sending so that a file picked by mistake,
			// such as a private key, never leaves the machine.
			requests, err := codersdk.ParseSecretsFile(format, string(content))
			if err != nil {
				return xerrors.Errorf("parse %q: %w", path, err)
			}
			if err := validateImportedSecrets(requests); err != nil {
				return xerrors.Errorf("validate %q: %w", path, err)
			}

			secrets, err := client.ImportUserSecrets(inv.Context(), codersdk.Me, codersdk.ImportUserSecretsRequest{
				Format:  format,
				Content: string(content),
			})
			if err != nil {
				return xerrors.Errorf("import secrets from %q: %w", path, err)
			}

			_, _ = fmt.Fprintf(inv.Stdout, "Imported %s.\n", english.Plural(len(secrets), "secret", ""))
			warnSecretsWithoutEnvName(inv.Stderr, secrets)
			return nil
		},
	}

	return cmd
}

// secretsFileFormatFromPath infers the format from the file extension.
// Extensions that do not map to a format, such as ".env.local", require
// --input-format.
func secretsFileFormatFromPath(path string) (codersdk.SecretsFileFormat, error) {
	switch strings.ToLower(filepath.Ext(path)) {
	case ".env":
		return codersdk.SecretsFileFormatEnv, nil
	case ".json":
		return codersdk.SecretsFileFormatJSON, nil
	case ".yaml", ".yml":
		return codersdk.SecretsFileFormatYAML, nil
	default:
		return "", xerrors.Errorf("cannot infer the secrets file format from %q, set --input-format to one of: %s", path, strings.Join(secretsFileFormats, ", "))
	}
}

// readSecretsFile reads the file at path, or stdin when path is "-". It never
// reads more than one byte past the limit the server accepts.
func readSecretsFile(inv *serpent.Invocation, path string) ([]byte, error) {
	reader := inv.Stdin
	if path == "-" && isTTYIn(inv) {
		return nil, xerrors.New("secrets file must be provided via non-interactive stdin (pipe or redirect)")
	}
	if path != "-" {
		file, err := os.Open(path)
		if err != nil {
			return nil, xerrors.Errorf("open secrets file: %w", err)
		}
		defer file.Close()
		reader = file
	}

	content, err := io.ReadAll(io.LimitReader(reader, codersdk.MaxSecretsFileBytes+1))
	if err != nil {
		return nil, xerrors.Errorf("read secrets file: %w", err)
	}
	if len(content) > codersdk.MaxSecretsFileBytes {
		return nil, xerrors.Errorf("secrets file exceeds the maximum allowed size of %d bytes", codersdk.MaxSecretsFileBytes)
	}
	if !utf8.Valid(content) {
		return nil, xerrors.New("secrets file must contain valid UTF-8")
	}
	return content, nil
}

func validateImportedSecrets(requests []codersdk.CreateUserSecretRequest) error {
	var validationErrors []string
	for i, request := range requests {
		for _, validation := range codersdk.ValidateCreateUserSecretRequest(request) {
			validationErrors = append(validationErrors, fmt.Sprintf("secret %d (%q) %s: %s", i+1, request.Name, validation.Field, validation.Detail))
		}
	}
	if len(validationErrors) > 0 {
		return xerrors.New(strings.Join(validationErrors, "; "))
	}
	return nil
}

// warnSecretsWithoutEnvName reports imported secrets whose key is not a valid
// environment variable name. They are stored with an empty env name and are
// never injected into workspaces until one is set.
func warnSecretsWithoutEnvName(w io.Writer, secrets []codersdk.UserSecret) {
	names := make([]string, 0, len(secrets))
	for _, secret := range secrets {
		if secret.EnvName == "" {
			names = append(names, strconv.Quote(secret.Name))
		}
	}
	if len(names) == 0 {
		return
	}

	cliui.Warn(w,
		fmt.Sprintf("%s imported without an environment variable name: %s",
			english.Plural(len(names), "secret", ""), strings.Join(names, ", ")),
		"Set each with `coder secret update <name> --env <ENV_NAME>` to inject it into workspaces.",
	)
}

func secretValue(inv *serpent.Invocation, value string) (string, bool, error) {
	valueProvided := userSetOption(inv, "value")
	stdinValue, stdinProvided, err := readInvocationStdin(inv)
	if err != nil {
		return "", false, err
	}

	sourceNames := make([]string, 0, 2)
	if valueProvided {
		sourceNames = append(sourceNames, "--value")
	}
	if stdinProvided {
		sourceNames = append(sourceNames, "stdin")
	}
	if len(sourceNames) > 1 {
		return "", false, xerrors.Errorf("secret value may be provided by only one source, got %s", strings.Join(sourceNames, ", "))
	}

	if valueProvided {
		return value, true, nil
	}

	if stdinProvided {
		warnSuspiciousTrailingNewline(inv.Stderr, stdinValue)
		return stdinValue, true, nil
	}

	return "", false, nil
}

func readInvocationStdin(inv *serpent.Invocation) (string, bool, error) {
	if isTTYIn(inv) {
		return "", false, nil
	}

	bytes, err := io.ReadAll(inv.Stdin)
	if err != nil {
		return "", false, xerrors.Errorf("reading stdin: %w", err)
	}
	if len(bytes) == 0 {
		return "", false, nil
	}

	return string(bytes), true, nil
}

// Shell helpers like echo usually append a line ending to piped stdin. We
// treat a single trailing LF or CRLF as suspicious, but avoid flagging values
// that are clearly multiline.
func hasSuspiciousTrailingNewline(value string) bool {
	switch {
	case strings.HasSuffix(value, "\r\n"):
		trimmed := strings.TrimSuffix(value, "\r\n")
		return !strings.ContainsAny(trimmed, "\r\n")
	case strings.HasSuffix(value, "\n"):
		trimmed := strings.TrimSuffix(value, "\n")
		return !strings.ContainsAny(trimmed, "\r\n")
	case strings.HasSuffix(value, "\r"):
		trimmed := strings.TrimSuffix(value, "\r")
		return !strings.ContainsAny(trimmed, "\r\n")
	default:
		return false
	}
}

func warnSuspiciousTrailingNewline(w io.Writer, value string) {
	if !hasSuspiciousTrailingNewline(value) {
		return
	}

	cliui.Warn(w, "secret value from stdin ends with a trailing newline")
}

type secretListRow struct {
	// Secret is the JSON output: a codersdk.UserSecret, or a
	// codersdk.WorkspaceSecret for a workspace build's secret.
	Secret any `table:"-"`

	Created     string `json:"-" table:"created"`
	Name        string `json:"-" table:"name,default_sort"`
	Updated     string `json:"-" table:"updated"`
	Source      string `json:"-" table:"source"`
	Env         string `json:"-" table:"env"`
	File        string `json:"-" table:"file"`
	Enabled     string `json:"-" table:"enabled"`
	ReplacedBy  string `json:"-" table:"replaced by"`
	Ephemeral   string `json:"-" table:"ephemeral"`
	Description string `json:"-" table:"description"`
}

var (
	secretListColumns      = []string{"name", "created", "updated", "env", "file", "enabled", "description"}
	secretListBuildColumns = []string{"name", "source", "created", "updated", "env", "file", "enabled", "replaced by", "description"}
)

// secretListRowFromUserSecret builds a table row for a user secret.
// replacedBy lists the build secrets that take its targets.
func secretListRowFromUserSecret(secret codersdk.UserSecret, replacedBy []string) secretListRow {
	return secretListRow{
		Secret:      secret,
		Created:     humanize.Time(secret.CreatedAt),
		Name:        secret.Name,
		Updated:     humanize.Time(secret.UpdatedAt),
		Source:      "user",
		Env:         secret.EnvName,
		File:        secret.FilePath,
		Enabled:     strconv.FormatBool(secret.Enabled),
		ReplacedBy:  strings.Join(replacedBy, ", "),
		Ephemeral:   "false",
		Description: secret.Description,
	}
}

func secretListRowFromBuildSecret(secret codersdk.WorkspaceSecret) secretListRow {
	row := secretListRowFromUserSecret(secret.UserSecret, nil)
	row.Secret = secret
	row.Source = "build"
	row.Ephemeral = strconv.FormatBool(secret.Ephemeral)
	return row
}

// secretListRows builds the listing rows. A user secret's "replaced by"
// names the build secrets that take its env var or file.
func secretListRows(userSecrets []codersdk.UserSecret, buildSecrets []codersdk.WorkspaceSecret) []secretListRow {
	replacedBy := make(map[uuid.UUID][]string)
	for _, s := range buildSecrets {
		if s.EnvReplaces != nil {
			replacedBy[*s.EnvReplaces] = append(replacedBy[*s.EnvReplaces], "env: build/"+s.Name)
		}
		if s.FileReplaces != nil {
			replacedBy[*s.FileReplaces] = append(replacedBy[*s.FileReplaces], "file: build/"+s.Name)
		}
	}
	rows := make([]secretListRow, 0, len(userSecrets)+len(buildSecrets))
	for _, s := range userSecrets {
		rows = append(rows, secretListRowFromUserSecret(s, replacedBy[s.ID]))
	}
	for _, s := range buildSecrets {
		rows = append(rows, secretListRowFromBuildSecret(s))
	}
	return rows
}

func (r *RootCmd) secretEnable() *serpent.Command {
	return r.secretEnabledSetter(secretEnabledStateEnabled)
}

func (r *RootCmd) secretDisable() *serpent.Command {
	return r.secretEnabledSetter(secretEnabledStateDisabled)
}

// secretEnabledState distinguishes the two `coder secret enable` and
// `coder secret disable` subcommands without using a bare bool, which
// revive's flag-parameter rule treats as a control coupling.
type secretEnabledState int

const (
	secretEnabledStateEnabled secretEnabledState = iota
	secretEnabledStateDisabled
)

// secretEnabledSetter builds the `coder secret enable` and `coder secret
// disable` subcommands. Both are a one-field PATCH that flips the enabled
// state. Disabling stops injection for new sessions but leaves the secret
// in place so it can be re-enabled later; existing sessions keep injected
// values until the workspace's agent manifest is refetched.
func (r *RootCmd) secretEnabledSetter(state secretEnabledState) *serpent.Command {
	var (
		verb       string
		participle string
		short      string
		enabled    bool
	)
	switch state {
	case secretEnabledStateEnabled:
		verb = "enable"
		participle = "Enabled"
		short = "Enable a secret so it is injected into workspaces"
		enabled = true
	case secretEnabledStateDisabled:
		verb = "disable"
		participle = "Disabled"
		short = "Disable a secret without removing it"
		enabled = false
	}

	cmd := &serpent.Command{
		Use:   fmt.Sprintf("%s <name>", verb),
		Short: short,
		Middleware: serpent.Chain(
			serpent.RequireNArgs(1),
		),
		Handler: func(inv *serpent.Invocation) error {
			client, err := r.InitClient(inv)
			if err != nil {
				return err
			}

			name := inv.Args[0]
			secret, err := client.UpdateUserSecret(inv.Context(), codersdk.Me, name, codersdk.UpdateUserSecretRequest{
				Enabled: new(enabled),
			})
			if err != nil {
				return xerrors.Errorf("%s secret %q: %w", verb, name, err)
			}

			_, _ = fmt.Fprintf(inv.Stdout, "%s secret %s.\n", participle, cliui.Keyword(secret.Name))
			return nil
		},
	}

	return cmd
}

// secretListBuildID picks the workspace build whose secrets to include.
// Explicit flags win; otherwise the current workspace is used when the
// command runs inside one. uuid.Nil means list user secrets only. fromEnv
// reports that the build came from the workspace environment rather than a
// flag.
func secretListBuildID(inv *serpent.Invocation, client *codersdk.Client, workspace, buildID string) (id uuid.UUID, fromEnv bool, err error) {
	if buildID != "" {
		id, err := uuid.Parse(buildID)
		if err != nil {
			return uuid.Nil, false, xerrors.Errorf("parse --build-id: %w", err)
		}
		return id, false, nil
	}
	if workspace != "" {
		ws, err := client.ResolveWorkspace(inv.Context(), workspace)
		if err != nil {
			return uuid.Nil, false, xerrors.Errorf("get workspace %q: %w", workspace, err)
		}
		return ws.LatestBuild.ID, false, nil
	}
	// Set by the agent inside a workspace.
	if v := inv.Environ.Get("CODER_WORKSPACE_BUILD_ID"); v != "" {
		id, err := uuid.Parse(v)
		if err != nil {
			return uuid.Nil, false, xerrors.Errorf("parse CODER_WORKSPACE_BUILD_ID: %w", err)
		}
		return id, true, nil
	}
	// Agents started before the server sent the build ID only set the
	// workspace ID, so fall back to the workspace's latest build.
	if v := inv.Environ.Get("CODER_WORKSPACE_ID"); v != "" {
		id, err := uuid.Parse(v)
		if err != nil {
			return uuid.Nil, false, xerrors.Errorf("parse CODER_WORKSPACE_ID: %w", err)
		}
		ws, err := client.Workspace(inv.Context(), id)
		if isNotFound(err) {
			warnCurrentWorkspaceNotFound(inv, client)
			return uuid.Nil, false, nil
		}
		if err != nil {
			return uuid.Nil, false, xerrors.Errorf("get current workspace: %w", err)
		}
		return ws.LatestBuild.ID, true, nil
	}
	return uuid.Nil, false, nil
}

func warnCurrentWorkspaceNotFound(inv *serpent.Invocation, client *codersdk.Client) {
	cliui.Warn(inv.Stderr, fmt.Sprintf("The current workspace is not visible to the logged-in user on %s, so only that user's secrets are listed.", client.URL),
		"Use --workspace or --build-id to list a workspace's secrets.")
}

func isNotFound(err error) bool {
	cerr, ok := codersdk.AsError(err)
	return ok && cerr.StatusCode() == http.StatusNotFound
}

// secretListBuildSecrets returns the secrets of the workspace build chosen by
// secretListBuildID. included is false when only user secrets are listed.
// Secrets of
// another user's build are never mixed into the logged-in user's listing: a
// build from the workspace environment is skipped, and an explicit one fails.
func secretListBuildSecrets(inv *serpent.Invocation, client *codersdk.Client, workspace, buildID string) (secrets []codersdk.WorkspaceSecret, included bool, err error) {
	id, fromEnv, err := secretListBuildID(inv, client, workspace, buildID)
	if err != nil || id == uuid.Nil {
		return nil, false, err
	}
	build, err := client.WorkspaceBuild(inv.Context(), id)
	if fromEnv && isNotFound(err) {
		warnCurrentWorkspaceNotFound(inv, client)
		return nil, false, nil
	}
	if err != nil {
		return nil, false, xerrors.Errorf("get workspace build %s: %w", id, err)
	}
	me, err := client.User(inv.Context(), codersdk.Me)
	if err != nil {
		return nil, false, xerrors.Errorf("get logged-in user: %w", err)
	}
	if build.WorkspaceOwnerID != me.ID {
		if fromEnv {
			return nil, false, nil
		}
		return nil, false, xerrors.Errorf("workspace build %s belongs to %q, not the logged-in user %q", id, build.WorkspaceOwnerName, me.Username)
	}
	secrets, err = client.WorkspaceBuildSecrets(inv.Context(), id)
	if err != nil {
		return nil, false, xerrors.Errorf("list workspace build secrets: %w", err)
	}
	return secrets, true, nil
}

func (r *RootCmd) secretList() *serpent.Command {
	var workspace, buildID string
	formatter := cliui.NewOutputFormatter(
		cliui.ChangeFormatterData(
			cliui.TableFormat(
				[]secretListRow{},
				secretListColumns,
			),
			func(data any) (any, error) {
				switch rows := data.(type) {
				case []secretListRow:
					return rows, nil
				case secretListRow:
					return []secretListRow{rows}, nil
				default:
					return nil, xerrors.Errorf("expected []secretListRow or secretListRow, got %T", data)
				}
			},
		),
		cliui.ChangeFormatterData(
			cliui.JSONFormat(),
			func(data any) (any, error) {
				switch rows := data.(type) {
				case []secretListRow:
					secrets := make([]any, len(rows))
					for i := range rows {
						secrets[i] = rows[i].Secret
					}
					return secrets, nil
				case secretListRow:
					return []any{rows.Secret}, nil
				default:
					return nil, xerrors.Errorf("expected []secretListRow or secretListRow, got %T", data)
				}
			},
		),
	)

	cmd := &serpent.Command{
		Use:     "list [name]",
		Aliases: []string{"ls"},
		Short:   "List secrets, or show one by name",
		Long: "Secret values are omitted from the output.\n\n" +
			"When listing all secrets inside a workspace, or with --workspace or --build-id, " +
			"the workspace build's secrets are included and the source and \"replaced by\" columns " +
			"are shown by default. If a build secret uses the same env var " +
			"or file as a user secret, the user secret is not delivered on that target and its " +
			"\"replaced by\" column names the build secret, for example \"env: build/github-token\".",
		Middleware: serpent.RequireRangeArgs(0, 1),
		Handler: func(inv *serpent.Invocation) error {
			client, err := r.InitClient(inv)
			if err != nil {
				return err
			}

			var data any
			if len(inv.Args) == 1 {
				if workspace != "" || buildID != "" {
					return xerrors.New("--workspace and --build-id cannot be used with a secret name")
				}
				secret, err := client.UserSecretByName(inv.Context(), codersdk.Me, inv.Args[0])
				if err != nil {
					return xerrors.Errorf("get secret %q: %w", inv.Args[0], err)
				}
				data = secretListRowFromUserSecret(secret, nil)
			} else {
				buildSecrets, included, err := secretListBuildSecrets(inv, client, workspace, buildID)
				if err != nil {
					return err
				}
				if included && !inv.ParsedFlags().Changed("column") {
					// Show where each secret comes from only when a build's
					// secrets are listed.
					if opt := inv.Command.Options.ByFlag("column"); opt != nil {
						if columns, ok := opt.Value.(*serpent.EnumArray); ok {
							if err := columns.Replace(secretListBuildColumns); err != nil {
								return xerrors.Errorf("set columns: %w", err)
							}
						}
					}
				}
				userSecrets, err := client.UserSecrets(inv.Context(), codersdk.Me)
				if err != nil {
					return xerrors.Errorf("list secrets: %w", err)
				}
				data = secretListRows(userSecrets, buildSecrets)
			}

			out, err := formatter.Format(inv.Context(), data)
			if err != nil {
				return xerrors.Errorf("format secrets: %w", err)
			}
			if out == "" {
				cliui.Infof(inv.Stderr, "No secrets found.")
				return nil
			}

			_, err = fmt.Fprintln(inv.Stdout, out)
			return err
		},
		Options: serpent.OptionSet{
			{
				Flag:        "workspace",
				Description: "Include the secrets of this workspace's latest build. Inside a workspace, the default is the build the workspace agent was started from.",
				Value:       serpent.StringOf(&workspace),
			},
			{
				Flag:        "build-id",
				Description: "Include the secrets of this workspace build. Takes precedence over --workspace.",
				Value:       serpent.StringOf(&buildID),
			},
		},
	}

	formatter.AttachOptions(&cmd.Options)
	return cmd
}

func (r *RootCmd) secretDelete() *serpent.Command {
	cmd := &serpent.Command{
		Use:     "delete <name>",
		Aliases: []string{"remove", "rm"},
		Short:   "Delete a secret",
		Middleware: serpent.Chain(
			serpent.RequireNArgs(1),
		),
		Options: serpent.OptionSet{
			cliui.SkipPromptOption(),
		},
		Handler: func(inv *serpent.Invocation) error {
			client, err := r.InitClient(inv)
			if err != nil {
				return err
			}

			name := inv.Args[0]
			_, err = cliui.Prompt(inv, cliui.PromptOptions{
				Text:      fmt.Sprintf("Delete secret %s?", pretty.Sprint(cliui.DefaultStyles.Code, name)),
				IsConfirm: true,
				Default:   cliui.ConfirmNo,
			})
			if err != nil {
				return err
			}

			if err = client.DeleteUserSecret(inv.Context(), codersdk.Me, name); err != nil {
				return xerrors.Errorf("delete secret %q: %w", name, err)
			}

			_, _ = fmt.Fprintf(inv.Stdout, "Deleted secret %s at %s.\n", cliui.Keyword(name), cliui.Timestamp(time.Now()))
			return nil
		},
	}

	return cmd
}