Skip to content

Commit e9fec47

Browse files
committed
feat(secrets): add interactive masked prompt for secrets set
- Prompt for secret values when name provided without value - Display asterisks per character for input length verification - Handle Ctrl+C, backspace, and non-printable character filtering - Maintain backwards compatibility with NAME=VALUE inline syntax - Skip SUPABASE_ prefixed names before prompting
1 parent 0aee214 commit e9fec47

File tree

6 files changed

+228
-11
lines changed

6 files changed

+228
-11
lines changed

cmd/secrets.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ var (
2626
}
2727

2828
secretsSetCmd = &cobra.Command{
29-
Use: "set <NAME=VALUE> ...",
29+
Use: "set [NAME=VALUE | NAME] ...",
3030
Short: "Set a secret(s) on Supabase",
31-
Long: "Set a secret(s) to the linked Supabase project.",
31+
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.",
3232
RunE: func(cmd *cobra.Command, args []string) error {
3333
return set.Run(cmd.Context(), flags.ProjectRef, envFilePath, args, afero.NewOsFs())
3434
},

internal/functions/serve/serve.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ func parseEnvFile(envFilePath string, fsys afero.Fs) ([]string, error) {
234234
envFilePath = filepath.Join(utils.CurrentDirAbs, envFilePath)
235235
}
236236
env := []string{}
237-
secrets, err := set.ListSecrets(envFilePath, fsys)
237+
secrets, err := set.ListSecrets(envFilePath, fsys, nil)
238238
for _, v := range secrets {
239239
env = append(env, fmt.Sprintf("%s=%s", v.Name, v.Value))
240240
}

internal/secrets/set/set.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import (
1313
"github.com/joho/godotenv"
1414
"github.com/spf13/afero"
1515
"github.com/supabase/cli/internal/utils"
16+
"github.com/supabase/cli/internal/utils/credentials"
1617
"github.com/supabase/cli/internal/utils/flags"
1718
"github.com/supabase/cli/pkg/api"
19+
"golang.org/x/term"
1820
)
1921

2022
func Run(ctx context.Context, projectRef, envFilePath string, args []string, fsys afero.Fs) error {
@@ -25,7 +27,22 @@ func Run(ctx context.Context, projectRef, envFilePath string, args []string, fsy
2527
if len(envFilePath) > 0 && !filepath.IsAbs(envFilePath) {
2628
envFilePath = filepath.Join(utils.CurrentDirAbs, envFilePath)
2729
}
28-
secrets, err := ListSecrets(envFilePath, fsys, args...)
30+
promptSecret := func(name string) (string, error) {
31+
// Guard: without this check, PromptMasked would silently consume all piped stdin
32+
if !term.IsTerminal(int(os.Stdin.Fd())) {
33+
return "", errors.Errorf("Cannot prompt for secret value in non-interactive mode. Use %s format instead.", name+"=VALUE")
34+
}
35+
fmt.Fprintf(os.Stderr, "Paste your secret for %s: ", utils.Aqua(name))
36+
value, err := credentials.PromptMaskedWithAsterisks(os.Stdin)
37+
if err != nil {
38+
return "", err
39+
}
40+
if len(value) == 0 {
41+
return "", errors.New("Secret value cannot be empty. Use NAME= to explicitly set an empty value.")
42+
}
43+
return value, nil
44+
}
45+
secrets, err := ListSecrets(envFilePath, fsys, promptSecret, args...)
2946
if err != nil {
3047
return err
3148
}
@@ -43,7 +60,7 @@ func Run(ctx context.Context, projectRef, envFilePath string, args []string, fsy
4360
return nil
4461
}
4562

46-
func ListSecrets(envFilePath string, fsys afero.Fs, envArgs ...string) (api.CreateSecretBody, error) {
63+
func ListSecrets(envFilePath string, fsys afero.Fs, promptSecret func(string) (string, error), envArgs ...string) (api.CreateSecretBody, error) {
4764
envMap := map[string]string{}
4865
for name, secret := range utils.Config.EdgeRuntime.Secrets {
4966
if len(secret.SHA256) > 0 {
@@ -60,7 +77,19 @@ func ListSecrets(envFilePath string, fsys afero.Fs, envArgs ...string) (api.Crea
6077
for _, pair := range envArgs {
6178
name, value, found := strings.Cut(pair, "=")
6279
if !found {
63-
return nil, errors.Errorf("Invalid secret pair: %s. Must be NAME=VALUE.", pair)
80+
if promptSecret == nil {
81+
return nil, errors.Errorf("Invalid secret pair: %s. Must be NAME=VALUE.", pair)
82+
}
83+
// Skip early to avoid prompting for a name that would be discarded below
84+
if strings.HasPrefix(name, "SUPABASE_") {
85+
fmt.Fprintln(os.Stderr, "Env name cannot start with SUPABASE_, skipping: "+name)
86+
continue
87+
}
88+
var err error
89+
value, err = promptSecret(name)
90+
if err != nil {
91+
return nil, err
92+
}
6493
}
6594
envMap[name] = value
6695
}

internal/secrets/set/set_test.go

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ func TestSecretSetCommand(t *testing.T) {
7979
assert.ErrorContains(t, err, "No arguments found. Use --env-file to read from a .env file.")
8080
})
8181

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

9696
t.Run("throws error on network error", func(t *testing.T) {
@@ -138,3 +138,71 @@ func TestSecretSetCommand(t *testing.T) {
138138
assert.Empty(t, apitest.ListUnmatchedRequests())
139139
})
140140
}
141+
142+
func TestListSecrets(t *testing.T) {
143+
fsys := afero.NewMemMapFs()
144+
145+
t.Run("errors on bare name with nil prompter", func(t *testing.T) {
146+
_, err := ListSecrets("", fsys, nil, "malformed")
147+
assert.ErrorContains(t, err, "Invalid secret pair: malformed. Must be NAME=VALUE.")
148+
})
149+
150+
t.Run("prompts for secret value interactively", func(t *testing.T) {
151+
mockPrompt := func(name string) (string, error) {
152+
assert.Equal(t, "MY_SECRET", name)
153+
return "prompted_value", nil
154+
}
155+
secrets, err := ListSecrets("", fsys, mockPrompt, "MY_SECRET")
156+
require.NoError(t, err)
157+
require.Len(t, secrets, 1)
158+
assert.Equal(t, "MY_SECRET", secrets[0].Name)
159+
assert.Equal(t, "prompted_value", secrets[0].Value)
160+
})
161+
162+
t.Run("prompts for multiple secrets", func(t *testing.T) {
163+
callCount := 0
164+
mockPrompt := func(name string) (string, error) {
165+
callCount++
166+
return "value_" + name, nil
167+
}
168+
secrets, err := ListSecrets("", fsys, mockPrompt, "KEY1", "KEY2")
169+
require.NoError(t, err)
170+
assert.Equal(t, 2, callCount)
171+
assert.Len(t, secrets, 2)
172+
})
173+
174+
t.Run("mixes inline and prompted secrets", func(t *testing.T) {
175+
mockPrompt := func(name string) (string, error) {
176+
assert.Equal(t, "KEY2", name)
177+
return "prompted_value", nil
178+
}
179+
secrets, err := ListSecrets("", fsys, mockPrompt, "KEY1=inline_value", "KEY2")
180+
require.NoError(t, err)
181+
assert.Len(t, secrets, 2)
182+
// Verify both secrets are present
183+
values := map[string]string{}
184+
for _, s := range secrets {
185+
values[s.Name] = s.Value
186+
}
187+
assert.Equal(t, "inline_value", values["KEY1"])
188+
assert.Equal(t, "prompted_value", values["KEY2"])
189+
})
190+
191+
t.Run("propagates prompt error", func(t *testing.T) {
192+
mockPrompt := func(name string) (string, error) {
193+
return "", errors.New("prompt failed")
194+
}
195+
_, err := ListSecrets("", fsys, mockPrompt, "MY_SECRET")
196+
assert.ErrorContains(t, err, "prompt failed")
197+
})
198+
199+
t.Run("skips SUPABASE_ prefixed bare name without prompting", func(t *testing.T) {
200+
mockPrompt := func(name string) (string, error) {
201+
t.Fatal("should not prompt for SUPABASE_ prefixed names")
202+
return "", nil
203+
}
204+
secrets, err := ListSecrets("", fsys, mockPrompt, "SUPABASE_FOO")
205+
require.NoError(t, err)
206+
assert.Empty(t, secrets)
207+
})
208+
}

internal/utils/credentials/input.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,50 @@ import (
99
"golang.org/x/term"
1010
)
1111

12+
// PromptMaskedWithAsterisks reads input character by character, echoing '*' for
13+
// each typed character. Handles backspace and Ctrl+C. Requires a TTY terminal.
14+
func PromptMaskedWithAsterisks(stdin *os.File) (string, error) {
15+
fd := int(stdin.Fd())
16+
oldState, err := term.MakeRaw(fd)
17+
if err != nil {
18+
return "", fmt.Errorf("failed to set raw terminal: %w", err)
19+
}
20+
defer term.Restore(fd, oldState)
21+
return readMaskedInput(stdin, os.Stderr)
22+
}
23+
24+
// readMaskedInput reads bytes one at a time from r, echoing '*' to echo for each
25+
// printable character. Handles backspace, Ctrl+C, and Enter.
26+
func readMaskedInput(r io.Reader, echo io.Writer) (string, error) {
27+
var buf []byte
28+
b := make([]byte, 1)
29+
for {
30+
if _, err := r.Read(b); err != nil {
31+
fmt.Fprint(echo, "\r\n")
32+
if err == io.EOF {
33+
return string(buf), nil
34+
}
35+
return "", fmt.Errorf("failed to read input: %w", err)
36+
}
37+
switch {
38+
case b[0] == 3: // Ctrl+C
39+
fmt.Fprint(echo, "\r\n")
40+
return "", fmt.Errorf("interrupted")
41+
case b[0] == 13 || b[0] == 10: // Enter
42+
fmt.Fprint(echo, "\r\n")
43+
return string(buf), nil
44+
case b[0] == 127 || b[0] == 8: // Backspace / Delete
45+
if len(buf) > 0 {
46+
buf = buf[:len(buf)-1]
47+
fmt.Fprint(echo, "\b \b")
48+
}
49+
case b[0] >= 32 && b[0] < 127: // Printable ASCII
50+
buf = append(buf, b[0])
51+
fmt.Fprint(echo, "*")
52+
}
53+
}
54+
}
55+
1256
func PromptMasked(stdin *os.File) string {
1357
// Start a new line after reading input
1458
defer fmt.Println()

internal/utils/credentials/input_test.go

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,88 @@
11
package credentials
22

33
import (
4+
"bytes"
5+
"io"
46
"os"
7+
"strings"
58
"testing"
69

710
"github.com/stretchr/testify/assert"
811
"github.com/stretchr/testify/require"
912
)
1013

14+
func TestReadMaskedInput(t *testing.T) {
15+
t.Run("reads until Enter", func(t *testing.T) {
16+
input := strings.NewReader("hello\r")
17+
result, err := readMaskedInput(input, io.Discard)
18+
require.NoError(t, err)
19+
assert.Equal(t, "hello", result)
20+
})
21+
22+
t.Run("reads until newline", func(t *testing.T) {
23+
input := strings.NewReader("hello\n")
24+
result, err := readMaskedInput(input, io.Discard)
25+
require.NoError(t, err)
26+
assert.Equal(t, "hello", result)
27+
})
28+
29+
t.Run("returns error on Ctrl+C", func(t *testing.T) {
30+
input := strings.NewReader("abc\x03")
31+
_, err := readMaskedInput(input, io.Discard)
32+
assert.ErrorContains(t, err, "interrupted")
33+
})
34+
35+
t.Run("handles backspace", func(t *testing.T) {
36+
// Type "abc", backspace, then "d", then Enter
37+
input := strings.NewReader("abc\x7fd\r")
38+
result, err := readMaskedInput(input, io.Discard)
39+
require.NoError(t, err)
40+
assert.Equal(t, "abd", result)
41+
})
42+
43+
t.Run("backspace on empty buffer is no-op", func(t *testing.T) {
44+
input := strings.NewReader("\x7f\x7fabc\r")
45+
result, err := readMaskedInput(input, io.Discard)
46+
require.NoError(t, err)
47+
assert.Equal(t, "abc", result)
48+
})
49+
50+
t.Run("ignores non-printable characters", func(t *testing.T) {
51+
// Tab (0x09), escape (0x1b), and other control chars should be ignored
52+
input := strings.NewReader("a\x09b\x1bc\r")
53+
result, err := readMaskedInput(input, io.Discard)
54+
require.NoError(t, err)
55+
assert.Equal(t, "abc", result)
56+
})
57+
58+
t.Run("echoes asterisks for each character", func(t *testing.T) {
59+
input := strings.NewReader("abc\r")
60+
var echo bytes.Buffer
61+
_, err := readMaskedInput(input, &echo)
62+
require.NoError(t, err)
63+
assert.Equal(t, "***\r\n", echo.String())
64+
})
65+
66+
t.Run("returns accumulated input on EOF", func(t *testing.T) {
67+
input := strings.NewReader("partial")
68+
result, err := readMaskedInput(input, io.Discard)
69+
require.NoError(t, err)
70+
assert.Equal(t, "partial", result)
71+
})
72+
}
73+
74+
func TestPromptMaskedWithAsterisks(t *testing.T) {
75+
t.Run("returns error on non-TTY", func(t *testing.T) {
76+
r, w, err := os.Pipe()
77+
require.NoError(t, err)
78+
defer r.Close()
79+
defer w.Close()
80+
// MakeRaw fails on pipes (non-TTY)
81+
_, err = PromptMaskedWithAsterisks(r)
82+
assert.ErrorContains(t, err, "failed to set raw terminal")
83+
})
84+
}
85+
1186
func TestPromptMasked(t *testing.T) {
1287
t.Run("reads from piped stdin", func(t *testing.T) {
1388
// Setup token
@@ -24,8 +99,9 @@ func TestPromptMasked(t *testing.T) {
2499

25100
t.Run("empty string on closed pipe", func(t *testing.T) {
26101
// Setup empty stdin
27-
r, _, err := os.Pipe()
102+
r, w, err := os.Pipe()
28103
require.NoError(t, err)
104+
require.NoError(t, w.Close())
29105
require.NoError(t, r.Close())
30106
// Run test
31107
input := PromptMasked(r)

0 commit comments

Comments
 (0)