diff --git a/cmd/email-mcp/main.go b/cmd/email-mcp/main.go index bcccbb1..267da43 100644 --- a/cmd/email-mcp/main.go +++ b/cmd/email-mcp/main.go @@ -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) diff --git a/internal/cli/setup.go b/internal/cli/setup.go index cfe80ad..ba402bf 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -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 +} diff --git a/internal/cli/setup_test.go b/internal/cli/setup_test.go index 9c88bf7..2073e4d 100644 --- a/internal/cli/setup_test.go +++ b/internal/cli/setup_test.go @@ -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 +} diff --git a/internal/cli/wire.go b/internal/cli/wire.go new file mode 100644 index 0000000..a09409f --- /dev/null +++ b/internal/cli/wire.go @@ -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, + ) +} diff --git a/internal/cli/wire_test.go b/internal/cli/wire_test.go new file mode 100644 index 0000000..3929a0b --- /dev/null +++ b/internal/cli/wire_test.go @@ -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) + } +}