From 1da92a8240bb1fbb31ba8581ef652dfa4811eb23 Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Tue, 14 Apr 2026 10:13:39 +0200 Subject: [PATCH] feat(cli): adopt rc4 declarative setup engine --- go.mod | 2 +- go.sum | 4 +-- internal/cli/setup.go | 66 ++++++++++++++++++++++++++++++++++++++ internal/cli/setup_test.go | 62 +++++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f129a21..65bdab3 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module email-mcp go 1.25.0 require ( - gitea.lclr.dev/AI/mcp-framework v1.2.0-rc3 + gitea.lclr.dev/AI/mcp-framework v1.2.0-rc4 github.com/emersion/go-imap/v2 v2.0.0-beta.8 github.com/emersion/go-message v0.18.2 github.com/godbus/dbus/v5 v5.2.2 diff --git a/go.sum b/go.sum index 0d0d322..6d48410 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -gitea.lclr.dev/AI/mcp-framework v1.2.0-rc3 h1:pbG3eFQbBBVZDlNMA1MY3ZYocVGiZT0z95dHOUbSJYQ= -gitea.lclr.dev/AI/mcp-framework v1.2.0-rc3/go.mod h1:kUVMrL3/UBYgjOsW7sJCs3V0pO0qoJJMpIpueoTsoA4= +gitea.lclr.dev/AI/mcp-framework v1.2.0-rc4 h1:gnnYBvnbhD/u3IRfJs6hzTbMU7kf1/KD1oUWIRTyjO8= +gitea.lclr.dev/AI/mcp-framework v1.2.0-rc4/go.mod h1:kUVMrL3/UBYgjOsW7sJCs3V0pO0qoJJMpIpueoTsoA4= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 1737070..45bba6f 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -39,6 +39,10 @@ func NewInteractiveConfigPrompter(input io.Reader, output io.Writer) *Interactiv } func (p *InteractiveConfigPrompter) PromptCredential(_ context.Context, existing secretstore.Credential, hasStoredPassword bool) (secretstore.Credential, error) { + if p.stdinFile != nil { + return p.promptCredentialWithSetupEngine(existing, hasStoredPassword) + } + host, err := frameworkcli.PromptLine(p.reader, p.output, "IMAP host", existing.Host) if err != nil { return secretstore.Credential{}, err @@ -66,6 +70,68 @@ func (p *InteractiveConfigPrompter) PromptCredential(_ context.Context, existing return cred, nil } +func (p *InteractiveConfigPrompter) promptCredentialWithSetupEngine(existing secretstore.Credential, hasStoredPassword bool) (secretstore.Credential, error) { + password := "" + if hasStoredPassword { + password = existing.Password + } + + result, err := frameworkcli.RunSetup(frameworkcli.SetupOptions{ + Stdin: p.stdinFile, + Stdout: p.output, + Fields: []frameworkcli.SetupField{ + { + Name: "host", + Label: "IMAP host", + Type: frameworkcli.SetupFieldString, + Required: true, + Default: existing.Host, + }, + { + Name: "username", + Label: "Username", + Type: frameworkcli.SetupFieldString, + Required: true, + Default: existing.Username, + }, + { + Name: "password", + Label: "Password", + Type: frameworkcli.SetupFieldSecret, + Required: true, + ExistingSecret: password, + }, + }, + }) + if err != nil { + return secretstore.Credential{}, err + } + + host, ok := result.Get("host") + if !ok { + return secretstore.Credential{}, fmt.Errorf("setup result is missing host") + } + username, ok := result.Get("username") + if !ok { + return secretstore.Credential{}, fmt.Errorf("setup result is missing username") + } + secret, ok := result.Get("password") + if !ok { + return secretstore.Credential{}, fmt.Errorf("setup result is missing password") + } + + cred := secretstore.Credential{ + Host: host.String, + Username: username.String, + Password: secret.String, + } + if err := cred.Validate(); err != nil { + return secretstore.Credential{}, err + } + + return cred, nil +} + func (p *InteractiveConfigPrompter) promptPassword(storedPassword string, hasStoredPassword bool) (string, error) { if p.stdinFile != nil { return frameworkcli.PromptSecret(p.stdinFile, p.output, "Password", hasStoredPassword, storedPassword) diff --git a/internal/cli/setup_test.go b/internal/cli/setup_test.go index be50882..0f00c75 100644 --- a/internal/cli/setup_test.go +++ b/internal/cli/setup_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "os" "strings" "testing" @@ -48,3 +49,64 @@ func TestInteractiveConfigPrompterPromptCredentialKeepsStoredPassword(t *testing t.Fatalf("unexpected prompts: %q", got) } } + +func TestInteractiveConfigPrompterPromptCredentialUsesSetupEngineWithFileInput(t *testing.T) { + input := setupInputFile(t, "imap.example.com\nalice\nsecret\n") + output := &bytes.Buffer{} + prompter := NewInteractiveConfigPrompter(input, output) + + cred, err := prompter.PromptCredential(context.Background(), secretstore.Credential{}, false) + if err != nil { + t.Fatalf("PromptCredential returned error: %v", err) + } + + if cred.Host != "imap.example.com" || cred.Username != "alice" || cred.Password != "secret" { + t.Fatalf("unexpected credential: %#v", cred) + } + if got := output.String(); got != "IMAP host: Username: Password: " { + t.Fatalf("unexpected prompts: %q", got) + } +} + +func TestInteractiveConfigPrompterPromptCredentialKeepsStoredPasswordWithSetupEngine(t *testing.T) { + input := setupInputFile(t, "imap.example.com\nalice\n\n") + output := &bytes.Buffer{} + prompter := NewInteractiveConfigPrompter(input, output) + + cred, err := prompter.PromptCredential(context.Background(), secretstore.Credential{ + Host: "imap.example.com", + Username: "alice", + Password: "stored-secret", + }, true) + if err != nil { + t.Fatalf("PromptCredential returned error: %v", err) + } + + if cred.Password != "stored-secret" { + t.Fatalf("expected stored password to be preserved, got %q", cred.Password) + } + if got := output.String(); !strings.Contains(got, "Password [stored, leave blank to keep]: ") { + t.Fatalf("unexpected prompts: %q", got) + } +} + +func setupInputFile(t *testing.T, content string) *os.File { + t.Helper() + + input, err := os.CreateTemp(t.TempDir(), "setup-input-*.txt") + if err != nil { + t.Fatalf("CreateTemp returned error: %v", err) + } + t.Cleanup(func() { + _ = input.Close() + }) + + if _, err := input.WriteString(content); err != nil { + t.Fatalf("WriteString returned error: %v", err) + } + if _, err := input.Seek(0, 0); err != nil { + t.Fatalf("Seek returned error: %v", err) + } + + return input +}