- Bump mcp-framework v1.10.0 → v1.12.0 - Wire Login hook via BitwardenLoginHandler (backend_policy = bitwarden-cli) - Remove ManifestDir/ManifestValidator from config test: le manifest est un artefact de build, pas une contrainte runtime (per framework design) - Drop doctorManifestDir(), validateManifestUpdate() and dead imports - Update tests accordingly (5 checks instead of 6, remove manifest test) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
738 lines
19 KiB
Go
738 lines
19 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"email-mcp/mcpgen"
|
|
frameworkbootstrap "forge.lclr.dev/AI/mcp-framework/bootstrap"
|
|
frameworkcli "forge.lclr.dev/AI/mcp-framework/cli"
|
|
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
|
|
frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
|
frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
|
|
frameworkupdate "forge.lclr.dev/AI/mcp-framework/update"
|
|
|
|
"email-mcp/internal/mcpserver"
|
|
"email-mcp/internal/secretstore"
|
|
)
|
|
|
|
const (
|
|
binaryName = mcpgen.BinaryName
|
|
defaultProfileEnv = "EMAIL_MCP_PROFILE"
|
|
hostEnv = "EMAIL_MCP_HOST"
|
|
usernameEnv = "EMAIL_MCP_USERNAME"
|
|
passwordEnv = "EMAIL_MCP_PASSWORD"
|
|
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 = frameworksecretstore.Store
|
|
|
|
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 args == nil {
|
|
args = []string{}
|
|
}
|
|
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,
|
|
EnableDoctorAlias: true,
|
|
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)
|
|
},
|
|
Login: frameworkbootstrap.BitwardenLoginHandler(metadata.BinaryName),
|
|
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.runDoctor(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 (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 shouldPersistPassword(hasStoredPassword, storedPassword, cred.Password) {
|
|
if err := secrets.SetSecret(passwordSecretName(profileName), "IMAP password", cred.Password); err != nil {
|
|
switch {
|
|
case errors.Is(err, frameworksecretstore.ErrReadOnly):
|
|
if strings.TrimSpace(os.Getenv(passwordEnv)) == "" {
|
|
return newUserFacingError(
|
|
fmt.Sprintf("secret backend is read-only; set %s and rerun `email-mcp setup`", passwordEnv),
|
|
err,
|
|
)
|
|
}
|
|
if _, writeErr := fmt.Fprintf(a.stdout, "secret backend is read-only; password is provided via %s\n", passwordEnv); writeErr != nil {
|
|
return writeErr
|
|
}
|
|
default:
|
|
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, mcpgen.ResolveFieldSpecs(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) 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 {
|
|
switch {
|
|
case errors.Is(err, frameworksecretstore.ErrNotFound):
|
|
case errors.Is(err, frameworksecretstore.ErrReadOnly):
|
|
if _, writeErr := fmt.Fprintf(a.stdout, "secret backend is read-only; %s cannot be deleted automatically\n", passwordEnv); writeErr != nil {
|
|
return writeErr
|
|
}
|
|
default:
|
|
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)
|
|
}
|
|
|
|
options, err := mcpgen.UpdateOptionsFrom(filepath.Dir(executablePath), a.version, a.stdout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
options.ExecutablePath = executablePath
|
|
|
|
return frameworkupdate.Run(ctx, options)
|
|
}
|
|
|
|
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, mcpgen.ResolveFieldSpecs(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 profileFieldSpecs(profileName string) []frameworkcli.FieldSpec {
|
|
specs := mcpgen.ResolveFieldSpecs(profileName)
|
|
profileSpecs := make([]frameworkcli.FieldSpec, 0, len(specs))
|
|
for _, spec := range specs {
|
|
if spec.Name == "host" || spec.Name == "username" {
|
|
profileSpecs = append(profileSpecs, spec)
|
|
}
|
|
}
|
|
return profileSpecs
|
|
}
|
|
|
|
func passwordOnlyFieldSpecs(profileName string) []frameworkcli.FieldSpec {
|
|
for _, spec := range mcpgen.ResolveFieldSpecs(profileName) {
|
|
if spec.Name == "password" {
|
|
return []frameworkcli.FieldSpec{spec}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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: frameworkcli.ResolveLookup(frameworkcli.ResolveLookupOptions{
|
|
Env: frameworkcli.EnvLookup(os.LookupEnv),
|
|
Config: frameworkcli.ConfigMap(configValues),
|
|
Secret: frameworkcli.SecretStore(store),
|
|
}),
|
|
})
|
|
}
|
|
|
|
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 {
|
|
for _, spec := range mcpgen.ResolveFieldSpecs(profileName) {
|
|
if spec.Name == "password" && strings.TrimSpace(spec.SecretKey) != "" {
|
|
return spec.SecretKey
|
|
}
|
|
}
|
|
return "imap-password/" + strings.TrimSpace(profileName)
|
|
}
|
|
|
|
func shouldPersistPassword(hasStoredPassword bool, storedPassword, newPassword string) bool {
|
|
if !hasStoredPassword {
|
|
return true
|
|
}
|
|
return storedPassword != newPassword
|
|
}
|
|
|
|
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: mcpgen.BinaryName,
|
|
Description: mcpgen.DefaultDescription,
|
|
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(strings.TrimSpace(err.Error()), 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,
|
|
}
|
|
}
|