Compare commits

...

7 Commits

Author SHA1 Message Date
Dylan Huff
88a2c3644e docs(docs): document personal secrets and register CLI reference pages 2026-04-10 23:14:43 +00:00
Dylan Huff
cdb1499631 fix(docs/reference/cli): regenerate secret create docs 2026-04-10 22:48:12 +00:00
Dylan Huff
9c52b0b862 fix(cli): require secret create values and cover error paths 2026-04-10 22:33:30 +00:00
dylanhuff-at-coder
0f4a784b62 Merge branch 'main' into dylan/implement-cli-for-user-secrets 2026-04-10 15:03:30 -07:00
Dylan Huff
4d1b687865 docs: generate CLI secret command reference 2026-04-10 21:57:49 +00:00
Dylan Huff
3b9cf94b63 fix(cli): quote secret help file paths 2026-04-10 21:52:01 +00:00
Dylan Huff
ee4dccb898 feat(cli): add secret command for managing user secrets 2026-04-10 18:28:17 +00:00
17 changed files with 1080 additions and 0 deletions

View File

@@ -103,6 +103,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
r.portForward(),
r.publickey(),
r.resetPassword(),
r.secrets(),
r.sharing(),
r.state(),
r.tasksCommand(),

340
cli/secret.go Normal file
View File

