Skip to content

Commit ab4e0ff

Browse files
authored
Expand Huh forms usage for interactive CLI operations (#14357)
1 parent 97f1497 commit ab4e0ff

File tree

7 files changed

+682
-15
lines changed

7 files changed

+682
-15
lines changed

pkg/cli/secret_set_command.go

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package cli
22

33
import (
4-
"bufio"
54
"crypto/rand"
65
"encoding/base64"
76
"encoding/json"
@@ -14,6 +13,7 @@ import (
1413
"github.com/cli/go-gh/v2/pkg/api"
1514
"github.com/github/gh-aw/pkg/console"
1615
"github.com/github/gh-aw/pkg/logger"
16+
"github.com/github/gh-aw/pkg/tty"
1717
"github.com/spf13/cobra"
1818
"golang.org/x/crypto/nacl/box"
1919
)
@@ -139,30 +139,41 @@ func resolveSecretValueForSet(fromEnv, fromFlag string) (string, error) {
139139
return fromFlag, nil
140140
}
141141

142+
// Check if stdin is connected to a terminal (interactive mode)
142143
info, err := os.Stdin.Stat()
143144
if err != nil {
144145
return "", err
145146
}
146147

147-
if info.Mode()&os.ModeCharDevice != 0 {
148-
fmt.Fprintln(os.Stderr, "Enter secret value, then press Ctrl+D:")
149-
}
150-
151-
reader := bufio.NewReader(os.Stdin)
152-
var b strings.Builder
148+
isTerminal := (info.Mode() & os.ModeCharDevice) != 0
153149

154-
for {
155-
line, err := reader.ReadString('\n')
156-
b.WriteString(line)
150+
// If we're in an interactive terminal, use Huh for a better UX with password masking
151+
if isTerminal && tty.IsStderrTerminal() {
152+
secretSetLog.Print("Using interactive password prompt with Huh")
153+
value, err := console.PromptSecretInput(
154+
"Enter secret value",
155+
"The value will be encrypted and stored in the repository",
156+
)
157157
if err != nil {
158-
if errors.Is(err, io.EOF) {
159-
break
160-
}
161-
return "", err
158+
secretSetLog.Printf("Interactive prompt failed: %v", err)
159+
return "", fmt.Errorf("failed to read secret value: %w", err)
162160
}
161+
return value, nil
162+
}
163+
164+
// Fallback to non-interactive stdin reading (piped input or non-TTY)
165+
secretSetLog.Print("Using non-interactive stdin reading")
166+
if isTerminal {
167+
fmt.Fprintln(os.Stderr, "Enter secret value, then press Ctrl+D:")
168+
}
169+
170+
reader := io.Reader(os.Stdin)
171+
data, err := io.ReadAll(reader)
172+
if err != nil {
173+
return "", err
163174
}
164175

165-
value := strings.TrimRight(b.String(), "\r\n")
176+
value := strings.TrimRight(string(data), "\r\n")
166177
if value == "" {
167178
return "", errors.New("secret value is empty")
168179
}

pkg/console/form.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package console
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/charmbracelet/huh"
7+
"github.com/github/gh-aw/pkg/tty"
8+
)
9+
10+
// FormField represents a generic form field configuration
11+
type FormField struct {
12+
Type string // "input", "password", "confirm", "select"
13+
Title string
14+
Description string
15+
Placeholder string
16+
Value any // Pointer to the value to store the result
17+
Options []SelectOption // For select fields
18+
Validate func(string) error // For input/password fields
19+
}
20+
21+
// RunForm executes a multi-field form with validation
22+
// This is a higher-level helper that creates a form with multiple fields
23+
func RunForm(fields []FormField) error {
24+
// Validate inputs first before checking TTY
25+
if len(fields) == 0 {
26+
return fmt.Errorf("no form fields provided")
27+
}
28+
29+
// Validate field configurations before checking TTY
30+
for _, field := range fields {
31+
if field.Type == "select" && len(field.Options) == 0 {
32+
return fmt.Errorf("select field '%s' requires options", field.Title)
33+
}
34+
if field.Type != "input" && field.Type != "password" && field.Type != "confirm" && field.Type != "select" {
35+
return fmt.Errorf("unknown field type: %s", field.Type)
36+
}
37+
}
38+
39+
// Check if stdin is a TTY - if not, we can't show interactive forms
40+
if !tty.IsStderrTerminal() {
41+
return fmt.Errorf("interactive forms not available (not a TTY)")
42+
}
43+
44+
// Build form fields
45+
var huhFields []huh.Field
46+
for _, field := range fields {
47+
switch field.Type {
48+
case "input":
49+
inputField := huh.NewInput().
50+
Title(field.Title).
51+
Description(field.Description).
52+
Placeholder(field.Placeholder)
53+
54+
if field.Validate != nil {
55+
inputField.Validate(field.Validate)
56+
}
57+
58+
// Type assert to *string
59+
if strPtr, ok := field.Value.(*string); ok {
60+
inputField.Value(strPtr)
61+
} else {
62+
return fmt.Errorf("input field '%s' requires *string value", field.Title)
63+
}
64+
65+
huhFields = append(huhFields, inputField)
66+
67+
case "password":
68+
passwordField := huh.NewInput().
69+
Title(field.Title).
70+
Description(field.Description).
71+
EchoMode(huh.EchoModePassword)
72+
73+
if field.Validate != nil {
74+
passwordField.Validate(field.Validate)
75+
}
76+
77+
// Type assert to *string
78+
if strPtr, ok := field.Value.(*string); ok {
79+
passwordField.Value(strPtr)
80+
} else {
81+
return fmt.Errorf("password field '%s' requires *string value", field.Title)
82+
}
83+
84+
huhFields = append(huhFields, passwordField)
85+
86+
case "confirm":
87+
confirmField := huh.NewConfirm().
88+
Title(field.Title)
89+
90+
// Type assert to *bool
91+
if boolPtr, ok := field.Value.(*bool); ok {
92+
confirmField.Value(boolPtr)
93+
} else {
94+
return fmt.Errorf("confirm field '%s' requires *bool value", field.Title)
95+
}
96+
97+
huhFields = append(huhFields, confirmField)
98+
99+
case "select":
100+
selectField := huh.NewSelect[string]().
101+
Title(field.Title).
102+
Description(field.Description)
103+
104+
// Convert options to huh.Option format
105+
huhOptions := make([]huh.Option[string], len(field.Options))
106+
for i, opt := range field.Options {
107+
huhOptions[i] = huh.NewOption(opt.Label, opt.Value)
108+
}
109+
selectField.Options(huhOptions...)
110+
111+
// Type assert to *string
112+
if strPtr, ok := field.Value.(*string); ok {
113+
selectField.Value(strPtr)
114+
} else {
115+
return fmt.Errorf("select field '%s' requires *string value", field.Title)
116+
}
117+
118+
huhFields = append(huhFields, selectField)
119+
120+
default:
121+
}
122+
}
123+
124+
// Create and run the form
125+
form := huh.NewForm(
126+
huh.NewGroup(huhFields...),
127+
).WithAccessible(IsAccessibleMode())
128+
129+
return form.Run()
130+
}

pkg/console/form_test.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
//go:build !integration
2+
3+
package console
4+
5+
import (
6+
"fmt"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestRunForm(t *testing.T) {
14+
t.Run("function signature", func(t *testing.T) {
15+
// Verify the function exists and has the right signature
16+
_ = RunForm
17+
})
18+
19+
t.Run("requires fields", func(t *testing.T) {
20+
fields := []FormField{}
21+
22+
err := RunForm(fields)
23+
require.Error(t, err, "Should error with no fields")
24+
assert.Contains(t, err.Error(), "no form fields", "Error should mention missing fields")
25+
})
26+
27+
t.Run("validates input field", func(t *testing.T) {
28+
var name string
29+
fields := []FormField{
30+
{
31+
Type: "input",
32+
Title: "Name",
33+
Description: "Enter your name",
34+
Value: &name,
35+
},
36+
}
37+
38+
err := RunForm(fields)
39+
// Will error in test environment (no TTY), but that's expected
40+
require.Error(t, err, "Should error when not in TTY")
41+
assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY")
42+
})
43+
44+
t.Run("validates password field", func(t *testing.T) {
45+
var password string
46+
fields := []FormField{
47+
{
48+
Type: "password",
49+
Title: "Password",
50+
Description: "Enter password",
51+
Value: &password,
52+
},
53+
}
54+
55+
err := RunForm(fields)
56+
// Will error in test environment (no TTY), but that's expected
57+
require.Error(t, err, "Should error when not in TTY")
58+
assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY")
59+
})
60+
61+
t.Run("validates confirm field", func(t *testing.T) {
62+
var confirmed bool
63+
fields := []FormField{
64+
{
65+
Type: "confirm",
66+
Title: "Confirm action",
67+
Value: &confirmed,
68+
},
69+
}
70+
71+
err := RunForm(fields)
72+
// Will error in test environment (no TTY), but that's expected
73+
require.Error(t, err, "Should error when not in TTY")
74+
assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY")
75+
})
76+
77+
t.Run("validates select field with options", func(t *testing.T) {
78+
var selected string
79+
fields := []FormField{
80+
{
81+
Type: "select",
82+
Title: "Choose option",
83+
Description: "Select one",
84+
Value: &selected,
85+
Options: []SelectOption{
86+
{Label: "Option 1", Value: "opt1"},
87+
{Label: "Option 2", Value: "opt2"},
88+
},
89+
},
90+
}
91+
92+
err := RunForm(fields)
93+
// Will error in test environment (no TTY), but that's expected
94+
require.Error(t, err, "Should error when not in TTY")
95+
assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY")
96+
})
97+
98+
t.Run("rejects select field without options", func(t *testing.T) {
99+
var selected string
100+
fields := []FormField{
101+
{
102+
Type: "select",
103+
Title: "Choose option",
104+
Value: &selected,
105+
Options: []SelectOption{},
106+
},
107+
}
108+
109+
err := RunForm(fields)
110+
require.Error(t, err, "Should error with no options")
111+
assert.Contains(t, err.Error(), "requires options", "Error should mention missing options")
112+
})
113+
114+
t.Run("rejects unknown field type", func(t *testing.T) {
115+
var value string
116+
fields := []FormField{
117+
{
118+
Type: "unknown",
119+
Title: "Test",
120+
Value: &value,
121+
},
122+
}
123+
124+
err := RunForm(fields)
125+
require.Error(t, err, "Should error with unknown field type")
126+
assert.Contains(t, err.Error(), "unknown field type", "Error should mention unknown type")
127+
})
128+
129+
t.Run("validates input field with custom validator", func(t *testing.T) {
130+
var name string
131+
fields := []FormField{
132+
{
133+
Type: "input",
134+
Title: "Name",
135+
Description: "Enter your name",
136+
Value: &name,
137+
Validate: func(s string) error {
138+
if len(s) < 3 {
139+
return fmt.Errorf("must be at least 3 characters")
140+
}
141+
return nil
142+
},
143+
},
144+
}
145+
146+
err := RunForm(fields)
147+
// Will error in test environment (no TTY), but that's expected
148+
require.Error(t, err, "Should error when not in TTY")
149+
assert.Contains(t, err.Error(), "not a TTY", "Error should mention TTY")
150+
})
151+
}
152+
153+
func TestFormField(t *testing.T) {
154+
t.Run("struct creation", func(t *testing.T) {
155+
var value string
156+
field := FormField{
157+
Type: "input",
158+
Title: "Test Field",
159+
Description: "Test Description",
160+
Placeholder: "Enter value",
161+
Value: &value,
162+
}
163+
164+
assert.Equal(t, "input", field.Type, "Type should match")
165+
assert.Equal(t, "Test Field", field.Title, "Title should match")
166+
assert.Equal(t, "Test Description", field.Description, "Description should match")
167+
assert.Equal(t, "Enter value", field.Placeholder, "Placeholder should match")
168+
})
169+
}

0 commit comments

Comments
 (0)