fix: wire default app and hide setup password input

This commit is contained in:
thibaud-leclere 2026-04-10 10:17:38 +02:00
parent f5f13c247d
commit 946eed15df
5 changed files with 310 additions and 7 deletions

View file

@ -8,7 +8,7 @@ import (
)
func main() {
app := cli.NewApp()
app := cli.BuildApp()
if err := app.Run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)

View file

@ -5,7 +5,10 @@ import (
"context"
"fmt"
"io"
"os"
"strings"
"syscall"
"unsafe"
"email-mcp/internal/secretstore"
)
@ -14,12 +17,30 @@ type SetupPrompter interface {
PromptSetup(ctx context.Context) (secretstore.Credential, error)
}
type PasswordReader interface {
ReadPassword(prompt string) (string, error)
}
type InteractiveSetupPrompter struct {
input *bufio.Reader
output io.Writer
input *bufio.Reader
rawInput io.Reader
output io.Writer
passwordReader PasswordReader
}
type terminalPasswordReader struct {
input io.Reader
output io.Writer
isTerminal func(io.Reader) bool
readHiddenPassword func(*os.File) ([]byte, error)
readFallbackPassword func(string) (string, error)
}
func NewInteractiveSetupPrompter(input io.Reader, output io.Writer) *InteractiveSetupPrompter {
return NewInteractiveSetupPrompterWithPasswordReader(input, output, nil)
}
func NewInteractiveSetupPrompterWithPasswordReader(input io.Reader, output io.Writer, passwordReader PasswordReader) *InteractiveSetupPrompter {
if input == nil {
input = strings.NewReader("")
}
@ -27,10 +48,17 @@ func NewInteractiveSetupPrompter(input io.Reader, output io.Writer) *Interactive
output = io.Discard
}
return &InteractiveSetupPrompter{
input: bufio.NewReader(input),
output: output,
prompter := &InteractiveSetupPrompter{
input: bufio.NewReader(input),
rawInput: input,
output: output,
}
if passwordReader == nil {
passwordReader = newTerminalPasswordReader(input, output, prompter.prompt)
}
prompter.passwordReader = passwordReader
return prompter
}
func (p *InteractiveSetupPrompter) PromptSetup(context.Context) (secretstore.Credential, error) {
@ -42,7 +70,7 @@ func (p *InteractiveSetupPrompter) PromptSetup(context.Context) (secretstore.Cre
if err != nil {
return secretstore.Credential{}, err
}
password, err := p.prompt("Password: ")
password, err := p.passwordReader.ReadPassword("Password: ")
if err != nil {
return secretstore.Credential{}, err
}
@ -69,3 +97,87 @@ func (p *InteractiveSetupPrompter) prompt(label string) (string, error) {
}
return strings.TrimSpace(value), nil
}
func newTerminalPasswordReader(input io.Reader, output io.Writer, fallback func(string) (string, error)) PasswordReader {
return terminalPasswordReader{
input: input,
output: output,
isTerminal: isTerminalReader,
readHiddenPassword: readHiddenPassword,
readFallbackPassword: fallback,
}
}
func (r terminalPasswordReader) ReadPassword(prompt string) (string, error) {
if r.isTerminal != nil && r.isTerminal(r.input) {
file, ok := r.input.(*os.File)
if ok {
if _, err := fmt.Fprint(r.output, prompt); err != nil {
return "", err
}
password, err := r.readHiddenPassword(file)
if _, printErr := fmt.Fprintln(r.output); err == nil && printErr != nil {
return "", printErr
}
if err != nil {
return "", err
}
return strings.TrimSpace(string(password)), nil
}
}
return r.readFallbackPassword(prompt)
}
func isTerminalReader(reader io.Reader) bool {
file, ok := reader.(*os.File)
if !ok {
return false
}
return isTerminalFile(file)
}
func isTerminalFile(file *os.File) bool {
_, err := getTermios(file.Fd())
return err == nil
}
func readHiddenPassword(file *os.File) ([]byte, error) {
state, err := getTermios(file.Fd())
if err != nil {
return nil, err
}
next := *state
next.Lflag &^= syscall.ECHO
if err := setTermios(file.Fd(), &next); err != nil {
return nil, err
}
defer func() {
_ = setTermios(file.Fd(), state)
}()
reader := bufio.NewReader(file)
value, err := reader.ReadBytes('\n')
if err != nil && err != io.EOF {
return nil, err
}
return []byte(strings.TrimRight(string(value), "\r\n")), nil
}
func getTermios(fd uintptr) (*syscall.Termios, error) {
state := &syscall.Termios{}
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, fd, uintptr(syscall.TCGETS), uintptr(unsafe.Pointer(state)))
if errno != 0 {
return nil, errno
}
return state, nil
}
func setTermios(fd uintptr, state *syscall.Termios) error {
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, fd, uintptr(syscall.TCSETS), uintptr(unsafe.Pointer(state)))
if errno != 0 {
return errno
}
return nil
}

View file