@@ -0,0 +1,340 @@
package cli
import (
"fmt"
"time"
"github.com/dustin/go-humanize"
"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 personal secrets",
Long: FormatExamples(
Example{
Description: "Create a secret",
Command: "coder secret create openai-key --value \"$SECRET_VALUE\" --description \"Personal OPENAI_API key\" --inject-env OPEN_AI_KEY --inject-file \"~/.openai-key\"",
},
Example{
Description: "Update a secret",
Command: "coder secret update openai-key --value \"$NEW_SECRET_VALUE\" --description \"Updated description\" --inject-env NEW_ENV_NAME --inject-file \"~/.new-path\"",
},
Example{
Description: "List your secrets",
Command: "coder secret list",
},
Example{
Description: "Show a specific secret",
Command: "coder secret list openai-key",
},
Example{
Description: "Delete a secret",
Command: "coder secret delete openai-key",
},
),
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*serpent.Command{
r.secretCreate(),
r.secretUpdate(),
r.secretList(),
r.secretDelete(),
},
}
return cmd
}
func (r *RootCmd) secretCreate() *serpent.Command {
var (
value string
description string
injectEnv string
injectFile string
)
cmd := &serpent.Command{
Use: "create <name>",
Short: "Create a secret",
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
),
Options: serpent.OptionSet{
{
Name: "value",
Flag: "value",
Description: "Set the secret value. This flag is required.",
Value: serpent.StringOf(&value),
Required: true,
},
{
Name: "description",
Flag: "description",
Description: "Set the secret description.",
Value: serpent.StringOf(&description),
},
{
Name: "inject-env",
Flag: "inject-env",
Description: "Inject the secret into workspaces as an environment variable.",
Value: serpent.StringOf(&injectEnv),
},
{
Name: "inject-file",
Flag: "inject-file",
Description: "Inject the secret into workspaces as a file.",
Value: serpent.StringOf(&injectFile),
},
},
Handler: func(inv *serpent.Invocation) error {
client, err := r.InitClient(inv)
if err != nil {
return err
}
secret, err := client.CreateUserSecret(inv.Context(), codersdk.Me, codersdk.CreateUserSecretRequest{
Name: inv.Args[0],
Value: value,
Description: description,
EnvName: injectEnv,
FilePath: injectFile,
})
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
injectEnv string
injectFile string
)
cmd := &serpent.Command{
Use: "update <name>",
Short: "Update a secret",
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
),
Options: serpent.OptionSet{
{
Name: "value",
Flag: "value",
Description: "Update the secret value.",
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: "inject-env",
Flag: "inject-env",
Description: "Update the environment variable injection target. Pass an empty string to clear it.",
Value: serpent.StringOf(&injectEnv),
},
{
Name: "inject-file",
Flag: "inject-file",
Description: "Update the file injection target. Pass an empty string to clear it.",
Value: serpent.StringOf(&injectFile),
},
},
Handler: func(inv *serpent.Invocation) error {
client, err := r.InitClient(inv)
if err != nil {
return err
}
req := codersdk.UpdateUserSecretRequest{}
if userSetOption(inv, "value") {
req.Value = &value
}
if userSetOption(inv, "description") {
req.Description = &description
}
if userSetOption(inv, "inject-env") {
req.EnvName = &injectEnv
}
if userSetOption(inv, "inject-file") {
req.FilePath = &injectFile
}
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
}
type secretListRow struct {
codersdk.UserSecret `table:"-"`
Name string `json:"-" table:"name,default_sort"`
Updated string `json:"-" table:"updated"`
Env string `json:"-" table:"env"`
File string `json:"-" table:"file"`
Description string `json:"-" table:"description"`
}
func secretListRowFromSecret(secret codersdk.UserSecret) secretListRow {
return secretListRow{
UserSecret: secret,
Name: secret.Name,
Updated: humanize.Time(secret.UpdatedAt),
Env: secret.EnvName,
File: secret.FilePath,
Description: secret.Description,
}
}
func (r *RootCmd) secretList() *serpent.Command {
formatter := cliui.NewOutputFormatter(
cliui.ChangeFormatterData(
cliui.TableFormat(
[]secretListRow{},
[]string{"name", "updated", "env", "file", "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",
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"},
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
}

356
cli/secret_test.go Normal file
View File

@@ -0,0 +1,356 @@
package cli_test
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
func TestSecretCreate(t *testing.T) {
t.Parallel()
t.Run("MissingValue", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
inv, root := clitest.New(t, "secret", "create", "openai-key")
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitMedium)
err := inv.WithContext(ctx).Run()
require.ErrorContains(t, err, "Missing values for the required flags: value")
})
t.Run("Success", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
inv, root := clitest.New(
t,
"secret",
"create",
"openai-key",
"--value", "super-secret-value",
"--description", "Personal OPENAI_API key",
"--inject-env", "OPEN_AI_KEY",
"--inject-file", "~/.openai-key",
)
output := clitest.Capture(inv)
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitMedium)
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
require.Contains(t, output.Stdout(), "openai-key")
secret, err := client.UserSecretByName(ctx, codersdk.Me, "openai-key")
require.NoError(t, err)
require.Equal(t, "openai-key", secret.Name)
require.Equal(t, "Personal OPENAI_API key", secret.Description)
require.Equal(t, "OPEN_AI_KEY", secret.EnvName)
require.Equal(t, "~/.openai-key", secret.FilePath)
})
}
func TestSecretUpdate(t *testing.T) {
t.Parallel()
t.Run("ServerValidationError", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
setupCtx := testutil.Context(t, testutil.WaitMedium)
_, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "my-secret",
Value: "original-value",
})
require.NoError(t, err)
inv, root := clitest.New(t, "secret", "update", "my-secret")
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitMedium)
err = inv.WithContext(ctx).Run()
require.ErrorContains(t, err, "At least one field must be provided")
})
t.Run("AllowsClearingFields", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
setupCtx := testutil.Context(t, testutil.WaitMedium)
_, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "my-secret",
Value: "original-value",
Description: "original description",
EnvName: "MY_SECRET",
FilePath: "~/.my-secret",
})
require.NoError(t, err)
inv, root := clitest.New(
t,
"secret",
"update",
"my-secret",
"--value", "rotated-secret",
"--description", "",
"--inject-env", "",
"--inject-file", "",
)
output := clitest.Capture(inv)
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitMedium)
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
require.Contains(t, output.Stdout(), "my-secret")
secret, err := client.UserSecretByName(ctx, codersdk.Me, "my-secret")
require.NoError(t, err)
require.Equal(t, "", secret.Description)
require.Equal(t, "", secret.EnvName)
require.Equal(t, "", secret.FilePath)
})
}
func TestSecretList(t *testing.T) {
t.Parallel()
t.Run("TableOutput", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
setupCtx := testutil.Context(t, testutil.WaitMedium)
_, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "aws-creds",
Value: "aws-value",
Description: "AWS credentials",
FilePath: "~/.aws/creds",
})
require.NoError(t, err)
_, err = client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "github-token",
Value: "ghp_xxxxxxxxxxxx",
Description: "Personal GitHub access token",
EnvName: "GITHUB_TOKEN",
})
require.NoError(t, err)
inv, root := clitest.New(t, "secret", "list")
output := clitest.Capture(inv)
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitMedium)
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
out := output.Stdout()
assert.Contains(t, out, "NAME")
assert.Contains(t, out, "UPDATED")
assert.Contains(t, out, "ENV")
assert.Contains(t, out, "FILE")
assert.Contains(t, out, "DESCRIPTION")
assert.Contains(t, out, "github-token")
assert.Contains(t, out, "GITHUB_TOKEN")
assert.Contains(t, out, "aws-creds")
assert.Contains(t, out, "~/.aws/creds")
})
t.Run("JSONOutput", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
setupCtx := testutil.Context(t, testutil.WaitMedium)
created, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "github-token",
Value: "ghp_xxxxxxxxxxxx",
Description: "Personal GitHub access token",
EnvName: "GITHUB_TOKEN",
})
require.NoError(t, err)
inv, root := clitest.New(t, "secret", "list", "--output=json")
output := clitest.Capture(inv)
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitMedium)
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
var got []codersdk.UserSecret
require.NoError(t, json.Unmarshal([]byte(output.Stdout()), &got))
require.Len(t, got, 1)
require.Equal(t, created, got[0])
})
t.Run("SingleSecretTableOutput", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
setupCtx := testutil.Context(t, testutil.WaitMedium)
_, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "aws-creds",
Value: "aws-value",
Description: "AWS credentials",
FilePath: "~/.aws/creds",
})
require.NoError(t, err)
_, err = client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "github-token",
Value: "ghp_xxxxxxxxxxxx",
Description: "Personal GitHub access token",
EnvName: "GITHUB_TOKEN",
})
require.NoError(t, err)
inv, root := clitest.New(t, "secret", "list", "github-token")
output := clitest.Capture(inv)
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitMedium)
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
out := output.Stdout()
assert.Contains(t, out, "NAME")
assert.Contains(t, out, "UPDATED")
assert.Contains(t, out, "ENV")
assert.Contains(t, out, "FILE")
assert.Contains(t, out, "DESCRIPTION")
assert.Contains(t, out, "github-token")
assert.Contains(t, out, "GITHUB_TOKEN")
assert.NotContains(t, out, "aws-creds")
assert.NotContains(t, out, "~/.aws/creds")
})
t.Run("SingleSecretJSONOutput", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
setupCtx := testutil.Context(t, testutil.WaitMedium)
created, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "github-token",
Value: "ghp_xxxxxxxxxxxx",
Description: "Personal GitHub access token",
EnvName: "GITHUB_TOKEN",
})
require.NoError(t, err)
inv, root := clitest.New(t, "secret", "list", "github-token", "--output=json")
output := clitest.Capture(inv)
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitMedium)
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
var got []codersdk.UserSecret
require.NoError(t, json.Unmarshal([]byte(output.Stdout()), &got))
require.Len(t, got, 1)
require.Equal(t, created, got[0])
})
t.Run("EmptyState", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
inv, root := clitest.New(t, "secret", "list")
output := clitest.Capture(inv)
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitMedium)
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
assert.Contains(t, output.Stderr(), "No secrets found.")
})
}
func TestSecretDelete(t *testing.T) {
t.Parallel()
t.Run("Success", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
setupCtx := testutil.Context(t, testutil.WaitMedium)
_, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "github-token",
Value: "ghp_xxxxxxxxxxxx",
})
require.NoError(t, err)
inv, root := clitest.New(t, "secret", "delete", "github-token")
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitMedium)
inv = inv.WithContext(ctx)
pty := ptytest.New(t).Attach(inv)
waiter := clitest.StartWithWaiter(t, inv)
pty.ExpectMatchContext(ctx, "Delete secret")
pty.ExpectMatchContext(ctx, "github-token")
pty.WriteLine("yes")
pty.ExpectMatchContext(ctx, "Deleted secret")
require.NoError(t, waiter.Wait())
_, err = client.UserSecretByName(setupCtx, codersdk.Me, "github-token")
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
inv, root := clitest.New(t, "secret", "delete", "missing-secret")
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitMedium)
inv = inv.WithContext(ctx)
pty := ptytest.New(t).Attach(inv)
waiter := clitest.StartWithWaiter(t, inv)
pty.ExpectMatchContext(ctx, "Delete secret")
pty.ExpectMatchContext(ctx, "missing-secret")
pty.WriteLine("yes")
err := waiter.Wait()
require.ErrorContains(t, err, `delete secret "missing-secret"`)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
}

