868 lines
22 KiB
Go
868 lines
22 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
frameworkbootstrap "gitea.lclr.dev/AI/mcp-framework/bootstrap"
|
|
frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli"
|
|
frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config"
|
|
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
|
|
frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
|
|
frameworkupdate "gitea.lclr.dev/AI/mcp-framework/update"
|
|
|
|
"email-mcp/internal/mcpserver"
|
|
"email-mcp/internal/secretstore"
|
|
)
|
|
|
|
const (
|
|
binaryName = "email-mcp"
|
|
defaultProfileEnv = "EMAIL_MCP_PROFILE"
|
|
hostEnv = "EMAIL_MCP_HOST"
|
|
usernameEnv = "EMAIL_MCP_USERNAME"
|
|
passwordEnv = "EMAIL_MCP_PASSWORD"
|
|
binaryDescription = "Local MCP server to read an IMAP mailbox."
|
|
fallbackProfile = "default"
|
|
)
|
|
|
|
type MCPRunner interface {
|
|
Run(ctx context.Context) error
|
|
}
|
|
|
|
type ConfigPrompter interface {
|
|
PromptCredential(ctx context.Context, existing secretstore.Credential, hasStoredPassword bool) (secretstore.Credential, error)
|
|
}
|
|
|
|
type profileConfigStore interface {
|
|
LoadDefault() (frameworkconfig.FileConfig[ProfileConfig], string, error)
|
|
SaveDefault(frameworkconfig.FileConfig[ProfileConfig]) (string, error)
|
|
}
|
|
|
|
type secretStore interface {
|
|
SetSecret(name, label, secret string) error
|
|
GetSecret(name string) (string, error)
|
|
DeleteSecret(name string) error
|
|
}
|
|
|
|
type manifestLoader func(startDir string) (frameworkmanifest.File, string, error)
|
|
type executableResolver func() (string, error)
|
|
|
|
type ProfileConfig struct {
|
|
Host string `json:"host"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
type App struct {
|
|
prompter ConfigPrompter
|
|
configStore profileConfigStore
|
|
openSecretStore func() (secretStore, error)
|
|
newMailService func() mcpserver.MailService
|
|
newRunner func(secretstore.Credential, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner
|
|
loadManifest manifestLoader
|
|
resolveExecutable executableResolver
|
|
stdin io.Reader
|
|
stdout io.Writer
|
|
stderr io.Writer
|
|
version string
|
|
}
|
|
|
|
func NewApp() *App {
|
|
return BuildApp("dev")
|
|
}
|
|
|
|
func NewAppWithDependencies(
|
|
prompter ConfigPrompter,
|
|
configStore profileConfigStore,
|
|
openSecretStore func() (secretStore, error),
|
|
newMailService func() mcpserver.MailService,
|
|
newRunner func(secretstore.Credential, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner,
|
|
loadManifest manifestLoader,
|
|
resolveExecutable executableResolver,
|
|
stdin io.Reader,
|
|
stdout io.Writer,
|
|
stderr io.Writer,
|
|
version string,
|
|
) *App {
|
|
if stdin == nil {
|
|
stdin = strings.NewReader("")
|
|
}
|
|
if stdout == nil {
|
|
stdout = io.Discard
|
|
}
|
|
if stderr == nil {
|
|
stderr = io.Discard
|
|
}
|
|
if version == "" {
|
|
version = "dev"
|
|
}
|
|
|
|
return &App{
|
|
prompter: prompter,
|
|
configStore: configStore,
|
|
openSecretStore: openSecretStore,
|
|
newMailService: newMailService,
|
|
newRunner: newRunner,
|
|
loadManifest: loadManifest,
|
|
resolveExecutable: resolveExecutable,
|
|
stdin: stdin,
|
|
stdout: stdout,
|
|
stderr: stderr,
|
|
version: version,
|
|
}
|
|
}
|
|
|
|
func (a *App) Run(args []string) error {
|
|
if isDoctorHelpCommand(args) {
|
|
return a.printDoctorHelp()
|
|
}
|
|
|
|
if len(args) > 0 && strings.TrimSpace(args[0]) == "doctor" {
|
|
return a.runDoctor(context.Background(), args[1:])
|
|
}
|
|
|
|
if isGlobalHelpCommand(args) {
|
|
return a.printGlobalHelp()
|
|
}
|
|
|
|
return a.runBootstrap(context.Background(), args)
|
|
}
|
|
|
|
func (a *App) runBootstrap(ctx context.Context, args []string) error {
|
|
metadata := a.runtimeMetadata()
|
|
|
|
return frameworkbootstrap.Run(ctx, frameworkbootstrap.Options{
|
|
BinaryName: metadata.BinaryName,
|
|
Description: metadata.Description,
|
|
Version: a.version,
|
|
Args: args,
|
|
Stdin: a.stdin,
|
|
Stdout: a.stdout,
|
|
Stderr: a.stderr,
|
|
Hooks: frameworkbootstrap.Hooks{
|
|
Setup: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
|
return a.runConfig(ctx, frameworkbootstrap.CommandSetup, inv.Args)
|
|
},
|
|
MCP: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
|
return a.runMCP(ctx, inv.Args)
|
|
},
|
|
ConfigShow: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
|
return a.runConfigShow(ctx, inv.Args)
|
|
},
|
|
ConfigTest: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
|
return a.runConfigTest(ctx, inv.Args)
|
|
},
|
|
ConfigDelete: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
|
return a.runConfigDelete(ctx, inv.Args)
|
|
},
|
|
Update: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
|
return a.runUpdate(ctx, inv.Args)
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func isGlobalHelpCommand(args []string) bool {
|
|
if len(args) == 0 {
|
|
return true
|
|
}
|
|
if len(args) != 1 {
|
|
return false
|
|
}
|
|
|
|
switch strings.TrimSpace(args[0]) {
|
|
case "help", "-h", "--help":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isDoctorHelpCommand(args []string) bool {
|
|
if len(args) != 2 {
|
|
return false
|
|
}
|
|
|
|
first := strings.TrimSpace(args[0])
|
|
second := strings.TrimSpace(args[1])
|
|
|
|
if first == "help" && second == "doctor" {
|
|
return true
|
|
}
|
|
if first == "doctor" && (second == "-h" || second == "--help") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a *App) printGlobalHelp() error {
|
|
metadata := a.runtimeMetadata()
|
|
|
|
if _, err := fmt.Fprintf(a.stdout, "%s\n\n", metadata.Description); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(a.stdout, "Usage:\n %s <command> [args]\n\n", metadata.BinaryName); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintln(a.stdout, "Common commands:"); err != nil {
|
|
return err
|
|
}
|
|
|
|
commands := []struct {
|
|
name string
|
|
description string
|
|
}{
|
|
{name: "setup", description: "Initialize or update local configuration."},
|
|
{name: "mcp", description: "Run the MCP server over stdio."},
|
|
{name: "config", description: "Inspect or test resolved configuration."},
|
|
{name: "doctor", description: "Run local diagnostics."},
|
|
{name: "update", description: "Run the self-update flow."},
|
|
{name: "version", description: "Print the binary version."},
|
|
}
|
|
for _, command := range commands {
|
|
if _, err := fmt.Fprintf(a.stdout, " %-7s %s\n", command.name, command.description); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
_, err := fmt.Fprintf(a.stdout, "\nDetailed help: %s help <command>\n", metadata.BinaryName)
|
|
return err
|
|
}
|
|
|
|
func (a *App) printDoctorHelp() error {
|
|
metadata := a.runtimeMetadata()
|
|
|
|
_, err := fmt.Fprintf(
|
|
a.stdout,
|
|
"Usage:\n %s doctor [--profile NAME]\n\nRun local diagnostics for config, wallet, manifest, and IMAP connectivity.\n",
|
|
metadata.BinaryName,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (a *App) runConfig(ctx context.Context, command string, args []string) error {
|
|
if a.prompter == nil {
|
|
return fmt.Errorf("config prompter is not configured")
|
|
}
|
|
if a.configStore == nil {
|
|
return fmt.Errorf("config store is not configured")
|
|
}
|
|
if a.openSecretStore == nil {
|
|
return fmt.Errorf("secret store is not configured")
|
|
}
|
|
|
|
profileFlag, err := parseProfileArgs(command, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cfg, _, err := a.configStore.LoadDefault()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
|
|
profile := cfg.Profiles[profileName]
|
|
|
|
secrets, err := a.openSecretStore()
|
|
if err != nil {
|
|
return mapAppError(err)
|
|
}
|
|
|
|
storedPassword, hasStoredPassword, err := loadStoredPassword(secrets, profileName)
|
|
if err != nil {
|
|
return mapAppError(err)
|
|
}
|
|
|
|
cred, err := a.prompter.PromptCredential(ctx, secretstore.Credential{
|
|
Host: profile.Host,
|
|
Username: profile.Username,
|
|
Password: storedPassword,
|
|
}, hasStoredPassword)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := cred.Validate(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := secrets.SetSecret(passwordSecretName(profileName), "IMAP password", cred.Password); err != nil {
|
|
return mapAppError(err)
|
|
}
|
|
|
|
if cfg.Profiles == nil {
|
|
cfg.Profiles = map[string]ProfileConfig{}
|
|
}
|
|
cfg.CurrentProfile = profileName
|
|
cfg.Profiles[profileName] = ProfileConfig{
|
|
Host: cred.Host,
|
|
Username: cred.Username,
|
|
}
|
|
configPath, err := a.configStore.SaveDefault(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Fprintf(a.stdout, "profile %q saved to %s\n", profileName, configPath)
|
|
return nil
|
|
}
|
|
|
|
func (a *App) runConfigShow(ctx context.Context, args []string) error {
|
|
if a.configStore == nil {
|
|
return fmt.Errorf("config store is not configured")
|
|
}
|
|
if a.openSecretStore == nil {
|
|
return fmt.Errorf("secret store is not configured")
|
|
}
|
|
|
|
profileFlag, err := parseProfileArgs("config show", args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cfg, _, err := a.configStore.LoadDefault()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
|
|
profile := cfg.Profiles[profileName]
|
|
|
|
secrets, err := a.openSecretStore()
|
|
if err != nil {
|
|
return mapAppError(err)
|
|
}
|
|
|
|
resolution, err := resolveCredentialFields(profile, secrets, credentialFieldSpecs(profileName))
|
|
if err != nil {
|
|
var missingErr *frameworkcli.MissingRequiredValuesError
|
|
if !errors.As(err, &missingErr) {
|
|
return mapAppError(err)
|
|
}
|
|
}
|
|
|
|
host, _ := resolution.Get("host")
|
|
username, _ := resolution.Get("username")
|
|
password, _ := resolution.Get("password")
|
|
|
|
if _, err := fmt.Fprintf(a.stdout, "profile: %s\n", profileName); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(a.stdout, "host: %s (%s)\n", renderVisibleField(host), renderSource(host)); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(a.stdout, "username: %s (%s)\n", renderVisibleField(username), renderSource(username)); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(a.stdout, "password: %s (%s)\n", renderSecretField(password), renderSource(password)); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) runConfigTest(ctx context.Context, args []string) error {
|
|
return a.runDoctor(ctx, args)
|
|
}
|
|
|
|
func (a *App) runConfigDelete(_ context.Context, args []string) error {
|
|
if a.configStore == nil {
|
|
return fmt.Errorf("config store is not configured")
|
|
}
|
|
if a.openSecretStore == nil {
|
|
return fmt.Errorf("secret store is not configured")
|
|
}
|
|
|
|
profileFlag, err := parseProfileArgs("config delete", args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cfg, _, err := a.configStore.LoadDefault()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
|
|
secrets, err := a.openSecretStore()
|
|
if err != nil {
|
|
return mapAppError(err)
|
|
}
|
|
if err := secrets.DeleteSecret(passwordSecretName(profileName)); err != nil && !errors.Is(err, frameworksecretstore.ErrNotFound) {
|
|
return mapAppError(err)
|
|
}
|
|
|
|
if cfg.Profiles != nil {
|
|
delete(cfg.Profiles, profileName)
|
|
}
|
|
if strings.TrimSpace(cfg.CurrentProfile) == profileName {
|
|
cfg.CurrentProfile = nextCurrentProfile(cfg.Profiles, a.runtimeMetadata().DefaultProfile)
|
|
}
|
|
|
|
configPath, err := a.configStore.SaveDefault(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := fmt.Fprintf(a.stdout, "profile %q deleted from %s\n", profileName, configPath); err != nil {
|
|
return err
|
|
}
|
|
if cfg.CurrentProfile != "" {
|
|
if _, err := fmt.Fprintf(a.stdout, "current profile: %s\n", cfg.CurrentProfile); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) runMCP(ctx context.Context, args []string) error {
|
|
if a.newRunner == nil {
|
|
return fmt.Errorf("mcp runner is not configured")
|
|
}
|
|
if a.newMailService == nil {
|
|
return fmt.Errorf("mail service is not configured")
|
|
}
|
|
|
|
profileFlag, err := parseProfileArgs("mcp", args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cred, err := a.loadCredential(profileFlag)
|
|
if err != nil {
|
|
return mapAppError(err)
|
|
}
|
|
|
|
runner := a.newRunner(cred, a.newMailService(), a.stdin, a.stdout, a.stderr)
|
|
if runner == nil {
|
|
return fmt.Errorf("mcp runner is not configured")
|
|
}
|
|
|
|
return mapAppError(runner.Run(ctx))
|
|
}
|
|
|
|
func (a *App) runUpdate(ctx context.Context, args []string) error {
|
|
if a.loadManifest == nil {
|
|
return fmt.Errorf("manifest loader is not configured")
|
|
}
|
|
if a.resolveExecutable == nil {
|
|
return fmt.Errorf("executable resolver is not configured")
|
|
}
|
|
|
|
if err := parseUpdateArgs(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
executablePath, err := a.resolveExecutable()
|
|
if err != nil {
|
|
return fmt.Errorf("resolve executable path: %w", err)
|
|
}
|
|
|
|
manifestFile, err := a.loadManifestForExecutable(executablePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return frameworkupdate.Run(ctx, frameworkupdate.Options{
|
|
CurrentVersion: a.version,
|
|
ExecutablePath: executablePath,
|
|
BinaryName: a.runtimeMetadata().BinaryName,
|
|
ReleaseSource: manifestFile.Update.ReleaseSource(),
|
|
Stdout: a.stdout,
|
|
})
|
|
}
|
|
|
|
func (a *App) loadManifestForExecutable(executablePath string) (frameworkmanifest.File, error) {
|
|
searchDirs := []string{filepath.Dir(executablePath), "."}
|
|
var firstErr error
|
|
|
|
for _, dir := range searchDirs {
|
|
file, _, err := a.loadManifest(dir)
|
|
if err == nil {
|
|
return file, nil
|
|
}
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
|
|
return frameworkmanifest.File{}, fmt.Errorf("load manifest: %w", firstErr)
|
|
}
|
|
|
|
func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error) {
|
|
if a.configStore == nil {
|
|
return secretstore.Credential{}, fmt.Errorf("config store is not configured")
|
|
}
|
|
if a.openSecretStore == nil {
|
|
return secretstore.Credential{}, fmt.Errorf("secret store is not configured")
|
|
}
|
|
|
|
cfg, _, err := a.configStore.LoadDefault()
|
|
if err != nil {
|
|
return secretstore.Credential{}, err
|
|
}
|
|
|
|
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
|
|
profile := cfg.Profiles[profileName]
|
|
|
|
secrets, err := a.openSecretStore()
|
|
if err != nil {
|
|
return secretstore.Credential{}, err
|
|
}
|
|
|
|
resolution, err := resolveCredentialFields(profile, secrets, credentialFieldSpecs(profileName))
|
|
if err != nil {
|
|
var missingErr *frameworkcli.MissingRequiredValuesError
|
|
if errors.As(err, &missingErr) {
|
|
return secretstore.Credential{}, fmt.Errorf(
|
|
"%w: profile %q is incomplete (missing: %s)",
|
|
mcpserver.ErrCredentialsNotConfigured,
|
|
profileName,
|
|
strings.Join(missingErr.Fields, ", "),
|
|
)
|
|
}
|
|
return secretstore.Credential{}, err
|
|
}
|
|
|
|
cred, err := credentialFromResolution(resolution)
|
|
if err != nil {
|
|
return secretstore.Credential{}, err
|
|
}
|
|
if err := cred.Validate(); err != nil {
|
|
return secretstore.Credential{}, fmt.Errorf("%w: profile %q is incomplete", mcpserver.ErrCredentialsNotConfigured, profileName)
|
|
}
|
|
|
|
return cred, nil
|
|
}
|
|
|
|
func credentialFieldSpecs(profileName string) []frameworkcli.FieldSpec {
|
|
return []frameworkcli.FieldSpec{
|
|
{
|
|
Name: "host",
|
|
Required: true,
|
|
Sources: []frameworkcli.ValueSource{
|
|
frameworkcli.SourceEnv,
|
|
frameworkcli.SourceConfig,
|
|
},
|
|
EnvKey: hostEnv,
|
|
ConfigKey: "host",
|
|
},
|
|
{
|
|
Name: "username",
|
|
Required: true,
|
|
Sources: []frameworkcli.ValueSource{
|
|
frameworkcli.SourceEnv,
|
|
frameworkcli.SourceConfig,
|
|
},
|
|
EnvKey: usernameEnv,
|
|
ConfigKey: "username",
|
|
},
|
|
passwordFieldSpec(profileName),
|
|
}
|
|
}
|
|
|
|
func profileFieldSpecs() []frameworkcli.FieldSpec {
|
|
return []frameworkcli.FieldSpec{
|
|
{
|
|
Name: "host",
|
|
Required: true,
|
|
Sources: []frameworkcli.ValueSource{
|
|
frameworkcli.SourceEnv,
|
|
frameworkcli.SourceConfig,
|
|
},
|
|
EnvKey: hostEnv,
|
|
ConfigKey: "host",
|
|
},
|
|
{
|
|
Name: "username",
|
|
Required: true,
|
|
Sources: []frameworkcli.ValueSource{
|
|
frameworkcli.SourceEnv,
|
|
frameworkcli.SourceConfig,
|
|
},
|
|
EnvKey: usernameEnv,
|
|
ConfigKey: "username",
|
|
},
|
|
}
|
|
}
|
|
|
|
func passwordFieldSpec(profileName string) frameworkcli.FieldSpec {
|
|
return frameworkcli.FieldSpec{
|
|
Name: "password",
|
|
Required: true,
|
|
Sources: []frameworkcli.ValueSource{
|
|
frameworkcli.SourceEnv,
|
|
frameworkcli.SourceSecret,
|
|
},
|
|
EnvKey: passwordEnv,
|
|
SecretKey: passwordSecretName(profileName),
|
|
}
|
|
}
|
|
|
|
func resolveCredentialFields(profile ProfileConfig, store secretStore, fields []frameworkcli.FieldSpec) (frameworkcli.Resolution, error) {
|
|
configValues := map[string]string{
|
|
"host": profile.Host,
|
|
"username": profile.Username,
|
|
}
|
|
|
|
return frameworkcli.ResolveFields(frameworkcli.ResolveOptions{
|
|
Fields: fields,
|
|
Lookup: func(source frameworkcli.ValueSource, key string) (string, bool, error) {
|
|
switch source {
|
|
case frameworkcli.SourceEnv:
|
|
value, ok := os.LookupEnv(strings.TrimSpace(key))
|
|
return value, ok, nil
|
|
case frameworkcli.SourceConfig:
|
|
value, ok := configValues[strings.TrimSpace(key)]
|
|
return value, ok, nil
|
|
case frameworkcli.SourceSecret:
|
|
if store == nil {
|
|
return "", false, nil
|
|
}
|
|
|
|
value, err := store.GetSecret(strings.TrimSpace(key))
|
|
if err != nil {
|
|
if errors.Is(err, frameworksecretstore.ErrNotFound) {
|
|
return "", false, nil
|
|
}
|
|
return "", false, err
|
|
}
|
|
return value, true, nil
|
|
default:
|
|
return "", false, nil
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
func credentialFromResolution(resolution frameworkcli.Resolution) (secretstore.Credential, error) {
|
|
host, ok := resolution.Get("host")
|
|
if !ok {
|
|
return secretstore.Credential{}, fmt.Errorf("resolve credential: host field is missing from resolution")
|
|
}
|
|
|
|
username, ok := resolution.Get("username")
|
|
if !ok {
|
|
return secretstore.Credential{}, fmt.Errorf("resolve credential: username field is missing from resolution")
|
|
}
|
|
|
|
password, ok := resolution.Get("password")
|
|
if !ok {
|
|
return secretstore.Credential{}, fmt.Errorf("resolve credential: password field is missing from resolution")
|
|
}
|
|
|
|
return secretstore.Credential{
|
|
Host: host.Value,
|
|
Username: username.Value,
|
|
Password: password.Value,
|
|
}, nil
|
|
}
|
|
|
|
func loadStoredPassword(store secretStore, profileName string) (string, bool, error) {
|
|
password, err := store.GetSecret(passwordSecretName(profileName))
|
|
if err != nil {
|
|
if errors.Is(err, frameworksecretstore.ErrNotFound) {
|
|
return "", false, nil
|
|
}
|
|
return "", false, err
|
|
}
|
|
|
|
return password, true, nil
|
|
}
|
|
|
|
func passwordSecretName(profileName string) string {
|
|
return "imap-password/" + strings.TrimSpace(profileName)
|
|
}
|
|
|
|
func parseProfileArgs(command string, args []string) (string, error) {
|
|
flagSet := flag.NewFlagSet(command, flag.ContinueOnError)
|
|
flagSet.SetOutput(io.Discard)
|
|
|
|
profile := flagSet.String("profile", "", "")
|
|
if err := flagSet.Parse(args); err != nil {
|
|
return "", fmt.Errorf("usage: email-mcp %s [--profile NAME]", command)
|
|
}
|
|
if flagSet.NArg() != 0 {
|
|
return "", fmt.Errorf("usage: email-mcp %s [--profile NAME]", command)
|
|
}
|
|
|
|
return strings.TrimSpace(*profile), nil
|
|
}
|
|
|
|
func parseUpdateArgs(args []string) error {
|
|
flagSet := flag.NewFlagSet("update", flag.ContinueOnError)
|
|
flagSet.SetOutput(io.Discard)
|
|
|
|
if err := flagSet.Parse(args); err != nil {
|
|
return fmt.Errorf("usage: email-mcp update")
|
|
}
|
|
if flagSet.NArg() != 0 {
|
|
return fmt.Errorf("usage: email-mcp update")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type runtimeMetadata struct {
|
|
BinaryName string
|
|
Description string
|
|
DefaultProfile string
|
|
}
|
|
|
|
func (a *App) runtimeMetadata() runtimeMetadata {
|
|
metadata := runtimeMetadata{
|
|
BinaryName: binaryName,
|
|
Description: binaryDescription,
|
|
DefaultProfile: fallbackProfile,
|
|
}
|
|
|
|
if a.loadManifest == nil {
|
|
return metadata
|
|
}
|
|
|
|
file, err := a.loadRuntimeManifest()
|
|
if err != nil {
|
|
return metadata
|
|
}
|
|
|
|
bootstrap := file.BootstrapInfo()
|
|
if bootstrap.BinaryName != "" {
|
|
metadata.BinaryName = bootstrap.BinaryName
|
|
}
|
|
if bootstrap.Description != "" {
|
|
metadata.Description = bootstrap.Description
|
|
}
|
|
if bootstrap.DefaultProfile != "" {
|
|
metadata.DefaultProfile = bootstrap.DefaultProfile
|
|
}
|
|
|
|
return metadata
|
|
}
|
|
|
|
func (a *App) loadRuntimeManifest() (frameworkmanifest.File, error) {
|
|
if a.loadManifest == nil {
|
|
return frameworkmanifest.File{}, fmt.Errorf("manifest loader is not configured")
|
|
}
|
|
|
|
if a.resolveExecutable != nil {
|
|
executablePath, err := a.resolveExecutable()
|
|
if err == nil {
|
|
file, loadErr := a.loadManifestForExecutable(executablePath)
|
|
if loadErr == nil {
|
|
return file, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
file, _, err := a.loadManifest(".")
|
|
if err != nil {
|
|
return frameworkmanifest.File{}, err
|
|
}
|
|
return file, nil
|
|
}
|
|
|
|
func (a *App) resolveProfileName(profileFlag, currentProfile string) string {
|
|
resolvedCurrent := strings.TrimSpace(currentProfile)
|
|
if resolvedCurrent == "" {
|
|
resolvedCurrent = strings.TrimSpace(a.runtimeMetadata().DefaultProfile)
|
|
}
|
|
|
|
return frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), resolvedCurrent)
|
|
}
|
|
|
|
func nextCurrentProfile(profiles map[string]ProfileConfig, preferred string) string {
|
|
if len(profiles) == 0 {
|
|
return ""
|
|
}
|
|
|
|
normalizedPreferred := strings.TrimSpace(preferred)
|
|
if normalizedPreferred != "" {
|
|
if _, ok := profiles[normalizedPreferred]; ok {
|
|
return normalizedPreferred
|
|
}
|
|
}
|
|
|
|
if _, ok := profiles[fallbackProfile]; ok {
|
|
return fallbackProfile
|
|
}
|
|
|
|
names := make([]string, 0, len(profiles))
|
|
for name := range profiles {
|
|
if trimmed := strings.TrimSpace(name); trimmed != "" {
|
|
names = append(names, trimmed)
|
|
}
|
|
}
|
|
if len(names) == 0 {
|
|
return ""
|
|
}
|
|
|
|
sort.Strings(names)
|
|
return names[0]
|
|
}
|
|
|
|
func mapAppError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
switch {
|
|
case errors.Is(err, mcpserver.ErrCredentialsNotConfigured):
|
|
return newUserFacingError("credentials not configured; run `email-mcp setup`", err)
|
|
case errors.Is(err, frameworksecretstore.ErrBackendUnavailable):
|
|
return newUserFacingError(
|
|
fmt.Sprintf("%s is not available; configure a supported OS wallet and retry", frameworksecretstore.BackendName()),
|
|
err,
|
|
)
|
|
case errors.Is(err, frameworksecretstore.ErrReadOnly):
|
|
return newUserFacingError("secret backend is read-only", err)
|
|
default:
|
|
return err
|
|
}
|
|
}
|
|
|
|
func renderSource(field frameworkcli.ResolvedField) string {
|
|
if !field.Found {
|
|
return "missing"
|
|
}
|
|
return string(field.Source)
|
|
}
|
|
|
|
func renderVisibleField(field frameworkcli.ResolvedField) string {
|
|
if !field.Found || strings.TrimSpace(field.Value) == "" {
|
|
return "<missing>"
|
|
}
|
|
return field.Value
|
|
}
|
|
|
|
func renderSecretField(field frameworkcli.ResolvedField) string {
|
|
if !field.Found || strings.TrimSpace(field.Value) == "" {
|
|
return "<missing>"
|
|
}
|
|
return "<set>"
|
|
}
|
|
|
|
type userFacingError struct {
|
|
message string
|
|
err error
|
|
}
|
|
|
|
func (e *userFacingError) Error() string {
|
|
return e.message
|
|
}
|
|
|
|
func (e *userFacingError) Unwrap() error {
|
|
return e.err
|
|
}
|
|
|
|
func newUserFacingError(message string, err error) error {
|
|
return &userFacingError{
|
|
message: message,
|
|
err: err,
|
|
}
|
|
}
|