-
Notifications
You must be signed in to change notification settings - Fork 114
Expand Huh forms usage for interactive CLI operations #14357
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,6 @@ | ||
| package cli | ||
|
|
||
| import ( | ||
| "bufio" | ||
| "crypto/rand" | ||
| "encoding/base64" | ||
| "encoding/json" | ||
|
|
@@ -14,6 +13,7 @@ import ( | |
| "github.com/cli/go-gh/v2/pkg/api" | ||
| "github.com/github/gh-aw/pkg/console" | ||
| "github.com/github/gh-aw/pkg/logger" | ||
| "github.com/github/gh-aw/pkg/tty" | ||
| "github.com/spf13/cobra" | ||
| "golang.org/x/crypto/nacl/box" | ||
| ) | ||
|
|
@@ -139,30 +139,41 @@ func resolveSecretValueForSet(fromEnv, fromFlag string) (string, error) { | |
| return fromFlag, nil | ||
| } | ||
|
|
||
| // Check if stdin is connected to a terminal (interactive mode) | ||
| info, err := os.Stdin.Stat() | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
|
|
||
| if info.Mode()&os.ModeCharDevice != 0 { | ||
| fmt.Fprintln(os.Stderr, "Enter secret value, then press Ctrl+D:") | ||
| } | ||
|
|
||
| reader := bufio.NewReader(os.Stdin) | ||
| var b strings.Builder | ||
| isTerminal := (info.Mode() & os.ModeCharDevice) != 0 | ||
|
|
||
| for { | ||
| line, err := reader.ReadString('\n') | ||
| b.WriteString(line) | ||
| // If we're in an interactive terminal, use Huh for a better UX with password masking | ||
| if isTerminal && tty.IsStderrTerminal() { | ||
| secretSetLog.Print("Using interactive password prompt with Huh") | ||
| value, err := console.PromptSecretInput( | ||
| "Enter secret value", | ||
| "The value will be encrypted and stored in the repository", | ||
| ) | ||
| if err != nil { | ||
| if errors.Is(err, io.EOF) { | ||
| break | ||
| } | ||
| return "", err | ||
| secretSetLog.Printf("Interactive prompt failed: %v", err) | ||
| return "", fmt.Errorf("failed to read secret value: %w", err) | ||
| } | ||
| return value, nil | ||
| } | ||
|
|
||
| // Fallback to non-interactive stdin reading (piped input or non-TTY) | ||
| secretSetLog.Print("Using non-interactive stdin reading") | ||
| if isTerminal { | ||
| fmt.Fprintln(os.Stderr, "Enter secret value, then press Ctrl+D:") | ||
| } | ||
|
|
||
| reader := io.Reader(os.Stdin) | ||
| data, err := io.ReadAll(reader) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
|
|
||
| value := strings.TrimRight(b.String(), "\r\n") | ||
| value := strings.TrimRight(string(data), "\r\n") | ||
|
Comment on lines
+142
to
+176
|
||
| if value == "" { | ||
| return "", errors.New("secret value is empty") | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| package console | ||
|
|
||
| import ( | ||
| "fmt" | ||
|
|
||
| "github.com/charmbracelet/huh" | ||
| "github.com/github/gh-aw/pkg/tty" | ||
| ) | ||
|
|
||
| // FormField represents a generic form field configuration | ||
| type FormField struct { | ||
| Type string // "input", "password", "confirm", "select" | ||
| Title string | ||
| Description string | ||
| Placeholder string | ||
| Value any // Pointer to the value to store the result | ||
| Options []SelectOption // For select fields | ||
| Validate func(string) error // For input/password fields | ||
| } | ||
|
|
||
| // RunForm executes a multi-field form with validation | ||
| // This is a higher-level helper that creates a form with multiple fields | ||
| func RunForm(fields []FormField) error { | ||
| // Validate inputs first before checking TTY | ||
| if len(fields) == 0 { | ||
| return fmt.Errorf("no form fields provided") | ||
| } | ||
|
|
||
| // Validate field configurations before checking TTY | ||
| for _, field := range fields { | ||
| if field.Type == "select" && len(field.Options) == 0 { | ||
| return fmt.Errorf("select field '%s' requires options", field.Title) | ||
| } | ||
| if field.Type != "input" && field.Type != "password" && field.Type != "confirm" && field.Type != "select" { | ||
| return fmt.Errorf("unknown field type: %s", field.Type) | ||
| } | ||
| } | ||
|
|
||
| // Check if stdin is a TTY - if not, we can't show interactive forms | ||
| if !tty.IsStderrTerminal() { | ||
| return fmt.Errorf("interactive forms not available (not a TTY)") | ||
| } | ||
|
Comment on lines
+39
to
+42
|
||
|
|
||
| // Build form fields | ||
| var huhFields []huh.Field | ||
| for _, field := range fields { | ||
| switch field.Type { | ||
| case "input": | ||
| inputField := huh.NewInput(). | ||
| Title(field.Title). | ||
| Description(field.Description). | ||
| Placeholder(field.Placeholder) | ||
|
|
||
| if field.Validate != nil { | ||
| inputField.Validate(field.Validate) | ||
| } | ||
|
|
||
| // Type assert to *string | ||
| if strPtr, ok := field.Value.(*string); ok { | ||
| inputField.Value(strPtr) | ||
| } else { | ||
| return fmt.Errorf("input field '%s' requires *string value", field.Title) | ||
| } | ||
|
|
||
| huhFields = append(huhFields, inputField) | ||
|
|
||
| case "password": | ||
| passwordField := huh.NewInput(). | ||
| Title(field.Title). | ||
| Description(field.Description). | ||
| EchoMode(huh.EchoModePassword) | ||
|
|
||
| if field.Validate != nil { | ||
| passwordField.Validate(field.Validate) | ||
| } | ||
|
|
||
| // Type assert to *string | ||
| if strPtr, ok := field.Value.(*string); ok { | ||
| passwordField.Value(strPtr) | ||
| } else { | ||
| return fmt.Errorf("password field '%s' requires *string value", field.Title) | ||
| } | ||
|
|
||
| huhFields = append(huhFields, passwordField) | ||
|
|
||
| case "confirm": | ||
| confirmField := huh.NewConfirm(). | ||
| Title(field.Title) | ||
|
|
||
| // Type assert to *bool | ||
| if boolPtr, ok := field.Value.(*bool); ok { | ||
| confirmField.Value(boolPtr) | ||
| } else { | ||
| return fmt.Errorf("confirm field '%s' requires *bool value", field.Title) | ||
| } | ||
|
|
||
| huhFields = append(huhFields, confirmField) | ||
|
|
||
| case "select": | ||
| selectField := huh.NewSelect[string](). | ||
| Title(field.Title). | ||
| Description(field.Description) | ||
|
|
||
| // Convert options to huh.Option format | ||
| huhOptions := make([]huh.Option[string], len(field.Options)) | ||
| for i, opt := range field.Options { | ||
| huhOptions[i] = huh.NewOption(opt.Label, opt.Value) | ||
| } | ||
| selectField.Options(huhOptions...) | ||
|
|
||
| // Type assert to *string | ||
| if strPtr, ok := field.Value.(*string); ok { | ||
| selectField.Value(strPtr) | ||
| } else { | ||
| return fmt.Errorf("select field '%s' requires *string value", field.Title) | ||
| } | ||
|
|
||
| huhFields = append(huhFields, selectField) | ||
|
|
||
| default: | ||
| } | ||
| } | ||
|
|
||
| // Create and run the form | ||
| form := huh.NewForm( | ||
| huh.NewGroup(huhFields...), | ||
| ).WithAccessible(IsAccessibleMode()) | ||
|
|
||
| return form.Run() | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,169 @@ | ||
| //go:build !integration | ||
|
|
||
| package console | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func TestRunForm(t *testing.T) { | ||
| t.Run("function signature", func(t *testing.T) { | ||
| // Verify the function exists and has the right signature | ||
| _ = RunForm | ||
| }) | ||
|
|
||
| t.Run("requires fields", func(t *testing.T) { | ||
| fields := []FormField{} | ||
|
|
||
| err := RunForm(fields) | ||
| require.Error(t, err, "Should error with no fields") | ||
| assert.Contains(t, err.Error(), "no form fields", "Error should mention missing fields") | ||
| }) | ||
|
|
||
| t.Run("validates input field", func(t *testing.T) { | ||
| var name string | ||
| fields := []FormField{ | ||
| { | ||
| Type: "input", | ||
| Title: "Name", | ||
| Description: "Enter your name", | ||
| Value: &name, | ||
| }, | ||
| } | ||
|
|
||
| err := RunForm(fields) | ||
| // Will error in test environment (no TTY), but that's expected | ||
| require.Error(t, err, "Should error when not in TTY") | ||
| assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY") | ||
| }) | ||
|
|
||
| t.Run("validates password field", func(t *testing.T) { | ||
| var password string | ||
| fields := []FormField{ | ||
| { | ||
| Type: "password", | ||
| Title: "Password", | ||
| Description: "Enter password", | ||
| Value: &password, | ||
| }, | ||
| } | ||
|
|
||
| err := RunForm(fields) | ||
| // Will error in test environment (no TTY), but that's expected | ||
| require.Error(t, err, "Should error when not in TTY") | ||
| assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY") | ||
| }) | ||
|
|
||
| t.Run("validates confirm field", func(t *testing.T) { | ||
| var confirmed bool | ||
| fields := []FormField{ | ||
| { | ||
| Type: "confirm", | ||
| Title: "Confirm action", | ||
| Value: &confirmed, | ||
| }, | ||
| } | ||
|
|
||
| err := RunForm(fields) | ||
| // Will error in test environment (no TTY), but that's expected | ||
| require.Error(t, err, "Should error when not in TTY") | ||
| assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY") | ||
| }) | ||
|
|
||
| t.Run("validates select field with options", func(t *testing.T) { | ||
| var selected string | ||
| fields := []FormField{ | ||
| { | ||
| Type: "select", | ||
| Title: "Choose option", | ||
| Description: "Select one", | ||
| Value: &selected, | ||
| Options: []SelectOption{ | ||
| {Label: "Option 1", Value: "opt1"}, | ||
| {Label: "Option 2", Value: "opt2"}, | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| err := RunForm(fields) | ||
| // Will error in test environment (no TTY), but that's expected | ||
| require.Error(t, err, "Should error when not in TTY") | ||
| assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY") | ||
| }) | ||
|
|
||
| t.Run("rejects select field without options", func(t *testing.T) { | ||
| var selected string | ||
| fields := []FormField{ | ||
| { | ||
| Type: "select", | ||
| Title: "Choose option", | ||
| Value: &selected, | ||
| Options: []SelectOption{}, | ||
| }, | ||
| } | ||
|
|
||
| err := RunForm(fields) | ||
| require.Error(t, err, "Should error with no options") | ||
| assert.Contains(t, err.Error(), "requires options", "Error should mention missing options") | ||
| }) | ||
|
|
||
| t.Run("rejects unknown field type", func(t *testing.T) { | ||
| var value string | ||
| fields := []FormField{ | ||
| { | ||
| Type: "unknown", | ||
| Title: "Test", | ||
| Value: &value, | ||
| }, | ||
| } | ||
|
|
||
| err := RunForm(fields) | ||
| require.Error(t, err, "Should error with unknown field type") | ||
| assert.Contains(t, err.Error(), "unknown field type", "Error should mention unknown type") | ||
| }) | ||
|
|
||
| t.Run("validates input field with custom validator", func(t *testing.T) { | ||
| var name string | ||
| fields := []FormField{ | ||
| { | ||
| Type: "input", | ||
| Title: "Name", | ||
| Description: "Enter your name", | ||
| Value: &name, | ||
| Validate: func(s string) error { | ||
| if len(s) < 3 { | ||
| return fmt.Errorf("must be at least 3 characters") | ||
| } | ||
| return nil | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| err := RunForm(fields) | ||
| // Will error in test environment (no TTY), but that's expected | ||
| require.Error(t, err, "Should error when not in TTY") | ||
| assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY") | ||
| }) | ||
| } | ||
|
|
||
| func TestFormField(t *testing.T) { | ||
| t.Run("struct creation", func(t *testing.T) { | ||
| var value string | ||
| field := FormField{ | ||
| Type: "input", | ||
| Title: "Test Field", | ||
| Description: "Test Description", | ||
| Placeholder: "Enter value", | ||
| Value: &value, | ||
| } | ||
|
|
||
| assert.Equal(t, "input", field.Type, "Type should match") | ||
| assert.Equal(t, "Test Field", field.Title, "Title should match") | ||
| assert.Equal(t, "Test Description", field.Description, "Description should match") | ||
| assert.Equal(t, "Enter value", field.Placeholder, "Placeholder should match") | ||
| }) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Switching interactive input from multi-line stdin read (Ctrl+D) to a single-line Huh password prompt removes the ability to enter multi-line secrets interactively (e.g., PEM keys/certificates). If multi-line secrets are a supported use case, consider preserving the previous Ctrl+D multi-line path (or offering an explicit multiline mode) in interactive terminals, while still supporting masked single-line entry when appropriate.