fix: wire default app and hide setup password input
This commit is contained in:
parent
f5f13c247d
commit
946eed15df
5 changed files with 310 additions and 7 deletions
|
|
@ -8,7 +8,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
app := cli.NewApp()
|
app := cli.BuildApp()
|
||||||
if err := app.Run(os.Args[1:]); err != nil {
|
if err := app.Run(os.Args[1:]); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
"email-mcp/internal/secretstore"
|
"email-mcp/internal/secretstore"
|
||||||
)
|
)
|
||||||
|
|
@ -14,12 +17,30 @@ type SetupPrompter interface {
|
||||||
PromptSetup(ctx context.Context) (secretstore.Credential, error)
|
PromptSetup(ctx context.Context) (secretstore.Credential, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PasswordReader interface {
|
||||||
|
ReadPassword(prompt string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
type InteractiveSetupPrompter struct {
|
type InteractiveSetupPrompter struct {
|
||||||
input *bufio.Reader
|
input *bufio.Reader
|
||||||
output io.Writer
|
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 {
|
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 {
|
if input == nil {
|
||||||
input = strings.NewReader("")
|
input = strings.NewReader("")
|
||||||
}
|
}
|
||||||
|
|
@ -27,10 +48,17 @@ func NewInteractiveSetupPrompter(input io.Reader, output io.Writer) *Interactive
|
||||||
output = io.Discard
|
output = io.Discard
|
||||||
}
|
}
|
||||||
|
|
||||||
return &InteractiveSetupPrompter{
|
prompter := &InteractiveSetupPrompter{
|
||||||
input: bufio.NewReader(input),
|
input: bufio.NewReader(input),
|
||||||
output: output,
|
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) {
|
func (p *InteractiveSetupPrompter) PromptSetup(context.Context) (secretstore.Credential, error) {
|
||||||
|
|
@ -42,7 +70,7 @@ func (p *InteractiveSetupPrompter) PromptSetup(context.Context) (secretstore.Cre
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return secretstore.Credential{}, err
|
return secretstore.Credential{}, err
|
||||||
}
|
}
|
||||||
password, err := p.prompt("Password: ")
|
password, err := p.passwordReader.ReadPassword("Password: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return secretstore.Credential{}, err
|
return secretstore.Credential{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -69,3 +97,87 @@ func (p *InteractiveSetupPrompter) prompt(label string) (string, error) {
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(value), nil
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ package cli
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestInteractiveSetupPrompterPromptSetupRejectsMissingFields(t *testing.T) {
|
||||||
input := strings.NewReader("imap.example.com\nalice\n \n")
|
input := strings.NewReader("imap.example.com\nalice\n \n")
|
||||||
prompter := NewInteractiveSetupPrompter(input, &bytes.Buffer{})
|
prompter := NewInteractiveSetupPrompter(input, &bytes.Buffer{})
|
||||||
|
|
@ -37,3 +62,88 @@ func TestInteractiveSetupPrompterPromptSetupRejectsMissingFields(t *testing.T) {
|
||||||
t.Fatalf("expected password validation error, got %v", err)
|
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
44
internal/cli/wire.go
Normal 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
37
internal/cli/wire_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue