Skip to content
Merged
12 changes: 11 additions & 1 deletion cmd/wfctl/infra_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,18 @@ func resolveSecretsProviderForEnv(cfg *SecretsConfig, envName string) (secrets.P
}
return secrets.NewKeychainProvider(service)

case "file":
path, _ := c["path"].(string)
if path == "" {
return nil, fmt.Errorf("secrets.file: 'path' is required")
}
if err := os.MkdirAll(path, 0o700); err != nil {
return nil, fmt.Errorf("secrets.file: create directory %s: %w", path, err)
}
return secrets.NewFileProvider(path), nil

default:
return nil, fmt.Errorf("unknown secrets provider %q (supported: github, vault, aws, env, keychain)", cfg.Provider)
return nil, fmt.Errorf("unknown secrets provider %q (supported: github, vault, aws, env, keychain, file)", cfg.Provider)
}
}

Expand Down
68 changes: 68 additions & 0 deletions cmd/wfctl/internal/prompt/confirm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package prompt

import (
"fmt"
"io"
"strings"

tea "charm.land/bubbletea/v2"
)

// Confirm asks a yes/no question. def is the default answer shown in the
// prompt (used when the user presses Enter without typing y/n).
//
// Returns (false, ErrNotInteractive) when stdin is not a terminal.
func Confirm(question string, def bool) (bool, error) {
if !isTTY() {
return false, ErrNotInteractive
}
hint := "y/N"
if def {
hint = "Y/n"
}
m := &confirmModel{question: question, hint: hint, def: def}
p := tea.NewProgram(m, tea.WithOutput(io.Discard))
result, err := p.Run()
if err != nil {
return false, fmt.Errorf("prompt confirm: %w", err)
}
fm := result.(*confirmModel)
if fm.quit {
return false, ErrNotInteractive
}
return fm.answer, nil
}

type confirmModel struct {
question string
hint string
def bool
answer bool
quit bool
}

func (m *confirmModel) Init() tea.Cmd { return nil }

func (m *confirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg, ok := msg.(tea.KeyPressMsg); ok {
switch strings.ToLower(msg.String()) {
case "ctrl+c":
m.quit = true
return m, tea.Quit
case "y":
m.answer = true
return m, tea.Quit
case "n":
m.answer = false
return m, tea.Quit
case "enter":
m.answer = m.def
return m, tea.Quit
}
}
return m, nil
}

func (m *confirmModel) View() tea.View {
return tea.NewView(fmt.Sprintf("%s [%s] ", m.question, m.hint))
}
70 changes: 70 additions & 0 deletions cmd/wfctl/internal/prompt/input.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package prompt

import (
"fmt"
"io"

"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)

// Input prompts the user for a single-line text value. When masked is true
// the input is displayed as asterisks (suitable for passwords/tokens).
//
// Returns ("", ErrNotInteractive) when stdin is not a terminal.
func Input(label string, masked bool) (string, error) {
if !isTTY() {
return "", ErrNotInteractive
}
ti := textinput.New()
ti.Placeholder = label
ti.Focus()
if masked {
ti.EchoMode = textinput.EchoPassword
ti.EchoCharacter = '*'
}

m := &inputModel{label: label, ti: ti}
p := tea.NewProgram(m, tea.WithOutput(io.Discard))
result, err := p.Run()
if err != nil {
return "", fmt.Errorf("prompt input: %w", err)
}
fm := result.(*inputModel)
if fm.quit {
return "", ErrNotInteractive
}
return fm.ti.Value(), nil
}

type inputModel struct {
label string
ti textinput.Model
quit bool
}

var labelStyle = lipgloss.NewStyle().Bold(true)

func (m *inputModel) Init() tea.Cmd {
return textinput.Blink
}

func (m *inputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
if kp, ok := msg.(tea.KeyPressMsg); ok {
switch kp.String() {
case "ctrl+c":
m.quit = true
return m, tea.Quit
case "enter":
return m, tea.Quit
}
}
m.ti, cmd = m.ti.Update(msg)
return m, cmd
}

func (m *inputModel) View() tea.View {
return tea.NewView(labelStyle.Render(m.label+": ") + m.ti.View() + "\n")
}
104 changes: 104 additions & 0 deletions cmd/wfctl/internal/prompt/multiselect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package prompt

import (
"fmt"
"io"
"strings"

tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)

// MultiSelect presents an interactive multi-choice list. Returns the indices
// of all selected items. Items can be pre-selected via Item.Preselected.
//
// Returns (nil, ErrNotInteractive) when stdin is not a terminal.
func MultiSelect(title string, items []Item) ([]int, error) {
if !isTTY() {
return nil, ErrNotInteractive
}
selected := make(map[int]bool, len(items))
for i, it := range items {
if it.Preselected {
selected[i] = true
}
}
m := &multiSelectModel{title: title, items: items, selected: selected}
p := tea.NewProgram(m, tea.WithOutput(io.Discard))
result, err := p.Run()
if err != nil {
return nil, fmt.Errorf("prompt multiselect: %w", err)
}
fm := result.(*multiSelectModel)
if fm.quit {
return nil, ErrNotInteractive
}
var indices []int
for i := range items {
if fm.selected[i] {
indices = append(indices, i)
}
}
return indices, nil
}

type multiSelectModel struct {
title string
items []Item
cursor int
selected map[int]bool
quit bool
}

var (
checkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
uncheckStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
)

func (m *multiSelectModel) Init() tea.Cmd { return nil }

func (m *multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg, ok := msg.(tea.KeyPressMsg); ok {
switch msg.String() {
case "ctrl+c", "q":
m.quit = true
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.items)-1 {
m.cursor++
}
case " ":
if m.selected[m.cursor] {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = true
}
case "enter":
return m, tea.Quit
}
}
return m, nil
}

func (m *multiSelectModel) View() tea.View {
var b strings.Builder
b.WriteString(titleStyle.Render(m.title))
b.WriteString("\n")
for i, it := range m.items {
cursor := " "
if i == m.cursor {
cursor = "> "
}
check := uncheckStyle.Render("[ ]")
if m.selected[i] {
check = checkStyle.Render("[x]")
}
b.WriteString(cursor + check + " " + it.Label + "\n")
}
b.WriteString("\n(space to toggle, enter to confirm)\n")
return tea.NewView(b.String())
}
30 changes: 30 additions & 0 deletions cmd/wfctl/internal/prompt/prompt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Package prompt provides reusable terminal UI widgets built on
// charm.land/bubbletea/v2 and charm.land/bubbles/v2.
//
// Every public constructor checks whether stdin is an interactive terminal
// before starting a bubbletea program. If stdin is not a terminal the
// constructor returns (zero, ErrNotInteractive) immediately so callers in
// CI / pipe mode can detect the condition and fall back to non-interactive
// paths without any risk of hanging.
package prompt

import (
"errors"
"os"

"github.com/mattn/go-isatty"
)

// ErrNotInteractive is returned by all constructors when stdin is not a terminal.
var ErrNotInteractive = errors.New("prompt: stdin is not a terminal")

// Item is a selectable entry for MultiSelect.
type Item struct {
Label string
Preselected bool
}

// isTTY reports whether os.Stdin is an interactive terminal.
func isTTY() bool {
return isatty.IsTerminal(os.Stdin.Fd())
}
88 changes: 88 additions & 0 deletions cmd/wfctl/internal/prompt/prompt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package prompt_test

import (
"testing"

"github.com/GoCodeAlone/workflow/cmd/wfctl/internal/prompt"
)

// All tests run in a non-TTY environment (stdin is a pipe in test
// subprocess), so every constructor must return ErrNotInteractive
// immediately rather than hanging.

func TestSelect_NonTTY(t *testing.T) {
_, err := prompt.Select("Pick one", []string{"a", "b"})
if err != prompt.ErrNotInteractive {
t.Fatalf("Select: got %v, want ErrNotInteractive", err)
}
}

func TestMultiSelect_NonTTY(t *testing.T) {
_, err := prompt.MultiSelect("Pick many", []prompt.Item{{Label: "a"}, {Label: "b"}})
if err != prompt.ErrNotInteractive {
t.Fatalf("MultiSelect: got %v, want ErrNotInteractive", err)
}
}

func TestInput_NonTTY(t *testing.T) {
_, err := prompt.Input("value", false)
if err != prompt.ErrNotInteractive {
t.Fatalf("Input: got %v, want ErrNotInteractive", err)
}
}

func TestInputMasked_NonTTY(t *testing.T) {
_, err := prompt.Input("password", true)
if err != prompt.ErrNotInteractive {
t.Fatalf("Input(masked): got %v, want ErrNotInteractive", err)
}
}

func TestConfirm_NonTTY(t *testing.T) {
_, err := prompt.Confirm("Are you sure?", true)
if err != prompt.ErrNotInteractive {
t.Fatalf("Confirm: got %v, want ErrNotInteractive", err)
}
}

func TestSelectZeroValue(t *testing.T) {
// When non-interactive the index is 0 (zero value).
idx, err := prompt.Select("Pick", []string{"x"})
if err != prompt.ErrNotInteractive {
t.Fatalf("unexpected err: %v", err)
}
if idx != 0 {
t.Errorf("idx = %d, want 0", idx)
}
}

func TestMultiSelectZeroValue(t *testing.T) {
indices, err := prompt.MultiSelect("Pick", []prompt.Item{{Label: "x"}})
if err != prompt.ErrNotInteractive {
t.Fatalf("unexpected err: %v", err)
}
if len(indices) != 0 {
t.Errorf("indices = %v, want []", indices)
}
}

func TestInputZeroValue(t *testing.T) {
s, err := prompt.Input("label", false)
if err != prompt.ErrNotInteractive {
t.Fatalf("unexpected err: %v", err)
}
if s != "" {
t.Errorf("s = %q, want empty", s)
}
}

func TestConfirmZeroValue(t *testing.T) {
// Default value is irrelevant when ErrNotInteractive is returned.
v, err := prompt.Confirm("Sure?", true)
if err != prompt.ErrNotInteractive {
t.Fatalf("unexpected err: %v", err)
}
if v {
t.Errorf("v = true, want false (zero value)")
}
}
Loading
Loading