mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
Adds `coder secret import <file>` to bulk-import dotenv, JSON, or YAML secrets through the existing batch API. The command infers the format from the extension or accepts `--input-format`, supports non-interactive stdin, validates files locally before upload, and warns when imported keys cannot be injected as environment variables. Reviewed and updated by Coder Agents on behalf of @dylanhuff-at-coder.
703 lines
20 KiB
Go
703 lines
20 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/dustin/go-humanize"
|
|
"github.com/dustin/go-humanize/english"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/cli/cliui"
|
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
|
"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 = ptr.Ref(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 = ptr.Ref(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 {
|
|
codersdk.UserSecret `table:"-"`
|
|
|
|
Created string `json:"-" table:"created"`
|
|
Name string `json:"-" table:"name,default_sort"`
|
|
Updated string `json:"-" table:"updated"`
|
|
Env string `json:"-" table:"env"`
|
|
File string `json:"-" table:"file"`
|
|
Enabled string `json:"-" table:"enabled"`
|
|
Description string `json:"-" table:"description"`
|
|
}
|
|
|
|
func secretListRowFromSecret(secret codersdk.UserSecret) secretListRow {
|
|
return secretListRow{
|
|
UserSecret: secret,
|
|
Created: humanize.Time(secret.CreatedAt),
|
|
Name: secret.Name,
|
|
Updated: humanize.Time(secret.UpdatedAt),
|
|
Env: secret.EnvName,
|
|
File: secret.FilePath,
|
|
Enabled: strconv.FormatBool(secret.Enabled),
|
|
Description: secret.Description,
|
|
}
|
|
}
|
|
|
|
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: ptr.Ref(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
|
|
}
|
|
|
|
func (r *RootCmd) secretList() *serpent.Command {
|
|
formatter := cliui.NewOutputFormatter(
|
|
cliui.ChangeFormatterData(
|
|
cliui.TableFormat(
|
|
[]secretListRow{},
|
|
[]string{"name", "created", "updated", "env", "file", "enabled", "description"},
|
|
),
|
|
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([]codersdk.UserSecret, len(rows))
|
|
for i := range rows {
|
|
secrets[i] = rows[i].UserSecret
|
|
}
|
|
return secrets, nil
|
|
case secretListRow:
|
|
return []codersdk.UserSecret{rows.UserSecret}, 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.",
|
|
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 {
|
|
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 = secretListRowFromSecret(secret)
|
|
} else {
|
|
secrets, err := client.UserSecrets(inv.Context(), codersdk.Me)
|
|
if err != nil {
|
|
return xerrors.Errorf("list secrets: %w", err)
|
|
}
|
|
|
|
rows := make([]secretListRow, len(secrets))
|
|
for i := range secrets {
|
|
rows[i] = secretListRowFromSecret(secrets[i])
|
|
}
|
|
data = rows
|
|
}
|
|
|
|
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
|
|
},
|
|
}
|
|
|
|
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
|
|
}
|