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() {
|
||||
app := cli.NewApp()
|
||||
app := cli.BuildApp()
|
||||
if err := app.Run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
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