Skip to content

Commit bf393eb

Browse files
authored
fix: show wfctl secrets setup prompts (#887)
* fix: show wfctl secrets setup prompts * fix: harden secrets setup prompt modes * fix: satisfy secrets setup lint * docs: clarify secrets setup modes
1 parent c84a8af commit bf393eb

11 files changed

Lines changed: 248 additions & 48 deletions

File tree

cmd/wfctl/internal/prompt/confirm.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package prompt
22

33
import (
44
"fmt"
5-
"io"
65
"strings"
76

87
tea "charm.land/bubbletea/v2"
@@ -16,19 +15,20 @@ func Confirm(question string, def bool) (bool, error) {
1615
if !isTTY() {
1716
return false, ErrNotInteractive
1817
}
18+
out, _ := outputWriter()
1919
hint := "y/N"
2020
if def {
2121
hint = "Y/n"
2222
}
2323
m := &confirmModel{question: question, hint: hint, def: def}
24-
p := tea.NewProgram(m, tea.WithOutput(io.Discard))
24+
p := tea.NewProgram(m, tea.WithOutput(out))
2525
result, err := p.Run()
2626
if err != nil {
2727
return false, fmt.Errorf("prompt confirm: %w", err)
2828
}
2929
fm := result.(*confirmModel)
3030
if fm.quit {
31-
return false, ErrNotInteractive
31+
return false, ErrInterrupted
3232
}
3333
return fm.answer, nil
3434
}

cmd/wfctl/internal/prompt/input.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package prompt
22

33
import (
44
"fmt"
5-
"io"
65

76
"charm.land/bubbles/v2/textinput"
87
tea "charm.land/bubbletea/v2"
@@ -24,6 +23,7 @@ func InputWithSuggestions(label string, masked bool, suggestions []string) (stri
2423
if !isTTY() {
2524
return "", ErrNotInteractive
2625
}
26+
out, _ := outputWriter()
2727
ti := textinput.New()
2828
ti.Placeholder = label
2929
ti.Focus()
@@ -37,14 +37,14 @@ func InputWithSuggestions(label string, masked bool, suggestions []string) (stri
3737
}
3838

3939
m := &inputModel{label: label, ti: ti}
40-
p := tea.NewProgram(m, tea.WithOutput(io.Discard))
40+
p := tea.NewProgram(m, tea.WithOutput(out))
4141
result, err := p.Run()
4242
if err != nil {
4343
return "", fmt.Errorf("prompt input: %w", err)
4444
}
4545
fm := result.(*inputModel)
4646
if fm.quit {
47-
return "", ErrNotInteractive
47+
return "", ErrInterrupted
4848
}
4949
return fm.ti.Value(), nil
5050
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package prompt
2+
3+
import (
4+
"testing"
5+
6+
tea "charm.land/bubbletea/v2"
7+
)
8+
9+
func TestPromptModelsMarkCtrlCAsInterrupted(t *testing.T) {
10+
msg := tea.KeyPressMsg(tea.Key{Code: 'c', Mod: tea.ModCtrl})
11+
12+
input := &inputModel{}
13+
input.Update(msg)
14+
if !input.quit {
15+
t.Fatal("inputModel did not mark ctrl+c as quit")
16+
}
17+
18+
confirm := &confirmModel{}
19+
confirm.Update(msg)
20+
if !confirm.quit {
21+
t.Fatal("confirmModel did not mark ctrl+c as quit")
22+
}
23+
24+
selectModel := &selectModel{}
25+
selectModel.Update(msg)
26+
if !selectModel.quit {
27+
t.Fatal("selectModel did not mark ctrl+c as quit")
28+
}
29+
30+
multiSelect := &multiSelectModel{}
31+
multiSelect.Update(msg)
32+
if !multiSelect.quit {
33+
t.Fatal("multiSelectModel did not mark ctrl+c as quit")
34+
}
35+
}

cmd/wfctl/internal/prompt/multiselect.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package prompt
22

33
import (
44
"fmt"
5-
"io"
65
"strings"
76

87
tea "charm.land/bubbletea/v2"
@@ -17,21 +16,22 @@ func MultiSelect(title string, items []Item) ([]int, error) {
1716
if !isTTY() {
1817
return nil, ErrNotInteractive
1918
}
19+
out, _ := outputWriter()
2020
selected := make(map[int]bool, len(items))
2121
for i, it := range items {
2222
if it.Preselected {
2323
selected[i] = true
2424
}
2525
}
2626
m := &multiSelectModel{title: title, items: items, selected: selected}
27-
p := tea.NewProgram(m, tea.WithOutput(io.Discard))
27+
p := tea.NewProgram(m, tea.WithOutput(out))
2828
result, err := p.Run()
2929
if err != nil {
3030
return nil, fmt.Errorf("prompt multiselect: %w", err)
3131
}
3232
fm := result.(*multiSelectModel)
3333
if fm.quit {
34-
return nil, ErrNotInteractive
34+
return nil, ErrInterrupted
3535
}
3636
var indices []int
3737
for i := range items {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package prompt
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
)
7+
8+
func TestChooseOutputWriterPrefersStderrTTY(t *testing.T) {
9+
stderr := &bytes.Buffer{}
10+
stdout := &bytes.Buffer{}
11+
got, ok := chooseOutputWriter(true, true, stderr, stdout)
12+
if !ok {
13+
t.Fatal("expected output writer")
14+
}
15+
if got != stderr {
16+
t.Fatalf("writer = %p, want stderr %p", got, stderr)
17+
}
18+
}
19+
20+
func TestChooseOutputWriterFallsBackToStdoutTTY(t *testing.T) {
21+
stderr := &bytes.Buffer{}
22+
stdout := &bytes.Buffer{}
23+
got, ok := chooseOutputWriter(false, true, stderr, stdout)
24+
if !ok {
25+
t.Fatal("expected output writer")
26+
}
27+
if got != stdout {
28+
t.Fatalf("writer = %p, want stdout %p", got, stdout)
29+
}
30+
}
31+
32+
func TestChooseOutputWriterRejectsNonTTYOutput(t *testing.T) {
33+
stderr := &bytes.Buffer{}
34+
stdout := &bytes.Buffer{}
35+
got, ok := chooseOutputWriter(false, false, stderr, stdout)
36+
if ok {
37+
t.Fatalf("expected no output writer, got %p", got)
38+
}
39+
}

cmd/wfctl/internal/prompt/prompt.go

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22
// charm.land/bubbletea/v2 and charm.land/bubbles/v2.
33
//
44
// Every public constructor checks whether stdin is an interactive terminal
5-
// before starting a bubbletea program. If stdin is not a terminal the
6-
// constructor returns (zero, ErrNotInteractive) immediately so callers in
7-
// CI / pipe mode can detect the condition and fall back to non-interactive
8-
// paths without any risk of hanging.
5+
// and whether stderr or stdout can render terminal output before starting a
6+
// bubbletea program. If either side is unavailable, the constructor returns
7+
// (zero, ErrNotInteractive) immediately so callers in CI / pipe mode can
8+
// detect the condition and fall back to non-interactive paths without any
9+
// risk of hanging.
910
package prompt
1011

1112
import (
1213
"errors"
14+
"io"
1315
"os"
1416

1517
"github.com/mattn/go-isatty"
@@ -18,6 +20,9 @@ import (
1820
// ErrNotInteractive is returned by all constructors when stdin is not a terminal.
1921
var ErrNotInteractive = errors.New("prompt: stdin is not a terminal")
2022

23+
// ErrInterrupted is returned when the user aborts an interactive prompt.
24+
var ErrInterrupted = errors.New("prompt: interrupted")
25+
2126
// Item is a selectable entry for MultiSelect.
2227
type Item struct {
2328
Label string
@@ -26,5 +31,34 @@ type Item struct {
2631

2732
// isTTY reports whether os.Stdin is an interactive terminal.
2833
func isTTY() bool {
29-
return isatty.IsTerminal(os.Stdin.Fd())
34+
return CanPrompt()
35+
}
36+
37+
// CanPrompt reports whether prompts can safely read input and render output.
38+
func CanPrompt() bool {
39+
if !isatty.IsTerminal(os.Stdin.Fd()) {
40+
return false
41+
}
42+
_, ok := outputWriter()
43+
return ok
44+
}
45+
46+
func outputWriter() (io.Writer, bool) {
47+
return chooseOutputWriter(
48+
isatty.IsTerminal(os.Stderr.Fd()),
49+
isatty.IsTerminal(os.Stdout.Fd()),
50+
os.Stderr,
51+
os.Stdout,
52+
)
53+
}
54+
55+
func chooseOutputWriter(stderrTTY, stdoutTTY bool, stderr, stdout io.Writer) (io.Writer, bool) {
56+
switch {
57+
case stderrTTY:
58+
return stderr, true
59+
case stdoutTTY:
60+
return stdout, true
61+
default:
62+
return nil, false
63+
}
3064
}

cmd/wfctl/internal/prompt/select.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package prompt
22

33
import (
44
"fmt"
5-
"io"
65
"strings"
76

87
tea "charm.land/bubbletea/v2"
@@ -17,15 +16,16 @@ func Select(title string, opts []string) (int, error) {
1716
if !isTTY() {
1817
return 0, ErrNotInteractive
1918
}
19+
out, _ := outputWriter()
2020
m := &selectModel{title: title, opts: opts}
21-
p := tea.NewProgram(m, tea.WithOutput(io.Discard))
21+
p := tea.NewProgram(m, tea.WithOutput(out))
2222
result, err := p.Run()
2323
if err != nil {
2424
return 0, fmt.Errorf("prompt select: %w", err)
2525
}
2626
fm := result.(*selectModel)
2727
if fm.quit {
28-
return 0, ErrNotInteractive
28+
return 0, ErrInterrupted
2929
}
3030
return fm.cursor, nil
3131
}

cmd/wfctl/secrets_setup.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ Options:
111111
return err
112112
}
113113
if target.kind == secretsSetupTargetManifest {
114+
if *autoGenKeys {
115+
return fmt.Errorf("--auto-gen-keys is not supported with wfctl.yaml manifest-backed secrets setup; use --from-env, --secret NAME=VALUE, or run interactively from a terminal")
116+
}
117+
manifestNonInteractive := *nonInteractive || !isatty.IsTerminal(os.Stdin.Fd())
114118
return runSecretsSetupManifestWithIO(&manifestSetupArgs{
115119
manifestPath: target.path,
116120
lockfilePath: *lockFile,
@@ -122,6 +126,7 @@ Options:
122126
visibility: *visibility,
123127
tokenEnv: *tokenEnv,
124128
fromEnv: *fromEnv,
129+
nonInteractive: manifestNonInteractive,
125130
secretLiterals: []string(secretFlag),
126131
only: only,
127132
skipExisting: *skipExisting,

0 commit comments

Comments
 (0)