@ -3,6 +3,9 @@ package cli
import (
"bytes"
"context"
"errors"
"io"
"os"
"strings"
"testing"
)
@ -25,6 +28,28 @@ func TestInteractiveSetupPrompterPromptSetupCollectsCredential(t *testing.T) {
}
}
func TestInteractiveSetupPrompterPromptSetupUsesPasswordReader(t *testing.T) {
output := &bytes.Buffer{}
passwordReader := &passwordReaderStub{password: "secret"}
prompter := NewInteractiveSetupPrompterWithPasswordReader(
strings.NewReader("imap.example.com\nalice\n"),
output,
passwordReader,
)
cred, err := prompter.PromptSetup(context.Background())
if err != nil {
t.Fatalf("PromptSetup returned error: %v", err)
}
if cred.Password != "secret" {
t.Fatalf("expected password from password reader, got %#v", cred)
}
if passwordReader.prompt != "Password: " {
t.Fatalf("unexpected password prompt %q", passwordReader.prompt)
}
}
func TestInteractiveSetupPrompterPromptSetupRejectsMissingFields(t *testing.T) {
input := strings.NewReader("imap.example.com\nalice\n \n")
prompter := NewInteractiveSetupPrompter(input, &bytes.Buffer{})
@ -37,3 +62,88 @@ func TestInteractiveSetupPrompterPromptSetupRejectsMissingFields(t *testing.T) {
t.Fatalf("expected password validation error, got %v", err)
}
}
func TestTerminalPasswordReaderReadPasswordUsesHiddenInputForTTY(t *testing.T) {
hiddenCalls := 0
fallbackCalls := 0
reader := terminalPasswordReader{
input: os.Stdin,
output: &bytes.Buffer{},
isTerminal: func(io.Reader) bool {
return true
},
readHiddenPassword: func(file *os.File) ([]byte, error) {
hiddenCalls++
if file != os.Stdin {
t.Fatalf("expected os.Stdin, got %v", file)
}
return []byte("secret"), nil
},
readFallbackPassword: func(string) (string, error) {
fallbackCalls++
return "", nil
},
}
password, err := reader.ReadPassword("Password: ")
if err != nil {
t.Fatalf("ReadPassword returned error: %v", err)
}
if password != "secret" {
t.Fatalf("expected hidden password, got %q", password)
}
if hiddenCalls != 1 {
t.Fatalf("expected hidden reader to be called once, got %d", hiddenCalls)
}
if fallbackCalls != 0 {
t.Fatalf("expected fallback reader not to be called, got %d", fallbackCalls)
}
}
func TestTerminalPasswordReaderReadPasswordFallsBackWhenInputIsNotTTY(t *testing.T) {
hiddenCalls := 0
reader := terminalPasswordReader{
input: strings.NewReader("ignored\n"),
output: &bytes.Buffer{},
isTerminal: func(io.Reader) bool {
return false
},
readHiddenPassword: func(*os.File) ([]byte, error) {
hiddenCalls++
return nil, errors.New("should not be called")
},
readFallbackPassword: func(prompt string) (string, error) {
if prompt != "Password: " {
t.Fatalf("unexpected prompt %q", prompt)
}
return "secret", nil
},
}
password, err := reader.ReadPassword("Password: ")
if err != nil {
t.Fatalf("ReadPassword returned error: %v", err)
}
if password != "secret" {
t.Fatalf("expected fallback password, got %q", password)
}
if hiddenCalls != 0 {
t.Fatalf("expected hidden reader not to be called, got %d", hiddenCalls)
}
}
type passwordReaderStub struct {
password string
err error
prompt string
}
func (p *passwordReaderStub) ReadPassword(prompt string) (string, error) {
p.prompt = prompt
if p.err != nil {
return "", p.err
}
return p.password, nil
}

44
internal/cli/wire.go Normal file
View file

@ -0,0 +1,44 @@
package cli
import (
"context"
"errors"
"io"
"os"
"email-mcp/internal/secretstore"
)
var errSecretStoreNotConfigured = errors.New("secret store is not available in this build yet")
var errMCPRunnerNotConfigured = errors.New("mcp runner is not available in this build yet")
type placeholderStore struct{}
func (placeholderStore) Save(context.Context, string, secretstore.Credential) error {
return errSecretStoreNotConfigured
}
func (placeholderStore) Load(context.Context, string) (secretstore.Credential, error) {
return secretstore.Credential{}, errSecretStoreNotConfigured
}
type placeholderRunner struct{}
func (placeholderRunner) Run(context.Context) error {
return errMCPRunnerNotConfigured
}
func BuildApp() *App {
return buildApp(os.Stdin, os.Stdout, os.Stderr)
}
func buildApp(stdin io.Reader, stdout io.Writer, stderr io.Writer) *App {
_ = stdout
return NewAppWithDependencies(
NewInteractiveSetupPrompter(stdin, stderr),
placeholderStore{},
placeholderRunner{},
stderr,
)
}

37
internal/cli/wire_test.go Normal file
View file

@ -0,0 +1,37 @@
package cli
import (
"bytes"
"strings"
"testing"
)
func TestBuildAppConfiguresSetupCommand(t *testing.T) {
stdin := strings.NewReader("imap.example.com\nalice\nsecret\n")
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
app := buildApp(stdin, stdout, stderr)
err := app.Run([]string{"setup"})
if err == nil {
t.Fatal("expected placeholder store error")
}
if err.Error() != errSecretStoreNotConfigured.Error() {
t.Fatalf("expected placeholder store error, got %v", err)
}
if got := stderr.String(); got != "IMAP host: Username: Password: " {
t.Fatalf("expected setup prompts on stderr, got %q", got)
}
}
func TestBuildAppConfiguresMCPCommand(t *testing.T) {
app := buildApp(strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
err := app.Run([]string{"mcp"})
if err == nil {
t.Fatal("expected placeholder runner error")
}
if err.Error() != errMCPRunnerNotConfigured.Error() {
t.Fatalf("expected placeholder runner error, got %v", err)
}
}