Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ var (
}

secretsSetCmd = &cobra.Command{
Use: "set <NAME=VALUE> ...",
Use: "set [NAME=VALUE | NAME] ...",
Short: "Set a secret(s) on Supabase",
Long: "Set a secret(s) to the linked Supabase project.",
Long: "Set a secret(s) to the linked Supabase project. When a secret name is provided without a value, you will be prompted to enter it interactively.",
RunE: func(cmd *cobra.Command, args []string) error {
return set.Run(cmd.Context(), flags.ProjectRef, envFilePath, args, afero.NewOsFs())
},
Expand Down
2 changes: 1 addition & 1 deletion internal/functions/serve/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ func parseEnvFile(envFilePath string, fsys afero.Fs) ([]string, error) {
envFilePath = filepath.Join(utils.CurrentDirAbs, envFilePath)
}
env := []string{}
secrets, err := set.ListSecrets(envFilePath, fsys)
secrets, err := set.ListSecrets(envFilePath, fsys, nil)
for _, v := range secrets {
env = append(env, fmt.Sprintf("%s=%s", v.Name, v.Value))
}
Expand Down
35 changes: 32 additions & 3 deletions internal/secrets/set/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import (
"github.com/joho/godotenv"
"github.com/spf13/afero"
"github.com/supabase/cli/internal/utils"
"github.com/supabase/cli/internal/utils/credentials"
"github.com/supabase/cli/internal/utils/flags"
"github.com/supabase/cli/pkg/api"
"golang.org/x/term"
)

func Run(ctx context.Context, projectRef, envFilePath string, args []string, fsys afero.Fs) error {
Expand All @@ -25,7 +27,22 @@ func Run(ctx context.Context, projectRef, envFilePath string, args []string, fsy
if len(envFilePath) > 0 && !filepath.IsAbs(envFilePath) {
envFilePath = filepath.Join(utils.CurrentDirAbs, envFilePath)
}
secrets, err := ListSecrets(envFilePath, fsys, args...)
promptSecret := func(name string) (string, error) {
// Guard: without this check, PromptMasked would silently consume all piped stdin
if !term.IsTerminal(int(os.Stdin.Fd())) {
return "", errors.Errorf("Cannot prompt for secret value in non-interactive mode. Use %s format instead.", name+"=VALUE")
}
fmt.Fprintf(os.Stderr, "Paste your secret for %s: ", utils.Aqua(name))
value, err := credentials.PromptMaskedWithAsterisks(os.Stdin)
if err != nil {
return "", err
}
if len(value) == 0 {
return "", errors.New("Secret value cannot be empty. Use NAME= to explicitly set an empty value.")
}
return value, nil
}
secrets, err := ListSecrets(envFilePath, fsys, promptSecret, args...)
if err != nil {
return err
}
Expand All @@ -43,7 +60,7 @@ func Run(ctx context.Context, projectRef, envFilePath string, args []string, fsy
return nil
}

func ListSecrets(envFilePath string, fsys afero.Fs, envArgs ...string) (api.CreateSecretBody, error) {
func ListSecrets(envFilePath string, fsys afero.Fs, promptSecret func(string) (string, error), envArgs ...string) (api.CreateSecretBody, error) {
envMap := map[string]string{}
for name, secret := range utils.Config.EdgeRuntime.Secrets {
if len(secret.SHA256) > 0 {
Expand All @@ -60,7 +77,19 @@ func ListSecrets(envFilePath string, fsys afero.Fs, envArgs ...string) (api.Crea
for _, pair := range envArgs {
name, value, found := strings.Cut(pair, "=")
if !found {
return nil, errors.Errorf("Invalid secret pair: %s. Must be NAME=VALUE.", pair)
if promptSecret == nil {
return nil, errors.Errorf("Invalid secret pair: %s. Must be NAME=VALUE.", pair)
}
// Skip early to avoid prompting for a name that would be discarded below
if strings.HasPrefix(name, "SUPABASE_") {
fmt.Fprintln(os.Stderr, "Env name cannot start with SUPABASE_, skipping: "+name)
continue
}
var err error
value, err = promptSecret(name)
if err != nil {
return nil, err
}
}
envMap[name] = value
}
Expand Down
76 changes: 72 additions & 4 deletions internal/secrets/set/set_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ func TestSecretSetCommand(t *testing.T) {
assert.ErrorContains(t, err, "No arguments found. Use --env-file to read from a .env file.")
})

t.Run("throws error on malformed secret", func(t *testing.T) {
t.Run("throws error on bare name in non-interactive mode", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
// Setup valid project ref
Expand All @@ -88,9 +88,9 @@ func TestSecretSetCommand(t *testing.T) {
token := apitest.RandomAccessToken(t)
t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
// Run test
err := Run(context.Background(), project, "", []string{"malformed"}, fsys)
// Check error
assert.ErrorContains(t, err, "Invalid secret pair: malformed. Must be NAME=VALUE.")
err := Run(context.Background(), project, "", []string{"MY_SECRET"}, fsys)
// Check error - non-TTY test environment triggers the non-interactive guard
assert.ErrorContains(t, err, "Cannot prompt for secret value in non-interactive mode")
})

t.Run("throws error on network error", func(t *testing.T) {
Expand Down Expand Up @@ -138,3 +138,71 @@ func TestSecretSetCommand(t *testing.T) {
assert.Empty(t, apitest.ListUnmatchedRequests())
})
}

func TestListSecrets(t *testing.T) {
fsys := afero.NewMemMapFs()

t.Run("errors on bare name with nil prompter", func(t *testing.T) {
_, err := ListSecrets("", fsys, nil, "malformed")
assert.ErrorContains(t, err, "Invalid secret pair: malformed. Must be NAME=VALUE.")
})

t.Run("prompts for secret value interactively", func(t *testing.T) {
mockPrompt := func(name string) (string, error) {
assert.Equal(t, "MY_SECRET", name)
return "prompted_value", nil
}
secrets, err := ListSecrets("", fsys, mockPrompt, "MY_SECRET")
require.NoError(t, err)
require.Len(t, secrets, 1)
assert.Equal(t, "MY_SECRET", secrets[0].Name)
assert.Equal(t, "prompted_value", secrets[0].Value)
})

t.Run("prompts for multiple secrets", func(t *testing.T) {
callCount := 0
mockPrompt := func(name string) (string, error) {
callCount++
return "value_" + name, nil
}
secrets, err := ListSecrets("", fsys, mockPrompt, "KEY1", "KEY2")
require.NoError(t, err)
assert.Equal(t, 2, callCount)
assert.Len(t, secrets, 2)
})

t.Run("mixes inline and prompted secrets", func(t *testing.T) {
mockPrompt := func(name string) (string, error) {
assert.Equal(t, "KEY2", name)
return "prompted_value", nil
}
secrets, err := ListSecrets("", fsys, mockPrompt, "KEY1=inline_value", "KEY2")
require.NoError(t, err)
assert.Len(t, secrets, 2)
// Verify both secrets are present
values := map[string]string{}
for _, s := range secrets {
values[s.Name] = s.Value
}
assert.Equal(t, "inline_value", values["KEY1"])
assert.Equal(t, "prompted_value", values["KEY2"])
})

t.Run("propagates prompt error", func(t *testing.T) {
mockPrompt := func(name string) (string, error) {
return "", errors.New("prompt failed")
}
_, err := ListSecrets("", fsys, mockPrompt, "MY_SECRET")
assert.ErrorContains(t, err, "prompt failed")
})

t.Run("skips SUPABASE_ prefixed bare name without prompting", func(t *testing.T) {
mockPrompt := func(name string) (string, error) {
t.Fatal("should not prompt for SUPABASE_ prefixed names")
return "", nil
}
secrets, err := ListSecrets("", fsys, mockPrompt, "SUPABASE_FOO")
require.NoError(t, err)
assert.Empty(t, secrets)
})
}
45 changes: 45 additions & 0 deletions internal/utils/credentials/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,51 @@ import (
"golang.org/x/term"
)

// PromptMaskedWithAsterisks reads input character by character, echoing '*' for
// each typed character. Handles backspace and Ctrl+C. Requires a TTY terminal.
func PromptMaskedWithAsterisks(stdin *os.File) (string, error) {
fd := int(stdin.Fd())
oldState, err := term.MakeRaw(fd)
if err != nil {
return "", fmt.Errorf("failed to set raw terminal: %w", err)
}
defer func() { _ = term.Restore(fd, oldState) }()
return readMaskedInput(stdin, os.Stderr)
}

// readMaskedInput reads bytes one at a time from r, echoing '*' to echo for each
// printable character. Handles backspace, Ctrl+C, and Enter.
func readMaskedInput(r io.Reader, echo io.Writer) (string, error) {
var buf []byte
var b [1]byte
for {
if _, err := io.ReadFull(r, b[:]); err != nil {
fmt.Fprint(echo, "\r\n")
if err == io.EOF || err == io.ErrUnexpectedEOF {
return string(buf), nil
}
return "", fmt.Errorf("failed to read input: %w", err)
}
ch := b[0]
switch {
case ch == 3: // Ctrl+C
fmt.Fprint(echo, "\r\n")
return "", fmt.Errorf("interrupted")
case ch == 13 || ch == 10: // Enter
fmt.Fprint(echo, "\r\n")
return string(buf), nil
case ch == 127 || ch == 8: // Backspace / Delete
if len(buf) > 0 {
buf = buf[:len(buf)-1]
fmt.Fprint(echo, "\b \b")
}
case ch >= 32 && ch != 127: // Printable (incl. non-ASCII bytes)
buf = append(buf, ch)
fmt.Fprint(echo, "*")
}
}
}

func PromptMasked(stdin *os.File) string {
// Start a new line after reading input
defer fmt.Println()
Expand Down
86 changes: 85 additions & 1 deletion internal/utils/credentials/input_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,96 @@
package credentials

import (
"bytes"
"io"
"os"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestReadMaskedInput(t *testing.T) {
t.Run("reads until Enter", func(t *testing.T) {
input := strings.NewReader("hello\r")
result, err := readMaskedInput(input, io.Discard)
require.NoError(t, err)
assert.Equal(t, "hello", result)
})

t.Run("reads until newline", func(t *testing.T) {
input := strings.NewReader("hello\n")
result, err := readMaskedInput(input, io.Discard)
require.NoError(t, err)
assert.Equal(t, "hello", result)
})

t.Run("returns error on Ctrl+C", func(t *testing.T) {
input := strings.NewReader("abc\x03")
_, err := readMaskedInput(input, io.Discard)
assert.ErrorContains(t, err, "interrupted")
})

t.Run("handles backspace", func(t *testing.T) {
// Type "abc", backspace, then "d", then Enter
input := strings.NewReader("abc\x7fd\r")
result, err := readMaskedInput(input, io.Discard)
require.NoError(t, err)
assert.Equal(t, "abd", result)
})

t.Run("backspace on empty buffer is no-op", func(t *testing.T) {
input := strings.NewReader("\x7f\x7fabc\r")
result, err := readMaskedInput(input, io.Discard)
require.NoError(t, err)
assert.Equal(t, "abc", result)
})

t.Run("ignores non-printable characters", func(t *testing.T) {
// Tab (0x09), escape (0x1b), and other control chars should be ignored
input := strings.NewReader("a\x09b\x1bc\r")
result, err := readMaskedInput(input, io.Discard)
require.NoError(t, err)
assert.Equal(t, "abc", result)
})

t.Run("accepts non-ASCII bytes", func(t *testing.T) {
// UTF-8 encoded "é" is 0xc3 0xa9
input := bytes.NewReader([]byte{'a', 0xc3, 0xa9, 'b', '\r'})
result, err := readMaskedInput(input, io.Discard)
require.NoError(t, err)
assert.Equal(t, "a\xc3\xa9b", result)
})

t.Run("echoes asterisks for each character", func(t *testing.T) {
input := strings.NewReader("abc\r")
var echo bytes.Buffer
_, err := readMaskedInput(input, &echo)
require.NoError(t, err)
assert.Equal(t, "***\r\n", echo.String())
})

t.Run("returns accumulated input on EOF", func(t *testing.T) {
input := strings.NewReader("partial")
result, err := readMaskedInput(input, io.Discard)
require.NoError(t, err)
assert.Equal(t, "partial", result)
})
}

func TestPromptMaskedWithAsterisks(t *testing.T) {
t.Run("returns error on non-TTY", func(t *testing.T) {
r, w, err := os.Pipe()
require.NoError(t, err)
defer r.Close()
defer w.Close()
// MakeRaw fails on pipes (non-TTY)
_, err = PromptMaskedWithAsterisks(r)
assert.ErrorContains(t, err, "failed to set raw terminal")
})
}

func TestPromptMasked(t *testing.T) {
t.Run("reads from piped stdin", func(t *testing.T) {
// Setup token
Expand All @@ -24,8 +107,9 @@ func TestPromptMasked(t *testing.T) {

t.Run("empty string on closed pipe", func(t *testing.T) {
// Setup empty stdin
r, _, err := os.Pipe()
r, w, err := os.Pipe()
require.NoError(t, err)
require.NoError(t, w.Close())
require.NoError(t, r.Close())
// Run test
input := PromptMasked(r)
Expand Down
Loading