View File

@@ -43,6 +43,7 @@ SUBCOMMANDS:
password
restart Restart a workspace
schedule Schedule automated start and stop times for workspaces
secret Manage personal secrets
server Start a Coder server
show Display details of a workspace's resources and agents
speedtest Run upload and download tests from your machine to a

41
cli/testdata/coder_secret_--help.golden vendored Normal file
View File

@@ -0,0 +1,41 @@
coder v0.0.0-devel
USAGE:
coder secret
Manage personal secrets
Aliases: secrets
- Create a secret:
$ coder secret create openai-key --value "$SECRET_VALUE" --description
"Personal OPENAI_API key" --inject-env OPEN_AI_KEY --inject-file
"~/.openai-key"
- Update a secret:
$ coder secret update openai-key --value "$NEW_SECRET_VALUE"
--description "Updated description" --inject-env NEW_ENV_NAME --inject-file
"~/.new-path"
- List your secrets:
$ coder secret list
- Show a specific secret:
$ coder secret list openai-key
- Delete a secret:
$ coder secret delete openai-key
SUBCOMMANDS:
create Create a secret
delete Delete a secret
list List secrets, or show one by name
update Update a secret
———
Run `coder --help` for a list of global options.

View File

@@ -0,0 +1,22 @@
coder v0.0.0-devel
USAGE:
coder secret create [flags] <name>
Create a secret
OPTIONS:
--description string
Set the secret description.
--inject-env string
Inject the secret into workspaces as an environment variable.
--inject-file string
Inject the secret into workspaces as a file.
--value string
Set the secret value. This flag is required.
———
Run `coder --help` for a list of global options.

