Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 26 additions & 15 deletions pkg/cli/secret_set_command.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package cli

import (
"bufio"
"crypto/rand"
"encoding/base64"
"encoding/json"
Expand All @@ -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"
)
Expand Down Expand Up @@ -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
}
Comment on lines +150 to +162
Copy link

Copilot AI Feb 7, 2026

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.

Copilot uses AI. Check for mistakes.

// 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
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveSecretValueForSet now has additional branches for interactive prompting and stdin ReadAll fallback, but the existing tests (in secret_set_command_test.go) only cover the env/flag cases. Adding unit tests for the stdin paths (e.g., temporarily swapping os.Stdin with an os.Pipe/temp file and asserting trimming/empty handling) would help prevent regressions in non-interactive and terminal scenarios.

Copilot uses AI. Check for mistakes.
if value == "" {
return "", errors.New("secret value is empty")
}
Expand Down
130 changes: 130 additions & 0 deletions pkg/console/form.go
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
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RunForm gates interactivity on tty.IsStderrTerminal() only, but Huh forms require interactive stdin for key events. If stdin is not a terminal (e.g., piped input) while stderr is a terminal, RunForm can still try to run interactively and block/fail. Consider checking stdin TTY status (and updating the tty package with an IsStdinTerminal() helper) before running the form.

Copilot uses AI. Check for mistakes.

// 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()
}
169 changes: 169 additions & 0 deletions pkg/console/form_test.go
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")
})
}
Loading
Loading