Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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 ASCII
buf = append(buf, ch)
fmt.Fprint(echo, "*")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

func PromptMasked(stdin *os.File) string {
// Start a new line after reading input
defer fmt.Println()
Expand Down
78 changes: 77 additions & 1 deletion internal/utils/credentials/input_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,88 @@
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("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 +99,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