View File

@@ -0,0 +1,15 @@
coder v0.0.0-devel
USAGE:
coder secret delete [flags] <name>
Delete a secret
Aliases: remove, rm
OPTIONS:
-y, --yes bool
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.

View File

@@ -0,0 +1,18 @@
coder v0.0.0-devel
USAGE:
coder secret list [flags] [name]
List secrets, or show one by name
Aliases: ls
OPTIONS:
-c, --column [name|updated|env|file|description] (default: name,updated,env,file,description)
Columns to display in table output.
-o, --output table|json (default: table)
Output format.
———
Run `coder --help` for a list of global options.

View File

@@ -0,0 +1,23 @@
coder v0.0.0-devel
USAGE:
coder secret update [flags] <name>
Update a secret
OPTIONS:
--description string
Update the secret description. Pass an empty string to clear it.
--inject-env string
Update the environment variable injection target. Pass an empty string
to clear it.
--inject-file string
Update the file injection target. Pass an empty string to clear it.
--value string
Update the secret value.
———
Run `coder --help` for a list of global options.

View File

@@ -42,6 +42,49 @@ Users can view their public key in their account settings:
> SSH keys are never stored in Coder workspaces, and are fetched only when
> SSH is invoked. The keys are held in-memory and never written to disk.
## Personal Secrets
Personal secrets let each user store their own secret values in Coder and make
them available in workspaces without adding those values to template code.
They are a good fit for per-user credentials such as API keys, cloud
credentials, or other values that should follow a user across workspaces.
Use the CLI to create and manage personal secrets:
```sh
# Create a secret and inject it into workspaces as an environment variable.
coder secret create openai-key \
--value "$OPENAI_API_KEY" \
--description "Personal OpenAI API key" \
--inject-env OPENAI_API_KEY
# Create a secret and inject it into a file in your workspace.
coder secret create aws-credentials \
--value "$AWS_CREDENTIALS_FILE_CONTENTS" \
--description "Personal AWS credentials" \
--inject-file ~/.aws/credentials
# List all of your secrets.
coder secret list
# Show a single secret by name.
coder secret list openai-key
# Delete a secret you no longer need.
coder secret delete openai-key
```
Use `--inject-env` to inject a secret into your workspaces as an environment
variable. Use `--inject-file` to inject it as a file in the workspace. File
paths must start with `~/` or `/`.
You can update a secret later with `coder secret update`, including rotating
the value or clearing an injection target by passing an empty string. Use
`coder secret delete` to remove a secret entirely. The secret value itself is
never returned by the API or CLI list output. For full command details, see
[`coder secret`](../../reference/cli/secret.md) and the
[Secrets API reference](../../reference/api/secrets.md).
## Dynamic Secrets
Dynamic secrets are attached to the workspace lifecycle and automatically

View File

@@ -2016,6 +2016,31 @@
"description": "Edit workspace stop schedule",
"path": "reference/cli/schedule_stop.md"
},
{
"title": "secret",
"description": "Manage personal secrets",
"path": "reference/cli/secret.md"
},
{
"title": "secret create",
"description": "Create a secret",
"path": "reference/cli/secret_create.md"
},
{
"title": "secret update",
"description": "Update a secret",
"path": "reference/cli/secret_update.md"
},
{
"title": "secret list",
"description": "List secrets, or show one by name",
"path": "reference/cli/secret_list.md"
},
{
"title": "secret delete",
"description": "Delete a secret",
"path": "reference/cli/secret_delete.md"
},
{
"title": "server",
"description": "Start a Coder server",

View File

@@ -35,6 +35,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr
| [<code>port-forward</code>](./port-forward.md) | Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". |
| [<code>publickey</code>](./publickey.md) | Output your Coder public key used for Git operations |
| [<code>reset-password</code>](./reset-password.md) | Directly connect to the database to reset a user's password |
| [<code>secret</code>](./secret.md) | Manage personal secrets |
| [<code>state</code>](./state.md) | Manually manage Terraform state to fix broken workspaces |
| [<code>task</code>](./task.md) | Manage tasks |
| [<code>templates</code>](./templates.md) | Manage templates |

47
docs/reference/cli/secret.md generated Normal file
View File

@@ -0,0 +1,47 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# secret
Manage personal secrets
Aliases:
* secrets
## Usage
```console
coder secret
```
## Description
```console
- Create a secret:
$ coder secret create openai-key --value "$SECRET_VALUE" --description "Personal OPENAI_API key" --inject-env OPEN_AI_KEY --inject-file "~/.openai-key"
- Update a secret:
$ coder secret update openai-key --value "$NEW_SECRET_VALUE" --description "Updated description" --inject-env NEW_ENV_NAME --inject-file "~/.new-path"
- List your secrets:
$ coder secret list
- Show a specific secret:
$ coder secret list openai-key
- Delete a secret:
$ coder secret delete openai-key
```
## Subcommands
| Name | Purpose |
|-------------------------------------------|-----------------------------------|
| [<code>create</code>](./secret_create.md) | Create a secret |
| [<code>update</code>](./secret_update.md) | Update a secret |
| [<code>list</code>](./secret_list.md) | List secrets, or show one by name |
| [<code>delete</code>](./secret_delete.md) | Delete a secret |

44
docs/reference/cli/secret_create.md generated Normal file
View File

@@ -0,0 +1,44 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# secret create
Create a secret
## Usage
```console
coder secret create [flags] <name>
```
## Options
### --value
| | |
|------|---------------------|
| Type | <code>string</code> |
Set the secret value. This flag is required.
### --description
| | |
|------|---------------------|
| Type | <code>string</code> |
Set the secret description.
### --inject-env
| | |
|------|---------------------|
| Type | <code>string</code> |
Inject the secret into workspaces as an environment variable.
### --inject-file
| | |
|------|---------------------|
| Type | <code>string</code> |
Inject the secret into workspaces as a file.

25
docs/reference/cli/secret_delete.md generated Normal file
View File

@@ -0,0 +1,25 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# secret delete
Delete a secret
Aliases:
* remove
* rm
## Usage
```console
coder secret delete [flags] <name>
```
## Options
### -y, --yes
| | |
|------|-------------------|
| Type | <code>bool</code> |
Bypass confirmation prompts.

34
docs/reference/cli/secret_list.md generated Normal file
View File

@@ -0,0 +1,34 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# secret list
List secrets, or show one by name
Aliases:
* ls
## Usage
```console
coder secret list [flags] [name]
```
## Options
### -c, --column
| | |
|---------|------------------------------------------------------|
| Type | <code>[name\|updated\|env\|file\|description]</code> |
| Default | <code>name,updated,env,file,description</code> |
Columns to display in table output.
### -o, --output
| | |
|---------|--------------------------|
| Type | <code>table\|json</code> |
| Default | <code>table</code> |
Output format.

44
docs/reference/cli/secret_update.md generated Normal file
View File

@@ -0,0 +1,44 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# secret update
Update a secret
## Usage
```console
coder secret update [flags] <name>
```
## Options
### --value
| | |
|------|---------------------|
| Type | <code>string</code> |
Update the secret value.
### --description
| | |
|------|---------------------|
| Type | <code>string</code> |
Update the secret description. Pass an empty string to clear it.
### --inject-env
| | |
|------|---------------------|
| Type | <code>string</code> |
Update the environment variable injection target. Pass an empty string to clear it.
### --inject-file
| | |
|------|---------------------|
| Type | <code>string</code> |
Update the file injection target. Pass an empty string to clear it.